TagCache.luau
Luau, 103 lines from Building overview
--!strict
-- TagCache: a CollectionService-tag cache that works under StreamingEnabled.
--
-- Why: GetInstanceAddedSignal(tag) does NOT fire for an instance that streams in already carrying the tag,
-- which is how everything outside the spawn area arrives. A cache built from GetTagged at startup and
-- maintained from that signal freezes at whatever was loaded on join (measured: hundreds of tagged parts
-- streamed in, the handler fired zero times). Rebuild on a timer with a cheap change check instead.
-- GetInstanceRemovedSignal DOES fire on stream-out, so it is still used for cleanup.
--
-- local cache = TagCache.new("Pressable", function(inst) return inst:IsA("BasePart") end)
-- cache.Changed:Connect(function(list) rebuildOverlapFilter(list) end)
-- cache:onRemoved(function(inst) restPose[inst] = nil end)
-- ...
-- cache:destroy()
local CollectionService = game:GetService("CollectionService")
local TagCache = {}
TagCache.__index = TagCache
export type TagCache = typeof(setmetatable(
{} :: {
tag: string,
filter: ((Instance) -> boolean)?,
list: { Instance },
set: { [Instance]: boolean },
Changed: BindableEvent,
_count: number,
_alive: boolean,
_conns: { RBXScriptConnection },
},
TagCache
))
function TagCache.new(tag: string, filter: ((Instance) -> boolean)?, period: number?): TagCache
local changed = Instance.new("BindableEvent")
local self = setmetatable({
tag = tag,
filter = filter,
list = {},
set = {},
Changed = changed,
_count = -1,
_alive = true,
_conns = {},
}, TagCache)
-- The signals are a fast path only; the timer below is what guarantees correctness.
table.insert(self._conns, CollectionService:GetInstanceAddedSignal(tag):Connect(function()
self:refresh(true)
end))
-- A stream-out plus a stream-in inside one period leaves the count unchanged; mark dirty on removal.
table.insert(self._conns, CollectionService:GetInstanceRemovedSignal(tag):Connect(function()
self._count = -1
end))
self:refresh(true)
task.spawn(function()
while self._alive do
task.wait(period or 1)
if self._alive then
self:refresh(false)
end
end
end)
return self
end
-- Rebuild when the tagged count changed (cheap check) or when forced.
function TagCache.refresh(self: TagCache, force: boolean)
local tagged = CollectionService:GetTagged(self.tag)
if not force and #tagged == self._count then
return
end
self._count = #tagged
local list, set = {}, {}
for _, inst in tagged do
if not self.filter or self.filter(inst) then
table.insert(list, inst)
set[inst] = true
end
end
self.list, self.set = list, set
self.Changed:Fire(list)
end
-- Cleanup hook for per-instance state (rest poses, tweens). Streaming out fires this.
-- For pooled objects: RemoveTag on release and AddTag on acquire, so this runs and state resets.
function TagCache.onRemoved(self: TagCache, fn: (Instance) -> ())
table.insert(self._conns, CollectionService:GetInstanceRemovedSignal(self.tag):Connect(function(inst)
self.set[inst] = nil
fn(inst)
end))
end
function TagCache.destroy(self: TagCache)
self._alive = false
for _, c in self._conns do
c:Disconnect()
end
self.Changed:Destroy()
end
return TagCache