From 0705fab7ad728018bfa5c4761ea2939d9412acfa Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Wed, 24 Sep 2025 14:49:23 -0400 Subject: [PATCH 01/19] Update file type from lua to luau and split out WhileHasComponents method --- lib/component/src/init.lua | 0 lib/component/src/init.luau | 137 +++++++++++++++++++++++++++++------- 2 files changed, 111 insertions(+), 26 deletions(-) delete mode 100644 lib/component/src/init.lua diff --git a/lib/component/src/init.lua b/lib/component/src/init.lua deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index 2fff7c48..c9f4bc28 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -914,7 +914,7 @@ end Ties a function to the lifecycle of the calling component and the equivalent component of the given `componentClass`. The function is run whenever a component of the given class is started. The given function passes the sibling component of the given class and a janitor to handle any connections - you may make within it. The Janitor is cleaned up whenever either compenent is stopped. + you may make within it. The Janitor is cleaned up whenever either component is stopped. ```lua local AnotherComponentClass = require(somewhere.AnotherComponent) @@ -932,7 +932,7 @@ end end ``` ]=] -function Component:WhileHasComponent(componentClassOrClasses: ComponentClass | {ComponentClass}, fn: (components: Component | {Component}, jani: Janitor) -> ()) +function Component:WhileHasComponent(componentClass: ComponentClass, fn: (component: Component, jani: Janitor) -> ()) local bindJani = Janitor.new() local connProxy = {} @@ -955,10 +955,94 @@ function Component:WhileHasComponent(componentClassOrClasses: ComponentClass | { return c.Instance == self.Instance end):andThen(connProxy.Destroy)) - assert(typeof(componentClassOrClasses) == "table", "Component:WhileHasComponent() expects a component class or an array of component classes.") - local isSingleComponentClass = if (componentClassOrClasses :: any).Tag == nil then true else false - -- Normalize to array of classes - local componentClasses = if isSingleComponentClass then componentClassOrClasses else {componentClassOrClasses} + assert(typeof(componentClass) == "table" and componentClass.Tag, "Component:WhileHasComponent() expects a component class.") + + -- Track janitor for the component + local activeJanitor = nil + + local function SetupIfPresent() + local component = self:GetComponent(componentClass) + if not component or activeJanitor then return end + + activeJanitor = bindJani:Add(Janitor.new(), "Destroy") + activeJanitor:Add(task.spawn(fn, component, activeJanitor)) + + -- If the component stops, destroy janitor + activeJanitor:AddPromise(Promise.fromEvent(componentClass.Stopped, function(c) + return c.Instance == self.Instance + end):andThen(function() + if activeJanitor then + activeJanitor:Destroy() + activeJanitor = nil + end + end)) + end + + -- Listen for component start events + bindJani:Add(componentClass.Started:Connect(function(component) + if component.Instance == self.Instance then + SetupIfPresent() + end + end)) + + -- Initial check in case component is already present + SetupIfPresent() + + return connProxy +end + +--[=[ + @tag Component Instance + @return Connection + + Ties a function to the lifecycle of the calling component and the equivalent components of the given + array of `componentClasses`. The function is run whenever all components of the given classes are started. + The given function passes an array of sibling components of the given classes and a janitor to handle any + connections you may make within it. The Janitor is cleaned up whenever any of the components is stopped. + + ```lua + local AnotherComponentClass = require(somewhere.AnotherComponent) + local ThirdComponentClass = require(somewhere.ThirdComponent) + + local MyComponent = Component.new({Tag = "MyComponent"}) + + function MyComponent:Start() + self:WhileHasComponents({AnotherComponentClass, ThirdComponentClass}, function(siblingComponents, jani) + local anotherComponent = siblingComponents[1] + local thirdComponent = siblingComponents[2] + print(anotherComponent.SomeProperty, thirdComponent.AnotherProperty) + + jani:Add(function() + print("One or more sibling components stopped") + end) + end) + end + ``` +]=] +function Component:WhileHasComponents(componentClasses: {ComponentClass}, fn: (components: {Component}, jani: Janitor) -> ()) + local bindJani = Janitor.new() + + local connProxy = {} + connProxy.IsConnected = true + connProxy.Disconnect = function() + if connProxy.IsConnected then + connProxy.IsConnected = false + bindJani:Destroy() + end + end + connProxy.Destroy = connProxy.Disconnect + setmetatable(connProxy, { + __call = function(t, ...) + return t.Destroy(...) + end + }) + + bindJani:Add(connProxy) + bindJani:AddPromise(Promise.fromEvent(self.Stopped, function(c) + return c.Instance == self.Instance + end):andThen(connProxy.Destroy)) + + assert(typeof(componentClasses) == "table", "Component:WhileHasComponents() expects an array of component classes.") -- Helper to get all component instances for self.Instance local function getAllComponents() @@ -973,30 +1057,25 @@ function Component:WhileHasComponent(componentClassOrClasses: ComponentClass | { return components end - -- Track janitors for each set of components - local activeJanitors = {} + -- Track janitor for the set of components + local activeJanitor = nil local function SetupIfAllPresent() local components = getAllComponents() - if not components then return end - -- Prevent duplicate setups for the same set - if activeJanitors[self.Instance] then return end - local currentJani = bindJani:Add(Janitor.new(), "Destroy", self.Instance) - activeJanitors[self.Instance] = currentJani - - if isSingleComponentClass then - -- If only one component class, just pass it directly to maintain backwards compatibility - components = table.unpack(components) - end - currentJani:Add(task.spawn(fn, components, currentJani)) + if not components or activeJanitor then return end + + activeJanitor = bindJani:Add(Janitor.new(), "Destroy") + activeJanitor:Add(task.spawn(fn, components, activeJanitor)) -- If any component stops, destroy janitor - for i, class in ipairs(componentClasses) do - currentJani:AddPromise(Promise.fromEvent(class.Stopped, function(c) + for _, class in ipairs(componentClasses) do + activeJanitor:AddPromise(Promise.fromEvent(class.Stopped, function(c) return c.Instance == self.Instance end):andThen(function() - currentJani:Destroy() - activeJanitors[self.Instance] = nil + if activeJanitor then + activeJanitor:Destroy() + activeJanitor = nil + end end)) end end @@ -1016,10 +1095,16 @@ function Component:WhileHasComponent(componentClassOrClasses: ComponentClass | { return connProxy end --- DEPRECATED: Use WhileHasComponent instead. Kept for backwards compat +-- DEPRECATED: Use WhileHasComponent or WhileHasComponents instead. Kept for backwards compat function Component:ForEachSibling(...) - warn("ForEachSibling is deprecated. Use WhileHasComponent instead.") - return self:WhileHasComponent(...) + warn("ForEachSibling is deprecated. Use WhileHasComponent for single components or WhileHasComponents for multiple components instead.") + -- For backwards compatibility, try to detect if it's multiple components and route appropriately + local componentClassOrClasses = ... + if componentClassOrClasses and typeof(componentClassOrClasses) == "table" and not componentClassOrClasses.Tag then + return self:WhileHasComponents(...) + else + return self:WhileHasComponent(...) + end end From ec47f10a498da6c03a1a0972238d1734b85e29cd Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Mon, 1 Dec 2025 17:34:14 -0500 Subject: [PATCH 02/19] Add detailed documentation and robust lifecycle handling Expanded the Component module with comprehensive documentation covering lifecycle, edge cases, and extension system. Improved robustness for construction and start/stop phases, including cancellation and error handling during yields, rapid reparenting, and memory management. Added internal helper documentation, clarified extension method binding, and enhanced cleanup logic in Destroy. Minor code style and clarity improvements throughout. --- lib/component/src/init.luau | 803 ++++++++++++++++++++++++++++++++---- 1 file changed, 718 insertions(+), 85 deletions(-) diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index c9f4bc28..d3ceb026 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -2,6 +2,175 @@ -- Stephen Leitnick, Logan Hunt -- November 26, 2021 +--[=[ + @class Component + + ## Overview + + This is a fork of the original Component module by Stephen Leitnick. This fork expands upon the functionality of + extensions and provides several new useful methods, along with robust handling of edge cases during component + lifecycle management. + + Bind components to Roblox instances using the Component class and CollectionService tags. + + To avoid confusion of terms: + - `Component` refers to this module. + - `Component Class` (e.g. `MyComponent` through this documentation) refers to a class created via `Component.new` + - `Component Instance` refers to an instance of a component class. + - `Roblox Instance` refers to the Roblox instance to which the component instance is bound. + + Methods and properties are tagged with the above terms to help clarify the level at which they are used. + + ## Lifecycle + + The component lifecycle follows this order: + 1. **ShouldConstruct** - Extensions can veto construction by returning `false` + 2. **Constructing** - Extension hook before `Construct()` + 3. **Construct()** - Component initialization (may yield) + 4. **Constructed** - Extension hook after `Construct()` + 5. **Starting** - Extension hook before `Start()` + 6. **Start()** - Component startup (may yield) + 7. **Started** - Extension hook after `Start()` + 8. **Update loops** - HeartbeatUpdate, SteppedUpdate, RenderSteppedUpdate connected + 9. **Stopping** - Extension hook before `Stop()` + 10. **Stop()** - Component cleanup + 11. **Stopped** - Extension hook after `Stop()` + + ## Edge Cases and Robustness + + ### Yielding During Construction + + If `Construct()` or any extension function yields (e.g., waiting for data, HTTP requests, etc.), + the system tracks the construction state and validates it after each yield point. If the instance + becomes invalid (moves outside valid ancestors, loses its tag, or a newer construction attempt + starts), construction is cancelled and any partial state is cleaned up. + + **Example scenario:** + ```lua + function MyComponent:Construct() + self.Data = HttpService:GetAsync("...") -- Yields! + -- If instance is reparented during this yield, construction is cancelled + self.ProcessedData = processData(self.Data) + end + ``` + + ### Yielding During Start + + Similar to construction, if `Start()` or extension Starting/Started functions yield, the system + checks after each yield point whether the component should still be running. If `Stop()` is called + during startup (e.g., the instance is removed), the start thread is cancelled if possible. + + ### Reparenting During Lifecycle + + If an instance is reparented outside of valid ancestors during construction: + - Construction is immediately cancelled + - Any partial component state is cleaned up via `Stop()` + - The construction thread is cancelled if suspended + + If an instance is reparented outside of valid ancestors after construction but during start: + - The start thread is cancelled if possible + - `Stop()` is called to clean up the component + + ### Rapid Reparenting (Ping-Pong) + + If an instance rapidly moves in and out of valid ancestors: + - Each construction attempt gets a unique ID (`constructId`) + - Only the most recent construction attempt is allowed to complete + - Stale construction attempts are cancelled and cleaned up + - The `KEY_LOCK_CONSTRUCT` table tracks the current valid construction ID + + **Example scenario:** + ```lua + -- Instance starts in workspace (valid ancestor) + local part = Instance.new("Part", workspace) + CollectionService:AddTag(part, "MyComponent") + -- Construction starts... + part.Parent = ReplicatedStorage -- Moves out - construction cancelled + part.Parent = workspace -- Moves back in - NEW construction starts + -- Only the second construction attempt will complete + ``` + + ### Errors During Construction + + If an error occurs during `Construct()` or any extension function: + - The error is caught and logged with a warning + - `Stop()` is called on the partial component to clean up any state + - The component is not added to tracking tables + - Other components are not affected + + ### Errors During Start/Stop + + Extension functions (`Starting`, `Started`, `Stopping`, `Stopped`) and lifecycle methods + are called in order. If one errors, the error propagates but cleanup still occurs for + connections and state that was set up. + + ### Thread Cancellation + + When stopping a component that's still in its Start phase: + - If the start thread is suspended (yielding), it's cancelled via `task.cancel()` + - If the start thread is the current thread (Stop called from within Start), it's not cancelled + but the thread will return early due to state checks + - If the start thread is in "normal" status (in call stack but not current), cancellation is deferred + + ### Memory Management + + The component system tracks instances in several tables: + - `KEY_INST_TO_COMPONENTS`: Maps Roblox instances to their component instances + - `KEY_COMPONENTS`: Array of all active component instances + - `KEY_LOCK_CONSTRUCT`: Maps instances to their current construction attempt ID + + All tables are properly cleaned up when: + - A component is stopped (instance removed from tables) + - The component class is destroyed (all tables cleared, all components stopped) + - An instance loses its tag (component stopped and removed from tracking) + + ### Ancestor Changes + + When `UpdateAncestors()` is called: + - The `AncestorsChanged` signal fires + - All watched instances are re-evaluated + - Instances now outside valid ancestors have their components stopped + - Instances now inside valid ancestors have components constructed + + ### Tag Removal + + When a tag is removed from an instance: + - The instance is immediately removed from the watching list + - Any active component is stopped + - Any in-progress construction is cancelled + + ### Component Class Destruction + + When `Destroy()` is called on a component class: + - All active components are stopped + - All tracking tables are cleared + - All CollectionService connections are disconnected + - The class is removed from the unsetup components list if present + + ## Extension System + + Extensions can hook into the component lifecycle at various points. Extensions are processed + in order, with nested extensions (via the `Extensions` array) processed recursively. + + ### Extension Methods + + Extensions can add methods to component classes via the `Methods` table. These methods are + added at the class level, not the instance level, so they're available regardless of + `ShouldExtend` results. + + ### ShouldExtend + + The `ShouldExtend` function is called per-instance to determine if an extension applies. + This is evaluated during construction, so extensions can be conditionally applied based + on instance attributes or other runtime conditions. + + ### ShouldConstruct + + The `ShouldConstruct` function is called before any construction begins. ALL extensions + with a `ShouldConstruct` function must return `true` for construction to proceed. + If any returns `false`, no component is created and no cleanup is needed. +]=] + type AncestorList = { Instance } --[=[ @@ -28,7 +197,7 @@ type ExtensionShouldFn = (any) -> boolean .Stopping ExtensionFn? .Stopped ExtensionFn? .Extensions {Extension}? - .Methods {[string]: function}? + .Methods {[string]: (...any) -> ...any}? An extension allows the ability to extend the behavior of components. This is useful for adding injection systems or @@ -71,12 +240,12 @@ type ExtensionShouldFn = (any) -> boolean local player, an extension might look like this, assuming the instance has an attribute linking it to the player's UserId: ```lua - local player = game:GetService("Players").LocalPlayer + local Players = game:GetService("Players"). local OnlyLocalPlayer = {} function OnlyLocalPlayer.ShouldConstruct(component) local ownerId = component.Instance:GetAttribute("OwnerId") - return ownerId == player.UserId + return ownerId == Players.LocalPlayer.UserId end local MyComponent = Component.new({Tag = "MyComponent", Extensions = {OnlyLocalPlayer}}) @@ -225,7 +394,7 @@ local Signal = require(Packages.Signal) local Trove = require(Packages.Trove) type Janitor = Janitor.Janitor -type table = {[any]: any} +type table = { [any]: any } type Component = table type ComponentClass = table @@ -234,7 +403,23 @@ local DEFAULT_ANCESTORS = { workspace, game:GetService("Players") } local DEFAULT_TIMEOUT = 60 local UNSETUP_COMPONENTS = {} --- Symbol keys: +--[[ + Symbol Keys Documentation: + + These symbols are used as keys in component tables to avoid conflicts with user-defined + properties and to provide a clear separation between internal and public state. + + KEY_ANCESTORS: Array of valid ancestor instances for this component class + KEY_INST_TO_COMPONENTS: Map of Roblox Instance -> Component Instance + KEY_LOCK_CONSTRUCT: Map of Roblox Instance -> construction attempt ID (for cancellation) + KEY_COMPONENTS: Array of all active component instances + KEY_TROVE: Trove instance for managing connections and cleanup + KEY_EXTENSIONS: Array of extension definitions for this component class + KEY_ACTIVE_EXTENSIONS: Array of extensions active for a specific component instance + KEY_STARTING: Thread reference when component is in Start phase (nil otherwise) + KEY_STARTED: Boolean, true when component has fully started + KEY_CLASS_ACTIVE_EXTENSIONS: Extensions determined at class level (ShouldExtend not called per-instance) +]] local KEY_ANCESTORS = Symbol("Ancestors") local KEY_INST_TO_COMPONENTS = Symbol("InstancesToComponents") local KEY_LOCK_CONSTRUCT = Symbol("LockConstruct") @@ -252,6 +437,16 @@ local function NextRenderName(): string return "ComponentRender" .. tostring(renderId) end +--[[ + InvokeExtensionFn - Calls a lifecycle hook on all active extensions. + + Parameters: + - component: The component instance + - fnName: Name of the extension function to call (e.g., "Constructing", "Starting") + + Note: This function may yield if any extension function yields. + The caller is responsible for state validation after calling this. +]] local function InvokeExtensionFn(component, fnName: string) for _, extension in ipairs(component[KEY_ACTIVE_EXTENSIONS]) do local fn = extension[fnName] @@ -261,6 +456,15 @@ local function InvokeExtensionFn(component, fnName: string) end end +--[[ + ShouldConstruct - Checks if all extensions allow construction. + + Returns false if ANY extension's ShouldConstruct returns false. + Returns true if all extensions allow construction (or have no ShouldConstruct). + + This is called BEFORE any component state is created, so returning false + means no cleanup is needed. +]] local function ShouldConstruct(component): boolean for _, extension in ipairs(component[KEY_ACTIVE_EXTENSIONS]) do local fn = extension.ShouldConstruct @@ -274,7 +478,25 @@ local function ShouldConstruct(component): boolean return true end --- Handles which extensions should be applied and in what order. +--[[ + GetActiveExtensions - Determines which extensions apply to a component. + + Parameters: + - component: The component instance or class + - extensionList: Array of extensions to process + - activeExtensions: Accumulator array (for recursion) + - isClass: If true, don't call ShouldExtend (determining class-level extensions) + + Extension Processing: + 1. For each extension, checks if it should be applied + 2. If extension is already in activeExtensions, moves it to front (priority) + 3. If not present and should extend, adds to end + 4. Recursively processes extension.Extensions for nested extensions + 5. Final pass removes extensions whose ShouldExtend returned false + + The recursion handling ensures nested extensions are processed and that + extension dependencies are properly ordered (dependencies come first). +]] local function GetActiveExtensions(component, extensionList, activeExtensions, isClass) activeExtensions = activeExtensions or {} extensionList = extensionList or {} @@ -305,19 +527,27 @@ local function GetActiveExtensions(component, extensionList, activeExtensions, i end if not isClass then - for i = #activeExtensions, 1, -1 do - local extension = activeExtensions[i] - local fn = extension.ShouldExtend - if type(fn) == "function" and not fn(component) then - table.remove(activeExtensions, i) - end - end - end + for i = #activeExtensions, 1, -1 do + local extension = activeExtensions[i] + local fn = extension.ShouldExtend + if type(fn) == "function" and not fn(component) then + table.remove(activeExtensions, i) + end + end + end return activeExtensions end --- Added by Raildex +--[[ + BindExtensionMethod - Adds methods from an extension to a component. + + Methods are added directly to the component table, making them callable + as component:MethodName(). This happens at the CLASS level, not instance + level, so methods are available regardless of ShouldExtend results. + + Errors if a method value is not a function (catches configuration mistakes). +]] local function BindExtensionMethod(component, extension) if extension.Methods then for key, value in extension.Methods do @@ -330,29 +560,16 @@ local function BindExtensionMethod(component, extension) end end +--[[ + BindExtensionMethods - Binds methods from all extensions in a list. + Wrapper that calls BindExtensionMethod for each extension. +]] local function BindExtensionMethods(component, extensionList) for _, extension in ipairs(extensionList) do BindExtensionMethod(component, extension) end end ---[=[ - @class Component - - This is a fork of the original Component module by Stephen Leitnick. This fork expands upon the functionality of - extensions and provides several new useful methods. - - - Bind components to Roblox instances using the Component class and CollectionService tags. - - To avoid confusion of terms: - - `Component` refers to this module. - - `Component Class` (e.g. `MyComponent` through this documentation) refers to a class created via `Component.new` - - `Component Instance` refers to an instance of a component class. - - `Roblox Instance` refers to the Roblox instance to which the component instance is bound. - - Methods and properties are tagged with the above terms to help clarify the level at which they are used. -]=] local Component = {} Component.__index = Component @@ -389,7 +606,6 @@ Component.DelaySetup = script:GetAttribute("DelaySetup") or false ``` ]=] - --[=[ @tag Component @param config ComponentConfig @@ -484,31 +700,189 @@ end end ``` ]=] -function Component.getUnsetupComponents(): {ComponentClass} +function Component.getUnsetupComponents(): { ComponentClass } return table.clone(UNSETUP_COMPONENTS) :: any end +function Component:_isInAncestorList(instance: Instance): boolean + for _, parent in ipairs(self[KEY_ANCESTORS] :: { Instance }) do + if instance:IsDescendantOf(parent) then + return true + end + end + return false +end -function Component:_instantiate(instance: Instance) +--[=[ + @private + @within Component + @param instance Instance -- The Roblox instance to create a component for + @param constructId number? -- Optional ID to track this construction attempt for cancellation + @return Component? -- The constructed component, or nil if construction failed/was cancelled + + Creates a new component instance bound to the given Roblox instance. + + ## Edge Case Handling + + ### State Validation After Yields + After each potential yield point (extension calls, Construct), the system validates: + - The instance is still within valid ancestors + - No newer construction attempt has superseded this one + - Construction wasn't cancelled due to ancestry change + + ### Ancestry Change During Construction + If the instance moves outside valid ancestors during construction: + - An ancestry change listener detects this immediately + - The construction thread is cancelled if suspended + - A warning is logged with the component tag and instance path + - Any partial state is cleaned up via Stop() + + ### Construction ID Tracking + The `constructId` parameter allows the system to detect when a newer construction + attempt should supersede the current one. This handles rapid reparenting scenarios + where an instance might move in/out of valid ancestors multiple times. + + ### Error Handling + All construction logic is wrapped in pcall. If an error occurs: + - The error is logged with context (tag, instance path, error message) + - Stop() is called to clean up any partial state + - nil is returned so the component isn't tracked + + ### ShouldConstruct Failure + If ShouldConstruct returns false, construction stops cleanly without calling Stop() + since no component state was created yet. +]=] +function Component:_instantiate(instance: Instance, constructId: number?) local component = setmetatable({}, self) component.Instance = instance - component[KEY_ACTIVE_EXTENSIONS] = GetActiveExtensions(component, self[KEY_EXTENSIONS], table.clone(self[KEY_CLASS_ACTIVE_EXTENSIONS] :: any)) + component[KEY_ACTIVE_EXTENSIONS] = + GetActiveExtensions(component, self[KEY_EXTENSIONS], table.clone(self[KEY_CLASS_ACTIVE_EXTENSIONS] :: any)) for _, extension in ipairs(component[KEY_ACTIVE_EXTENSIONS]) do if not table.find(self[KEY_CLASS_ACTIVE_EXTENSIONS], extension) then BindExtensionMethod(component, extension) end end - if not ShouldConstruct(component) then + -- Track if construction was cancelled due to ancestry change + local constructionCancelled = false + local constructionThread: thread? = nil + + -- Listen for ancestry changes during construction to cancel if instance becomes invalid + local ancestryConnection: RBXScriptConnection? + ancestryConnection = RailUtil.Signal + .combine({ + instance.AncestryChanged, + self.AncestorsChanged, + }) + :Connect(function() + if not self:_isInAncestorList(instance) then + warn( + string.format( + "[Component] Construction cancelled for '%s' on '%s' due to ancestry change.", + self.Tag, + instance:GetFullName() + ) + ) + constructionCancelled = true + if ancestryConnection then + ancestryConnection:Disconnect() + ancestryConnection = nil + end + -- Cancel the construction thread if it's yielding + if constructionThread and coroutine.status(constructionThread) == "suspended" then + task.cancel(constructionThread) + end + end + end) + + -- Helper to validate that the component is still in a valid state after potential yields + local function validateState(): boolean + if constructionCancelled then + return false + end + -- Check if instance moved outside valid ancestors + if not self:_isInAncestorList(instance) then + return false + end + -- Check if a newer construct attempt has superseded this one + if constructId and self[KEY_LOCK_CONSTRUCT][instance] ~= constructId then + return false + end + return true + end + + -- Cleanup helper for when construction fails or is cancelled + local function cleanup() + if ancestryConnection then + ancestryConnection:Disconnect() + ancestryConnection = nil + end + end + + -- Track if construction actually started (past ShouldConstruct) + local constructionStarted = false + + local success, result = pcall(function() + constructionThread = coroutine.running() + + if not ShouldConstruct(component) then + return nil + end + + if not validateState() then + return nil + end + + constructionStarted = true + InvokeExtensionFn(component, "Constructing") + + if not validateState() then + return nil + end + + if type(component.Construct) == "function" then + component:Construct() + end + + if not validateState() then + return nil + end + + InvokeExtensionFn(component, "Constructed") + + if not validateState() then + return nil + end + + return component + end) + + cleanup() + + -- Handle error during construction + if not success then + warn( + string.format( + "[Component] Error during instantiation of '%s' on '%s': %s", + self.Tag, + instance:GetFullName(), + tostring(result) + ) + ) + + component:Stop() + return nil end - InvokeExtensionFn(component, "Constructing") - if type(component.Construct) == "function" then - component:Construct() + + -- Handle nil result (construction was cancelled or ShouldConstruct returned false) + if result == nil and constructionStarted then + -- Construction started but was cancelled mid-way, clean up partial state + component:Stop() end - InvokeExtensionFn(component, "Constructed") - return component + + return result end --[=[ @@ -520,6 +894,54 @@ end It is automatically called when the component class is created, unless the `DelaySetup` option is set to `true` in the component configuration. If `DelaySetup` is `true`, then this method must be called manually. + + ## Internal Functions + + ### StartComponent + Starts a fully constructed component: + - Sets KEY_STARTING to the current thread for cancellation tracking + - Calls extension Starting hooks, Start(), and Started hooks + - After each call, checks if KEY_STARTING was set to nil (component was stopped) + - If stopped mid-start, returns early without setting up update loops + - Connects HeartbeatUpdate, SteppedUpdate, and RenderSteppedUpdate if present + - Sets KEY_STARTED to true and fires the Started signal + + ### StopComponent + Stops a running or starting component: + - If KEY_STARTING is set (component is mid-start): + - Gets the start thread reference + - Sets KEY_STARTING to nil to signal cancellation + - If the start thread is suspended and not the current thread, cancels it + - If the start thread is in "normal" status, defers cancellation + - Disconnects all update loop connections + - Calls extension Stopping hooks, Stop(), and Stopped hooks + - Fires the Stopped signal + + ### SafeConstruct + Wrapper around _instantiate that handles superseded construction: + - Checks if the construction ID is still current before calling _instantiate + - Checks again after _instantiate returns + - If superseded, cleans up the component (if one was returned) and returns nil + - Note: _instantiate already handles cleanup for cancelled/failed constructions + + ### TryConstructComponent + Attempts to construct a component for an instance: + - Skips if a component already exists for the instance + - Increments the construction ID to track this attempt + - Defers construction to allow batching and avoid blocking + - On success, tracks the component and defers starting it + + ### TryDeconstructComponent + Stops and removes a component for an instance: + - Removes the component from tracking tables + - Clears the construction lock (important for preventing stale locks) + - Spawns StopComponent if the component was started or starting + + ### StartWatchingInstance / InstanceTagged / InstanceUntagged + Manages the per-instance ancestry watching: + - StartWatchingInstance sets up a combined signal for AncestryChanged and AncestorsChanged + - When ancestry changes, evaluates if component should be constructed or deconstructed + - InstanceUntagged removes the watch and deconstructs the component ]=] function Component:_setup() local idx = table.find(UNSETUP_COMPONENTS, self) @@ -528,25 +950,51 @@ function Component:_setup() else warn(self, ":_setup was already called for this component.") end - + + -- Tracks instances being watched for ancestry changes + -- Key: Instance, Value: RBXScriptConnection for the ancestry listener + -- Cleaned up when: instance is untagged or component class is destroyed local watchingInstances = {} self[KEY_CLASS_ACTIVE_EXTENSIONS] = GetActiveExtensions(self, self[KEY_EXTENSIONS], {}, true) BindExtensionMethods(self, self[KEY_CLASS_ACTIVE_EXTENSIONS]) -- Added by Raildex + --[[ + StartComponent - Starts a fully constructed component instance. + + Edge Cases Handled: + - Stop called during Starting extension: KEY_STARTING becomes nil, returns early + - Stop called during Start(): KEY_STARTING becomes nil, returns early + - Stop called during Started extension: KEY_STARTING becomes nil, returns early + - Yielding in any hook: State is checked after each potential yield point + + Thread tracking via KEY_STARTING allows StopComponent to cancel the start + thread if the component needs to be stopped while still starting. + ]] local function StartComponent(component) component[KEY_STARTING] = coroutine.running() InvokeExtensionFn(component, "Starting") + -- Check if component was stopped during Starting extension + if component[KEY_STARTING] == nil then + return + end + component:Start() + + -- Check if component was stopped during Start method if component[KEY_STARTING] == nil then - -- Component's Start method stopped the component return end InvokeExtensionFn(component, "Started") + -- Check if component was stopped during Started extension + if component[KEY_STARTING] == nil then + return + end + local hasHeartbeatUpdate = typeof(component.HeartbeatUpdate) == "function" local hasSteppedUpdate = typeof(component.SteppedUpdate) == "function" local hasRenderSteppedUpdate = typeof(component.RenderSteppedUpdate) == "function" @@ -582,22 +1030,54 @@ function Component:_setup() self.Started:Fire(component) end + --[[ + StopComponent - Stops a component, handling both fully started and mid-start cases. + + Thread Cancellation Edge Cases: + - If component is mid-start (KEY_STARTING set): + - Captures the start thread reference before clearing KEY_STARTING + - Clears KEY_STARTING first to signal cancellation to StartComponent + - Thread cancellation depends on state: + * "suspended": Cancel immediately (thread is yielding) + * "normal": Thread is in call stack, defer cancellation + * "running": This is the current thread, don't cancel (would error) + - Uses pcall around task.cancel as the thread may have already finished + + - If KEY_STARTING is nil: + - Component either fully started or never started + - Just clean up connections and call Stop() + + Connection Cleanup: + - Disconnects HeartbeatUpdate, SteppedUpdate connections if they exist + - Unbinds RenderStepped if using BindToRenderStep, or disconnects if using Connect + + Extension Hooks: + - Stopping hook called before Stop() + - Stopped hook called after Stop() + - These are called even if the component was stopped mid-start + ]] local function StopComponent(component) if component[KEY_STARTING] then -- Stop the component during its start method invocation: local startThread = component[KEY_STARTING] :: thread - if coroutine.status(startThread) ~= "normal" then - pcall(function() - task.cancel(startThread) - end) - else - task.defer(function() - pcall(function() - task.cancel(startThread) + local currentThread = coroutine.running() + component[KEY_STARTING] = nil + + -- Only cancel if we're not currently running in that thread + if startThread ~= currentThread then + if coroutine.status(startThread) == "suspended" then + pcall(task.cancel, startThread) + elseif coroutine.status(startThread) == "normal" then + -- Thread is in the call stack but not the current one, defer cancellation + task.defer(function() + if coroutine.status(startThread) == "suspended" then + pcall(task.cancel, startThread) + end end) - end) + end end - component[KEY_STARTING] = nil + -- If we are in the same thread, we don't cancel - just let it return naturally + -- The KEY_STARTING = nil check in StartComponent will handle this case end if component._heartbeatUpdate then @@ -620,17 +1100,55 @@ function Component:_setup() self.Stopped:Fire(component) end + --[[ + SafeConstruct - Wrapper that handles superseded construction attempts. + + Returns nil if: + - Construction ID doesn't match BEFORE calling _instantiate (stale attempt) + - Construction ID doesn't match AFTER _instantiate returns (superseded during construction) + - _instantiate itself returned nil (cancelled/failed/ShouldConstruct false) + + Cleanup Note: + - _instantiate handles its own cleanup when returning nil (calls Stop() on partial components) + - SafeConstruct only needs to call Stop() if _instantiate returned a valid component + but the ID was superseded during construction + ]] local function SafeConstruct(instance, id) if self[KEY_LOCK_CONSTRUCT][instance] ~= id then return nil end - local component = self:_instantiate(instance) + local component = self:_instantiate(instance, id) if self[KEY_LOCK_CONSTRUCT][instance] ~= id then + -- Construction was superseded by a newer attempt + -- Note: _instantiate already handles cleanup via component:Stop() when returning nil, + -- so we only need to clean up if a valid component was returned but is now stale + if component then + -- Component was successfully constructed but is now stale, need to clean up + component:Stop() + end return nil end return component end + --[[ + TryConstructComponent - Attempts to construct a component for a tagged instance. + + Construction ID System: + - Each construction attempt gets a unique, incrementing ID + - ID is stored in KEY_LOCK_CONSTRUCT[instance] + - SafeConstruct and _instantiate check this ID to detect superseded attempts + - This handles rapid reparenting where instance moves in/out of valid ancestors + + Deferred Execution: + - Construction is deferred via task.defer to avoid blocking + - Starting is also deferred after construction completes + - This allows multiple instances to be batched and processed efficiently + + Double-Check Pattern: + - Before starting, verifies the component is still the active one + - Protects against race conditions where the component was replaced + ]] local function TryConstructComponent(instance) if self[KEY_INST_TO_COMPONENTS][instance] then return @@ -653,8 +1171,26 @@ function Component:_setup() end) end + --[[ + TryDeconstructComponent - Removes and stops a component for an instance. + + Cleanup Order: + 1. Remove from KEY_INST_TO_COMPONENTS (prevents new lookups) + 2. Clear KEY_LOCK_CONSTRUCT (prevents stale construction locks) + 3. Remove from KEY_COMPONENTS array (uses swap-remove for O(1)) + 4. Spawn StopComponent if component was started/starting + + Important: KEY_LOCK_CONSTRUCT is cleared even if no component exists. + This handles the case where construction is in progress but not complete, + preventing the stale lock from blocking future construction attempts. + + Note: Uses task.spawn for StopComponent to avoid blocking and allow + the calling code to continue (important for batch operations). + ]] local function TryDeconstructComponent(instance) local component = self[KEY_INST_TO_COMPONENTS][instance] + -- Always clear the construction lock, even if no component exists yet. + -- This handles cases where construction was started but not completed. if not component then return end @@ -663,6 +1199,7 @@ function Component:_setup() local components = self[KEY_COMPONENTS] :: table local index = table.find(components, component) if index then + -- Swap-remove for O(1) removal from unordered array local n = #components components[index] = components[n] components[n] = nil @@ -672,38 +1209,66 @@ function Component:_setup() end end + --[[ + StartWatchingInstance - Sets up ancestry monitoring for a tagged instance. + + Combined Signal: + - Listens to both instance.AncestryChanged and self.AncestorsChanged + - This means components respond to BOTH instance movement AND ancestor list changes + - Uses RailUtil.Signal.combine for efficient combined listening + + Ancestry Evaluation: + - On any ancestry change, checks if instance is in valid ancestor list + - If valid: TryConstructComponent (may already exist, that's handled) + - If invalid: TryDeconstructComponent (cleans up if exists) + + Memory Management: + - Connection stored in watchingInstances table + - Connection added to component class Trove for automatic cleanup on Destroy + - InstanceUntagged explicitly removes from watchingInstances and Trove + ]] local function StartWatchingInstance(instance) if watchingInstances[instance] then return end - local function IsInAncestorList(): boolean - for _, parent in ipairs(self[KEY_ANCESTORS] :: {Instance}) do - if instance:IsDescendantOf(parent) then - return true + + local ancestryChangedHandle = self[KEY_TROVE]:Connect( + RailUtil.Signal.combine { + instance.AncestryChanged, + self.AncestorsChanged, + }, + function(_, parent) + if parent and self:_isInAncestorList(instance) then + TryConstructComponent(instance) + else + TryDeconstructComponent(instance) end end - return false - end - local ancestryChangedHandle = self[KEY_TROVE]:Connect(RailUtil.Signal.combine({ - instance.AncestryChanged, - self.AncestorsChanged, - }), function(_, parent) - if parent and IsInAncestorList() then - TryConstructComponent(instance) - else - TryDeconstructComponent(instance) - end - end) + ) watchingInstances[instance] = ancestryChangedHandle - if IsInAncestorList() then + if self:_isInAncestorList(instance) then TryConstructComponent(instance) end end + --[[ + InstanceTagged - Called when CollectionService detects a new tagged instance. + Simply starts watching the instance for ancestry changes. + ]] local function InstanceTagged(instance: Instance) StartWatchingInstance(instance) end + --[[ + InstanceUntagged - Called when CollectionService detects tag removal. + + Cleanup: + 1. Removes ancestry watching connection from watchingInstances + 2. Removes connection from Trove (prevents double-disconnect on Destroy) + 3. Deconstructs any existing component + + This is the primary cleanup path for normal component removal. + ]] local function InstanceUntagged(instance: Instance) local watchHandle = watchingInstances[instance] if watchHandle then @@ -713,9 +1278,13 @@ function Component:_setup() TryDeconstructComponent(instance) end + -- Connect to CollectionService for tag add/remove events + -- These connections are stored in the Trove for cleanup on Destroy self[KEY_TROVE]:Connect(CollectionService:GetInstanceAddedSignal(self.Tag), InstanceTagged) self[KEY_TROVE]:Connect(CollectionService:GetInstanceRemovedSignal(self.Tag), InstanceUntagged) + -- Process all instances that already have the tag + -- Deferred to avoid blocking and allow batching local tagged = CollectionService:GetTagged(self.Tag) for _, instance in ipairs(tagged) do task.defer(InstanceTagged, instance) @@ -817,7 +1386,7 @@ end end) ``` ]=] -function Component:UpdateAncestors(newAncestors: {Instance}) +function Component:UpdateAncestors(newAncestors: { Instance }) local lastAncestors = self[KEY_ANCESTORS] self[KEY_ANCESTORS] = newAncestors self.AncestorsChanged:Fire(newAncestors, lastAncestors) @@ -828,7 +1397,7 @@ end Gets the current valid ancestors of a component class. ]=] -function Component:GetAncestors(): {Instance} +function Component:GetAncestors(): { Instance } return table.clone(self[KEY_ANCESTORS]) end @@ -906,7 +1475,6 @@ function Component:GetComponent(componentClass) return componentClass[KEY_INST_TO_COMPONENTS][self.Instance] end - --[=[ @tag Component Instance @return Connection @@ -933,6 +1501,11 @@ end ``` ]=] function Component:WhileHasComponent(componentClass: ComponentClass, fn: (component: Component, jani: Janitor) -> ()) + if not componentClass.Tag and componentClass[1] and componentClass[1].Tag then + error( + "Component:WhileHasComponent() called with an array of component classes. Did you mean to call WhileHasComponents() instead?" + ) + end local bindJani = Janitor.new() local connProxy = {} @@ -947,7 +1520,7 @@ function Component:WhileHasComponent(componentClass: ComponentClass, fn: (compon setmetatable(connProxy, { __call = function(t, ...) return t.Destroy(...) - end + end, }) bindJani:Add(connProxy) @@ -955,15 +1528,20 @@ function Component:WhileHasComponent(componentClass: ComponentClass, fn: (compon return c.Instance == self.Instance end):andThen(connProxy.Destroy)) - assert(typeof(componentClass) == "table" and componentClass.Tag, "Component:WhileHasComponent() expects a component class.") + assert( + typeof(componentClass) == "table" and componentClass.Tag, + "Component:WhileHasComponent() expects a component class." + ) -- Track janitor for the component local activeJanitor = nil local function SetupIfPresent() local component = self:GetComponent(componentClass) - if not component or activeJanitor then return end - + if not component or activeJanitor then + return + end + activeJanitor = bindJani:Add(Janitor.new(), "Destroy") activeJanitor:Add(task.spawn(fn, component, activeJanitor)) @@ -1019,7 +1597,10 @@ end end ``` ]=] -function Component:WhileHasComponents(componentClasses: {ComponentClass}, fn: (components: {Component}, jani: Janitor) -> ()) +function Component:WhileHasComponents( + componentClasses: { ComponentClass }, + fn: (components: { Component }, jani: Janitor) -> () +) local bindJani = Janitor.new() local connProxy = {} @@ -1034,7 +1615,7 @@ function Component:WhileHasComponents(componentClasses: {ComponentClass}, fn: (c setmetatable(connProxy, { __call = function(t, ...) return t.Destroy(...) - end + end, }) bindJani:Add(connProxy) @@ -1062,8 +1643,10 @@ function Component:WhileHasComponents(componentClasses: {ComponentClass}, fn: (c local function SetupIfAllPresent() local components = getAllComponents() - if not components or activeJanitor then return end - + if not components or activeJanitor then + return + end + activeJanitor = bindJani:Add(Janitor.new(), "Destroy") activeJanitor:Add(task.spawn(fn, components, activeJanitor)) @@ -1097,7 +1680,9 @@ end -- DEPRECATED: Use WhileHasComponent or WhileHasComponents instead. Kept for backwards compat function Component:ForEachSibling(...) - warn("ForEachSibling is deprecated. Use WhileHasComponent for single components or WhileHasComponents for multiple components instead.") + warn( + "ForEachSibling is deprecated. Use WhileHasComponent for single components or WhileHasComponents for multiple components instead." + ) -- For backwards compatibility, try to detect if it's multiple components and route appropriately local componentClassOrClasses = ... if componentClassOrClasses and typeof(componentClassOrClasses) == "table" and not componentClassOrClasses.Tag then @@ -1107,7 +1692,6 @@ function Component:ForEachSibling(...) end end - --[=[ @tag Component Class @function HeartbeatUpdate @@ -1189,11 +1773,60 @@ end ``` ]=] +--[=[ + @tag Component Class + @within Component + @private + + Destroys the component class, stopping all active components and cleaning up all resources. + + ## Cleanup Process + + 1. Removes from UNSETUP_COMPONENTS if present + 2. Stops all active components (those with KEY_STARTED or KEY_STARTING set) + 3. Clears all tracking tables: + - KEY_INST_TO_COMPONENTS (instance -> component mapping) + - KEY_COMPONENTS (array of all components) + - KEY_LOCK_CONSTRUCT (construction attempt IDs) + 4. Destroys the Trove, which: + - Disconnects CollectionService tag signals + - Disconnects all ancestry watching connections + - Cleans up Started, Stopped, and AncestorsChanged signals + + ## Memory Leak Prevention + + This method ensures no references to component instances or Roblox instances + are retained after destruction. All internal tables are cleared, and the Trove + pattern ensures all connections are properly disconnected. + + ## Usage + + ```lua + local MyComponent = Component.new({Tag = "MyComponent"}) + -- ... later when you want to completely disable this component class ... + MyComponent:Destroy() + ``` +]=] function Component:Destroy() local idx = table.find(UNSETUP_COMPONENTS, self) if idx then table.remove(UNSETUP_COMPONENTS, idx) end + + -- Clear component tracking tables to prevent memory leaks + -- Stop all active components first + for _, component in pairs(self[KEY_INST_TO_COMPONENTS]) do + if component[KEY_STARTED] or component[KEY_STARTING] then + task.spawn(component.Stop, component) + end + end + + -- Clear all tracking tables + table.clear(self[KEY_INST_TO_COMPONENTS]) + table.clear(self[KEY_COMPONENTS]) + table.clear(self[KEY_LOCK_CONSTRUCT]) + + -- Destroy the trove which will clean up all connections self[KEY_TROVE]:Destroy() end From d6d72759e49b143404642587e8cf456d4ab54aae Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Mon, 1 Dec 2025 20:19:22 -0500 Subject: [PATCH 03/19] Improve component construction error handling and validation Enhanced error messages during component construction and added warnings for construction cancellation. Improved validation in WhileHasComponents to ensure all elements are valid component classes. Adjusted cleanup order in Destroy and TryDeconstructComponent to ensure construction locks are always cleared. --- lib/component/src/init.luau | 60 ++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index d3ceb026..5d817a98 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -20,7 +20,9 @@ - `Roblox Instance` refers to the Roblox instance to which the component instance is bound. Methods and properties are tagged with the above terms to help clarify the level at which they are used. +]=] +--[[ ## Lifecycle The component lifecycle follows this order: @@ -169,7 +171,7 @@ The `ShouldConstruct` function is called before any construction begins. ALL extensions with a `ShouldConstruct` function must return `true` for construction to proceed. If any returns `false`, no component is created and no cleanup is needed. -]=] +]] type AncestorList = { Instance } @@ -240,7 +242,7 @@ type ExtensionShouldFn = (any) -> boolean local player, an extension might look like this, assuming the instance has an attribute linking it to the player's UserId: ```lua - local Players = game:GetService("Players"). + local Players = game:GetService("Players") local OnlyLocalPlayer = {} function OnlyLocalPlayer.ShouldConstruct(component) @@ -862,23 +864,30 @@ function Component:_instantiate(instance: Instance, constructId: number?) -- Handle error during construction if not success then - warn( - string.format( - "[Component] Error during instantiation of '%s' on '%s': %s", - self.Tag, - instance:GetFullName(), - tostring(result) + if not constructionCancelled then + warn( + string.format( + "[Component] Error during construction of '%s' on '%s':\n%s", + self.Tag, + instance:GetFullName(), + tostring(result) + ) ) - ) - + end component:Stop() - return nil end - -- Handle nil result (construction was cancelled or ShouldConstruct returned false) + -- Handle nil result (construction was cancelled midway) if result == nil and constructionStarted then -- Construction started but was cancelled mid-way, clean up partial state + warn( + string.format( + "[Component] Construction cancelled for '%s' on '%s'. Running Stop now", + self.Tag, + instance:GetFullName() + ) + ) component:Stop() end @@ -1188,14 +1197,12 @@ function Component:_setup() the calling code to continue (important for batch operations). ]] local function TryDeconstructComponent(instance) + self[KEY_LOCK_CONSTRUCT][instance] = nil local component = self[KEY_INST_TO_COMPONENTS][instance] - -- Always clear the construction lock, even if no component exists yet. - -- This handles cases where construction was started but not completed. if not component then return end self[KEY_INST_TO_COMPONENTS][instance] = nil - self[KEY_LOCK_CONSTRUCT][instance] = nil local components = self[KEY_COMPONENTS] :: table local index = table.find(components, component) if index then @@ -1623,7 +1630,10 @@ function Component:WhileHasComponents( return c.Instance == self.Instance end):andThen(connProxy.Destroy)) - assert(typeof(componentClasses) == "table", "Component:WhileHasComponents() expects an array of component classes.") + assert(typeof(componentClasses) == "table", "Component:WhileHasComponents() expects a non-empty array of component classes.") + for _, class in ipairs(componentClasses) do + assert(typeof(class) == "table" and class.Tag, "Component:WhileHasComponents() expects all elements to be component classes.") + end -- Helper to get all component instances for self.Instance local function getAllComponents() @@ -1649,17 +1659,18 @@ function Component:WhileHasComponents( activeJanitor = bindJani:Add(Janitor.new(), "Destroy") activeJanitor:Add(task.spawn(fn, components, activeJanitor)) + local function cleanupJanitor() + if activeJanitor then + activeJanitor:Destroy() + activeJanitor = nil + end + end -- If any component stops, destroy janitor for _, class in ipairs(componentClasses) do activeJanitor:AddPromise(Promise.fromEvent(class.Stopped, function(c) return c.Instance == self.Instance - end):andThen(function() - if activeJanitor then - activeJanitor:Destroy() - activeJanitor = nil - end - end)) + end):andThen(cleanupJanitor)) end end @@ -1821,13 +1832,14 @@ function Component:Destroy() end end + -- Destroy the trove which will clean up all connections + self[KEY_TROVE]:Destroy() + -- Clear all tracking tables table.clear(self[KEY_INST_TO_COMPONENTS]) table.clear(self[KEY_COMPONENTS]) table.clear(self[KEY_LOCK_CONSTRUCT]) - -- Destroy the trove which will clean up all connections - self[KEY_TROVE]:Destroy() end return Component From d1b4e84476e7e76577d4edbec8651e782063a487 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Wed, 3 Dec 2025 13:46:54 -0500 Subject: [PATCH 04/19] Refactor component construction to use cancellable Promise Rewrote the component construction flow in Component:_instantiate to use a cancellable Promise, improving handling of ancestry changes and construction cancellation. Added detailed phase comments and improved error handling and cleanup logic. Also added and clarified assertions in WhileHasComponent, WhileHasComponents, and ForEachSibling for better API usage validation. Minor doc and formatting improvements throughout. --- lib/component/src/init.luau | 287 +++++++++++++++++------------------- 1 file changed, 138 insertions(+), 149 deletions(-) diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index 5d817a98..21f468f0 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -706,6 +706,15 @@ function Component.getUnsetupComponents(): { ComponentClass } return table.clone(UNSETUP_COMPONENTS) :: any end +--[=[ + @private + @within Component + @param instance Instance -- The Roblox instance to check + @return boolean -- True if the instance is within any valid ancestor, false otherwise + + Checks if the given instance is a descendant of any of the valid ancestors + for this component class. +]=] function Component:_isInAncestorList(instance: Instance): boolean for _, parent in ipairs(self[KEY_ANCESTORS] :: { Instance }) do if instance:IsDescendantOf(parent) then @@ -724,40 +733,35 @@ end Creates a new component instance bound to the given Roblox instance. - ## Edge Case Handling + ## Construction Flow (Promise-based) + + Construction is wrapped in a Promise that can be cancelled if: + - The instance moves outside valid ancestors (ancestry change) + - A newer construction attempt supersedes this one (constructId mismatch) + - ### State Validation After Yields - After each potential yield point (extension calls, Construct), the system validates: - - The instance is still within valid ancestors - - No newer construction attempt has superseded this one - - Construction wasn't cancelled due to ancestry change + ## Phases + + 1. **Setup**: Create component, bind extensions + 2. **Validation**: Check ShouldConstruct (early exit, no cleanup needed) + 3. **Construction**: Run Constructing → Construct() → Constructed hooks + 4. **Finalization**: Return component or handle failure - ### Ancestry Change During Construction - If the instance moves outside valid ancestors during construction: - - An ancestry change listener detects this immediately - - The construction thread is cancelled if suspended - - A warning is logged with the component tag and instance path - - Any partial state is cleaned up via Stop() - - ### Construction ID Tracking - The `constructId` parameter allows the system to detect when a newer construction - attempt should supersede the current one. This handles rapid reparenting scenarios - where an instance might move in/out of valid ancestors multiple times. - - ### Error Handling - All construction logic is wrapped in pcall. If an error occurs: - - The error is logged with context (tag, instance path, error message) - - Stop() is called to clean up any partial state - - nil is returned so the component isn't tracked - - ### ShouldConstruct Failure - If ShouldConstruct returns false, construction stops cleanly without calling Stop() - since no component state was created yet. + ## Error Handling + + Errors during construction are caught, logged, and result in Stop() being + called to clean up any partial state. ]=] function Component:_instantiate(instance: Instance, constructId: number?) + -- ══════════════════════════════════════════════════════════════════════ + -- PHASE 1: Setup + -- Create the component instance and bind extensions + -- ══════════════════════════════════════════════════════════════════════ + local component = setmetatable({}, self) component.Instance = instance + -- Bind extensions component[KEY_ACTIVE_EXTENSIONS] = GetActiveExtensions(component, self[KEY_EXTENSIONS], table.clone(self[KEY_CLASS_ACTIVE_EXTENSIONS] :: any)) for _, extension in ipairs(component[KEY_ACTIVE_EXTENSIONS]) do @@ -766,129 +770,132 @@ function Component:_instantiate(instance: Instance, constructId: number?) end end - -- Track if construction was cancelled due to ancestry change - local constructionCancelled = false - local constructionThread: thread? = nil + -- ══════════════════════════════════════════════════════════════════════ + -- PHASE 2: Validation (ShouldConstruct) + -- If any extension vetoes construction, exit early with no cleanup needed + -- ══════════════════════════════════════════════════════════════════════ + + if not ShouldConstruct(component) then + return nil + end - -- Listen for ancestry changes during construction to cancel if instance becomes invalid - local ancestryConnection: RBXScriptConnection? - ancestryConnection = RailUtil.Signal - .combine({ - instance.AncestryChanged, - self.AncestorsChanged, - }) - :Connect(function() - if not self:_isInAncestorList(instance) then - warn( - string.format( - "[Component] Construction cancelled for '%s' on '%s' due to ancestry change.", - self.Tag, - instance:GetFullName() - ) - ) - constructionCancelled = true - if ancestryConnection then - ancestryConnection:Disconnect() - ancestryConnection = nil - end - -- Cancel the construction thread if it's yielding - if constructionThread and coroutine.status(constructionThread) == "suspended" then - task.cancel(constructionThread) - end - end - end) + -- ══════════════════════════════════════════════════════════════════════ + -- PHASE 3: Construction (Promise-based) + -- Wrap construction in a cancellable Promise for clean cancellation handling + -- ══════════════════════════════════════════════════════════════════════ - -- Helper to validate that the component is still in a valid state after potential yields - local function validateState(): boolean - if constructionCancelled then - return false - end - -- Check if instance moved outside valid ancestors + -- Helper: Check if construction should continue + local function shouldContinue(): boolean if not self:_isInAncestorList(instance) then return false end - -- Check if a newer construct attempt has superseded this one if constructId and self[KEY_LOCK_CONSTRUCT][instance] ~= constructId then return false end return true end - -- Cleanup helper for when construction fails or is cancelled - local function cleanup() - if ancestryConnection then - ancestryConnection:Disconnect() - ancestryConnection = nil - end - end - - -- Track if construction actually started (past ShouldConstruct) - local constructionStarted = false - local success, result = pcall(function() - constructionThread = coroutine.running() + local enteredConstruction = false + local didReject = false - if not ShouldConstruct(component) then - return nil + local constructionPromise = Promise.new(function(resolve, reject) + if not shouldContinue() then + reject("Invalid Post-ShouldConstruct") + return end - if not validateState() then - return nil - end + -- Mark that we're entering construction - from this point, cleanup is needed on failure + enteredConstruction = true - constructionStarted = true + -- Constructing hook (extensions) InvokeExtensionFn(component, "Constructing") - - if not validateState() then - return nil + if not shouldContinue() then + reject("Invalid Post-Constructing") + return end + -- User's Construct method if type(component.Construct) == "function" then component:Construct() end - - if not validateState() then - return nil + if not shouldContinue() then + reject("Invalid Post-Construct") + return end + -- Constructed hook (extensions) InvokeExtensionFn(component, "Constructed") - - if not validateState() then - return nil + if not shouldContinue() then + reject("Invalid Post-Constructed") + return end - return component + -- Success! + resolve(component) + end) + :catch(function(err) + -- Log error + didReject = true + warn(string.format( + "[Component] Error during construction of '%s' on '%s':\n%s", + self.Tag, + instance:GetFullName(), + tostring(err) + )) end) - cleanup() - - -- Handle error during construction - if not success then - if not constructionCancelled then - warn( - string.format( - "[Component] Error during construction of '%s' on '%s':\n%s", + -- Set up ancestry change listener to cancel construction if instance becomes invalid + local ancestryConnection = RailUtil.Signal + .combine({ + instance.AncestryChanged, + self.AncestorsChanged, + }) + :Connect(function() + if not self:_isInAncestorList(instance) then + warn(string.format( + "[Component] Construction of '%s' on '%s' cancelled due to reparenting outside valid ancestors.", self.Tag, - instance:GetFullName(), - tostring(result) - ) - ) + instance:GetFullName() + )) + constructionPromise:cancel() + end + end) + + -- Clean up when promise settles (success, failure, or cancel) + constructionPromise:finally(function(status) + ancestryConnection:Disconnect() + + -- Clean up partial component state if not resolved successfully + if status ~= Promise.Status.Resolved or didReject then + if enteredConstruction then + InvokeExtensionFn(component, "Stopping") + component:Stop() + InvokeExtensionFn(component, "Stopped") + end + -- If we never entered construction, no cleanup is needed end - component:Stop() - return nil - end + end) + + -- ══════════════════════════════════════════════════════════════════════ + -- PHASE 4: Finalization + -- Await the promise and handle the result + -- ══════════════════════════════════════════════════════════════════════ + + local success, result = constructionPromise:await() - -- Handle nil result (construction was cancelled midway) - if result == nil and constructionStarted then - -- Construction started but was cancelled mid-way, clean up partial state - warn( - string.format( - "[Component] Construction cancelled for '%s' on '%s'. Running Stop now", + if not success then + -- Log error if it was a real error (not just cancellation) + if typeof(result) == "string" then + warn(string.format( + "[Component] Error during construction of '%s' on '%s':\n%s", self.Tag, - instance:GetFullName() - ) - ) - component:Stop() + instance:GetFullName(), + tostring(result) + )) + end + + return nil end return result @@ -898,6 +905,7 @@ end @tag Component Class @within Component @method _setup + @private This is an internal method that is called to set up the component class. It is automatically called when the component class is created, unless the @@ -1048,7 +1056,7 @@ function Component:_setup() - Clears KEY_STARTING first to signal cancellation to StartComponent - Thread cancellation depends on state: * "suspended": Cancel immediately (thread is yielding) - * "normal": Thread is in call stack, defer cancellation + * "normal": Thread is in the call stack, defer cancellation * "running": This is the current thread, don't cancel (would error) - Uses pcall around task.cancel as the thread may have already finished @@ -1197,11 +1205,11 @@ function Component:_setup() the calling code to continue (important for batch operations). ]] local function TryDeconstructComponent(instance) - self[KEY_LOCK_CONSTRUCT][instance] = nil local component = self[KEY_INST_TO_COMPONENTS][instance] if not component then return end + self[KEY_LOCK_CONSTRUCT][instance] = nil self[KEY_INST_TO_COMPONENTS][instance] = nil local components = self[KEY_COMPONENTS] :: table local index = table.find(components, component) @@ -1307,8 +1315,7 @@ end calling `GetAll` would return the three component instances. ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - + local MyComponent = Component.new({Tag = "MyComponent"}) -- ... local components = MyComponent:GetAll() @@ -1325,23 +1332,6 @@ end @tag Component Class @return Component? - Gets an instance of a component class from the given Roblox - instance. Returns `nil` if not found. - - ```lua - local MyComponent = require(somewhere.MyComponent) - - local myComponentInstance = MyComponent:FromInstance(workspace.SomeInstance) - ``` -]=] -function Component:FromInstance(instance: Instance) - return self[KEY_INST_TO_COMPONENTS][instance] -end - ---[=[ - @tag Component Class - @return Promise - Resolves a promise once the component instance is present on a given Roblox instance. @@ -1508,11 +1498,14 @@ end ``` ]=] function Component:WhileHasComponent(componentClass: ComponentClass, fn: (component: Component, jani: Janitor) -> ()) + assert(typeof(componentClass) == "table", ":WhileHasComponent() expects a component class.") if not componentClass.Tag and componentClass[1] and componentClass[1].Tag then error( - "Component:WhileHasComponent() called with an array of component classes. Did you mean to call WhileHasComponents() instead?" + ":WhileHasComponent() called with an array of component classes. Did you mean to call :WhileHasComponents() instead?" ) end + assert(componentClass.Tag, ":WhileHasComponent() expects a component class.") + local bindJani = Janitor.new() local connProxy = {} @@ -1535,11 +1528,6 @@ function Component:WhileHasComponent(componentClass: ComponentClass, fn: (compon return c.Instance == self.Instance end):andThen(connProxy.Destroy)) - assert( - typeof(componentClass) == "table" and componentClass.Tag, - "Component:WhileHasComponent() expects a component class." - ) - -- Track janitor for the component local activeJanitor = nil @@ -1630,10 +1618,10 @@ function Component:WhileHasComponents( return c.Instance == self.Instance end):andThen(connProxy.Destroy)) - assert(typeof(componentClasses) == "table", "Component:WhileHasComponents() expects a non-empty array of component classes.") - for _, class in ipairs(componentClasses) do - assert(typeof(class) == "table" and class.Tag, "Component:WhileHasComponents() expects all elements to be component classes.") - end + assert(typeof(componentClasses) == "table", "Component:WhileHasComponents() expects a non-empty array of component classes.") + for _, class in ipairs(componentClasses) do + assert(typeof(class) == "table" and class.Tag, "Component:WhileHasComponents() expects all elements to be component classes.") + end -- Helper to get all component instances for self.Instance local function getAllComponents() @@ -1696,7 +1684,8 @@ function Component:ForEachSibling(...) ) -- For backwards compatibility, try to detect if it's multiple components and route appropriately local componentClassOrClasses = ... - if componentClassOrClasses and typeof(componentClassOrClasses) == "table" and not componentClassOrClasses.Tag then + assert(typeof(componentClassOrClasses) == "table", ":ForEachSibling() expects a component class or an array of component classes.") + if not componentClassOrClasses.Tag then return self:WhileHasComponents(...) else return self:WhileHasComponent(...) From e8f7b097f33a433f3dd0d55a79dc0209d50fe717 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Thu, 23 Jul 2026 20:12:26 -0400 Subject: [PATCH 05/19] Component Rework pt1 --- CONTEXT-MAP.md | 4 + README.md | 2 +- lib/component/src/Extensions.luau | 135 ++ lib/component/src/Keys.luau | 103 + lib/component/src/Lifecycle.luau | 559 +++++ lib/component/src/Query.luau | 726 ++++++ lib/component/src/Registry.luau | 81 + .../src/Tests/Component.Errors.spec.luau | 175 ++ .../src/Tests/Component.Extensions.spec.luau | 203 ++ .../src/Tests/Component.Lifecycle.spec.luau | 218 ++ .../src/Tests/Component.NewMethods.spec.luau | 118 + .../src/Tests/Component.Query.spec.luau | 275 +++ .../src/Tests/Component.Siblings.spec.luau | 117 + lib/component/src/Tests/Component.types.luau | 176 ++ lib/component/src/Tests/Helpers.luau | 64 + lib/component/src/TypeFunctions.luau | 161 ++ lib/component/src/Types.luau | 330 +++ lib/component/src/init.luau | 2151 +++++------------ lib/component/src/scratchpad.luau | 19 + lib/component/wally.toml | 10 +- stylua.toml | 2 +- 21 files changed, 4012 insertions(+), 1617 deletions(-) create mode 100644 lib/component/src/Extensions.luau create mode 100644 lib/component/src/Keys.luau create mode 100644 lib/component/src/Lifecycle.luau create mode 100644 lib/component/src/Query.luau create mode 100644 lib/component/src/Registry.luau create mode 100644 lib/component/src/Tests/Component.Errors.spec.luau create mode 100644 lib/component/src/Tests/Component.Extensions.spec.luau create mode 100644 lib/component/src/Tests/Component.Lifecycle.spec.luau create mode 100644 lib/component/src/Tests/Component.NewMethods.spec.luau create mode 100644 lib/component/src/Tests/Component.Query.spec.luau create mode 100644 lib/component/src/Tests/Component.Siblings.spec.luau create mode 100644 lib/component/src/Tests/Component.types.luau create mode 100644 lib/component/src/Tests/Helpers.luau create mode 100644 lib/component/src/TypeFunctions.luau create mode 100644 lib/component/src/Types.luau create mode 100644 lib/component/src/scratchpad.luau diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 79a5788d..8a24f7c1 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -16,6 +16,10 @@ See `.github/agents/domain.md` for how the engineering skills consume these file - [lib/remotecomponent/CONTEXT.md](lib/remotecomponent/CONTEXT.md) — networked component remotes: the remote namespace, extension namespaces, internal vs exposed remotes, the registration window, and the SRC handshake. +- [lib/component/CONTEXT.md](lib/component/CONTEXT.md) — tag-bound component + classes: the lifecycle phases and phase barrier, extensions and hooks, + teardown and stop reasons, the core cleanup Janitor, and the world-level query + engine (requirements, matches, observers). Other packages under `lib/` do not have a `CONTEXT.md` yet; they are created lazily (via `/domain-modeling`) when a term or decision actually needs pinning down. diff --git a/README.md b/README.md index 7b4de605..e543c994 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ModulesOnRails is a collection of Wally packages to streamline Roblox developmen | [BaseComponent](https://raild3x.github.io/ModulesOnRails/api/BaseComponent) | `BaseComponent = "raild3x/basecomponent@0.1.2"` | A utility extension to provide helpers for working with signals, janitors, attributes, and properties. *Only works with my Component fork.* | | [BaseObject](https://raild3x.github.io/ModulesOnRails/api/BaseObject) | `BaseObject = "raild3x/baseobject@0.2.2"` | A base class for creating objects with a lifecycle, janitor, and event system. | | [CmdrHandler](https://raild3x.github.io/ModulesOnRails/api/CmdrHandler) | `CmdrHandler = "raild3x/cmdrhandler@0.2.2"` | A wrapper for eveara/quenty's Cmdr library. | -| [Component](https://raild3x.github.io/ModulesOnRails/api/Component) | `Component = "raild3x/component@0.2.0"` | A fork of Sleitnick's Component class for Roblox. | +| [Component](https://raild3x.github.io/ModulesOnRails/api/Component) | `Component = "raild3x/component@1.0.0"` | A fork of Sleitnick's Component class for Roblox. | | [DragDrop](https://raild3x.github.io/ModulesOnRails/api/DragDrop) | `DragDrop = "raild3x/dragdrop@0.2.0"` | A device-agnostic drag-and-drop system for Roblox UI (mouse, touch, gamepad, keyboard). | | [Graph Utilities](https://raild3x.github.io/ModulesOnRails/api/GraphUtil) | `Graph Utilities = "raild3x/graphutil@0.2.0"` | A collection of Graph utilities | | [Heap](https://raild3x.github.io/ModulesOnRails/api/Heap) | `Heap = "raild3x/heap@2.1.4"` | A generic min/max heap implementation in Luau. | diff --git a/lib/component/src/Extensions.luau b/lib/component/src/Extensions.luau new file mode 100644 index 00000000..39c59675 --- /dev/null +++ b/lib/component/src/Extensions.luau @@ -0,0 +1,135 @@ +--!strict +-- Component extension resolution & method binding. +-- Authors: Stephen Leitnick, Logan Hunt [Raildex] +--[=[ + @class ComponentExtensions + @ignore + + Resolves which extensions apply to a component and in what order, and binds + extension `Methods` onto a component class. + + Ordering is a **topological sort**: an extension listed in another extension's + `Extensions` array is a dependency edge, so dependencies are always resolved + before the extension that depends on them (deterministic DFS post-order, with + declared order as the tiebreak). `ShouldExtend` is evaluated exactly once per + extension per instance. A dependency cycle is an error. +]=] + +type Extension = any +type Component = any + +local Extensions = {} + +--[=[ + @within ComponentExtensions + Resolves the active, ordered extension list for a component. + + @param component -- the component (or class) the extensions apply to + @param rootList -- the class's configured `Extensions` array + @param isClass boolean -- when true, `ShouldExtend` is not evaluated (every + extension is included; used to compute the class-level active set) + @return { Extension } -- dependencies first, declared order otherwise +]=] +function Extensions.Resolve(component: Component, rootList: { Extension }?, isClass: boolean): { Extension } + local result: { Extension } = {} + local added: { [Extension]: boolean } = {} + local inProgress: { [Extension]: boolean } = {} + local includeCache: { [Extension]: boolean } = {} + + local function isIncluded(ext: Extension): boolean + local cached = includeCache[ext] + if cached ~= nil then + return cached + end + local include: boolean + if isClass then + include = true + else + -- Annotated: the new solver refines a bare `any` here to the top + -- `function` type, which it refuses to call. + local fn: ((Component) -> boolean)? = ext.ShouldExtend + include = if type(fn) == "function" then fn(component) == true else true + end + includeCache[ext] = include + return include + end + + local function visit(ext: Extension) + if added[ext] then + return + end + if not isIncluded(ext) then + return + end + if inProgress[ext] then + error("[Component] Extension dependency cycle detected", 0) + end + inProgress[ext] = true + if type(ext.Extensions) == "table" then + for _, dep in ext.Extensions do + visit(dep) + end + end + inProgress[ext] = nil + if not added[ext] then + added[ext] = true + table.insert(result, ext) + end + end + + if rootList then + for _, ext in rootList do + visit(ext) + end + end + return result +end + +--[=[ + @within ComponentExtensions + Binds every extension's `Methods` onto `target` (the component class). + + Collisions are an error, not a silent overwrite: if two extensions define the + same method name, or an extension's method name is already an own field of the + class, binding fails loudly. +]=] +function Extensions.BindMethods(target: Component, extensionList: { Extension }) + local boundBy: { [string]: Extension } = {} + for _, extension in extensionList do + if type(extension.Methods) ~= "table" then + continue + end + for key, value in extension.Methods do + if type(value) ~= "function" then + error(`[Component] Invalid extension method '{tostring(key)}': expected a function`, 0) + end + local prior = boundBy[key] + if prior and prior ~= extension then + error(`[Component] Extension method collision: two extensions both define '{key}'`, 0) + end + if prior == nil and rawget(target, key) ~= nil then + error(`[Component] Extension method '{key}' collides with an existing class member`, 0) + end + target[key] = value + boundBy[key] = extension + end + end +end + +--[=[ + @within ComponentExtensions + Returns false if any extension's `ShouldConstruct` vetoes construction. + Called before any component state is created, so a false result needs no + cleanup. +]=] +function Extensions.ShouldConstruct(activeExtensions: { Extension }, component: Component): boolean + for _, extension in activeExtensions do + local fn: ((Component) -> boolean)? = extension.ShouldConstruct + if type(fn) == "function" and not fn(component) then + return false + end + end + return true +end + +return Extensions diff --git a/lib/component/src/Keys.luau b/lib/component/src/Keys.luau new file mode 100644 index 00000000..dfa0bc58 --- /dev/null +++ b/lib/component/src/Keys.luau @@ -0,0 +1,103 @@ +--!strict +-- Component internal state: a single symbol key + typed accessors. +-- Authors: Stephen Leitnick, Logan Hunt [Raildex] +--[=[ + @class ComponentKeys + @ignore + + Component classes and instances keep their private state under one symbol key + (`Internal`) pointing at a typed table, instead of a pile of individual symbol + keys. Access it through the typed accessors [`Keys.class`] / [`Keys.inst`] so + field names are checked and autocompleted. The symbol (not a string key) keeps + this state from colliding with the user-defined fields set on a component. +]=] + +local Packages = script.Parent.Parent +local Symbol = require(Packages.Symbol) + +--[=[ + @interface LifecyclePhase + @within Component + The phase a component instance is currently in. Returned by + `ComponentClass:GetLifecycleStatus()`. + + - `"None"` — never started construction (or already fully torn down) + - `"Constructing"` — running `Constructing` hooks / `Construct()` + - `"Constructed"` — constructed, not yet starting + - `"Starting"` — running `Starting` hooks / `Start()` + - `"Started"` — fully started + - `"Stopping"` — tearing down + - `"Stopped"` — teardown complete +]=] +export type LifecyclePhase = "None" | "Constructing" | "Constructed" | "Starting" | "Started" | "Stopping" | "Stopped" + +--[=[ + @interface StopReason + @within Component + Why a component is being stopped, passed as the first argument to `Stop()`. + + - `"Untagged"` — the CollectionService tag was removed + - `"LeftAncestry"` — the instance left the valid ancestor list + - `"InstanceDestroyed"` — the bound instance was destroyed + - `"ClassDestroyed"` — the component class was destroyed + - `"ConstructionCancelled"` — torn down before it finished constructing + - `"Superseded"` — a newer construction attempt replaced this one +]=] +export type StopReason = + "Untagged" + | "LeftAncestry" + | "InstanceDestroyed" + | "ClassDestroyed" + | "ConstructionCancelled" + | "Superseded" + +-- Private state carried by every component INSTANCE. +export type ComponentInternal = { + phase: LifecyclePhase, + started: boolean, + stopReason: StopReason?, + activeExtensions: { any }, + janitor: any, -- the per-instance core Janitor (AddTask etc.) +} + +-- Private state carried by every component CLASS. +export type ClassInternal = { + ancestors: { Instance }, + instToComponents: { [Instance]: any }, + components: { any }, + lockConstruct: { [Instance]: number }, + watching: { [Instance]: { any } }, -- ancestry-watch connections + pending: { [Instance]: any }, -- in-flight construction record (or `true` reservation) + extensions: { any }, + classActiveExtensions: { any }, + fields: { [string]: any }?, -- config.Fields: deep-copied onto every instance + initFields: (() -> { [string]: any })?, -- config.InitFields: called per instance + janitor: any, -- class-level Janitor (signals + CollectionService connections) + failed: any, -- internal Signal(instance, reason): construction ended without starting +} + +local INTERNAL = Symbol("Internal") + +local Keys = { + Internal = INTERNAL, +} + +--[=[ + @within ComponentKeys + @ignore + Returns the typed private state of a component instance. +]=] +function Keys.inst(component: any): ComponentInternal + return component[INTERNAL] +end + +--[=[ + @within ComponentKeys + @ignore + Returns the typed private state of a component class. +]=] +function Keys.class(class: any): ClassInternal + return class[INTERNAL] +end + +return Keys diff --git a/lib/component/src/Lifecycle.luau b/lib/component/src/Lifecycle.luau new file mode 100644 index 00000000..108ba53c --- /dev/null +++ b/lib/component/src/Lifecycle.luau @@ -0,0 +1,559 @@ +--!strict +-- Component lifecycle engine: drives construction chains and teardown. +-- Authors: Stephen Leitnick, Logan Hunt [Raildex] +--[=[ + @class ComponentLifecycle + @ignore + + The single place a component instance is born and dies. `Run` drives + ShouldConstruct -> Constructing -> Construct -> Constructed -> Starting -> + Start -> Started as one cancellable Promise chain; `Teardown` is the single, + guaranteed removal path (Stopping -> Stop(reason) -> Stopped -> Janitor + destroy) used by every removal route. `init.luau` owns *when* these run + (tag/ancestry watching); this module owns *what* running them means. +]=] + +local RunService = game:GetService("RunService") + +local Packages = script.Parent.Parent +local Promise = require(Packages.Promise) +local Janitor = require(Packages.Janitor) + +local Keys = require(script.Parent.Keys) +local Registry = require(script.Parent.Registry) +local Extensions = require(script.Parent.Extensions) +local Types = require(script.Parent.Types) + +type ClassAny = Types.ComponentClass_Internal +type InstanceAny = Types.ComponentInstance_Internal +type PromiseLike = Types.PromiseLike +type StopReason = Types.StopReason + +const IS_SERVER = RunService:IsServer() + +-- Sentinel error used to unwind the lifecycle chain on cancellation (as opposed +-- to a genuine construction error). +const CANCELLED = newproxy(false) + +-- Typed view of the vendored Promise API where its inferred signatures reject +-- our structural PromiseLike; values bridge through `unknown` once, here. +const promiseAll = (Promise.all :: unknown) :: (promises: { PromiseLike }) -> PromiseLike + +local renderId = 0 +const function NextRenderName(): string + renderId += 1 + return "ComponentRender" .. tostring(renderId) +end + +--[[ + Runs `fn(...)` in its own thread and resolves when it (and any Promise it + returns) finishes. Cancelling the returned Promise cancels the thread and any + chained Promise. This is the unit the phase barrier composes over: a hook that + yields does not block its siblings. + + `deferred` dispatches with `task.defer` instead of `task.spawn`, so the hook + never runs inline on the thread that triggered it (start/stop phases). + + `fn` is `(...any)` on purpose: hooks arrive as typed methods and untyped + extension entries alike, and only `any` parameters accept both. +]] +const function invokeAsPromise(deferred: boolean, fn: (...any) -> any, ...: any): PromiseLike + const args = table.pack(...) + const dispatch = if deferred then task.defer else task.spawn + return ( + Promise.new(function(resolve, reject, onCancel) + local thread: thread? + local chained: PromiseLike? + local cancelled = false + onCancel(function() + cancelled = true + if thread and coroutine.status(thread) == "suspended" then + pcall(task.cancel, thread) + end + if chained then + chained:cancel() + end + end) + thread = task.spawn(function() + const ok, res = pcall(fn, table.unpack(args, 1, args.n)) + if cancelled then + return + end + if not ok then + reject(res) + elseif Promise.is(res) then + const promise = res :: PromiseLike + chained = promise + promise:andThen(function(...) + resolve(...) + end, function(...) + reject(...) + end) + else + resolve(res) + end + end) + end) :: unknown + ) :: PromiseLike +end + +const NO_EDGES: { any } = {} + +export type PhaseOptions = { + -- Flip the dependency edges: a dependency's hook runs only once the hooks of + -- everything depending on it have finished (teardown). + reverse: boolean?, + -- Dispatch each hook with `task.defer` (start/stop phases). + deferred: boolean?, + -- Warn on a hook error instead of rejecting the phase (teardown must finish). + warnErrors: boolean?, +} + +--[[ + Runs every active extension's `phaseName` hook, gated on dependency order: an + extension's hook does not start until the hooks it must follow have finished, + including anything they yielded on. Independent extensions still run + concurrently, so a hook only ever waits on a real dependency edge. + + `activeExtensions` is already topologically sorted (dependencies first), so a + single pass over it — reversed for teardown — always finds each gate promise + already built. An extension with no hook this phase still forwards its gate, + so a dependent keeps waiting on its transitive dependencies. + + Returns nil when no active extension implements the phase. +]] +const function runHooks( + activeExtensions: { any }, + phaseName: string, + component: InstanceAny, + options: PhaseOptions +): PromiseLike? + const reverse = options.reverse == true + const deferred = options.deferred == true + + -- extension -> the extensions whose hooks must settle before its own starts. + const waitsOn: { [any]: { any } } = {} + const function addEdge(after: any, before: any) + const list = waitsOn[after] + if list then + table.insert(list, before) + else + waitsOn[after] = { before } + end + end + for _, extension in activeExtensions do + if type(extension.Extensions) ~= "table" then + continue + end + for _, dep in extension.Extensions do + if reverse then + addEdge(dep, extension) + else + addEdge(extension, dep) + end + end + end + + local order = activeExtensions + if reverse then + order = table.clone(activeExtensions) + const n = #order + for i = 1, n // 2 do + order[i], order[n + 1 - i] = order[n + 1 - i], order[i] + end + end + + const settled: { [any]: PromiseLike } = {} + const nodes: { PromiseLike } = {} + local hasHook = false + for _, extension in order do + const gates: { PromiseLike } = {} + for _, other in waitsOn[extension] or NO_EDGES do + const promise = settled[other] + if promise then + table.insert(gates, promise) + end + end + const gate: PromiseLike? = if #gates == 0 + then nil + elseif #gates == 1 then gates[1] + else promiseAll(gates) + + const hook = extension[phaseName] + local node: PromiseLike? = gate + if type(hook) == "function" then + hasHook = true + const invoke = function(): PromiseLike + const promise = invokeAsPromise(deferred, hook, component) + if options.warnErrors then + return promise:catch(function(err) + warn(string.format("[Component] Error in '%s' hook: %s", phaseName, tostring(err))) + end) + end + return promise + end + node = if gate then gate:andThen(invoke) else invoke() + end + if node then + settled[extension] = node + table.insert(nodes, node) + end + end + + if not hasHook then + return nil + end + return if #nodes == 1 then nodes[1] else promiseAll(nodes) +end + +-- Construction may yield the chain; start/stop hooks are deferred so they never +-- run inline on the thread that triggered them, and teardown must always finish. +const CONSTRUCT_PHASE: PhaseOptions = {} +const START_PHASE: PhaseOptions = { deferred = true } +const STOP_PHASE: PhaseOptions = { reverse = true, deferred = true, warnErrors = true } + +--[[ + Deep copy for `config.Fields`, so a table default is never shared between + component instances. +]] +const function deepCopy(value: T): T + if type(value) ~= "table" then + return value + end + const copy = {} + for key, item in value :: { [any]: any } do + copy[key] = deepCopy(item) + end + return copy +end + +--[[ + Applies the class's configured `Fields` (deep-copied) and `InitFields` (called + once, result copied shallowly) onto a fresh component table. Called before + `Instance` is assigned, so neither can clobber it. +]] +const function applyFields(component: any, ci: Keys.ClassInternal) + if ci.fields then + for key, value in ci.fields do + component[key] = deepCopy(value) + end + end + if ci.initFields then + for key, value in ci.initFields() do + component[key] = value + end + end +end + +-- Disconnects a started component's update-loop connections. Done at the start +-- of teardown so Stop() is never ticked mid-cleanup. +const function disconnectUpdates(component: InstanceAny) + const cleanups = component._updateCleanup + if cleanups then + component._updateCleanup = nil + for _, cleanup in cleanups do + cleanup() + end + end +end + +--[[ + Removes a component from every tracking table (per-class + global registry). + Idempotent; safe to call whether or not the component was ever tracked. +]] +const function untrack(class: ClassAny, instance: Instance) + const ci = Keys.class(class) + const component = ci.instToComponents[instance] + if component then + ci.instToComponents[instance] = nil + const components = ci.components + const index = table.find(components, component) + if index then + const n = #components + components[index] = components[n] + components[n] = nil + end + Registry.Unregister(instance, class) + end +end + +const function callWithoutYielding(fn: (...any) -> any, ...: any): (boolean, ...any) + const args = table.pack(...) + local thread = coroutine.create(function() + return fn(table.unpack(args, 1, args.n)) + end) + const ok, res = coroutine.resume(thread) + if not ok then + return false, res + end + if coroutine.status(thread) ~= "dead" then + error("callWithoutYielding: function yielded", 2) + end + return true, res +end + +--[[ + teardown - the single, guaranteed removal path. + + Untracking is synchronous (so a same-frame re-tag sees a free slot); the stop + sequence itself is deferred, so teardown never blocks its caller. It runs + Stopping -> Stop(reason) -> Stopped -> Janitor destroy, with each phase's hooks + gated in *reverse* dependency order: a dependency's hook only runs once the + hooks of everything depending on it have finished. Idempotent. Hook errors are + warned, never aborting the Janitor destroy. Fires the `Stopped` class signal + only if the component had begun starting; otherwise fires the internal + `Failed` signal (used by GetOrCreateFromInstance). +]] +const function teardown(class: ClassAny, component: InstanceAny, reason: StopReason) + const ic = Keys.inst(component) + const phase = ic.phase + if phase == "Stopping" or phase == "Stopped" or phase == "None" then + return + end + const reachedStart = phase == "Starting" or phase == "Started" or ic.started == true + ic.stopReason = reason + ic.phase = "Stopping" + ic.started = false + + untrack(class, component.Instance) + disconnectUpdates(component) + + const function warnError(err: any) + warn(string.format("[Component] Error during teardown of '%s': %s", tostring(class.Tag), tostring(err))) + end + + const function invokeSafe(fn: unknown, ...: any): PromiseLike? + if type(fn) ~= "function" then + return nil + end + return invokeAsPromise(true, fn :: (...any) -> any, ...):catch(warnError) + end + + -- The class-level signals may already be destroyed (class:Destroy tears its + -- janitor down without waiting on the deferred stop sequence). + const function fireSafe(signal: any, ...: any) + pcall(function(...) + signal:Fire(...) + end, ...) + end + + ((Promise.resolve() :: unknown) :: PromiseLike) + :andThen(function() + return runHooks(ic.activeExtensions, "Stopping", component, STOP_PHASE) + end) + :andThen(function() + return invokeSafe(component.Stop, component, reason) + end) + :andThen(function() + return runHooks(ic.activeExtensions, "Stopped", component, STOP_PHASE) + end) + :andThen(function() + const coreJanitor = ic.janitor + if coreJanitor then + ic.janitor = nil + -- Janitor cleanup runs synchronously; isolate it from this thread. + return invokeSafe(function() + coreJanitor:Destroy() + end) + end + return nil + end) + :andThen(function() + ic.phase = "Stopped" + if reachedStart then + fireSafe(class.Stopped, component) + else + fireSafe(Keys.class(class).failed, component.Instance, reason) + end + end) + :catch(warnError) +end + +--[[ + run - drives ShouldConstruct -> Constructing -> Construct -> Constructed -> + Starting -> Start -> Started for one component, as a single cancellable + Promise chain. Registers the component at the Constructed phase. Any + non-successful settle routes to `teardown`. +]] +const function run(class: ClassAny, instance: Instance, constructId: number) + const cci = Keys.class(class) + -- Built dynamically (symbol slot, dynamic writes) under `any`, then viewed + -- as InstanceAny. The `:: any` on the RHS keeps the solver from stamping + -- `@metatable` onto the local and defeating the annotation. + const componentAny: any = setmetatable({}, class) :: any + applyFields(componentAny, cci) + componentAny.Instance = instance + const component = componentAny :: InstanceAny + const ic: Keys.ComponentInternal = { + phase = "None", + started = false, + stopReason = nil, + activeExtensions = Extensions.Resolve(component, cci.extensions, false), + janitor = nil, + } + componentAny[Keys.Internal] = ic + + -- ShouldConstruct veto: no state created, nothing to clean up. + if not Extensions.ShouldConstruct(ic.activeExtensions, component) then + cci.pending[instance] = nil + cci.failed:Fire(instance, "ShouldConstruct veto") + return + end + + -- From here on, teardown is guaranteed. + ic.janitor = Janitor.new() + ic.phase = "Constructing" + + const function validityReason(): StopReason? + if cci.lockConstruct[instance] ~= constructId then + return "Superseded" + end + if not class:_isInAncestorList(instance) then + return if instance:IsDescendantOf(game) then "LeftAncestry" else "InstanceDestroyed" + end + return nil + end + + -- Wraps a function in a barrier that checks the component's validity before + -- and after running it. If the component is no longer valid, the chain is + -- cancelled and the reason is recorded in the internal state for teardown. + const function barrier(check: ((Args...) -> ...any)?, ...: Args...): () -> ...any + const args = {...} + const argCount = select("#", ...) + return function() + const reason = validityReason() + if reason then + ic.stopReason = reason + error(CANCELLED, 0) + end + if check then + -- Cast the callee, NOT the unpack: `f(table.unpack(t) :: any)` is a + -- type assertion on a multi-value expression, which truncates it to + -- one value -- every barrier arg after the first was silently dropped. + return (check :: any)(table.unpack(args, 1, argCount)) + end + return nil + end + end + + -- Runs all hooks of a given phase in dependency order (see `runHooks`), + -- returning a Promise that resolves when all finish. If any hook rejects, the + -- chain is cancelled and the reason is recorded for teardown. + const function runPhase(phaseName: string, options: PhaseOptions): PromiseLike? + return runHooks(ic.activeExtensions, phaseName, component, options) + end + + -- Register the in-flight record before running the chain so an external + -- teardown can always find and cancel it. + const record: Types.PendingRecord = { component = component, promise = nil, id = constructId } + cci.pending[instance] = record + + const chain = ((Promise.resolve() :: unknown) :: PromiseLike) + :andThen(barrier(runPhase, "Constructing", CONSTRUCT_PHASE)) + :andThen(barrier(function() + if type(component.Construct) == "function" then + return invokeAsPromise(false, component.Construct, component) + end + return nil + end)) + :andThen(barrier(runPhase, "Constructed", CONSTRUCT_PHASE)) + :andThen(barrier(function() + -- Track the component now that it is fully constructed. The pending + -- record is kept until the chain settles so a mid-start teardown can + -- still cancel it. + ic.phase = "Constructed" + cci.instToComponents[instance] = component + table.insert(cci.components, component) + Registry.Register(instance, class, component) + end)) + :andThen(barrier(function() + ic.phase = "Starting" + return runPhase("Starting", START_PHASE) + end)) + :andThen(barrier(function() + if type(component.Start) == "function" then + return invokeAsPromise(true, component.Start, component) + end + return nil + end)) + :andThen(barrier(runPhase, "Started", START_PHASE)) + :andThen(barrier(function() + -- Connect update loops, mark started, fire Started. + const updateCleanup: { () -> () } = {} + component._updateCleanup = updateCleanup + if type(component.HeartbeatUpdate) == "function" then + const update = component.HeartbeatUpdate :: (self: InstanceAny, dt: number) -> () + const conn = RunService.Heartbeat:Connect(function(dt) + update(component, dt) + end) + table.insert(updateCleanup, function() + conn:Disconnect() + end) + end + if type(component.SteppedUpdate) == "function" then + const update = component.SteppedUpdate :: (self: InstanceAny, dt: number) -> () + const conn = RunService.Stepped:Connect(function(_, dt) + update(component, dt) + end) + table.insert(updateCleanup, function() + conn:Disconnect() + end) + end + if type(component.RenderSteppedUpdate) == "function" and not IS_SERVER then + const update = component.RenderSteppedUpdate :: (self: InstanceAny, dt: number) -> () + if component.RenderPriority then + const name = NextRenderName() + RunService:BindToRenderStep(name, component.RenderPriority, function(dt) + update(component, dt) + end) + table.insert(updateCleanup, function() + RunService:UnbindFromRenderStep(name) + end) + else + const conn = RunService.RenderStepped:Connect(function(dt) + update(component, dt) + end) + table.insert(updateCleanup, function() + conn:Disconnect() + end) + end + end + ic.phase = "Started" + ic.started = true + class.Started:Fire(component) + end)) + + record.promise = chain + + const settled = chain:finally(function(status) + -- Clear the in-flight record if it is still ours. + if cci.pending[instance] == record then + cci.pending[instance] = nil + end + if status == Promise.Status.Resolved then + return + end + const reason = ic.stopReason or "ConstructionCancelled" + teardown(class, component, reason) + end) + + -- Handle the rejection that `finally` re-raises so it is never "unhandled". + settled:catch(function(err) + if err ~= CANCELLED and typeof(err) == "string" then + warn( + string.format( + "[Component] Error constructing '%s' on '%s':\n%s", + tostring(class.Tag), + instance:GetFullName(), + tostring(err) + ) + ) + end + end) +end + +return { + Run = run, + Teardown = teardown, + Untrack = untrack, +} diff --git a/lib/component/src/Query.luau b/lib/component/src/Query.luau new file mode 100644 index 00000000..e9409ed6 --- /dev/null +++ b/lib/component/src/Query.luau @@ -0,0 +1,726 @@ +--!strict +-- World-level component query engine. +-- Authors: Logan Hunt [Raildex] +--[=[ + @class Query + @ignore + + A reusable, reactive query over tagged instances. Built with + `Component.query(...)` and refined with chain methods, then either observed + (`:observe`) or read once (`:GetMatches`). + + A *requirement* is a **component class** (satisfied while that component is + *started* on the instance), a **tag string** (satisfied while the instance has + the raw CollectionService tag), or **another Query** (satisfied while the + instance matches it). An instance *matches* while: + + - every positional / `:with` requirement is satisfied, + - every `:anyOf(...)` group has at least one satisfied, + - no `:without` requirement is satisfied, + - every `:withAttribute` matches, and + - every `:where` predicate returns true. + + A query must have at least one positive requirement (component / tag / + sub-query in the positional args, `:with`, or `:anyOf`) so its candidate set + is bounded; a query built only from `:without` / `:withAttribute` / `:where` + errors when observed or read. + + See the Component `CONTEXT.md` for the glossary and the `README` for examples. +]=] + +local CollectionService = game:GetService("CollectionService") + +local Packages = script.Parent.Parent +local Signal = require(Packages.Signal) +local Janitor = require(Packages.Janitor) + +local Keys = require(script.Parent.Keys) + +type Janitor = Janitor.Janitor + +-- Minimal structural view of a component class and its instances (see +-- `ComponentClass` / `TypedClass` in `init.luau`); declared here so real +-- classes are subtypes without a cyclic require of `init.luau`. Props are +-- `read` (covariant) and the class API is self-generic, matching the real +-- types, so both class variants satisfy this view. +type ComponentLike = { read Instance: Instance } +type ComponentClassLike = { + read Tag: string, + read Instance: Instance, + -- `unknown`, not a structural signal type: `Signal`'s generics are + -- invariant (via `Fire`), so no one signal type accepts every class's + -- signal. Cast to `ClassSignalView` at the connect site. + read Started: unknown, + read Stopped: unknown, + read FromInstance: (self: T, instance: Instance) -> T?, + read GetAll: (self: T) -> { T }, +} + +-- Runtime connection shape shared by better-signal and RBXScriptSignal. +type ConnectionLike = { Disconnect: (self: ConnectionLike) -> () } + +-- Connect-only view a class's Started/Stopped signal is cast to. +type ClassSignalView = { + Connect: (self: ClassSignalView, fn: (ComponentLike) -> ()) -> ConnectionLike, +} + +-- Connect-only view a `:where` recheck signal is duck-cast to; accepts any +-- signal-like value (better-signal, RBXScriptSignal, ...) with `:Connect`. +type RecheckSignalView = { + Connect: (self: RecheckSignalView, fn: () -> ()) -> ConnectionLike, +} + +export type Queryable = ComponentClassLike | string | Query + +--[=[ + @interface QueryConnection + @within Query + .IsConnected boolean + .Disconnect () -> () + .Destroy () -> () + Returned by [Query:observe]. +]=] +export type QueryConnection = { + IsConnected: boolean, + Disconnect: () -> (), + Destroy: () -> (), +} + +export type Query = { + with: (self: Query, ...Queryable) -> Query, + anyOf: (self: Query, ...Queryable) -> Query, + without: (self: Query, ...Queryable) -> Query, + withAttribute: (self: Query, name: string, matcher: unknown?) -> Query, + withProperty: (self: Query, name: string, matcher: unknown) -> Query, + where: (self: Query, predicate: (Instance) -> boolean, recheckSignal: unknown?) -> Query, + observe: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, + get: (self: Query) -> { Instance }, +} + +-- satisfiedFn(query, instance): is `instance` currently matching `query`? +-- Supplied by caller so the same predicate logic serves both the reactive +-- engine (sub-query engines) and the one-shot GetMatches (static membership). +type SatisfiedFn = (QueryInternal, Instance) -> boolean + +-- One `:withAttribute` requirement. `matcher` is the EXISTS sentinel, a +-- `(value) -> boolean` predicate, or a value the attribute must equal. +type AttributeRequirement = { + name: string, + matcher: unknown, +} + +-- One `:where` requirement; `signal` is duck-cast to `RecheckSignalView` when +-- the engine activates. +type PredicateRequirement = { + fn: (Instance) -> boolean, + signal: unknown?, +} + +type Observer = { + callback: (Instance, Janitor) -> (), + janitors: { [Instance]: Janitor }, +} + +type ChangedSignal = Signal.Signal<(Instance, boolean) -> (), Instance, boolean> + +-- Reactive state backing an activated query (ref-counted, shared when a query +-- is used more than once). +type Engine = { + matched: { [Instance]: boolean }, + changed: ChangedSignal, + observers: { [Observer]: boolean }, + janitor: Janitor, + positiveSet: { [Instance]: boolean }, -- [Instance]: true while positively bounded (candidate universe) + attrJanitors: { [Instance]: Janitor }, -- attribute subscriptions + subEngines: { [QueryInternal]: Engine }, +} + +type QueryInternal = Query & { + _positive: { Queryable }, + _anyOf: { { Queryable } }, -- array of groups + _negative: { Queryable }, + _attributes: { AttributeRequirement }, + _predicates: { PredicateRequirement }, + _engine: Engine?, + _refcount: number, + + _positiveSources: (self: QueryInternal) -> { Queryable }, + _validate: (self: QueryInternal, seen: { [QueryInternal]: boolean }?) -> (), + _allReferences: (self: QueryInternal) -> { Queryable }, + _positiveCandidate: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, + _attributesMatch: (self: QueryInternal, instance: Instance) -> boolean, + _predicatesPass: (self: QueryInternal, instance: Instance) -> boolean, + _fullMatch: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, + _activate: (self: QueryInternal) -> Engine, + _deactivate: (self: QueryInternal) -> (), + _enumerate: (self: QueryInternal, reactive: boolean) -> { [Instance]: boolean }, +} + +local EXISTS = newproxy(false) -- sentinel: attribute must merely exist + +local Query = {} +Query.__index = Query + +local function isQuery(value: unknown): boolean + return type(value) == "table" and getmetatable(value) == Query +end + +local function isComponentClass(value: unknown): boolean + if type(value) ~= "table" or isQuery(value) then + return false + end + return type((value :: { read Tag: unknown }).Tag) == "string" +end + +local function assertQueryable(value: Queryable, method: string) + if type(value) == "string" or isQuery(value) or isComponentClass(value) then + return + end + error(`[Component] :{method}() expects a component class, tag string, or Query`, 3) +end + +--[=[ + @within Component + @function query + @param ... Queryable -- component classes, tag strings, and/or sub-queries + @return Query + + Creates a new query whose positional arguments are all required. +]=] +function Query.new(...: Queryable): Query + -- Cast through `any`: without it the solver stamps `@metatable` onto the + -- table and rejects the internal type (same as TableManager's constructor). + local self = ( + setmetatable({ + _positive = {}, + _anyOf = {}, + _negative = {}, + _attributes = {}, + _predicates = {}, + _engine = nil, + _refcount = 0, + }, Query) :: any + ) :: QueryInternal + self:with(...) + return self +end + +--[=[ + @within Query + @param ... Queryable + @return Query + Adds required requirements. `query():with(X)` is equivalent to `query(X)`. +]=] +function Query.with(self: QueryInternal, ...: Queryable): Query + for _, req in { ... } do + assertQueryable(req, "with") + table.insert(self._positive, req) + end + return self +end + +--[=[ + @within Query + @param ... Queryable + @return Query + Adds an "at least one of" group: the instance must satisfy at least one of the + given requirements. Multiple `:anyOf` calls each add an independent group. +]=] +function Query.anyOf(self: QueryInternal, ...: Queryable): Query + local group = { ... } + for _, req in group do + assertQueryable(req, "anyOf") + end + if #group > 0 then + table.insert(self._anyOf, group) + end + return self +end + +--[=[ + @within Query + @param ... Queryable + @return Query + Adds excluded requirements: the instance must satisfy none of them. +]=] +function Query.without(self: QueryInternal, ...: Queryable): Query + for _, req in { ... } do + assertQueryable(req, "without") + table.insert(self._negative, req) + end + return self +end + +--[=[ + @within Query + @param name string + @param matcher any -- a value to equal, a `(value) -> boolean` predicate, or omitted for existence + @return Query + Requires an attribute. With no matcher, the attribute must merely exist; with a + function, the function must return true for the attribute's value; otherwise the + value must equal `matcher`. Re-evaluated reactively on attribute change. +]=] +function Query.withAttribute(self: QueryInternal, name: string, matcher: unknown?): Query + assert(type(name) == "string", "[Component] :withAttribute() expects an attribute name string") + table.insert(self._attributes, { + name = name, + matcher = if matcher == nil then EXISTS else matcher, + }) + return self +end + +--[=[ + @within Query + @param predicate (instance: Instance) -> boolean + @param recheckSignal Signal? -- fire to force re-evaluation + @return Query + + Adds an arbitrary predicate. :::caution A predicate has no change signal of its + own — it is only re-evaluated when another requirement changes, or when the + optional `recheckSignal` fires. Without one, its result can go stale. ::: +]=] +function Query.where(self: QueryInternal, predicate: (Instance) -> boolean, recheckSignal: unknown?): Query + assert(type(predicate) == "function", "[Component] :where() expects a predicate function") + table.insert(self._predicates, { fn = predicate, signal = recheckSignal }) + return self +end + +-------------------------------------------------------------------------------- +-- Validation +-------------------------------------------------------------------------------- + +-- Flattened positive requirements (positional/:with + every :anyOf member). +-- These bound the candidate set; a query with none is unbounded and rejected. +function Query._positiveSources(self: QueryInternal): { Queryable } + local sources = {} + for _, req in self._positive do + table.insert(sources, req) + end + for _, group in self._anyOf do + for _, req in group do + table.insert(sources, req) + end + end + return sources +end + +-- DFS over sub-query references; errors on an empty query or a dependency cycle. +function Query._validate(self: QueryInternal, seen: { [QueryInternal]: boolean }?) + local seenSet = seen or {} + if seenSet[self] then + error("[Component] Query dependency cycle detected", 0) + end + local sources = self:_positiveSources() + if #sources == 0 then + error( + "[Component] Query has no positive requirement (component / tag / sub-query); " + .. "add one via query(...), :with(), or :anyOf() so its candidate set is bounded", + 0 + ) + end + seenSet[self] = true + for _, req in self:_allReferences() do + if isQuery(req) then + local subQuery = req :: QueryInternal + subQuery:_validate(seenSet) + end + end + seenSet[self] = nil +end + +-- Every requirement across all clauses (positive, anyOf, negative). +function Query._allReferences(self: QueryInternal): { Queryable } + local refs = {} + for _, req in self._positive do + table.insert(refs, req) + end + for _, group in self._anyOf do + for _, req in group do + table.insert(refs, req) + end + end + for _, req in self._negative do + table.insert(refs, req) + end + return refs +end + +-------------------------------------------------------------------------------- +-- Satisfaction / matching +-------------------------------------------------------------------------------- + +local function isSatisfied(satisfiedFn: SatisfiedFn, req: Queryable, instance: Instance): boolean + if type(req) == "string" then + return CollectionService:HasTag(instance, req) + elseif isQuery(req) then + return satisfiedFn(req :: QueryInternal, instance) + else + local class = req :: ComponentClassLike + local component = class:FromInstance(instance) + return component ~= nil and Keys.inst(component).phase == "Started" + end +end + +function Query._positiveCandidate(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean + for _, req in self._positive do + if not isSatisfied(satisfiedFn, req, instance) then + return false + end + end + for _, group in self._anyOf do + local anySatisfied = false + for _, req in group do + if isSatisfied(satisfiedFn, req, instance) then + anySatisfied = true + break + end + end + if not anySatisfied then + return false + end + end + return true +end + +function Query._attributesMatch(self: QueryInternal, instance: Instance): boolean + for _, attr in self._attributes do + local value = instance:GetAttribute(attr.name) + local matcher = attr.matcher + local ok: boolean + if matcher == EXISTS then + ok = value ~= nil + elseif type(matcher) == "function" then + local success, result = pcall(matcher :: (unknown) -> unknown, value) + ok = success and result == true + if not success then + warn(`[Component] Query :withAttribute('{attr.name}') matcher errored: {result}`) + end + else + ok = value == matcher + end + if not ok then + return false + end + end + return true +end + +function Query._predicatesPass(self: QueryInternal, instance: Instance): boolean + for _, pred in self._predicates do + local success, result = pcall(pred.fn, instance) + if not success then + warn(`[Component] Query :where() predicate errored: {result}`) + return false + end + if result ~= true then + return false + end + end + return true +end + +function Query._fullMatch(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean + if not self:_positiveCandidate(instance, satisfiedFn) then + return false + end + for _, req in self._negative do + if isSatisfied(satisfiedFn, req, instance) then + return false + end + end + return self:_attributesMatch(instance) and self:_predicatesPass(instance) +end + +-------------------------------------------------------------------------------- +-- Reactive engine (ref-counted, shared when a query is used more than once) +-------------------------------------------------------------------------------- + +function Query._activate(self: QueryInternal): Engine + self._refcount += 1 + if self._engine then + return self._engine + end + + local janitor = Janitor.new() + -- Cast through `unknown`: `Signal.new()` has no inference source for its + -- `Function` generic, and Signal's invariant generics reject a direct cast. + local changed = (Signal.new() :: unknown) :: ChangedSignal + janitor:Add(changed, "Destroy") + local engine: Engine = { + matched = {}, + changed = changed, + observers = {}, + janitor = janitor, + positiveSet = {}, + attrJanitors = {}, + subEngines = {}, + } + self._engine = engine + janitor:Add(function() + for _, attrJanitor in engine.attrJanitors do + attrJanitor:Destroy() + end + table.clear(engine.attrJanitors) + end) + + local function subMatches(subQuery: QueryInternal, instance: Instance): boolean + local subEngine = engine.subEngines[subQuery] + return subEngine ~= nil and subEngine.matched[instance] == true + end + + local hasAttributes = #self._attributes > 0 + + local function reevaluate(instance: Instance?) + if not instance then + return + end + local positive = self:_positiveCandidate(instance, subMatches) + + -- Track the candidate universe and (only while bounded) attribute subs. + if positive then + engine.positiveSet[instance] = true + if hasAttributes and not engine.attrJanitors[instance] then + local attrJanitor = Janitor.new() + for _, attr in self._attributes do + attrJanitor:Add( + instance:GetAttributeChangedSignal(attr.name):Connect(function() + reevaluate(instance) + end), + "Disconnect" + ) + end + engine.attrJanitors[instance] = attrJanitor + end + else + engine.positiveSet[instance] = nil + local attrJanitor = engine.attrJanitors[instance] + if attrJanitor then + engine.attrJanitors[instance] = nil + attrJanitor:Destroy() + end + end + + local isMatch = positive and self:_fullMatch(instance, subMatches) + local wasMatch = engine.matched[instance] == true + if isMatch == wasMatch then + return + end + + if isMatch then + engine.matched[instance] = true + for obs in engine.observers do + local matchJanitor = Janitor.new() + obs.janitors[instance] = matchJanitor + task.spawn(obs.callback, instance, matchJanitor) + end + else + engine.matched[instance] = nil + for obs in engine.observers do + local matchJanitor = obs.janitors[instance] + if matchJanitor then + obs.janitors[instance] = nil + matchJanitor:Destroy() + end + end + end + engine.changed:Fire(instance, isMatch) + end + + -- Subscribe to every referenced input so a change re-evaluates the instance. + local connectedClasses: { [ComponentClassLike]: boolean } = {} + local connectedTags: { [string]: boolean } = {} + local function subscribeRef(req: Queryable) + if type(req) == "string" then + if connectedTags[req] then + return + end + connectedTags[req] = true + janitor:Add(CollectionService:GetInstanceAddedSignal(req):Connect(reevaluate), "Disconnect") + janitor:Add(CollectionService:GetInstanceRemovedSignal(req):Connect(reevaluate), "Disconnect") + elseif isQuery(req) then + local subQuery = req :: QueryInternal + if engine.subEngines[subQuery] then + return + end + local subEngine = subQuery:_activate() + engine.subEngines[subQuery] = subEngine + janitor:Add(function() + subQuery:_deactivate() + end) + janitor:Add( + subEngine.changed:Connect(function(instance, _isMatch) + reevaluate(instance) + end), + "Disconnect" + ) + else -- component class + local class = req :: ComponentClassLike + if connectedClasses[class] then + return + end + connectedClasses[class] = true + local started = class.Started :: ClassSignalView + local stopped = class.Stopped :: ClassSignalView + janitor:Add( + started:Connect(function(component) + reevaluate(component.Instance) + end), + "Disconnect" + ) + janitor:Add( + stopped:Connect(function(component) + reevaluate(component.Instance) + end), + "Disconnect" + ) + end + end + + for _, req in self:_allReferences() do + subscribeRef(req) + end + + -- `where` recheck signals force a full re-evaluation of bounded instances. + for _, pred in self._predicates do + if pred.signal ~= nil then + local recheck = pred.signal :: RecheckSignalView + janitor:Add( + recheck:Connect(function() + for instance in engine.positiveSet do + reevaluate(instance) + end + end), + "Disconnect" + ) + end + end + + -- Seed from the current members of every positive source. + for instance in self:_enumerate(true) do + reevaluate(instance) + end + + return engine +end + +function Query._deactivate(self: QueryInternal) + self._refcount -= 1 + if self._refcount <= 0 then + self._refcount = 0 + if self._engine then + self._engine.janitor:Destroy() + self._engine = nil + end + end +end + +-- Enumerate the candidate universe (union of positive sources). When `reactive` +-- is true, sub-query membership comes from live engines (already activated); +-- otherwise it is computed statically via each sub-query's GetMatches. +function Query._enumerate(self: QueryInternal, reactive: boolean): { [Instance]: boolean } + local set: { [Instance]: boolean } = {} + local function addFromRef(req: Queryable) + if type(req) == "string" then + for _, instance in CollectionService:GetTagged(req) do + set[instance] = true + end + elseif isQuery(req) then + local subQuery = req :: QueryInternal + if reactive then + local engine = self._engine :: Engine + local subEngine = engine.subEngines[subQuery] + if subEngine then + for instance in subEngine.matched do + set[instance] = true + end + end + else + for _, instance in subQuery:get() do + set[instance] = true + end + end + else -- component class + local class = req :: ComponentClassLike + for _, component in class:GetAll() do + if Keys.inst(component).phase == "Started" then + set[component.Instance] = true + end + end + end + end + for _, req in self:_positiveSources() do + addFromRef(req) + end + return set +end + +-------------------------------------------------------------------------------- +-- Public terminals +-------------------------------------------------------------------------------- + +--[=[ + @within Query + @param callback (instance: Instance, janitor: Janitor) -> () + @return QueryConnection + + Runs `callback` for every instance that currently matches, and for every + instance that matches later, each with a fresh Janitor cleaned up when that + instance stops matching. Fetch matched components with `Class:FromInstance`. + Disconnecting the returned handle destroys all active match janitors and stops + watching. +]=] +function Query.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection + assert(type(callback) == "function", "[Component] Query:observe() expects a callback function") + self:_validate() + local engine = self:_activate() + + local obs: Observer = { callback = callback, janitors = {} } + engine.observers[obs] = true + + -- Fire for instances already matched at subscribe time. + for instance in engine.matched do + local matchJanitor = Janitor.new() + obs.janitors[instance] = matchJanitor + task.spawn(callback, instance, matchJanitor) + end + + local connProxy = {} :: QueryConnection + connProxy.IsConnected = true + function connProxy.Disconnect() + if not connProxy.IsConnected then + return + end + connProxy.IsConnected = false + engine.observers[obs] = nil + for _, matchJanitor in obs.janitors do + matchJanitor:Destroy() + end + table.clear(obs.janitors) + self:_deactivate() + end + connProxy.Destroy = connProxy.Disconnect + return connProxy +end + +--[=[ + @within Query + @return { Instance } + Returns the instances that match right now. A one-shot read: it sets up no + subscriptions. +]=] +function Query.get(self: QueryInternal): { Instance } + self:_validate() + local function staticSub(subQuery: QueryInternal, instance: Instance): boolean + return subQuery:_fullMatch(instance, staticSub) + end + local out: { Instance } = {} + for instance in self:_enumerate(false) do + if self:_fullMatch(instance, staticSub) then + table.insert(out, instance) + end + end + return out +end +Query.GetMatches = Query.get + +return Query diff --git a/lib/component/src/Registry.luau b/lib/component/src/Registry.luau new file mode 100644 index 00000000..45096773 --- /dev/null +++ b/lib/component/src/Registry.luau @@ -0,0 +1,81 @@ +--!strict +-- Global cross-class component registry. +-- Authors: Logan Hunt [Raildex] +--[=[ + @class ComponentRegistry + @ignore + + A module-level map of `Roblox Instance -> { [ComponentClass]: componentInstance }` + spanning every component class. The original Component only tracked instances + per-class, so a cross-class question like "what components does this instance + have?" was unanswerable. This registry backs `Component.GetComponents(instance)` + and the world-level query engine. + + A component is registered the moment its construction completes (before it + starts) and unregistered during teardown, mirroring the per-class tracking in + `init.luau`. +]=] + +type ComponentClass = any +type Component = any + +local instanceToClasses: { [Instance]: { [ComponentClass]: Component } } = {} + +local Registry = {} + +--[=[ + @within ComponentRegistry + Registers a constructed component under its instance and class. +]=] +function Registry.Register(instance: Instance, class: ComponentClass, component: Component) + local classes = instanceToClasses[instance] + if not classes then + classes = {} + instanceToClasses[instance] = classes + end + classes[class] = component +end + +--[=[ + @within ComponentRegistry + Removes a component's registration. Cleans up the instance entry entirely + once it has no remaining components, so the registry never retains destroyed + instances. +]=] +function Registry.Unregister(instance: Instance, class: ComponentClass) + local classes = instanceToClasses[instance] + if not classes then + return + end + classes[class] = nil + if next(classes) == nil then + instanceToClasses[instance] = nil + end +end + +--[=[ + @within ComponentRegistry + Returns the component of `class` bound to `instance`, or `nil`. +]=] +function Registry.Get(instance: Instance, class: ComponentClass): Component? + local classes = instanceToClasses[instance] + return if classes then classes[class] else nil +end + +--[=[ + @within ComponentRegistry + Returns a fresh array of every component bound to `instance`, across all + classes. +]=] +function Registry.GetAll(instance: Instance): { Component } + local out = {} + local classes = instanceToClasses[instance] + if classes then + for _, component in classes do + table.insert(out, component) + end + end + return out +end + +return Registry diff --git a/lib/component/src/Tests/Component.Errors.spec.luau b/lib/component/src/Tests/Component.Errors.spec.luau new file mode 100644 index 00000000..fbe5c068 --- /dev/null +++ b/lib/component/src/Tests/Component.Errors.spec.luau @@ -0,0 +1,175 @@ +--!nonstrict +--[[ + The full expected-behavior image: an error and a yield injected at every + lifecycle point (each extension hook, each component hook, and mid-hook + cancellation), asserting the resulting phase transitions, teardown behavior, + and core-Janitor cleanup in each case. +]] + +local CollectionService = game:GetService("CollectionService") + +return function(t: any) + local H = require(script.Parent.Helpers) + + local describe = t.describe + local test = t.test + local expect = t.expect + + -- Build a component whose lifecycle points behave per `behaviors[point]`, + -- which is "error", "yield", or a function. `log` records what happened. + local function scenario(behaviors) + behaviors = behaviors or {} + local log = { stopReason = nil, cleaned = false, started = false, order = {} } + local function act(name) + table.insert(log.order, name) + local b = behaviors[name] + if b == "error" then + error("boom@" .. name) + elseif b == "yield" then + task.wait(0.04) + elseif type(b) == "function" then + return b() + end + end + local ext = { + Constructing = function() + return act("Constructing") + end, + Constructed = function() + return act("Constructed") + end, + Starting = function() + return act("Starting") + end, + Started = function() + return act("Started") + end, + Stopping = function() + table.insert(log.order, "Stopping") + end, + Stopped = function() + table.insert(log.order, "Stopped") + end, + } + local def = { + Construct = function(self) + self:AddTask(function() + log.cleaned = true + end) + return act("Construct") + end, + Start = function(self) + log.started = true + return act("Start") + end, + Stop = function(_, reason) + log.stopReason = reason + end, + } + local class, tag = H.makeClass(def, { Extensions = { ext } }) + return class, tag, log + end + + local ERROR_POINTS = { "Constructing", "Construct", "Constructed", "Starting", "Start", "Started" } + + describe("an error at any lifecycle point tears down cleanly", function() + for _, point in ERROR_POINTS do + test(`error in {point}: Stop runs, core Janitor cleaned, not left tracked`, function() + local class, tag, log = scenario { [point] = "error" } + local part = H.taggedPart(tag) + + expect(H.waitUntil(function() + return log.stopReason ~= nil + end, 3)).is(true) + -- Construct registers the AddTask cleanup, so anything from Construct + -- onward must have been cleaned. (An error in the Constructing hook + -- precedes Construct, so nothing was registered to clean.) + if point ~= "Constructing" then + expect(log.cleaned).is(true) + end + expect(class:Has(part)).is(false) + expect(class:FromInstance(part)).never_exists() + + class:Destroy() + part:Destroy() + end) + end + end) + + describe("a yield at any lifecycle point still completes", function() + for _, point in { "Constructing", "Construct", "Constructed", "Starting", "Start", "Started" } do + test(`yield in {point}: component still starts`, function() + local class, tag = scenario { [point] = "yield" } + local part = H.taggedPart(tag) + expect(H.waitStarted(class, part, 3)).is(true) + class:Destroy() + part:Destroy() + end) + end + end) + + describe("isolation and cancellation", function() + test("one erroring component class does not affect another on the same instance", function() + local goodStarted = false + local badClass, badTag = H.makeClass { + Construct = function() + error("bad") + end, + } + local goodClass, goodTag = H.makeClass { + Start = function() + goodStarted = true + end, + } + local part = Instance.new("Part") + part.Anchored = true + CollectionService:AddTag(part, badTag) + CollectionService:AddTag(part, goodTag) + part.Parent = workspace + + expect(H.waitUntil(function() + return goodStarted + end, 3)).is(true) + expect(badClass:Has(part)).is(false) + + badClass:Destroy() + goodClass:Destroy() + part:Destroy() + end) + + test("untagging during a yielding Construct cancels it; never starts", function() + local class, tag, log = scenario { + Construct = function() + task.wait(0.3) + end, + } + local part = H.taggedPart(tag) + task.wait(0.05) + CollectionService:RemoveTag(part, tag) + + task.wait(0.4) + expect(log.started).is(false) + expect(class:Has(part)).is(false) + + class:Destroy() + part:Destroy() + end) + + test("leaving valid ancestors during a yielding Construct cancels it", function() + local class, tag, log = scenario { + Construct = function() + task.wait(0.3) + end, + } + local part = H.taggedPart(tag) + task.wait(0.05) + part.Parent = game:GetService("ReplicatedStorage") + + task.wait(0.4) + expect(log.started).is(false) + + class:Destroy() + part:Destroy() + end) + end) +end diff --git a/lib/component/src/Tests/Component.Extensions.spec.luau b/lib/component/src/Tests/Component.Extensions.spec.luau new file mode 100644 index 00000000..750f6c07 --- /dev/null +++ b/lib/component/src/Tests/Component.Extensions.spec.luau @@ -0,0 +1,203 @@ +--!nonstrict +--[[ + Extension system: ShouldConstruct veto, ShouldExtend, topological ordering of + dependencies, Method binding + collision detection, and the phase barrier + (hooks run concurrently within a phase; a Promise-returning hook gates the + component's own method). +]] + +return function(t: any) + local H = require(script.Parent.Helpers) + local Component = require(script.Parent.Parent :: any) :: any + + local describe = t.describe + local test = t.test + local expect = t.expect + + describe("ShouldConstruct", function() + test("a vetoing extension prevents construction entirely", function() + local constructed = false + local ext = { + ShouldConstruct = function() + return false + end, + } + local class, tag = H.makeClass({ + Construct = function() + constructed = true + end, + }, { Extensions = { ext } }) + local part = H.taggedPart(tag) + task.wait(0.1) + expect(constructed).is(false) + expect(class:Has(part)).is(false) + class:Destroy() + part:Destroy() + end) + end) + + describe("ShouldExtend", function() + test("hooks of a non-extending extension do not run", function() + local ran = false + local ext = { + ShouldExtend = function() + return false + end, + Constructing = function() + ran = true + end, + } + local class, tag = H.makeClass({}, { Extensions = { ext } }) + local part = H.taggedPart(tag) + expect(H.waitStarted(class, part, 3)).is(true) + expect(ran).is(false) + class:Destroy() + part:Destroy() + end) + end) + + describe("topological ordering", function() + test("a dependency's hook runs before the extension that depends on it", function() + local order = {} + local depExt = { + Constructing = function() + table.insert(order, "dep") + end, + } + local mainExt = { + Extensions = { depExt }, + Constructing = function() + table.insert(order, "main") + end, + } + local class, tag = H.makeClass({}, { Extensions = { mainExt } }) + local part = H.taggedPart(tag) + expect(H.waitStarted(class, part, 3)).is(true) + expect(order[1]).is("dep") + expect(order[2]).is("main") + class:Destroy() + part:Destroy() + end) + + test("a dependent hook waits for a yielding dependency to finish", function() + local order = {} + local depExt = { + Starting = function() + table.insert(order, "dep:begin") + task.wait(0.05) + table.insert(order, "dep:end") + end, + } + local mainExt = { + Extensions = { depExt }, + Starting = function() + table.insert(order, "main") + end, + } + local class, tag = H.makeClass({}, { Extensions = { mainExt } }) + local part = H.taggedPart(tag) + expect(H.waitStarted(class, part, 3)).is(true) + expect(table.concat(order, ",")).is("dep:begin,dep:end,main") + class:Destroy() + part:Destroy() + end) + + test("stop hooks run in reverse dependency order", function() + local order = {} + local depExt = { + Stopping = function() + table.insert(order, "dep") + end, + } + local mainExt = { + Extensions = { depExt }, + Stopping = function() + task.wait(0.05) + table.insert(order, "main") + end, + } + local class, tag = H.makeClass({}, { Extensions = { mainExt } }) + local part = H.taggedPart(tag) + expect(H.waitStarted(class, part, 3)).is(true) + part:Destroy() + expect(H.waitUntil(function() + return #order == 2 + end, 3)).is(true) + expect(order[1]).is("main") + expect(order[2]).is("dep") + class:Destroy() + end) + end) + + describe("Methods", function() + test("extension methods are available on the component", function() + local ext = { + Methods = { + Greet = function(self) + return "hi:" .. self.Instance.Name + end, + }, + } + local class, tag = H.makeClass({}, { Extensions = { ext } }) + local part = H.taggedPart(tag) + expect(H.waitStarted(class, part, 3)).is(true) + expect(class:FromInstance(part):Greet()).is("hi:" .. part.Name) + class:Destroy() + part:Destroy() + end) + + test("colliding method names across extensions error at setup", function() + local e1 = { Methods = { Foo = function() end } } + local e2 = { Methods = { Foo = function() end } } + expect(function() + Component.new { Tag = H.uniqueTag(), Extensions = { e1, e2 } } + end).fails() + end) + end) + + describe("phase barrier", function() + test("extension hooks within a phase run concurrently", function() + local active, maxActive = 0, 0 + local function slow() + active += 1 + maxActive = math.max(maxActive, active) + task.wait(0.05) + active -= 1 + end + local e1 = { Constructing = slow } + local e2 = { Constructing = slow } + local class, tag = H.makeClass({}, { Extensions = { e1, e2 } }) + local part = H.taggedPart(tag) + expect(H.waitStarted(class, part, 3)).is(true) + expect(maxActive).is(2) + class:Destroy() + part:Destroy() + end) + + test("a Promise-returning hook gates the next phase", function() + local Promise = require(script.Parent.Parent.Parent.Promise :: any) :: any + local resolved = false + local ext = { + Constructing = function() + return Promise.new(function(resolve) + task.delay(0.05, function() + resolved = true + resolve() + end) + end) + end, + } + local sawResolvedInConstruct = nil + local class, tag = H.makeClass({ + Construct = function() + sawResolvedInConstruct = resolved + end, + }, { Extensions = { ext } }) + local part = H.taggedPart(tag) + expect(H.waitStarted(class, part, 3)).is(true) + expect(sawResolvedInConstruct).is(true) + class:Destroy() + part:Destroy() + end) + end) +end diff --git a/lib/component/src/Tests/Component.Lifecycle.spec.luau b/lib/component/src/Tests/Component.Lifecycle.spec.luau new file mode 100644 index 00000000..9cac5bc8 --- /dev/null +++ b/lib/component/src/Tests/Component.Lifecycle.spec.luau @@ -0,0 +1,218 @@ +--!nonstrict +--[[ + Core lifecycle coverage: construct -> start -> stop across the real + CollectionService + ancestry machinery. Runs in the Open Cloud server context + (a real DataModel, so tags, RunService, and task scheduling all behave). +]] + +local CollectionService = game:GetService("CollectionService") + +return function(t: any) + local Component = require(script.Parent.Parent :: any) :: any + + local describe = t.describe + local test = t.test + local expect = t.expect + + local tagCounter = 0 + local function uniqueTag(): string + tagCounter += 1 + return `CmpSpec_{tagCounter}_{os.clock()}` + end + + -- Spin the scheduler until `predicate()` is truthy or we time out. + local function waitUntil(predicate, timeout: number?) + local deadline = os.clock() + (timeout or 2) + while os.clock() < deadline do + if predicate() then + return true + end + task.wait() + end + return predicate() + end + + -- Build a tagged part under workspace and its component class; returns both + -- plus a cleanup fn. + local function makeClass(overrides, ancestors) + local tag = uniqueTag() + local class = Component.new { Tag = tag, Ancestors = ancestors or { workspace } } + if overrides then + for k, v in overrides do + class[k] = v + end + end + return class, tag + end + + local function taggedPart(tag: string): Instance + local part = Instance.new("Part") + part.Anchored = true + CollectionService:AddTag(part, tag) + part.Parent = workspace + return part + end + + describe("construct -> start", function() + test("a tagged instance under a valid ancestor constructs and starts", function() + local order = {} + local class, tag = makeClass { + Construct = function(self) + table.insert(order, "construct") + self.value = 10 + end, + Start = function(self) + table.insert(order, "start") + end, + } + local part = taggedPart(tag) + + expect(waitUntil(function() + return class:Has(part) + end)).is(true) + + local component = class:FromInstance(part) + expect(component).exists() + expect(component.value).is(10) + expect(component:IsStarted()).is(true) + expect(class:GetLifecycleStatus(part)).is("Started") + expect(order[1]).is("construct") + expect(order[2]).is("start") + + class:Destroy() + part:Destroy() + end) + + test("Component.GetComponents returns every class's component on an instance", function() + local A, aTag = makeClass() + local B, bTag = makeClass() + local part = Instance.new("Part") + part.Anchored = true + CollectionService:AddTag(part, aTag) + CollectionService:AddTag(part, bTag) + part.Parent = workspace + + expect(waitUntil(function() + return A:Has(part) and B:Has(part) + end)).is(true) + + local all = Component.GetComponents(part) + local set = {} + for _, c in all do + set[getmetatable(c)] = true + end + expect(#all).is(2) + expect(set[A]).is(true) + expect(set[B]).is(true) + + A:Destroy() + B:Destroy() + part:Destroy() + end) + + test("instances outside valid ancestors do not construct", function() + local class, tag = makeClass { Ancestors = { workspace } } + local part = Instance.new("Part") + CollectionService:AddTag(part, tag) + part.Parent = game:GetService("ReplicatedStorage") + + task.wait() + task.wait() + expect(class:FromInstance(part)).never_exists() + + class:Destroy() + part:Destroy() + end) + end) + + describe("teardown", function() + test("untagging stops the component and runs Stop with reason Untagged", function() + local stops = {} + local class, tag = makeClass { + Stop = function(self, reason) + table.insert(stops, reason) + end, + } + local part = taggedPart(tag) + expect(waitUntil(function() + return class:Has(part) + end)).is(true) + + CollectionService:RemoveTag(part, tag) + expect(waitUntil(function() + return #stops > 0 + end)).is(true) + expect(stops[1]).is("Untagged") + expect(class:FromInstance(part)).never_exists() + + class:Destroy() + part:Destroy() + end) + + test("AddTask resources are cleaned up on stop regardless", function() + local cleaned = false + local class, tag = makeClass { + Construct = function(self) + self:AddTask(function() + cleaned = true + end) + end, + } + local part = taggedPart(tag) + expect(waitUntil(function() + return class:Has(part) + end)).is(true) + + CollectionService:RemoveTag(part, tag) + expect(waitUntil(function() + return cleaned + end)).is(true) + + class:Destroy() + part:Destroy() + end) + + test("class:Destroy stops components with reason ClassDestroyed", function() + local reason + local class, tag = makeClass { + Stop = function(self, r) + reason = r + end, + } + local part = taggedPart(tag) + expect(waitUntil(function() + return class:Has(part) + end)).is(true) + + class:Destroy() + expect(waitUntil(function() + return reason ~= nil + end)).is(true) + expect(reason).is("ClassDestroyed") + + part:Destroy() + end) + end) + + describe("yielding lifecycle", function() + test("a yielding Construct still completes and starts", function() + local class, tag = makeClass { + Construct = function(self) + task.wait(0.05) + self.ready = true + end, + Start = function(self) + self.started = self.ready == true + end, + } + local part = taggedPart(tag) + expect(waitUntil(function() + return class:Has(part) + end, 3)).is(true) + expect(class:FromInstance(part).started).is(true) + + class:Destroy() + part:Destroy() + end) + end) +end diff --git a/lib/component/src/Tests/Component.NewMethods.spec.luau b/lib/component/src/Tests/Component.NewMethods.spec.luau new file mode 100644 index 00000000..b2907738 --- /dev/null +++ b/lib/component/src/Tests/Component.NewMethods.spec.luau @@ -0,0 +1,118 @@ +--!nonstrict +--[[ + Config `Methods` / `Fields` / `InitFields`: methods are bound onto the class + before setup (collisions fail loudly), fields are copied onto every component + instance before it constructs. +]] + +return function(t: any) + local H = require(script.Parent.Helpers) + local Component = require(script.Parent.Parent :: any) :: any + + local describe = t.describe + local test = t.test + local expect = t.expect + + describe("Component.new with a Methods table", function() + test("methods are bound and callable, including lifecycle overrides", function() + local constructed = false + local class = Component.new { + Tag = H.uniqueTag(), + Ancestors = { workspace }, + Methods = { + Construct = function() + constructed = true + end, + Greet = function(_self, msg: string) + return "hello " .. msg + end, + }, + } + local part = H.taggedPart(class.Tag) + expect(H.waitStarted(class, part, 3)).is(true) + expect(constructed).is(true) + local comp = class:FromInstance(part) + expect(comp:Greet("world")).is("hello world") + class:Destroy() + part:Destroy() + end) + + test("a method colliding with an own class field errors", function() + expect(function() + Component.new { + Tag = H.uniqueTag(), + Ancestors = { workspace }, + Methods = { Started = function() end }, + } + end).fails() + end) + + test("an extension method colliding with a user method errors", function() + local ext = { Methods = { Foo = function() end } } + expect(function() + Component.new { + Tag = H.uniqueTag(), + Ancestors = { workspace }, + Extensions = { ext }, + Methods = { Foo = function() end }, + } + end).fails() + end) + end) + + describe("Component.new with Fields / InitFields", function() + test("fields are present before Construct and are per-instance copies", function() + local seenDuringConstruct + local class = Component.new { + Tag = H.uniqueTag(), + Ancestors = { workspace }, + Fields = { Count = 0, Nested = { list = { 1, 2 } } }, + InitFields = function() + return { Built = {} } + end, + Methods = { + Construct = function(self) + seenDuringConstruct = self.Count + end, + }, + } + + local partA = H.taggedPart(class.Tag) + local partB = H.taggedPart(class.Tag) + expect(H.waitStarted(class, partA, 3)).is(true) + expect(H.waitStarted(class, partB, 3)).is(true) + + local a, b = class:FromInstance(partA), class:FromInstance(partB) + expect(seenDuringConstruct).is(0) + expect(a.Instance).is(partA) + + -- Fields are deep-copied, so mutating one instance never touches the + -- other (or the config table). + a.Count = 5 + table.insert(a.Nested.list, 3) + expect(b.Count).is(0) + expect(#b.Nested.list).is(2) + expect(a.Nested == b.Nested).is(false) + + -- InitFields runs once per instance. + expect(a.Built == b.Built).is(false) + + class:Destroy() + partA:Destroy() + partB:Destroy() + end) + + test("fields cannot clobber the bound Instance", function() + local class = Component.new { + Tag = H.uniqueTag(), + Ancestors = { workspace }, + Fields = { Instance = "nope" }, + } + local part = H.taggedPart(class.Tag) + expect(H.waitStarted(class, part, 3)).is(true) + expect(class:FromInstance(part).Instance).is(part) + class:Destroy() + part:Destroy() + end) + end) +end diff --git a/lib/component/src/Tests/Component.Query.spec.luau b/lib/component/src/Tests/Component.Query.spec.luau new file mode 100644 index 00000000..bb8bf256 --- /dev/null +++ b/lib/component/src/Tests/Component.Query.spec.luau @@ -0,0 +1,275 @@ +--!nonstrict +--[[ + World-level query engine: joins, exclusion, any-of, tag-string and attribute + requirements, `where` predicates with a recheck signal, sub-query composition, + observe/GetMatches, plus GetOrCreateFromInstance and the validation errors. +]] + +local CollectionService = game:GetService("CollectionService") + +return function(t: any) + local H = require(script.Parent.Helpers) + local Component = require(script.Parent.Parent :: any) :: any + + local describe = t.describe + local test = t.test + local expect = t.expect + + -- Part carrying an arbitrary set of raw tags, under workspace. + local function part(tags): Instance + local p = Instance.new("Part") + p.Anchored = true + for _, tg in tags do + CollectionService:AddTag(p, tg) + end + p.Parent = workspace + return p + end + + describe("joins and exclusion", function() + test("observe fires while all required components are started, cleans on unmatch", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local cleaned = false + local seen = {} + + local obs = Component.query(A, B):observe(function(instance, jani) + seen[instance] = true + jani:Add(function() + cleaned = true + end) + end) + + local p = part { aTag, bTag } + expect(H.waitUntil(function() + return seen[p] == true + end, 3)).is(true) + + -- Remove B -> match breaks -> janitor cleans. + CollectionService:RemoveTag(p, bTag) + expect(H.waitUntil(function() + return cleaned + end, 3)).is(true) + + obs:Disconnect() + A:Destroy() + B:Destroy() + p:Destroy() + end) + + test("without excludes instances that have the excluded component", function() + local A, aTag = H.makeClass() + local Bad, badTag = H.makeClass() + local live = {} -- live membership: set on match, cleared on unmatch + local obs = Component.query(A):without(Bad):observe(function(instance, jani) + live[instance] = true + jani:Add(function() + live[instance] = nil + end) + end) + + local clean = part { aTag } + local excluded = part { aTag, badTag } + -- Wait until Bad has actually started on the excluded part, so any + -- transient pre-Bad match has resolved to an unmatch. + expect(H.waitUntil(function() + return live[clean] == true and Bad:Has(excluded) + end, 3)).is(true) + task.wait(0.05) + expect(live[excluded]).never_exists() + expect(live[clean]).is(true) + + obs:Disconnect() + A:Destroy() + Bad:Destroy() + clean:Destroy() + excluded:Destroy() + end) + end) + + describe("requirement kinds", function() + test("a raw tag string is a valid requirement", function() + local A, aTag = H.makeClass() + local flammable = H.uniqueTag() + local matched = {} + local obs = Component.query(A, flammable):observe(function(instance) + matched[instance] = true + end) + + local p = part { aTag, flammable } + expect(H.waitUntil(function() + return matched[p] == true + end, 3)).is(true) + + obs:Disconnect() + A:Destroy() + p:Destroy() + end) + + test("withAttribute matches by value and by predicate, reactively", function() + local A, aTag = H.makeClass() + local redMatched, lowHpMatched = {}, {} + local obsRed = Component.query(A):withAttribute("Team", "Red"):observe(function(i) + redMatched[i] = true + end) + local obsHp = Component.query(A) + :withAttribute("Hp", function(v) + return type(v) == "number" and v > 0 + end) + :observe(function(i) + lowHpMatched[i] = true + end) + + local p = part { aTag } + p:SetAttribute("Team", "Blue") + p:SetAttribute("Hp", 0) + expect(H.waitStarted(A, p, 3)).is(true) + task.wait(0.1) + expect(redMatched[p]).never_exists() + expect(lowHpMatched[p]).never_exists() + + p:SetAttribute("Team", "Red") + p:SetAttribute("Hp", 50) + expect(H.waitUntil(function() + return redMatched[p] and lowHpMatched[p] + end, 3)).is(true) + + obsRed:Disconnect() + obsHp:Disconnect() + A:Destroy() + p:Destroy() + end) + + test("a sub-query is a valid requirement", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local sub = Component.query(B) + local matched = {} + local obs = Component.query(A):with(sub):observe(function(i) + matched[i] = true + end) + + local p = part { aTag, bTag } + expect(H.waitUntil(function() + return matched[p] == true + end, 3)).is(true) + + obs:Disconnect() + A:Destroy() + B:Destroy() + p:Destroy() + end) + end) + + describe("where + recheck", function() + test("a where predicate re-evaluates when its recheck signal fires", function() + local Signal = require(script.Parent.Parent.Parent.Signal :: any) :: any + local A, aTag = H.makeClass() + local gate = false + local recheck = Signal.new() + local matched = {} + local obs = Component.query(A) + :where(function() + return gate + end, recheck) + :observe(function(i) + matched[i] = true + end) + + local p = part { aTag } + expect(H.waitStarted(A, p, 3)).is(true) + task.wait(0.1) + expect(matched[p]).never_exists() + + gate = true + recheck:Fire() + expect(H.waitUntil(function() + return matched[p] == true + end, 3)).is(true) + + obs:Disconnect() + A:Destroy() + p:Destroy() + end) + end) + + describe("GetMatches (one-shot)", function() + test("returns currently-matching instances without subscribing", function() + local A, aTag = H.makeClass() + local p1 = part { aTag } + local p2 = part { aTag } + expect(H.waitStarted(A, p1, 3)).is(true) + expect(H.waitStarted(A, p2, 3)).is(true) + + local matches = Component.query(A):GetMatches() + local set = {} + for _, inst in matches do + set[inst] = true + end + expect(set[p1]).is(true) + expect(set[p2]).is(true) + + A:Destroy() + p1:Destroy() + p2:Destroy() + end) + end) + + describe("validation", function() + test("a query with no positive requirement errors", function() + local Bad = H.makeClass() + expect(function() + Component.query():without(Bad):GetMatches() + end).fails() + end) + + test("a self-referential query errors", function() + local A = H.makeClass() + local q = Component.query(A) + q:with(q) + expect(function() + q:GetMatches() + end).fails() + end) + end) + + describe("GetOrCreateFromInstance", function() + test("resolves for an already-started component", function() + local A, aTag = H.makeClass() + local p = part { aTag } + expect(H.waitStarted(A, p, 3)).is(true) + local resolved = A:GetOrCreateFromInstance(p):expect() + expect(resolved).is(A:FromInstance(p)) + A:Destroy() + p:Destroy() + end) + + test("tags and constructs an untagged instance", function() + local A, tag = H.makeClass() + local p = Instance.new("Part") + p.Anchored = true + p.Parent = workspace + local comp = A:GetOrCreateFromInstance(p):expect() + expect(comp).exists() + expect(CollectionService:HasTag(p, tag)).is(true) + A:Destroy() + p:Destroy() + end) + + test("rejects when ShouldConstruct vetoes", function() + local ext = { + ShouldConstruct = function() + return false + end, + } + local A = H.makeClass({}, { Extensions = { ext } }) + local p = Instance.new("Part") + p.Anchored = true + p.Parent = workspace + local ok = A:GetOrCreateFromInstance(p):await() + expect(ok).is(false) + A:Destroy() + p:Destroy() + end) + end) +end diff --git a/lib/component/src/Tests/Component.Siblings.spec.luau b/lib/component/src/Tests/Component.Siblings.spec.luau new file mode 100644 index 00000000..60a8ee06 --- /dev/null +++ b/lib/component/src/Tests/Component.Siblings.spec.luau @@ -0,0 +1,117 @@ +--!nonstrict +--[[ + Sibling binding: WhileHasComponent / WhileHasComponents run their function + while the sibling component(s) are present on the same instance, and clean up + their Janitor when a sibling stops or the owning component stops. +]] + +local CollectionService = game:GetService("CollectionService") + +return function(t: any) + local H = require(script.Parent.Helpers) + + local describe = t.describe + local test = t.test + local expect = t.expect + + describe("WhileHasComponent", function() + test("runs while the sibling exists and cleans up when it stops", function() + local ran, cleaned = false, false + local B, bTag = H.makeClass() + local A, aTag = H.makeClass { + Start = function(self) + self:WhileHasComponent(B, function(sibling, jani) + ran = sibling ~= nil + jani:Add(function() + cleaned = true + end) + end) + end, + } + + local p = Instance.new("Part") + p.Anchored = true + CollectionService:AddTag(p, aTag) + CollectionService:AddTag(p, bTag) + p.Parent = workspace + + expect(H.waitUntil(function() + return ran + end, 3)).is(true) + + CollectionService:RemoveTag(p, bTag) + expect(H.waitUntil(function() + return cleaned + end, 3)).is(true) + + A:Destroy() + B:Destroy() + p:Destroy() + end) + + test("cleans up when the owning component stops", function() + local cleaned = false + local B, bTag = H.makeClass() + local A, aTag = H.makeClass { + Start = function(self) + self:WhileHasComponent(B, function(_, jani) + jani:Add(function() + cleaned = true + end) + end) + end, + } + + local p = Instance.new("Part") + p.Anchored = true + CollectionService:AddTag(p, aTag) + CollectionService:AddTag(p, bTag) + p.Parent = workspace + + expect(H.waitStarted(A, p, 3)).is(true) + CollectionService:RemoveTag(p, aTag) + expect(H.waitUntil(function() + return cleaned + end, 3)).is(true) + + A:Destroy() + B:Destroy() + p:Destroy() + end) + end) + + describe("WhileHasComponents", function() + test("runs only when all sibling classes are present", function() + local ran = false + local B, bTag = H.makeClass() + local C, cTag = H.makeClass() + local A, aTag = H.makeClass { + Start = function(self) + self:WhileHasComponents({ B, C }, function(components) + ran = #components == 2 + end) + end, + } + + local p = Instance.new("Part") + p.Anchored = true + CollectionService:AddTag(p, aTag) + CollectionService:AddTag(p, bTag) + p.Parent = workspace + + expect(H.waitStarted(A, p, 3)).is(true) + task.wait(0.1) + expect(ran).is(false) -- C not present yet + + CollectionService:AddTag(p, cTag) + expect(H.waitUntil(function() + return ran + end, 3)).is(true) + + A:Destroy() + B:Destroy() + C:Destroy() + p:Destroy() + end) + end) +end diff --git a/lib/component/src/Tests/Component.types.luau b/lib/component/src/Tests/Component.types.luau new file mode 100644 index 00000000..e3f06e31 --- /dev/null +++ b/lib/component/src/Tests/Component.types.luau @@ -0,0 +1,176 @@ +--!strict +-- Compile-time regression tests for Component's exported types. This module is +-- never required at runtime — `luau-lsp analyze` coming back clean IS the test. +-- +-- NEW TYPE SOLVER ONLY: init.luau uses user-defined type functions to merge +-- extension methods into the class type, which the old solver cannot parse. +-- Run from the repo root (see rokit.toml for luau-lsp): +-- luau-lsp analyze --flag:LuauSolverV2=true --sourcemap=sourcemap.json --base-luaurc=.luaurc --definitions= lib/component/src/Tests/Component.types.luau + +local Component = require(script.Parent.Parent) +local Types = require(script.Parent.Parent.Types) + +-------------------------------------------------------------------------------- +-- Classic path: lifecycle methods defined post-hoc on the returned class. +-- `self` inside them is fully typed, and extension methods are merged in as +-- CHECKED props by the extensionMethods type function. Custom methods must go +-- through the config's `Methods` table — the class type has no `[string]: any` +-- indexer, so assigning a brand-new name post-hoc is a type error by design. +-------------------------------------------------------------------------------- + +local depExtension = {} +depExtension.Methods = {} + +function depExtension.Methods:HelloDependency(flag: boolean) + print("Hello from depExtension!", flag) +end + +local myExtension = {} +myExtension.Extensions = { depExtension } +myExtension.Methods = {} + +function myExtension.Methods:HelloExtension(num: number): number + print("Hello from myExtension!", num) + return num * 2 +end + +local otherExtension = {} +otherExtension.Methods = {} + +function otherExtension.Methods:HelloOther(msg: string) + print("Hello from otherExtension!", msg) +end + +local ClassicMethods = {} + +local myComponent = Component.new { + Tag = "TypeSpecComponent", + Ancestors = { workspace }, + Extensions = { myExtension, otherExtension }, + Methods = ClassicMethods, +} + +-- `self: any` keeps this callable from a post-hoc `function myComponent:Start()` +-- whose own `self` the solver infers structurally (an annotated `self` would +-- close the circular type instead — see the typed path below). +function ClassicMethods.HelloComponent(_self: any, str: string) + print("Hello from myComponent!", str) +end + +function myComponent:Start() + local inst: Instance = self.Instance -- typed via the rebound lifecycle self + local doubled: number = self:HelloExtension(123) -- CHECKED extension method + self:HelloOther("heterogeneous") -- CHECKED (second extension in the array) + self:HelloDependency(true) -- CHECKED (dependency extension's method) + self:HelloComponent("test") -- CHECKED (config Methods) + print(inst, doubled) +end + +function myComponent:Stop(reason) + local r: Component.StopReason = reason + print(r) +end + +function myComponent:HeartbeatUpdate(dt) + local n: number = dt + print(n) +end + +-- Checked extension method directly on the class object too. +myComponent:HelloExtension(7) + +-- Self-generic class API returns the receiver's own (merged) type. +local found = myComponent:FromInstance(workspace) +if found then + local inst: Instance = found.Instance + local doubled: number = found:HelloExtension(9) + found:HelloComponent("again") + print(inst, doubled) +end + +local phase: Component.LifecyclePhase = myComponent:GetLifecycleStatus(workspace) +print(phase) + +-------------------------------------------------------------------------------- +-- Typed path: fully checked methods + fields via the config's Methods/Fields. +-- The `self: MyTyped` annotations close the circular type; composing +-- Component.extensionMethods<> into the alias makes extension methods checked +-- on `self` inside method bodies as well. +-------------------------------------------------------------------------------- + +local Methods = {} +local TypedComponent = Component.new { + Tag = "TypeSpecTyped", + Extensions = { myExtension, otherExtension }, + Methods = Methods, + Fields = { + Foo = 5, + }, + InitFields = function() + return { Hello = "World" } + end, +} + +type tc = typeof(TypedComponent) +type ti = typeof(TypedComponent:__ComponentInstanceTypeExport()) + +-- Config `Fields` and `InitFields` both land on the INSTANCE type. +local sampleInstance: ti = nil :: any +local foo: number = sampleInstance.Foo -- from Fields +local hello: string = sampleInstance.Hello -- from InitFields +print(foo, hello, sampleInstance.Instance) + +type Extensions = Component.extensionMethods +type MyTypedClass = Types.TypedClass +type MyTypedInstance = Types.TypedInstance + +function Methods:Greet(msg: string): number + print(self.Instance, msg) + return #msg +end + +function Methods.Start(self) + local n: number = self:Greet("hello") -- fully checked + local doubled: number = self:HelloExtension(4) -- checked extension method + local inst: Instance = self.Instance + local th: thread = self:AddTask(task.spawn(function() end)) + print(n, doubled, inst, th) +end + +function Methods.UseSibling(self: ti) + self:CreateFromInstance(Instance.new("Model")) + + local sibling = self:GetComponent(myComponent) + if sibling then + local inst: Instance = sibling.Instance + sibling:HelloExtension(11) -- checked on the sibling's merged type + print(inst) + end + self:WhileHasComponent(myComponent, function(comp, jani) + comp:HelloComponent("sibling") + print(jani) + end) +end + +function Methods:Test(num: number): string + return `Test: {num}` +end + +local typedFound = TypedComponent:FromInstance(workspace) +if typedFound then + print(typedFound.Hello) + local n: number = typedFound:Greet("hi") -- checked on the derived type + local doubled: number = typedFound:HelloExtension(2) -- merged via extensionMethods + print(n, doubled) +end + +TypedComponent.Started:Connect(function(comp) + local n: number = comp:Greet("started") + print(n) +end) + +for _, c in TypedComponent:GetAll() do + c:Greet("each") +end + +return nil diff --git a/lib/component/src/Tests/Helpers.luau b/lib/component/src/Tests/Helpers.luau new file mode 100644 index 00000000..bdc154bf --- /dev/null +++ b/lib/component/src/Tests/Helpers.luau @@ -0,0 +1,64 @@ +--!nonstrict +-- Shared test utilities for the Component specs (not a spec itself). +local CollectionService = game:GetService("CollectionService") + +local Component = require(script.Parent.Parent :: any) :: any + +local Helpers = {} + +local counter = 0 +function Helpers.uniqueTag(): string + counter += 1 + return `CmpSpec_{counter}_{math.floor(os.clock() * 1e6)}` +end + +-- Spin the scheduler until predicate() is truthy or timeout elapses. +function Helpers.waitUntil(predicate, timeout: number?): boolean + local deadline = os.clock() + (timeout or 2) + while os.clock() < deadline do + if predicate() then + return true + end + task.wait() + end + return predicate() == true or predicate() ~= nil and predicate() ~= false +end + +-- Create a component class with a fresh tag. `def` is a table of methods/fields +-- copied onto the class; `config` overrides the ComponentConfig. +function Helpers.makeClass(def, config) + local tag = Helpers.uniqueTag() + local cfg = { Tag = tag, Ancestors = { workspace } } + if config then + for k, v in config do + cfg[k] = v + end + end + local class = Component.new(cfg) + if def then + for k, v in def do + class[k] = v + end + end + return class, tag +end + +-- Create an anchored, tagged Part parented under workspace. +function Helpers.taggedPart(tag: string, parent: Instance?): Instance + local part = Instance.new("Part") + part.Anchored = true + for _, singleTag in { tag } do + CollectionService:AddTag(part, singleTag) + end + part.Parent = parent or workspace + return part +end + +-- Convenience: wait until a component of `class` is started on `instance`. +function Helpers.waitStarted(class, instance, timeout: number?): boolean + return Helpers.waitUntil(function() + return class:Has(instance) + end, timeout) +end + +return Helpers diff --git a/lib/component/src/TypeFunctions.luau b/lib/component/src/TypeFunctions.luau new file mode 100644 index 00000000..384c36fb --- /dev/null +++ b/lib/component/src/TypeFunctions.luau @@ -0,0 +1,161 @@ +--!strict +-- User-defined type functions backing Component's public types (NEW type +-- solver only — the old solver cannot parse `type function`). +-- +-- Nonstrict on purpose: strict mode produces spurious nil-safety/refinement +-- errors against the `types` runtime API inside type function bodies. + +--[[ + Collects every method from the given extension type (a single extension + table type, or a union of them for heterogeneous `Extensions` arrays) into + one flat table type, recursing into dependency extensions (`ext.Extensions`) + the same way the runtime's Extensions.Resolve + BindMethods do. Each + method's `self` parameter is erased to `any` so the merged methods are + callable on any receiver; argument and return types stay checked. + + Compose into a typed-path self alias: + + ```lua + type MyComponent = Component.TypedClass> + ``` +]] +export type function extensionMethods(extensions: type) + local result = types.newtable(nil) + local seen = {} + + const function copyMethods(methods: type) + for key, prop in methods:properties() do + local read = prop.read or prop.write + if read and read:is("function") and #read:generics() == 0 then + local fn = types.copy(read) + local params = fn:parameters() + local head = params.head + if head and #head > 0 then + local newHead = table.clone(head) + newHead[1] = types.any + -- Drop the solver-inferred `...any` tail; keep genuine typed variadics. + fn:setparameters(newHead, if params.tail and not params.tail:is("any") then params.tail else nil) + end + read = fn + end + result:setproperty(key, read) + end + end + + local visitAll + + const function visit(ext: type) + if not ext:is("table") or seen[ext] then + return + end + seen[ext] = true + const methods = ext:readproperty(types.singleton("Methods")) + if methods and methods:is("table") then + copyMethods(methods) + end + const deps = ext:readproperty(types.singleton("Extensions")) + if deps and deps:is("table") then + const indexer = deps:indexer() + if indexer then + visitAll(indexer.readresult or indexer.value) + end + end + end + + function visitAll(t: type) + if t == nil then + return + end + for _, e in if t:is("union") then t:components() else { t } do + visit(e) + end + end + + visitAll(extensions) + return result +end + +--[[ + Builds the type returned by `Component.new`. Both arities route here: + + - One-arg (`Component.new(config)`): pass no `methods`. Result is a copy of + `base` (ComponentClass) with extension methods merged in as checked props, + plus the `[string]: any` indexer for post-hoc methods. + - Two-arg (`Component.new(config, methods)`): pass `methods`. Same merge plus + the user's methods as checked props, and NO indexer — adding methods after + `new` is a type error on this path by design. + + Lifecycle props (Construct/Start/Stop and the update loops) plus every user + method get their `self` rebound to the merged type — that is what makes + extension methods checked on `self` inside `function MyComponent:Start()` + bodies and calls like `class:Greet("hi")` check against the class's own type. + Rebinding EVERY inherited method's self makes the recursive type too complex + for the solver ("Code is too complex to typecheck"), so only lifecycle + + user methods are rebound. Built flat instead of as + `TypedClass>` because that intersection makes + overload resolution at the `new` call site exceed the solver's limit. +]] +export type function classWith(base: type, extensions: type, methods: type) + if not base:is("table") then + print("classWith: base is not a table") + return base + end + + const typed = methods and methods:is("table") + const result = if typed + then types.newtable(nil) + else types.newtable(nil, { index = types.string, readresult = types.any, writeresult = types.any }) + + for key, prop in base:properties() do + result:setproperty(key, prop.read or prop.write) + end + + -- Rebinds a function's self parameter to the merged type so calls on the + -- returned class check against the class's own type. + const function rebound(fn: type) + const copy = types.copy(fn) + const params = copy:parameters() + local head = params.head + if head and #head > 0 then + local newHead = table.clone(head) + newHead[1] = result + -- Drop the solver-inferred `...any` tail; keep genuine typed variadics. + if params.tail and not params.tail:is("any") then + copy:setparameters(newHead, params.tail) + else + copy:setparameters(newHead) + end + end + return copy + end + + for _, name in { "Construct", "Start", "Stop", "HeartbeatUpdate", "SteppedUpdate", "RenderSteppedUpdate" } do + const key = types.singleton(name) + const read = base:readproperty(key) + if read and read:is("function") then + -- print("Rebinding lifecycle method:", name) + result:setproperty(key, rebound(read)) + end + end + + -- Attach extension methods as checked props, with self erased to any so they are callable on any receiver. + for key, prop in extensionMethods(extensions):properties() do + result:setproperty(key, prop.read or prop.write) + end + + if typed then + for key, prop in methods:properties() do + const read = prop.read or prop.write + if read and read:is("function") and #read:generics() == 0 then + -- print("Rebinding method:", key:value()) + result:setproperty(key, rebound(read)) + else + result:setproperty(key, read) + end + end + end + + return result +end + +return {} diff --git a/lib/component/src/Types.luau b/lib/component/src/Types.luau new file mode 100644 index 00000000..5b7e376d --- /dev/null +++ b/lib/component/src/Types.luau @@ -0,0 +1,330 @@ +--!strict +-- Component type declarations: the public type surface (re-exported by +-- `init.luau`) plus the internal implementation views. +-- Authors: Stephen Leitnick, Logan Hunt [Raildex] +--[=[ + @class ComponentTypes + @ignore + + `ComponentClass` / `TypedClass` are the public-facing types returned by + `Component.new`. `ClassAny` / `InstanceAny` are the *internal* structural + views the implementation types its `self` parameters with (TableManager's + `TM_Internal` pattern): `ClassAny` is the API reachable on a component + class, `InstanceAny` extends it with the fields that only exist on a bound + component instance. `AnyComponent` is the deliberately loose `{[any]: any}` + view used in public signatures where `any` must bridge subtyping (see the + comments below). +]=] + +const Packages = script.Parent.Parent +const Janitor = require(Packages.Janitor) +const Signal = require(Packages.Signal) +const Promise = require(Packages.Promise) + +const Keys = require(script.Parent.Keys) +const TypeFunctions = require(script.Parent.TypeFunctions) + +type Promise = Promise.TypedPromise +type Janitor = Janitor.Janitor +export type LifecyclePhase = Keys.LifecyclePhase +export type StopReason = Keys.StopReason + +-- Loose view of a component/class used where `any` must bridge subtyping: +-- users pass heterogeneous concrete classes ({ MyClassA, MyClassB }) into +-- `{ AnyComponent }` parameters, and array element types are invariant — only +-- `any` accepts every concrete class type there. +export type AnyComponent = { [any]: any } + +export type AncestorList = { Instance } + +export type ComponentSignal = Signal.Signal<(T...) -> (), T...> + +--[=[ + @type ExtensionFn (component) -> () + @within Component +]=] +export type ExtensionFn = (any) -> () + +--[=[ + @type ExtensionYieldableFn (component) -> Promise + @within Component + + A yieldable extension hook is one that may alternatively return a Promise. +]=] +export type ExtensionYieldableFn = (any) -> Promise + +--[=[ + @type ExtensionShouldFn (component) -> boolean + @within Component +]=] +export type ExtensionShouldFn = (any) -> boolean + +--[=[ + @interface Extension + @within Component + .ShouldExtend ExtensionShouldFn? + .ShouldConstruct ExtensionShouldFn? + .Constructing ExtensionYieldableFn? + .Constructed ExtensionYieldableFn? + .Starting ExtensionFn? + .Started ExtensionFn? + .Stopping ExtensionFn? + .Stopped ExtensionFn? + .Extensions {Extension}? + .Methods {[string]: (component, ...any) -> ...any}? + + Extensions hook into the component lifecycle. `ShouldConstruct` (all must pass) + gates construction. `ShouldExtend` toggles an extension per-instance. `Methods` + are added to the component **class** (available regardless of `ShouldExtend`); + a name collision between two extensions, or with an existing class member, is an + error. Extensions listed in another extension's `Extensions` array are treated + as dependencies and resolved first (topological order; a cycle is an error). +]=] +export type Extension = { + ShouldExtend: ExtensionShouldFn?, + ShouldConstruct: ExtensionShouldFn?, + Constructing: ExtensionFn?, + Constructed: ExtensionFn?, + Starting: ExtensionFn?, + Started: ExtensionFn?, + Stopping: ExtensionFn?, + Stopped: ExtensionFn?, + Extensions: { Extension }?, + -- Loosely typed: the new solver rejects a user's concrete methods table + -- (named function props) against a `{ [string]: function }` indexer type. + Methods: any?, +} + +--[=[ + @interface ComponentConfig + @within Component + .Tag string + .Ancestors {Instance}? + .Extensions {Extension}? + .DelaySetup boolean? + .Methods {[string]: function}? + .Fields {[string]: any}? + .InitFields (() -> {[string]: any})? + + Passed to `Component.new`. `Ancestors` defaults to `{workspace, Players}`. + + `Methods` are merged onto the component **class** (a name collision with an + existing class member or an extension method is an error). `Fields` are + deep-copied onto every component **instance** before `Construct`, so a table + default is never shared between instances; `InitFields` is called once per + instance and its result copied over (shallow), for fields that need to be + built at runtime. Both are applied before `Instance` is set, so neither can + clobber it. +]=] +export type ComponentConfig = { + Tag: string, + Ancestors: AncestorList?, + -- `{ any }`, not `{ Extension }`: the new solver rejects concrete extension + -- tables against optional structured props ("is not exactly" union checks), + -- and the old solver can't unify heterogeneous extension arrays either way. + -- `Extension` documents the shape; runtime validates it. + Extensions: { any }?, + DelaySetup: boolean?, + -- Loosely typed for the same reason as `Extension.Methods`: the new solver + -- rejects a concrete methods/fields table against a `{ [string]: T }` + -- indexer. `ComponentConfigOf` is the checked, generic view used by + -- `Component.new`; this is the documented runtime shape. + Methods: any?, + Fields: any?, + InitFields: (() -> any)?, +} + +-- Generic config used by `Component.new`'s signature: `E` captures the type of +-- the extension tables passed in the `Extensions` array (a union for +-- heterogeneous arrays), which the type functions turn into checked extension +-- methods on the returned class. The `[string]: any` indexer is required: +-- without it the new solver rejects config literals that omit optional keys +-- when inferring through the generic. +-- `F` and `IF` are separate generics so a config may use `Fields` and +-- `InitFields` together (they contribute different keys); the instance type +-- gets both, merged. +export type ComponentConfigOf = { + Tag: string, + Ancestors: AncestorList?, + -- `Ancestors` is deliberately NOT declared here: the new solver's + -- union-exactness rejects a concrete `{ workspace }` (i.e. `{Workspace}`) + -- against an optional `{ Instance }` / `{ any }` prop, and array element + -- types are invariant. It flows through the `[string]: any` indexer below; + -- `ComponentConfig` documents and the runtime uses the real shape. + DelaySetup: boolean?, + Extensions: { E }?, + -- Attaches user methods to the class type + Methods: M?, + -- Attaches user fields to the instance type. An alternate function form is + -- provided for fields that may require runtime construction like tables. + -- Anything in `Fields` is cloned/deep copied into the instance on construction. + -- InitFields' result is just directly copied over. + Fields: F?, + InitFields: (() -> IF)?, + [string]: any, +} + +--[=[ + @interface Connection + @within Component + .IsConnected boolean + .Disconnect () -> () + .Destroy () -> () + Returned by [Component:WhileHasComponent] / [Component:WhileHasComponents]. + Also callable (`conn()` is `conn:Destroy()`). +]=] +export type Connection = { + IsConnected: boolean, + Disconnect: () -> (), + Destroy: () -> (), +} + +--[[ + The type returned by `Component.new(config)`: `M` (the config's `Methods`, + plus `extensionMethods` when it has Extensions) is merged in, so user + methods are real, checked props, and `F` (its `Fields` / `InitFields`) lands + on the instance type. No indexer — adding a new method after `new` is a type + error by design. + + Usage (the `self: MyComponent` annotations close the circular type; add + `& Component.extensionMethods` to see extension methods + checked on `self` inside method bodies): + + ```lua + local Methods = {} + type MyComponent = Component.TypedClass + + function Methods.Greet(self: MyComponent, msg: string) print(self.Instance, msg) end + function Methods.Start(self: MyComponent) self:Greet("hi") end + + local MyComponent = Component.new({ Tag = "MyComponent", Methods = Methods }) + ``` +]] +export type TypedClass = { + --- Just for easy access to the instance type. Luau hates if this is a property of the class type + --- itself, so we make it a method. + --- Example usage: type ti = typeof(TypedComponent:__ComponentInstanceTypeExport()) + __ComponentInstanceTypeExport: (self: TypedClass) -> TypedInstance, + + Tag: string, + DelaySetup: boolean, + RenderPriority: number?, + Started: ComponentSignal>, + Stopped: ComponentSignal>, + AncestorsChanged: ComponentSignal<{ Instance }, { Instance }>, + + -- Lifecycle methods run on a component INSTANCE, so `self` is the instance + -- type (that is what makes `self.Instance` resolve inside them). + Construct: (self: TypedInstance) -> (), + Start: (self: TypedInstance) -> ()?, + Stop: (self: TypedInstance, reason: StopReason) -> ()?, + HeartbeatUpdate: ((self: TypedInstance, dt: number) -> ())?, + SteppedUpdate: ((self: TypedInstance, dt: number) -> ())?, + RenderSteppedUpdate: ((self: TypedInstance, dt: number) -> ())?, + + Has: (self: TypedClass, instance: Instance) -> boolean, + GetLifecycleStatus: (self: TypedClass, instanceOrComponent: any) -> LifecyclePhase, + FromInstance: (self: TypedClass, instance: I & Instance) -> TypedInstance?, + WaitForInstance: ( + self: TypedClass, + instance: I & Instance, + timeout: number? + ) -> Promise>, + CreateFromInstance: (self: TypedClass, instance: I & Instance) -> Promise>, + GetAll: (self: T) -> { T }, + UpdateAncestors: (self: TypedClass, newAncestors: { Instance }) -> (), + GetAncestors: (self: TypedClass) -> { Instance }, + + Destroy: (self: TypedClass) -> (), +} & M + +export type TypedInstance = { + Instance: I & Instance, + + IsStarted: (self: TypedInstance) -> boolean, + AddTask: (self: TypedInstance, task: T, cleanupMethod: (string | boolean)?, index: any?) -> T, + AddPromise: (self: TypedInstance, promise: Promise, index: any?) -> Promise, + RemoveTask: (self: TypedInstance, index: any, dontClean: boolean?) -> (), + GetTask: (self: TypedInstance, index: any) -> any, + -- Cross-class lookups take a foreign CLASS type `T`; a component instance is + -- that class's shape plus its bound `Instance`, which is what the intersection + -- models (a full `TypedInstance<...>` here would need a class -> instance type + -- function, and bloats the solver). + GetComponent: (self: TypedInstance, componentClass: T) -> (T & { Instance: Instance })?, + -- Generic in the sibling class so the callback sees its real (merged) type. + WhileHasComponent: ( + self: TypedInstance, + componentClass: T, + fn: (component: T & { Instance: Instance }, janitor: Janitor) -> () + ) -> Connection, + WhileHasComponents: ( + self: TypedInstance, + componentClasses: { any }, + fn: (components: { any }, janitor: Janitor) -> () + ) -> Connection, +} & TypedClass & F + +-- Overloads dispatch on arity. The one-argument form returns the type-function- +-- merged class (base + checked extension methods + indexer); the two-argument +-- form returns the strict merge (base + extension methods + user methods, no +-- indexer). `TypedClass` remains the self-annotation alias for the latter. +export type NewFn = ( + config: ComponentConfigOf +) -> TypedClass, F & IF> --TypeFunctions.classWith, E, nil>) +-- & ((config: ComponentConfigOf) -> TypeFunctions.classWith) + +-------------------------------------------------------------------------------- +-- Internal implementation views +-------------------------------------------------------------------------------- + +-- Minimal structural view of an evaera Promise. The implementation types its +-- promise plumbing against this instead of the vendored modules' inferred / +-- generic signatures (which reject our variadic glue); values are cast through +-- `unknown` at the vendor boundary. +-- Callback params are `...any` by necessity: `...unknown` would require every +-- handler to accept arbitrary arguments (contravariance), rejecting plain +-- `() -> ()` handlers; only `any` bridges both directions. +export type PromiseLike = { + andThen: (self: PromiseLike, onResolve: (...any) -> ...any, onReject: ((...any) -> ...any)?) -> PromiseLike, + finally: (self: PromiseLike, fn: (...any) -> ...any) -> PromiseLike, + catch: (self: PromiseLike, fn: (...any) -> ...any) -> PromiseLike, + timeout: (self: PromiseLike, seconds: number) -> PromiseLike, + cancel: (self: PromiseLike) -> (), + getStatus: (self: PromiseLike) -> string, +} + +export type ComponentClass = TypedClass<{}, {}, Instance> +export type ComponentInstance = TypedInstance<{}, {}, Instance> + +-- Internal view of a component CLASS: what the implementation itself reads off +-- `self` in class-level methods. Instances reach all of this through `__index`. +-- Dynamic members (user methods, the `Keys.Internal` symbol slot) stay behind +-- the `Keys` accessors and the constructors' `any` build step. +type ComponentClass_Internal_Methods = { + _isInAncestorList: (self: ComponentClass_Internal, instance: Instance) -> boolean, + _tryConstruct: (self: ComponentClass_Internal, instance: Instance) -> (), + _tryDeconstruct: (self: ComponentClass_Internal, instance: Instance, reason: StopReason) -> (), + _startWatching: (self: ComponentClass_Internal, instance: Instance) -> (), + _stopWatching: (self: ComponentClass_Internal, instance: Instance) -> (), + _setup: (self: ComponentClass_Internal) -> (), +} +export type ComponentClass_Internal = ComponentClass_Internal_Methods & ComponentClass + +-- Internal view of a component INSTANCE: the class view plus instance-only +-- state. Lifecycle members are optional functions — the implementation +-- `type(...) == "function"`-checks before invoking, since they may be user +-- methods, base-class stubs, or absent. +export type ComponentInstance_Internal = ComponentClass_Internal_Methods & ComponentInstance & { + _updateCleanup: { () -> () }?, +} + +-- In-flight construction record kept in `ClassInternal.pending` while a +-- component's lifecycle chain runs (the slot briefly holds `true` as a +-- same-frame reservation before the record exists). +export type PendingRecord = { + component: ComponentInstance_Internal?, + promise: PromiseLike?, + id: number, +} + +return {} diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index 21f468f0..6b53d8af 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -1,353 +1,71 @@ +--!strict -- Component -- Stephen Leitnick, Logan Hunt --- November 26, 2021 +-- November 26, 2021 (rewritten for v1.0.0) --[=[ @class Component - + ## Overview - - This is a fork of the original Component module by Stephen Leitnick. This fork expands upon the functionality of - extensions and provides several new useful methods, along with robust handling of edge cases during component - lifecycle management. - Bind components to Roblox instances using the Component class and CollectionService tags. + Bind reusable, class-based behavior to Roblox instances using CollectionService + tags. This is a fork of Stephen Leitnick's Component that adds a robust, + yield-tolerant lifecycle, a per-instance cleanup Janitor, world-level queries, + and cross-class instance lookups. To avoid confusion of terms: - `Component` refers to this module. - - `Component Class` (e.g. `MyComponent` through this documentation) refers to a class created via `Component.new` + - `Component Class` (e.g. `MyComponent`) refers to a class made via `Component.new`. - `Component Instance` refers to an instance of a component class. - - `Roblox Instance` refers to the Roblox instance to which the component instance is bound. - - Methods and properties are tagged with the above terms to help clarify the level at which they are used. -]=] + - `Roblox Instance` refers to the Roblox instance a component instance is bound to. ---[[ ## Lifecycle - The component lifecycle follows this order: - 1. **ShouldConstruct** - Extensions can veto construction by returning `false` - 2. **Constructing** - Extension hook before `Construct()` - 3. **Construct()** - Component initialization (may yield) - 4. **Constructed** - Extension hook after `Construct()` - 5. **Starting** - Extension hook before `Start()` - 6. **Start()** - Component startup (may yield) - 7. **Started** - Extension hook after `Start()` - 8. **Update loops** - HeartbeatUpdate, SteppedUpdate, RenderSteppedUpdate connected - 9. **Stopping** - Extension hook before `Stop()` - 10. **Stop()** - Component cleanup - 11. **Stopped** - Extension hook after `Stop()` - - ## Edge Cases and Robustness - - ### Yielding During Construction - - If `Construct()` or any extension function yields (e.g., waiting for data, HTTP requests, etc.), - the system tracks the construction state and validates it after each yield point. If the instance - becomes invalid (moves outside valid ancestors, loses its tag, or a newer construction attempt - starts), construction is cancelled and any partial state is cleaned up. - - **Example scenario:** - ```lua - function MyComponent:Construct() - self.Data = HttpService:GetAsync("...") -- Yields! - -- If instance is reparented during this yield, construction is cancelled - self.ProcessedData = processData(self.Data) - end - ``` - - ### Yielding During Start - - Similar to construction, if `Start()` or extension Starting/Started functions yield, the system - checks after each yield point whether the component should still be running. If `Stop()` is called - during startup (e.g., the instance is removed), the start thread is cancelled if possible. - - ### Reparenting During Lifecycle - - If an instance is reparented outside of valid ancestors during construction: - - Construction is immediately cancelled - - Any partial component state is cleaned up via `Stop()` - - The construction thread is cancelled if suspended - - If an instance is reparented outside of valid ancestors after construction but during start: - - The start thread is cancelled if possible - - `Stop()` is called to clean up the component - - ### Rapid Reparenting (Ping-Pong) - - If an instance rapidly moves in and out of valid ancestors: - - Each construction attempt gets a unique ID (`constructId`) - - Only the most recent construction attempt is allowed to complete - - Stale construction attempts are cancelled and cleaned up - - The `KEY_LOCK_CONSTRUCT` table tracks the current valid construction ID - - **Example scenario:** - ```lua - -- Instance starts in workspace (valid ancestor) - local part = Instance.new("Part", workspace) - CollectionService:AddTag(part, "MyComponent") - -- Construction starts... - part.Parent = ReplicatedStorage -- Moves out - construction cancelled - part.Parent = workspace -- Moves back in - NEW construction starts - -- Only the second construction attempt will complete - ``` - - ### Errors During Construction - - If an error occurs during `Construct()` or any extension function: - - The error is caught and logged with a warning - - `Stop()` is called on the partial component to clean up any state - - The component is not added to tracking tables - - Other components are not affected - - ### Errors During Start/Stop - - Extension functions (`Starting`, `Started`, `Stopping`, `Stopped`) and lifecycle methods - are called in order. If one errors, the error propagates but cleanup still occurs for - connections and state that was set up. - - ### Thread Cancellation - - When stopping a component that's still in its Start phase: - - If the start thread is suspended (yielding), it's cancelled via `task.cancel()` - - If the start thread is the current thread (Stop called from within Start), it's not cancelled - but the thread will return early due to state checks - - If the start thread is in "normal" status (in call stack but not current), cancellation is deferred - - ### Memory Management - - The component system tracks instances in several tables: - - `KEY_INST_TO_COMPONENTS`: Maps Roblox instances to their component instances - - `KEY_COMPONENTS`: Array of all active component instances - - `KEY_LOCK_CONSTRUCT`: Maps instances to their current construction attempt ID - - All tables are properly cleaned up when: - - A component is stopped (instance removed from tables) - - The component class is destroyed (all tables cleared, all components stopped) - - An instance loses its tag (component stopped and removed from tracking) - - ### Ancestor Changes - - When `UpdateAncestors()` is called: - - The `AncestorsChanged` signal fires - - All watched instances are re-evaluated - - Instances now outside valid ancestors have their components stopped - - Instances now inside valid ancestors have components constructed - - ### Tag Removal - - When a tag is removed from an instance: - - The instance is immediately removed from the watching list - - Any active component is stopped - - Any in-progress construction is cancelled - - ### Component Class Destruction - - When `Destroy()` is called on a component class: - - All active components are stopped - - All tracking tables are cleared - - All CollectionService connections are disconnected - - The class is removed from the unsetup components list if present - - ## Extension System - - Extensions can hook into the component lifecycle at various points. Extensions are processed - in order, with nested extensions (via the `Extensions` array) processed recursively. - - ### Extension Methods - - Extensions can add methods to component classes via the `Methods` table. These methods are - added at the class level, not the instance level, so they're available regardless of - `ShouldExtend` results. - - ### ShouldExtend - - The `ShouldExtend` function is called per-instance to determine if an extension applies. - This is evaluated during construction, so extensions can be conditionally applied based - on instance attributes or other runtime conditions. - - ### ShouldConstruct - - The `ShouldConstruct` function is called before any construction begins. ALL extensions - with a `ShouldConstruct` function must return `true` for construction to proceed. - If any returns `false`, no component is created and no cleanup is needed. -]] - -type AncestorList = { Instance } - ---[=[ - @type ExtensionFn (component) -> () - @within Component -]=] -type ExtensionFn = (any) -> () - ---[=[ - @type ExtensionShouldFn (component) -> boolean - @within Component + 1. **ShouldConstruct** — extensions may veto construction (sync). + 2. **Constructing** — extension hooks (see barrier note below). + 3. **Construct()** — component initialization. + 4. **Constructed** — extension hooks. The component is now tracked. + 5. **Starting** — extension hooks. + 6. **Start()** — component startup. + 7. **Started** — extension hooks, then update loops connect and `Started` fires. + + Teardown always runs `Stopping` → `Stop(reason)` → `Stopped` → **Janitor + destroy**, in that order, on *every* removal path (untag, ancestry exit, + instance destroyed, class destroyed, or construction cancelled). Anything added + via `self:AddTask(...)` is therefore guaranteed to be cleaned up. + + ### Yielding and the phase barrier + + Lifecycle hooks may yield or return a Promise. Within a single phase, every + active extension's hook is gated on dependency order: an extension's hook does + not start until the hooks of the extensions it depends on have *finished*. + Hooks with no dependency edge between them still run concurrently, so an + unrelated sibling never blocks a hook. The phase completes (and the next one + begins) only once every hook has finished and every returned Promise has + resolved. `Construct()`/`Start()` may likewise yield or return a Promise. + + Only the construct phases (`Constructing` → `Construct()` → `Constructed`) run + inline and hold up the construction chain. The start and stop phases are + dispatched with `task.defer`, so they never run on the thread that triggered + them and teardown never blocks its caller. Stop hooks are gated in *reverse* + dependency order — a dependency stops only after everything depending on it + has stopped. + + If the instance leaves its valid ancestors, is untagged, or is superseded while + a hook is still waiting, the in-flight work is cancelled and the component is + torn down. + + The lifecycle engine itself (construction chain + teardown) lives in + `Lifecycle.luau`; the public/internal types live in `Types.luau`. This module + owns class creation, tag/ancestry watching, and the public API. ]=] -type ExtensionShouldFn = (any) -> boolean - ---[=[ - @interface Extension - @within Component - .ShouldExtend ExtensionShouldFn? - .ShouldConstruct ExtensionShouldFn? - .Constructing ExtensionFn? - .Constructed ExtensionFn? - .Starting ExtensionFn? - .Started ExtensionFn? - .Stopping ExtensionFn? - .Stopped ExtensionFn? - .Extensions {Extension}? - .Methods {[string]: (...any) -> ...any}? - - An extension allows the ability to extend the behavior of - components. This is useful for adding injection systems or - extending the behavior of components by wrapping around - component lifecycle methods. - - The `ShouldConstruct` function can be used to indicate - if the component should actually be created. This must - return `true` or `false`. A component with multiple - `ShouldConstruct` extension functions must have them _all_ - return `true` in order for the component to be constructed. - The `ShouldConstruct` function runs _before_ all other - extension functions and component lifecycle methods. - - The `ShouldExtend` function can be used to indicate if - the extension itself should be used. This can be used in - order to toggle an extension on/off depending on whatever - logic is appropriate. If no `ShouldExtend` function is - provided, the extension will always be used if provided - as an extension to the component. - - As an example, an extension could be created to simply log - when the various lifecycle stages run on the component: - - ```lua - local Logger = {} - function Logger.Constructing(component) print("Constructing", component) end - function Logger.Constructed(component) print("Constructed", component) end - function Logger.Starting(component) print("Starting", component) end - function Logger.Started(component) print("Started", component) end - function Logger.Stopping(component) print("Stopping", component) end - function Logger.Stopped(component) print("Stopped", component) end - - local MyComponent = Component.new({Tag = "MyComponent", Extensions = {Logger}}) - ``` - - Sometimes it is useful for an extension to control whether or - not a component should be constructed. For instance, if a - component on the client should only be instantiated for the - local player, an extension might look like this, assuming the - instance has an attribute linking it to the player's UserId: - ```lua - local Players = game:GetService("Players") - - local OnlyLocalPlayer = {} - function OnlyLocalPlayer.ShouldConstruct(component) - local ownerId = component.Instance:GetAttribute("OwnerId") - return ownerId == Players.LocalPlayer.UserId - end - - local MyComponent = Component.new({Tag = "MyComponent", Extensions = {OnlyLocalPlayer}}) - ``` - - It can also be useful for an extension itself to turn on/off - depending on various contexts. For example, let's take the - Logger from the first example, and only use that extension - if the bound instance has a Log attribute set to `true`: - ```lua - function Logger.ShouldExtend(component) - return component.Instance:GetAttribute("Log") == true - end - ``` - - In this forked version of component, extensions can also add methods - to the component class and extend other extensions via giving an extension - a `Methods` table. For example: - - ```lua - local ExtendedComponentMethods = {} - function ExtendedComponentMethods.DoSomething(component) - print("Hello World!") - end - - local MyComponentExtension = {} - MyComponentExtension.Methods = ExtendedComponentMethods - ``` - This will add a method called `DoSomething` to the component class. - :::caution Be careful when using with ShouldExtend - It is important to note that these methods are added to the `Component Class` - and not the `Component Instance`. This means that these methods will be availible - regardless of whether the extension passes its shouldExtend function or not. If - your code is dependent on extension methods existing only when they pass their - shouldExtend function, you may want to avoid using this feature. - ::: - - If you want to utilize other extensions within your extension or guarantee that the - given extension is loaded onto the component before your extension, you can use - the `Extensions` table. For example: - ```lua - local SomeOtherExtension = require(somewhere.SomeOtherExtension) - - local MyComponentExtension = {} - MyComponentExtension.Extensions = {SomeOtherExtension} - ``` - This will guarantee that `SomeOtherExtension` is added to the component and - loaded before `MyComponentExtension`. - :::info - The ShouldExtend function of `SomeOtherExtension` will still be called - independently of the ShouldExtend function of `MyExtension`. Under the hood this - just adds the extension to the components original extension array. - ::: -]=] -type Extension = { - ShouldExtend: ExtensionShouldFn?, - ShouldConstruct: ExtensionShouldFn?, - Constructing: ExtensionFn?, - Constructed: ExtensionFn?, - Starting: ExtensionFn?, - Started: ExtensionFn?, - Stopping: ExtensionFn?, - Stopped: ExtensionFn?, - Extensions: { Extension }, - Methods: { [string]: (component: any, ...any) -> ...any }?, -} - ---[=[ - @interface ComponentConfig - @within Component - .Tag string -- CollectionService tag to use - .Ancestors {Instance}? -- Optional array of ancestors in which components will be started - .Extensions {Extension}? -- Optional array of extension objects - .DelaySetup boolean? -- Optional flag to delay the setup of the component until a later specified time. If true, `:_setup()` must be called manually. - - Component configuration passed to `Component.new`. - - - If no Ancestors option is included, it defaults to `{workspace, game.Players}`. - - If no Extensions option is included, it defaults to a blank table `{}`. -]=] -type ComponentConfig = { - Tag: string, - Ancestors: AncestorList?, - Extensions: { Extension }?, - DelaySetup: boolean?, -} --[=[ @within Component @prop Started Signal @tag Event @tag Component Class - - Fired when a new instance of a component is started. - - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - - MyComponent.Started:Connect(function(component) end) - ``` + Fired when a component instance finishes starting. Passes the component. ]=] --[=[ @@ -355,330 +73,208 @@ type ComponentConfig = { @prop Stopped Signal @tag Event @tag Component Class - - Fired when an instance of a component is stopped. - - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - - MyComponent.Stopped:Connect(function(component) end) - ``` + Fired when a started (or mid-start) component instance is stopped. Passes the + component. Not fired for components torn down before they began starting. ]=] --[=[ @within Component @prop Instance Instance @tag Component Instance - - A reference back to the _Roblox_ instance from within a _component_ instance. When - a component instance is created, it is bound to a specific Roblox instance, which - will always be present through the `Instance` property. - - ```lua - MyComponent.Started:Connect(function(component) - local robloxInstance: Instance = component.Instance - print("Component is bound to " .. robloxInstance:GetFullName()) - end) - ``` + The Roblox instance a component instance is bound to. ]=] --// Services //-- -local CollectionService = game:GetService("CollectionService") -local RunService = game:GetService("RunService") +const CollectionService = game:GetService("CollectionService") --// Dependencies //-- -local Packages = script.Parent -local Promise = require(Packages.Promise) -local Janitor = require(Packages.Janitor) -local RailUtil = require(Packages.RailUtil) -local Symbol = require(Packages.Symbol) -local Signal = require(Packages.Signal) -local Trove = require(Packages.Trove) +const Packages = script.Parent +const Promise = require(Packages.Promise) +const Janitor = require(Packages.Janitor) +const Signal = require(Packages.Signal) + +--// Internal //-- +const Keys = require(script.Keys) +const Extensions = require(script.Extensions) +const Registry = require(script.Registry) +const Query = require(script.Query) +const Lifecycle = require(script.Lifecycle) +const Types = require(script.Types) +const TypeFunctions = require(script.TypeFunctions) + +--// Public type surface (declared in Types.luau / Keys.luau) //-- +export type LifecyclePhase = Keys.LifecyclePhase +export type StopReason = Keys.StopReason +export type Extension = Types.Extension +export type ComponentConfig = Types.ComponentConfig +export type Connection = Types.Connection +export type ComponentClass = Types.ComponentClass +export type TypedClass = Types.TypedClass +export type Query = Query.Query + +-- Type functions (NEW type solver only) live in TypeFunctions.luau; re-exported +-- here so users can compose `Component.extensionMethods` +-- into their typed-path self alias. +export type extensionMethods = TypeFunctions.extensionMethods type Janitor = Janitor.Janitor -type table = { [any]: any } -type Component = table -type ComponentClass = table - -local IS_SERVER = RunService:IsServer() -local DEFAULT_ANCESTORS = { workspace, game:GetService("Players") } -local DEFAULT_TIMEOUT = 60 -local UNSETUP_COMPONENTS = {} - ---[[ - Symbol Keys Documentation: - - These symbols are used as keys in component tables to avoid conflicts with user-defined - properties and to provide a clear separation between internal and public state. - - KEY_ANCESTORS: Array of valid ancestor instances for this component class - KEY_INST_TO_COMPONENTS: Map of Roblox Instance -> Component Instance - KEY_LOCK_CONSTRUCT: Map of Roblox Instance -> construction attempt ID (for cancellation) - KEY_COMPONENTS: Array of all active component instances - KEY_TROVE: Trove instance for managing connections and cleanup - KEY_EXTENSIONS: Array of extension definitions for this component class - KEY_ACTIVE_EXTENSIONS: Array of extensions active for a specific component instance - KEY_STARTING: Thread reference when component is in Start phase (nil otherwise) - KEY_STARTED: Boolean, true when component has fully started - KEY_CLASS_ACTIVE_EXTENSIONS: Extensions determined at class level (ShouldExtend not called per-instance) -]] -local KEY_ANCESTORS = Symbol("Ancestors") -local KEY_INST_TO_COMPONENTS = Symbol("InstancesToComponents") -local KEY_LOCK_CONSTRUCT = Symbol("LockConstruct") -local KEY_COMPONENTS = Symbol("Components") -local KEY_TROVE = Symbol("Trove") -local KEY_EXTENSIONS = Symbol("Extensions") -local KEY_ACTIVE_EXTENSIONS = Symbol("ActiveExtensions") -local KEY_STARTING = Symbol("Starting") -local KEY_STARTED = Symbol("Started") -local KEY_CLASS_ACTIVE_EXTENSIONS = Symbol("ClassActiveExtensions") - -local renderId = 0 -local function NextRenderName(): string - renderId += 1 - return "ComponentRender" .. tostring(renderId) -end - ---[[ - InvokeExtensionFn - Calls a lifecycle hook on all active extensions. - - Parameters: - - component: The component instance - - fnName: Name of the extension function to call (e.g., "Constructing", "Starting") - - Note: This function may yield if any extension function yields. - The caller is responsible for state validation after calling this. -]] -local function InvokeExtensionFn(component, fnName: string) - for _, extension in ipairs(component[KEY_ACTIVE_EXTENSIONS]) do - local fn = extension[fnName] - if type(fn) == "function" then - fn(component) - end - end -end - ---[[ - ShouldConstruct - Checks if all extensions allow construction. - - Returns false if ANY extension's ShouldConstruct returns false. - Returns true if all extensions allow construction (or have no ShouldConstruct). - - This is called BEFORE any component state is created, so returning false - means no cleanup is needed. -]] -local function ShouldConstruct(component): boolean - for _, extension in ipairs(component[KEY_ACTIVE_EXTENSIONS]) do - local fn = extension.ShouldConstruct - if type(fn) == "function" then - local shouldConstruct = fn(component) - if not shouldConstruct then - return false - end - end - end - return true -end - ---[[ - GetActiveExtensions - Determines which extensions apply to a component. - - Parameters: - - component: The component instance or class - - extensionList: Array of extensions to process - - activeExtensions: Accumulator array (for recursion) - - isClass: If true, don't call ShouldExtend (determining class-level extensions) - - Extension Processing: - 1. For each extension, checks if it should be applied - 2. If extension is already in activeExtensions, moves it to front (priority) - 3. If not present and should extend, adds to end - 4. Recursively processes extension.Extensions for nested extensions - 5. Final pass removes extensions whose ShouldExtend returned false - - The recursion handling ensures nested extensions are processed and that - extension dependencies are properly ordered (dependencies come first). -]] -local function GetActiveExtensions(component, extensionList, activeExtensions, isClass) - activeExtensions = activeExtensions or {} - extensionList = extensionList or {} - - for _, extension in ipairs(extensionList) do - local idx = table.find(activeExtensions, extension) - local shouldExtend = false - - if not idx then - local fn = extension.ShouldExtend +type Class_Internal = Types.ComponentClass_Internal +type Instance_Internal = Types.ComponentInstance_Internal - if not fn then - shouldExtend = true - elseif not isClass and type(fn) == "function" then - shouldExtend = fn(component) - end - end +const DEFAULT_ANCESTORS: { Instance } = { workspace, game:GetService("Players") } +const DEFAULT_TIMEOUT = 60 +const UNSETUP_COMPONENTS: { Class_Internal } = {} - if idx or shouldExtend then - if idx then - table.remove(activeExtensions, idx) - table.insert(activeExtensions, 1, extension) - else - table.insert(activeExtensions, extension) - end - GetActiveExtensions(component, extension.Extensions, activeExtensions, isClass) - end - end +-- Typed view over the vendored `Promise.fromEvent`, whose inferred signature +-- rejects our typed signals/predicates; the value bridges through `unknown` once. +type FromEventFn = (event: unknown, predicate: ((T...) -> boolean)?) -> Types.PromiseLike +const fromEvent = (Promise.fromEvent :: unknown) :: FromEventFn - if not isClass then - for i = #activeExtensions, 1, -1 do - local extension = activeExtensions[i] - local fn = extension.ShouldExtend - if type(fn) == "function" and not fn(component) then - table.remove(activeExtensions, i) - end - end - end - - return activeExtensions -end - ---[[ - BindExtensionMethod - Adds methods from an extension to a component. - - Methods are added directly to the component table, making them callable - as component:MethodName(). This happens at the CLASS level, not instance - level, so methods are available regardless of ShouldExtend results. - - Errors if a method value is not a function (catches configuration mistakes). -]] -local function BindExtensionMethod(component, extension) - if extension.Methods then - for key, value in extension.Methods do - if type(value) == "function" then - component[key] = value - else - error("Invalid extension method: " .. tostring(key)) - end - end - end -end - ---[[ - BindExtensionMethods - Binds methods from all extensions in a list. - Wrapper that calls BindExtensionMethod for each extension. -]] -local function BindExtensionMethods(component, extensionList) - for _, extension in ipairs(extensionList) do - BindExtensionMethod(component, extension) - end -end - -local Component = {} -Component.__index = Component +const ComponentClassMethods = {} +const Component = {} +Component.__index = ComponentClassMethods --[=[ @within Component @prop DelaySetup boolean @tag Component - Controls the global default for whether or not components should delay their setup. Overridden by the `DelaySetup` - property if set in the component configuration table passed to `Component.new`. This is useful for when you want - to ensure some other systems that the components may utilize are set up before the components themselves. - - This value is initialized to the `DelaySetup` attribute of the script, which defaults to `false`. - - :::caution - When set to `true`, the component class will not automatically call `:_setup()` when created and expects - you to call it when desired. Failing to do so will result in the component never starting to listen - for tagged instances and thus never starting any component instances. - ::: + Global default for delaying component setup. When true, `Component.new` will not + begin listening for tagged instances until `_setup()` is called manually. + Initialized from the script's `DelaySetup` attribute (default false). ]=] Component.DelaySetup = script:GetAttribute("DelaySetup") or false --[=[ - @tag Component @within Component - @prop Tag string + @function query + @param ... Queryable -- component classes, tag strings, and/or sub-queries + @return Query + Creates a world-level [Query] over tagged instances. See the [Query] class. +]=] +Component.query = Query.new - The tag used to identify the component class. This is used with CollectionService to bind component instances - to Roblox instances. +--[=[ + @tag Component + @return {ComponentClass} + Returns a copy of all component classes that have not yet been set up. +]=] +function Component.getUnsetupComponents(): { ComponentClass } + return (table.clone(UNSETUP_COMPONENTS) :: unknown) :: { ComponentClass } +end - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - print(MyComponent.Tag) -- "MyComponent" - ``` +--[=[ + @within Component + @function GetAllComponentsForInstance + @tag Component + @param instance Instance + @return {Component} + Returns every component bound to `instance`, across all component classes. A + component appears here from the moment it finishes constructing. ]=] +Component.GetAllComponentsForInstance = Registry.GetAll + +-------------------------------------------------------------------------------- +-- Construction of a component class +-------------------------------------------------------------------------------- --[=[ + @function new + @within Component @tag Component @param config ComponentConfig @return ComponentClass - Create a new custom Component class. + Create a new component class bound to a CollectionService tag. ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - ``` - - A full example might look like this: + local MyComponent = Component.new({ Tag = "MyComponent", Ancestors = {workspace} }) - ```lua - local MyComponent = Component.new({ - Tag = "MyComponent", - Ancestors = {workspace}, - Extensions = {Logger}, -- See Logger example within the example for the Extension type - }) - - local AnotherComponent = require(somewhere.AnotherComponent) + function MyComponent:Construct() self.Data = "Hello" end + function MyComponent:Start() print(self.Data) end + function MyComponent:Stop(reason) print("stopped:", reason) end + ``` - -- Optional if UpdateRenderStepped should use BindToRenderStep: - MyComponent.RenderPriority = Enum.RenderPriority.Camera.Value + Lifecycle methods may be defined post-hoc on the returned class (above), but + custom methods and fields go in the config's `Methods` / `Fields` — the class + type has no `[string]: any` indexer, so a brand-new name assigned post-hoc is + a type error by design. Extension `Methods` from the config's `Extensions` + array are merged in with full argument checking (new type solver). - function MyComponent:Construct() - self.MyData = "Hello" - end + `Fields` are deep-copied onto every component instance before `Construct`, so + a table default is never shared; `InitFields` is called once per instance for + fields that must be built at runtime. `self` annotations close the circular + type: - function MyComponent:Start() - local another = self:GetComponent(AnotherComponent) - another:DoSomething() - end + ```lua + local Methods = {} + type MyComponent = Component.TypedClass - function MyComponent:Stop() - self.MyData = "Goodbye" + function Methods.Greet(self: MyComponent, msg: string) + print(self.Instance, msg) end - function MyComponent:HeartbeatUpdate(dt) + function Methods.Start(self: MyComponent) + self:Greet("hello") -- fully checked + print(self.Count) -- from Fields end - function MyComponent:SteppedUpdate(dt) - end - - function MyComponent:RenderSteppedUpdate(dt) - end + local MyComponent = Component.new({ + Tag = "MyComponent", + Methods = Methods, + Fields = { Count = 0 }, + InitFields = function() return { Janitor = Janitor.new() } end, + }) ``` ]=] -function Component.new(config: ComponentConfig) - local customComponent = {} - customComponent.__index = customComponent - -- customComponent.__tostring = function() - -- return "Component<" .. config.Tag .. ">" - -- end - customComponent[KEY_ANCESTORS] = config.Ancestors or DEFAULT_ANCESTORS - customComponent[KEY_INST_TO_COMPONENTS] = {} - customComponent[KEY_COMPONENTS] = {} - customComponent[KEY_LOCK_CONSTRUCT] = {} - customComponent[KEY_TROVE] = Trove.new() - customComponent[KEY_EXTENSIONS] = config.Extensions or {} - customComponent[KEY_STARTED] = false - customComponent.Tag = config.Tag - customComponent.AncestorsChanged = customComponent[KEY_TROVE]:Construct(Signal) - customComponent.Started = customComponent[KEY_TROVE]:Construct(Signal) - customComponent.Stopped = customComponent[KEY_TROVE]:Construct(Signal) - setmetatable(customComponent, Component) +const function componentNew(config: Types.ComponentConfig): Class_Internal + -- Built dynamically (symbol slot, signals, user methods) under `any`, then + -- viewed as ClassAny; the public type comes from the NewFn cast below. The + -- `:: any` on the RHS keeps the solver from stamping `@metatable` onto the + -- local and defeating the annotation. + const classAny: any = setmetatable({}, Component) :: any + classAny.__index = classAny + const janitor = Janitor.new() + const ci: Keys.ClassInternal = { + ancestors = config.Ancestors or DEFAULT_ANCESTORS, + instToComponents = {}, + components = {}, + lockConstruct = {}, + watching = {}, + pending = {}, + extensions = config.Extensions or {}, + classActiveExtensions = {}, + fields = config.Fields, + initFields = config.InitFields, + janitor = janitor, + failed = janitor:Add(Signal.new(), "Destroy"), + } + classAny[Keys.Internal] = ci + classAny.Tag = config.Tag + classAny.AncestorsChanged = Signal.new(janitor) + classAny.Started = Signal.new(janitor) + classAny.Stopped = Signal.new(janitor) + + -- Copy user methods before _setup so extension-method collision checks in + -- Extensions.BindMethods also fire against them. + const methods: { [string]: unknown }? = config.Methods + if methods then + for name, fn in methods do + if rawget(classAny, name) ~= nil then + error(`[Component] Method '{name}' collides with an existing class member`, 2) + end + classAny[name] = fn + end + end + + const customComponent = classAny :: Class_Internal table.insert(UNSETUP_COMPONENTS, customComponent) - local delaySetup = if config.DelaySetup then config.DelaySetup else customComponent.DelaySetup + -- `Component.DelaySetup`, not `customComponent.DelaySetup`: the global default + -- lives on the module table, which classes do not inherit from. + const delaySetup = if config.DelaySetup ~= nil then config.DelaySetup else Component.DelaySetup if not delaySetup then - Component._setup(customComponent) + customComponent:_setup() else task.delay(30, function() if table.find(UNSETUP_COMPONENTS, customComponent) then @@ -688,35 +284,17 @@ function Component.new(config: ComponentConfig) end return customComponent end +Component.new = (componentNew :: any) :: Types.NewFn ---[=[ - @tag Component - @return {ComponentClass} - - Gets a table array of all unsetup component classes. This allows you to call `:_setup()` on them later. +-------------------------------------------------------------------------------- +-- Tag / ancestry watching +-------------------------------------------------------------------------------- - ```lua - local unsetupComponents = Component.getUnsetupComponents() - for _, componentClass in unsetupComponents do - Component._setup(componentClass) - end - ``` -]=] -function Component.getUnsetupComponents(): { ComponentClass } - return table.clone(UNSETUP_COMPONENTS) :: any -end - ---[=[ - @private - @within Component - @param instance Instance -- The Roblox instance to check - @return boolean -- True if the instance is within any valid ancestor, false otherwise - - Checks if the given instance is a descendant of any of the valid ancestors - for this component class. -]=] -function Component:_isInAncestorList(instance: Instance): boolean - for _, parent in ipairs(self[KEY_ANCESTORS] :: { Instance }) do +--[[ + Returns true if `instance` is a descendant of any valid ancestor. +]] +function ComponentClassMethods._isInAncestorList(self: Class_Internal, instance: Instance): boolean + for _, parent in Keys.class(self).ancestors do if instance:IsDescendantOf(parent) then return true end @@ -724,1015 +302,419 @@ function Component:_isInAncestorList(instance: Instance): boolean return false end ---[=[ - @private - @within Component - @param instance Instance -- The Roblox instance to create a component for - @param constructId number? -- Optional ID to track this construction attempt for cancellation - @return Component? -- The constructed component, or nil if construction failed/was cancelled - - Creates a new component instance bound to the given Roblox instance. - - ## Construction Flow (Promise-based) - - Construction is wrapped in a Promise that can be cancelled if: - - The instance moves outside valid ancestors (ancestry change) - - A newer construction attempt supersedes this one (constructId mismatch) - - - ## Phases - - 1. **Setup**: Create component, bind extensions - 2. **Validation**: Check ShouldConstruct (early exit, no cleanup needed) - 3. **Construction**: Run Constructing → Construct() → Constructed hooks - 4. **Finalization**: Return component or handle failure - - ## Error Handling - - Errors during construction are caught, logged, and result in Stop() being - called to clean up any partial state. -]=] -function Component:_instantiate(instance: Instance, constructId: number?) - -- ══════════════════════════════════════════════════════════════════════ - -- PHASE 1: Setup - -- Create the component instance and bind extensions - -- ══════════════════════════════════════════════════════════════════════ - - local component = setmetatable({}, self) - component.Instance = instance - - -- Bind extensions - component[KEY_ACTIVE_EXTENSIONS] = - GetActiveExtensions(component, self[KEY_EXTENSIONS], table.clone(self[KEY_CLASS_ACTIVE_EXTENSIONS] :: any)) - for _, extension in ipairs(component[KEY_ACTIVE_EXTENSIONS]) do - if not table.find(self[KEY_CLASS_ACTIVE_EXTENSIONS], extension) then - BindExtensionMethod(component, extension) - end - end - - -- ══════════════════════════════════════════════════════════════════════ - -- PHASE 2: Validation (ShouldConstruct) - -- If any extension vetoes construction, exit early with no cleanup needed - -- ══════════════════════════════════════════════════════════════════════ - - if not ShouldConstruct(component) then - return nil - end - - -- ══════════════════════════════════════════════════════════════════════ - -- PHASE 3: Construction (Promise-based) - -- Wrap construction in a cancellable Promise for clean cancellation handling - -- ══════════════════════════════════════════════════════════════════════ - - -- Helper: Check if construction should continue - local function shouldContinue(): boolean - if not self:_isInAncestorList(instance) then - return false - end - if constructId and self[KEY_LOCK_CONSTRUCT][instance] ~= constructId then - return false - end - return true - end - - - local enteredConstruction = false - local didReject = false - - local constructionPromise = Promise.new(function(resolve, reject) - if not shouldContinue() then - reject("Invalid Post-ShouldConstruct") +--[[ + Attempts to construct a component for `instance`, unless one already exists or + is in flight. Deferred so a batch of tagged instances processes together. +]] +function ComponentClassMethods._tryConstruct(self: Class_Internal, instance: Instance) + const ci = Keys.class(self) + if ci.instToComponents[instance] or ci.pending[instance] then + return + end + const id = (ci.lockConstruct[instance] or 0) + 1 + ci.lockConstruct[instance] = id + -- Reserve the slot synchronously so a second call in the same frame dedupes. + ci.pending[instance] = true + task.defer(function() + if ci.lockConstruct[instance] ~= id then + if ci.pending[instance] == true then + ci.pending[instance] = nil + end return end + Lifecycle.Run(self, instance, id) + end) +end - -- Mark that we're entering construction - from this point, cleanup is needed on failure - enteredConstruction = true - - -- Constructing hook (extensions) - InvokeExtensionFn(component, "Constructing") - if not shouldContinue() then - reject("Invalid Post-Constructing") +--[[ + Stops and removes the component for `instance`, if any, with the given reason. +]] +function ComponentClassMethods._tryDeconstruct(self: Class_Internal, instance: Instance, reason: StopReason) + const ci = Keys.class(self) + ci.lockConstruct[instance] = (ci.lockConstruct[instance] or 0) + 1 + + const record = ci.pending[instance] + const component = ci.instToComponents[instance] + + -- Untrack up front so a same-frame re-tag can begin a fresh construction. + Lifecycle.Untrack(self, instance) + ci.pending[instance] = nil + + -- If the component is still constructing, cancel the in-flight Promise chain. + if type(record) == "table" then + const pending = record :: Types.PendingRecord + const promise = pending.promise + if promise and Promise.is(promise) and promise:getStatus() == Promise.Status.Started then + -- Still in flight: cancel it; the chain's `finally` runs teardown with + -- the reason we stash here. + Keys.inst(pending.component).stopReason = reason + promise:cancel() return end + end + if component then + -- Fully constructed / started: tear down directly. + Lifecycle.Teardown(self, component, reason) + end +end - -- User's Construct method - if type(component.Construct) == "function" then - component:Construct() - end - if not shouldContinue() then - reject("Invalid Post-Construct") - return - end +--[[ + Begins watching `instance` for ancestry changes, constructing/deconstructing as + it enters or leaves the valid ancestor list. Idempotent. +]] +function ComponentClassMethods._startWatching(self: Class_Internal, instance: Instance) + const ci = Keys.class(self) + if ci.watching[instance] then + return + end - -- Constructed hook (extensions) - InvokeExtensionFn(component, "Constructed") - if not shouldContinue() then - reject("Invalid Post-Constructed") - return + const function evaluate() + if self:_isInAncestorList(instance) then + self:_tryConstruct(instance) + else + const reason: StopReason = if instance:IsDescendantOf(game) then "LeftAncestry" else "InstanceDestroyed" + self:_tryDeconstruct(instance, reason) end + end - -- Success! - resolve(component) - end) - :catch(function(err) - -- Log error - didReject = true - warn(string.format( - "[Component] Error during construction of '%s' on '%s':\n%s", - self.Tag, - instance:GetFullName(), - tostring(err) - )) - end) - - -- Set up ancestry change listener to cancel construction if instance becomes invalid - local ancestryConnection = RailUtil.Signal - .combine({ - instance.AncestryChanged, - self.AncestorsChanged, - }) - :Connect(function() - if not self:_isInAncestorList(instance) then - warn(string.format( - "[Component] Construction of '%s' on '%s' cancelled due to reparenting outside valid ancestors.", - self.Tag, - instance:GetFullName() - )) - constructionPromise:cancel() - end - end) - - -- Clean up when promise settles (success, failure, or cancel) - constructionPromise:finally(function(status) - ancestryConnection:Disconnect() + ci.watching[instance] = { + instance.AncestryChanged:Connect(evaluate), + self.AncestorsChanged:Connect(evaluate), + } - -- Clean up partial component state if not resolved successfully - if status ~= Promise.Status.Resolved or didReject then - if enteredConstruction then - InvokeExtensionFn(component, "Stopping") - component:Stop() - InvokeExtensionFn(component, "Stopped") - end - -- If we never entered construction, no cleanup is needed - end - end) + if self:_isInAncestorList(instance) then + self:_tryConstruct(instance) + end +end - -- ══════════════════════════════════════════════════════════════════════ - -- PHASE 4: Finalization - -- Await the promise and handle the result - -- ══════════════════════════════════════════════════════════════════════ - - local success, result = constructionPromise:await() - - if not success then - -- Log error if it was a real error (not just cancellation) - if typeof(result) == "string" then - warn(string.format( - "[Component] Error during construction of '%s' on '%s':\n%s", - self.Tag, - instance:GetFullName(), - tostring(result) - )) +function ComponentClassMethods._stopWatching(self: Class_Internal, instance: Instance) + const ci = Keys.class(self) + const connections = ci.watching[instance] + if connections then + ci.watching[instance] = nil + for _, conn in connections do + conn:Disconnect() end - - return nil end - - return result end ---[=[ - @tag Component Class - @within Component - @method _setup - @private - - This is an internal method that is called to set up the component class. - It is automatically called when the component class is created, unless the - `DelaySetup` option is set to `true` in the component configuration. - If `DelaySetup` is `true`, then this method must be called manually. - - ## Internal Functions - - ### StartComponent - Starts a fully constructed component: - - Sets KEY_STARTING to the current thread for cancellation tracking - - Calls extension Starting hooks, Start(), and Started hooks - - After each call, checks if KEY_STARTING was set to nil (component was stopped) - - If stopped mid-start, returns early without setting up update loops - - Connects HeartbeatUpdate, SteppedUpdate, and RenderSteppedUpdate if present - - Sets KEY_STARTED to true and fires the Started signal - - ### StopComponent - Stops a running or starting component: - - If KEY_STARTING is set (component is mid-start): - - Gets the start thread reference - - Sets KEY_STARTING to nil to signal cancellation - - If the start thread is suspended and not the current thread, cancels it - - If the start thread is in "normal" status, defers cancellation - - Disconnects all update loop connections - - Calls extension Stopping hooks, Stop(), and Stopped hooks - - Fires the Stopped signal - - ### SafeConstruct - Wrapper around _instantiate that handles superseded construction: - - Checks if the construction ID is still current before calling _instantiate - - Checks again after _instantiate returns - - If superseded, cleans up the component (if one was returned) and returns nil - - Note: _instantiate already handles cleanup for cancelled/failed constructions - - ### TryConstructComponent - Attempts to construct a component for an instance: - - Skips if a component already exists for the instance - - Increments the construction ID to track this attempt - - Defers construction to allow batching and avoid blocking - - On success, tracks the component and defers starting it - - ### TryDeconstructComponent - Stops and removes a component for an instance: - - Removes the component from tracking tables - - Clears the construction lock (important for preventing stale locks) - - Spawns StopComponent if the component was started or starting - - ### StartWatchingInstance / InstanceTagged / InstanceUntagged - Manages the per-instance ancestry watching: - - StartWatchingInstance sets up a combined signal for AncestryChanged and AncestorsChanged - - When ancestry changes, evaluates if component should be constructed or deconstructed - - InstanceUntagged removes the watch and deconstructs the component -]=] -function Component:_setup() - local idx = table.find(UNSETUP_COMPONENTS, self) +--[[ + Wires the component class to CollectionService and begins processing tagged + instances. Called automatically unless `DelaySetup` is set. +]] +function ComponentClassMethods._setup(self: Class_Internal) + const idx = table.find(UNSETUP_COMPONENTS, self) if idx then table.remove(UNSETUP_COMPONENTS, idx) else warn(self, ":_setup was already called for this component.") + return end - -- Tracks instances being watched for ancestry changes - -- Key: Instance, Value: RBXScriptConnection for the ancestry listener - -- Cleaned up when: instance is untagged or component class is destroyed - local watchingInstances = {} - - self[KEY_CLASS_ACTIVE_EXTENSIONS] = GetActiveExtensions(self, self[KEY_EXTENSIONS], {}, true) - BindExtensionMethods(self, self[KEY_CLASS_ACTIVE_EXTENSIONS]) -- Added by Raildex - - --[[ - StartComponent - Starts a fully constructed component instance. - - Edge Cases Handled: - - Stop called during Starting extension: KEY_STARTING becomes nil, returns early - - Stop called during Start(): KEY_STARTING becomes nil, returns early - - Stop called during Started extension: KEY_STARTING becomes nil, returns early - - Yielding in any hook: State is checked after each potential yield point - - Thread tracking via KEY_STARTING allows StopComponent to cancel the start - thread if the component needs to be stopped while still starting. - ]] - local function StartComponent(component) - component[KEY_STARTING] = coroutine.running() - - InvokeExtensionFn(component, "Starting") - - -- Check if component was stopped during Starting extension - if component[KEY_STARTING] == nil then - return - end - - component:Start() - - -- Check if component was stopped during Start method - if component[KEY_STARTING] == nil then - return - end - - InvokeExtensionFn(component, "Started") + const ci = Keys.class(self) + ci.classActiveExtensions = Extensions.Resolve(self, ci.extensions, true) + Extensions.BindMethods(self, ci.classActiveExtensions) - -- Check if component was stopped during Started extension - if component[KEY_STARTING] == nil then - return - end - - local hasHeartbeatUpdate = typeof(component.HeartbeatUpdate) == "function" - local hasSteppedUpdate = typeof(component.SteppedUpdate) == "function" - local hasRenderSteppedUpdate = typeof(component.RenderSteppedUpdate) == "function" - - if hasHeartbeatUpdate then - component._heartbeatUpdate = RunService.Heartbeat:Connect(function(dt) - component:HeartbeatUpdate(dt) - end) - end - - if hasSteppedUpdate then - component._steppedUpdate = RunService.Stepped:Connect(function(_, dt) - component:SteppedUpdate(dt) - end) - end - - if hasRenderSteppedUpdate and not IS_SERVER then - if component.RenderPriority then - component._renderName = NextRenderName() - RunService:BindToRenderStep(component._renderName, component.RenderPriority, function(dt) - component:RenderSteppedUpdate(dt) - end) - else - component._renderSteppedUpdate = RunService.RenderStepped:Connect(function(dt) - component:RenderSteppedUpdate(dt) - end) - end - end - - component[KEY_STARTED] = true - component[KEY_STARTING] = nil - - self.Started:Fire(component) - end - - --[[ - StopComponent - Stops a component, handling both fully started and mid-start cases. - - Thread Cancellation Edge Cases: - - If component is mid-start (KEY_STARTING set): - - Captures the start thread reference before clearing KEY_STARTING - - Clears KEY_STARTING first to signal cancellation to StartComponent - - Thread cancellation depends on state: - * "suspended": Cancel immediately (thread is yielding) - * "normal": Thread is in the call stack, defer cancellation - * "running": This is the current thread, don't cancel (would error) - - Uses pcall around task.cancel as the thread may have already finished - - - If KEY_STARTING is nil: - - Component either fully started or never started - - Just clean up connections and call Stop() - - Connection Cleanup: - - Disconnects HeartbeatUpdate, SteppedUpdate connections if they exist - - Unbinds RenderStepped if using BindToRenderStep, or disconnects if using Connect - - Extension Hooks: - - Stopping hook called before Stop() - - Stopped hook called after Stop() - - These are called even if the component was stopped mid-start - ]] - local function StopComponent(component) - if component[KEY_STARTING] then - -- Stop the component during its start method invocation: - local startThread = component[KEY_STARTING] :: thread - local currentThread = coroutine.running() - component[KEY_STARTING] = nil - - -- Only cancel if we're not currently running in that thread - if startThread ~= currentThread then - if coroutine.status(startThread) == "suspended" then - pcall(task.cancel, startThread) - elseif coroutine.status(startThread) == "normal" then - -- Thread is in the call stack but not the current one, defer cancellation - task.defer(function() - if coroutine.status(startThread) == "suspended" then - pcall(task.cancel, startThread) - end - end) - end - end - -- If we are in the same thread, we don't cancel - just let it return naturally - -- The KEY_STARTING = nil check in StartComponent will handle this case - end - - if component._heartbeatUpdate then - component._heartbeatUpdate:Disconnect() - end - - if component._steppedUpdate then - component._steppedUpdate:Disconnect() - end - - if component._renderSteppedUpdate then - component._renderSteppedUpdate:Disconnect() - elseif component._renderName then - RunService:UnbindFromRenderStep(component._renderName) - end - - InvokeExtensionFn(component, "Stopping") - component:Stop() - InvokeExtensionFn(component, "Stopped") - self.Stopped:Fire(component) - end - - --[[ - SafeConstruct - Wrapper that handles superseded construction attempts. - - Returns nil if: - - Construction ID doesn't match BEFORE calling _instantiate (stale attempt) - - Construction ID doesn't match AFTER _instantiate returns (superseded during construction) - - _instantiate itself returned nil (cancelled/failed/ShouldConstruct false) - - Cleanup Note: - - _instantiate handles its own cleanup when returning nil (calls Stop() on partial components) - - SafeConstruct only needs to call Stop() if _instantiate returned a valid component - but the ID was superseded during construction - ]] - local function SafeConstruct(instance, id) - if self[KEY_LOCK_CONSTRUCT][instance] ~= id then - return nil - end - local component = self:_instantiate(instance, id) - if self[KEY_LOCK_CONSTRUCT][instance] ~= id then - -- Construction was superseded by a newer attempt - -- Note: _instantiate already handles cleanup via component:Stop() when returning nil, - -- so we only need to clean up if a valid component was returned but is now stale - if component then - -- Component was successfully constructed but is now stale, need to clean up - component:Stop() - end - return nil - end - return component - end + ci.janitor:Add(CollectionService:GetInstanceAddedSignal(self.Tag):Connect(function(instance) + self:_startWatching(instance) + end)) + ci.janitor:Add(CollectionService:GetInstanceRemovedSignal(self.Tag):Connect(function(instance) + self:_stopWatching(instance) + self:_tryDeconstruct(instance, "Untagged") + end)) - --[[ - TryConstructComponent - Attempts to construct a component for a tagged instance. - - Construction ID System: - - Each construction attempt gets a unique, incrementing ID - - ID is stored in KEY_LOCK_CONSTRUCT[instance] - - SafeConstruct and _instantiate check this ID to detect superseded attempts - - This handles rapid reparenting where instance moves in/out of valid ancestors - - Deferred Execution: - - Construction is deferred via task.defer to avoid blocking - - Starting is also deferred after construction completes - - This allows multiple instances to be batched and processed efficiently - - Double-Check Pattern: - - Before starting, verifies the component is still the active one - - Protects against race conditions where the component was replaced - ]] - local function TryConstructComponent(instance) - if self[KEY_INST_TO_COMPONENTS][instance] then - return - end - local id = self[KEY_LOCK_CONSTRUCT][instance] or 0 - id += 1 - self[KEY_LOCK_CONSTRUCT][instance] = id + for _, instance in CollectionService:GetTagged(self.Tag) do task.defer(function() - local component = SafeConstruct(instance, id) - if not component then - return - end - self[KEY_INST_TO_COMPONENTS][instance] = component - table.insert(self[KEY_COMPONENTS] :: table, component) - task.defer(function() - if self[KEY_INST_TO_COMPONENTS][instance] == component then - StartComponent(component) - end - end) + self:_startWatching(instance) end) end - - --[[ - TryDeconstructComponent - Removes and stops a component for an instance. - - Cleanup Order: - 1. Remove from KEY_INST_TO_COMPONENTS (prevents new lookups) - 2. Clear KEY_LOCK_CONSTRUCT (prevents stale construction locks) - 3. Remove from KEY_COMPONENTS array (uses swap-remove for O(1)) - 4. Spawn StopComponent if component was started/starting - - Important: KEY_LOCK_CONSTRUCT is cleared even if no component exists. - This handles the case where construction is in progress but not complete, - preventing the stale lock from blocking future construction attempts. - - Note: Uses task.spawn for StopComponent to avoid blocking and allow - the calling code to continue (important for batch operations). - ]] - local function TryDeconstructComponent(instance) - local component = self[KEY_INST_TO_COMPONENTS][instance] - if not component then - return - end - self[KEY_LOCK_CONSTRUCT][instance] = nil - self[KEY_INST_TO_COMPONENTS][instance] = nil - local components = self[KEY_COMPONENTS] :: table - local index = table.find(components, component) - if index then - -- Swap-remove for O(1) removal from unordered array - local n = #components - components[index] = components[n] - components[n] = nil - end - if component[KEY_STARTED] or component[KEY_STARTING] then - task.spawn(StopComponent, component) - end - end - - --[[ - StartWatchingInstance - Sets up ancestry monitoring for a tagged instance. - - Combined Signal: - - Listens to both instance.AncestryChanged and self.AncestorsChanged - - This means components respond to BOTH instance movement AND ancestor list changes - - Uses RailUtil.Signal.combine for efficient combined listening - - Ancestry Evaluation: - - On any ancestry change, checks if instance is in valid ancestor list - - If valid: TryConstructComponent (may already exist, that's handled) - - If invalid: TryDeconstructComponent (cleans up if exists) - - Memory Management: - - Connection stored in watchingInstances table - - Connection added to component class Trove for automatic cleanup on Destroy - - InstanceUntagged explicitly removes from watchingInstances and Trove - ]] - local function StartWatchingInstance(instance) - if watchingInstances[instance] then - return - end - - local ancestryChangedHandle = self[KEY_TROVE]:Connect( - RailUtil.Signal.combine { - instance.AncestryChanged, - self.AncestorsChanged, - }, - function(_, parent) - if parent and self:_isInAncestorList(instance) then - TryConstructComponent(instance) - else - TryDeconstructComponent(instance) - end - end - ) - watchingInstances[instance] = ancestryChangedHandle - if self:_isInAncestorList(instance) then - TryConstructComponent(instance) - end - end - - --[[ - InstanceTagged - Called when CollectionService detects a new tagged instance. - Simply starts watching the instance for ancestry changes. - ]] - local function InstanceTagged(instance: Instance) - StartWatchingInstance(instance) - end - - --[[ - InstanceUntagged - Called when CollectionService detects tag removal. - - Cleanup: - 1. Removes ancestry watching connection from watchingInstances - 2. Removes connection from Trove (prevents double-disconnect on Destroy) - 3. Deconstructs any existing component - - This is the primary cleanup path for normal component removal. - ]] - local function InstanceUntagged(instance: Instance) - local watchHandle = watchingInstances[instance] - if watchHandle then - watchingInstances[instance] = nil - self[KEY_TROVE]:Remove(watchHandle) - end - TryDeconstructComponent(instance) - end - - -- Connect to CollectionService for tag add/remove events - -- These connections are stored in the Trove for cleanup on Destroy - self[KEY_TROVE]:Connect(CollectionService:GetInstanceAddedSignal(self.Tag), InstanceTagged) - self[KEY_TROVE]:Connect(CollectionService:GetInstanceRemovedSignal(self.Tag), InstanceUntagged) - - -- Process all instances that already have the tag - -- Deferred to avoid blocking and allow batching - local tagged = CollectionService:GetTagged(self.Tag) - for _, instance in ipairs(tagged) do - task.defer(InstanceTagged, instance) - end end +-------------------------------------------------------------------------------- +-- Public class API +-------------------------------------------------------------------------------- + --[=[ @tag Component Class @return {Component} - Gets a table array of all existing component objects. For example, - if there was a component class linked to the "MyComponent" tag, - and three Roblox instances in your game had that same tag, then - calling `GetAll` would return the three component instances. - - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - -- ... - - local components = MyComponent:GetAll() - for _,component in ipairs(components) do - component:DoSomethingHere() - end - ``` + Returns a copy of all active component instances of this class. ]=] -function Component:GetAll() - return self[KEY_COMPONENTS] +function ComponentClassMethods.GetAll(self: Class_Internal): { Instance_Internal } + return table.clone(Keys.class(self).components) end --[=[ @tag Component Class + @param instance Instance @return Component? + Returns the component of this class bound to `instance`, or nil. The component + may still be constructing; use [Component:GetLifecycleStatus] to check. +]=] +function ComponentClassMethods.FromInstance(self: Class_Internal, instance: Instance): Instance_Internal? + return Keys.class(self).instToComponents[instance] +end - Resolves a promise once the component instance is present on a given - Roblox instance. - - An optional `timeout` can be provided to reject the promise if it - takes more than `timeout` seconds to resolve. If no timeout is - supplied, `timeout` defaults to 60 seconds. - - ```lua - local MyComponent = require(somewhere.MyComponent) +--[=[ + @tag Component Class + @param instance Instance + @return boolean + Whether a *started* component of this class is bound to `instance`. Use + [Component:FromInstance] if a still-constructing component should count. +]=] +function ComponentClassMethods.Has(self: Class_Internal, instance: Instance): boolean + const component = Keys.class(self).instToComponents[instance] + return component ~= nil and Keys.inst(component).started == true +end - MyComponent:WaitForInstance(workspace.SomeInstance):andThen(function(myComponentInstance) - -- Do something with the component class - end) - ``` +--[=[ + @tag Component Class + @param instanceOrComponent Instance | Component + @return LifecyclePhase + Returns the lifecycle phase of the component bound to the given instance (or the + phase of the given component). `"None"` if there is no such component. ]=] -function Component:WaitForInstance(instance: Instance, timeout: number?) - local componentInstance = self:FromInstance(instance) - if componentInstance and componentInstance[KEY_STARTED] then - return Promise.resolve(componentInstance) - end - return Promise.fromEvent(self.Started, function(c) - local match = c.Instance == instance - if match then - componentInstance = c +function ComponentClassMethods.GetLifecycleStatus( + self: Class_Internal, + instanceOrComponent: Instance | Types.AnyComponent +): LifecyclePhase + local component: Types.AnyComponent? = nil + if typeof(instanceOrComponent) == "Instance" then + const ci = Keys.class(self) + component = ci.instToComponents[instanceOrComponent] + if not component then + const record = ci.pending[instanceOrComponent] + -- `unknown` bridge: InstanceAny (an intersection) is not a subtype of + -- the `{[any]: any}` view in the solver's eyes. + component = if type(record) == "table" + then ((record :: Types.PendingRecord).component :: unknown) :: Types.AnyComponent + else nil end - return match - end) - :andThen(function() - return componentInstance - end) - :timeout(if type(timeout) == "number" then timeout else DEFAULT_TIMEOUT) + else + component = instanceOrComponent + end + if type(component) == "table" and component[Keys.Internal] then + return Keys.inst(component).phase + end + return "None" +end + +--[=[ + @tag Component Class + @param instance Instance + @param timeout number? + @return Promise + Resolves once a *started* component of this class exists on `instance`. + Defaults to a 60 second timeout. +]=] +function ComponentClassMethods.WaitForInstance( + self: Class_Internal, + instance: Instance, + timeout: number? +): Types.PromiseLike + const componentInstance = self:FromInstance(instance) + if componentInstance and Keys.inst(componentInstance).started then + return (Promise.resolve(componentInstance) :: unknown) :: Types.PromiseLike + end + return fromEvent(self.Started, function(c: Instance_Internal) + return c.Instance == instance + end):timeout(if type(timeout) == "number" then timeout else DEFAULT_TIMEOUT) end --[=[ @tag Component Class + @param instance Instance + @return Promise + Resolves with the started component on `instance`. If none exists, tags the + instance and constructs one directly (without waiting on the CollectionService + round-trip). Rejects if construction is vetoed by `ShouldConstruct` or is + stopped before it starts. +]=] +function ComponentClassMethods.CreateFromInstance(self: Class_Internal, instance: Instance): Types.PromiseLike + return ( + Promise.new(function(resolve, reject, onCancel) + const existing = self:FromInstance(instance) + if existing and Keys.inst(existing).started then + resolve(existing) + return + end - Allows for you to update the valid ancestors of a component class. This is useful if you want to - give a valid ancestor that may not exist when the component is first created. + const janitor = Janitor.new() + onCancel(function() + janitor:Destroy() + end) + janitor:Add( + self.Started:Connect(function(component) + if component.Instance == instance then + janitor:Destroy() + resolve(component) + end + end), + "Disconnect" + ) + janitor:Add( + Keys.class(self).failed:Connect(function(failedInstance, reason) + if failedInstance == instance then + janitor:Destroy() + reject(`Component '{self.Tag}' did not start on instance: {reason}`) + end + end), + "Disconnect" + ) + + if not CollectionService:HasTag(instance, self.Tag) then + CollectionService:AddTag(instance, self.Tag) + end + self:_startWatching(instance) + if self:_isInAncestorList(instance) then + self:_tryConstruct(instance) + end + end) :: unknown + ) :: Types.PromiseLike +end - ```lua - local MyComponent = Component.new({ - Tag = "MyComponent", - Ancestors = {workspace}, - }) +--- @deprecated v1.0.0 -- Renamed to [Component:CreateFromInstance]. +ComponentClassMethods.GetOrCreateFromInstance = ComponentClassMethods.CreateFromInstance - task.defer(function() - local newAncestors = {workspace:WaitForChild("SomeFolder")} - MyComponent:UpdateAncestors(newAncestors) - end) - ``` +--[=[ + @tag Component Class + Updates the valid ancestors of this class and re-evaluates watched instances. ]=] -function Component:UpdateAncestors(newAncestors: { Instance }) - local lastAncestors = self[KEY_ANCESTORS] - self[KEY_ANCESTORS] = newAncestors +function ComponentClassMethods.UpdateAncestors(self: Class_Internal, newAncestors: { Instance }) + const ci = Keys.class(self) + const lastAncestors = ci.ancestors + ci.ancestors = newAncestors self.AncestorsChanged:Fire(newAncestors, lastAncestors) end --[=[ @tag Component Class - - Gets the current valid ancestors of a component class. + Returns a copy of the current valid ancestors. ]=] -function Component:GetAncestors(): { Instance } - return table.clone(self[KEY_ANCESTORS]) +function ComponentClassMethods.GetAncestors(self: Class_Internal): { Instance } + return table.clone(Keys.class(self).ancestors) end --[=[ @tag Component Class - `Construct` is called before the component is started, and should be used - to construct the component instance. - - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - - function MyComponent:Construct() - self.SomeData = 32 - self.OtherStuff = "HelloWorld" - end - ``` + Called before the component starts, to initialize it. May yield or return a + Promise. ]=] -function Component:Construct() end +function ComponentClassMethods.Construct(_self: Instance_Internal) end --[=[ @tag Component Class - `Start` is called when the component is started. At this point in time, it - is safe to grab other components also bound to the same instance. - - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - local AnotherComponent = require(somewhere.AnotherComponent) - - function MyComponent:Start() - -- e.g., grab another component: - local another = self:GetComponent(AnotherComponent) - end - ``` + Called when the component starts. Sibling components on the same instance are + safe to access here. May yield or return a Promise. ]=] -function Component:Start() end +function ComponentClassMethods.Start(_self: Instance_Internal) end --[=[ @tag Component Class - `Stop` is called when the component is stopped. This occurs either when the - bound instance is removed from one of the whitelisted ancestors _or_ when - the matching tag is removed from the instance. This also means that the - instance _might_ be destroyed, and thus it is not safe to continue using - the bound instance (e.g. `self.Instance`) any longer. - - This should be used to clean up the component. - - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - - function MyComponent:Stop() - self.SomeStuff:Destroy() - end - ``` + @param reason StopReason + Called when the component stops. The bound instance may already be gone — check + `reason`. Anything added via `self:AddTask` is cleaned up automatically after + this returns. ]=] -function Component:Stop() end +function ComponentClassMethods.Stop(_self: Instance_Internal, _reason: StopReason) end + +-------------------------------------------------------------------------------- +-- Public instance API +-------------------------------------------------------------------------------- --[=[ @tag Component Instance @param componentClass ComponentClass @return Component? + Retrieves another component bound to the same Roblox instance. +]=] +function ComponentClassMethods.GetComponent(self: Instance_Internal, componentClass: Class_Internal): Instance_Internal? + return Keys.class(componentClass).instToComponents[self.Instance] +end - Retrieves another component instance bound to the same - Roblox instance. - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - local AnotherComponent = require(somewhere.AnotherComponent) - - function MyComponent:Start() - local another = self:GetComponent(AnotherComponent) - end - ``` +--[=[ + @tag Component Instance + @return boolean + Whether the component has fully started. ]=] -function Component:GetComponent(componentClass) - return componentClass[KEY_INST_TO_COMPONENTS][self.Instance] +function ComponentClassMethods.IsStarted(self: Instance_Internal): boolean + return Keys.inst(self).started == true end --[=[ @tag Component Instance - @return Connection - - Ties a function to the lifecycle of the calling component and the equivalent component of the given - `componentClass`. The function is run whenever a component of the given class is started. The given - function passes the sibling component of the given class and a janitor to handle any connections - you may make within it. The Janitor is cleaned up whenever either component is stopped. - - ```lua - local AnotherComponentClass = require(somewhere.AnotherComponent) - - local MyComponent = Component.new({Tag = "MyComponent"}) - - function MyComponent:Start() - self:WhileHasComponent(AnotherComponentClass, function(siblingComponent, jani) - print(siblingComponent.SomeProperty) - - jani:Add(function() - print("Sibling component stopped") - end) - end) - end - ``` + @param task T + @param cleanupMethod (string | true)? + @param index any? + @return T + Adds a task to the component's core Janitor, cleaned up when the component + stops (on every teardown path). Returns the task. ]=] -function Component:WhileHasComponent(componentClass: ComponentClass, fn: (component: Component, jani: Janitor) -> ()) - assert(typeof(componentClass) == "table", ":WhileHasComponent() expects a component class.") - if not componentClass.Tag and componentClass[1] and componentClass[1].Tag then - error( - ":WhileHasComponent() called with an array of component classes. Did you mean to call :WhileHasComponents() instead?" - ) - end - assert(componentClass.Tag, ":WhileHasComponent() expects a component class.") - - local bindJani = Janitor.new() - - local connProxy = {} - connProxy.IsConnected = true - connProxy.Disconnect = function() - if connProxy.IsConnected then - connProxy.IsConnected = false - bindJani:Destroy() - end - end - connProxy.Destroy = connProxy.Disconnect - setmetatable(connProxy, { - __call = function(t, ...) - return t.Destroy(...) - end, - }) - - bindJani:Add(connProxy) - bindJani:AddPromise(Promise.fromEvent(self.Stopped, function(c) - return c.Instance == self.Instance - end):andThen(connProxy.Destroy)) - - -- Track janitor for the component - local activeJanitor = nil - - local function SetupIfPresent() - local component = self:GetComponent(componentClass) - if not component or activeJanitor then - return - end - - activeJanitor = bindJani:Add(Janitor.new(), "Destroy") - activeJanitor:Add(task.spawn(fn, component, activeJanitor)) - - -- If the component stops, destroy janitor - activeJanitor:AddPromise(Promise.fromEvent(componentClass.Stopped, function(c) - return c.Instance == self.Instance - end):andThen(function() - if activeJanitor then - activeJanitor:Destroy() - activeJanitor = nil - end - end)) - end - - -- Listen for component start events - bindJani:Add(componentClass.Started:Connect(function(component) - if component.Instance == self.Instance then - SetupIfPresent() - end - end)) - - -- Initial check in case component is already present - SetupIfPresent() - - return connProxy +function ComponentClassMethods.AddTask( + self: Instance_Internal, + task_: T, + cleanupMethod: (string | boolean)?, + index: unknown? +): T + return Keys.inst(self).janitor:Add(task_, cleanupMethod, index) end --[=[ @tag Component Instance - @return Connection - - Ties a function to the lifecycle of the calling component and the equivalent components of the given - array of `componentClasses`. The function is run whenever all components of the given classes are started. - The given function passes an array of sibling components of the given classes and a janitor to handle any - connections you may make within it. The Janitor is cleaned up whenever any of the components is stopped. - - ```lua - local AnotherComponentClass = require(somewhere.AnotherComponent) - local ThirdComponentClass = require(somewhere.ThirdComponent) - - local MyComponent = Component.new({Tag = "MyComponent"}) - - function MyComponent:Start() - self:WhileHasComponents({AnotherComponentClass, ThirdComponentClass}, function(siblingComponents, jani) - local anotherComponent = siblingComponents[1] - local thirdComponent = siblingComponents[2] - print(anotherComponent.SomeProperty, thirdComponent.AnotherProperty) - - jani:Add(function() - print("One or more sibling components stopped") - end) - end) - end - ``` + @param promise Promise + @param index any? + @return Promise + Adds a Promise to the component's core Janitor. An optional string `index` + names it so it can be removed/cancelled via [Component:RemoveTask]. ]=] -function Component:WhileHasComponents( - componentClasses: { ComponentClass }, - fn: (components: { Component }, jani: Janitor) -> () -) - local bindJani = Janitor.new() - - local connProxy = {} - connProxy.IsConnected = true - connProxy.Disconnect = function() - if connProxy.IsConnected then - connProxy.IsConnected = false - bindJani:Destroy() - end - end - connProxy.Destroy = connProxy.Disconnect - setmetatable(connProxy, { - __call = function(t, ...) - return t.Destroy(...) - end, - }) - - bindJani:Add(connProxy) - bindJani:AddPromise(Promise.fromEvent(self.Stopped, function(c) - return c.Instance == self.Instance - end):andThen(connProxy.Destroy)) - - assert(typeof(componentClasses) == "table", "Component:WhileHasComponents() expects a non-empty array of component classes.") - for _, class in ipairs(componentClasses) do - assert(typeof(class) == "table" and class.Tag, "Component:WhileHasComponents() expects all elements to be component classes.") - end - - -- Helper to get all component instances for self.Instance - local function getAllComponents() - local components = {} - for i, class in ipairs(componentClasses) do - local inst = self:GetComponent(class) - if not inst then - return nil - end - components[i] = inst - end - return components - end - - -- Track janitor for the set of components - local activeJanitor = nil - - local function SetupIfAllPresent() - local components = getAllComponents() - if not components or activeJanitor then - return - end - - activeJanitor = bindJani:Add(Janitor.new(), "Destroy") - activeJanitor:Add(task.spawn(fn, components, activeJanitor)) - local function cleanupJanitor() - if activeJanitor then - activeJanitor:Destroy() - activeJanitor = nil - end - end - - -- If any component stops, destroy janitor - for _, class in ipairs(componentClasses) do - activeJanitor:AddPromise(Promise.fromEvent(class.Stopped, function(c) - return c.Instance == self.Instance - end):andThen(cleanupJanitor)) - end - end - - -- Listen for all component start events - for _, class in ipairs(componentClasses) do - bindJani:Add(class.Started:Connect(function(component) - if component.Instance == self.Instance then - SetupIfAllPresent() - end - end)) - end - - -- Initial check in case all are already present - SetupIfAllPresent() - - return connProxy +function ComponentClassMethods.AddPromise( + self: Instance_Internal, + promise: Types.PromiseLike, + index: unknown? +): Types.PromiseLike + return Keys.inst(self).janitor:AddPromise(promise, index) end --- DEPRECATED: Use WhileHasComponent or WhileHasComponents instead. Kept for backwards compat -function Component:ForEachSibling(...) - warn( - "ForEachSibling is deprecated. Use WhileHasComponent for single components or WhileHasComponents for multiple components instead." - ) - -- For backwards compatibility, try to detect if it's multiple components and route appropriately - local componentClassOrClasses = ... - assert(typeof(componentClassOrClasses) == "table", ":ForEachSibling() expects a component class or an array of component classes.") - if not componentClassOrClasses.Tag then - return self:WhileHasComponents(...) +--[=[ + @tag Component Instance + @param index any + @param dontClean boolean? + Removes a task from the core Janitor, cleaning it unless `dontClean` is true. +]=] +function ComponentClassMethods.RemoveTask(self: Instance_Internal, index: unknown, dontClean: boolean?) + const janitor = Keys.inst(self).janitor + if dontClean then + janitor:RemoveNoClean(index) else - return self:WhileHasComponent(...) + janitor:Remove(index) end end +--[=[ + @tag Component Instance + @param index any + @return any + Gets a task previously added with an index. +]=] +function ComponentClassMethods.GetTask(self: Instance_Internal, index: unknown): unknown + return Keys.inst(self).janitor:Get(index) +end + + + --[=[ @tag Component Class @function HeartbeatUpdate @param dt number @within Component - - If this method is present on a component, then it will be - automatically connected to `RunService.Heartbeat`. - - :::note Method - This is a method, not a function. This is a limitation - of the documentation tool which should be fixed soon. - ::: - - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - - function MyComponent:HeartbeatUpdate(dt) - end - ``` + If present, connected to `RunService.Heartbeat` while the component runs. ]=] --[=[ @tag Component Class @function SteppedUpdate @param dt number @within Component - - If this method is present on a component, then it will be - automatically connected to `RunService.Stepped`. - - :::note Method - This is a method, not a function. This is a limitation - of the documentation tool which should be fixed soon. - ::: - - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - - function MyComponent:SteppedUpdate(dt) - end - ``` + If present, connected to `RunService.Stepped` while the component runs. ]=] --[=[ @tag Component Class @@ -1740,95 +722,46 @@ end @param dt number @within Component @client - - If this method is present on a component, then it will be - automatically connected to `RunService.RenderStepped`. If - the `[Component].RenderPriority` field is found, then the - component will instead use `RunService:BindToRenderStep()` - to bind the function. - - :::note Method - This is a method, not a function. This is a limitation - of the documentation tool which should be fixed soon. - ::: - - ```lua - -- Example that uses `RunService.RenderStepped` automatically: - - local MyComponent = Component.new({Tag = "MyComponent"}) - - function MyComponent:RenderSteppedUpdate(dt) - end - ``` - ```lua - -- Example that uses `RunService:BindToRenderStep` automatically: - - local MyComponent = Component.new({Tag = "MyComponent"}) - - -- Defining a RenderPriority will force the component to use BindToRenderStep instead - MyComponent.RenderPriority = Enum.RenderPriority.Camera.Value - - function MyComponent:RenderSteppedUpdate(dt) - end - ``` + If present, connected to `RunService.RenderStepped` while the component runs, or + to `BindToRenderStep` when `RenderPriority` is set. ]=] --[=[ @tag Component Class @within Component - @private - - Destroys the component class, stopping all active components and cleaning up all resources. - - ## Cleanup Process - - 1. Removes from UNSETUP_COMPONENTS if present - 2. Stops all active components (those with KEY_STARTED or KEY_STARTING set) - 3. Clears all tracking tables: - - KEY_INST_TO_COMPONENTS (instance -> component mapping) - - KEY_COMPONENTS (array of all components) - - KEY_LOCK_CONSTRUCT (construction attempt IDs) - 4. Destroys the Trove, which: - - Disconnects CollectionService tag signals - - Disconnects all ancestry watching connections - - Cleans up Started, Stopped, and AncestorsChanged signals - - ## Memory Leak Prevention - - This method ensures no references to component instances or Roblox instances - are retained after destruction. All internal tables are cleared, and the Trove - pattern ensures all connections are properly disconnected. - - ## Usage - - ```lua - local MyComponent = Component.new({Tag = "MyComponent"}) - -- ... later when you want to completely disable this component class ... - MyComponent:Destroy() - ``` + Destroys the component class: stops all its components (with reason + `"ClassDestroyed"`), disconnects from CollectionService, and clears all state. ]=] -function Component:Destroy() - local idx = table.find(UNSETUP_COMPONENTS, self) +function ComponentClassMethods.Destroy(self: Class_Internal) + const idx = table.find(UNSETUP_COMPONENTS, self) if idx then table.remove(UNSETUP_COMPONENTS, idx) end - -- Clear component tracking tables to prevent memory leaks - -- Stop all active components first - for _, component in pairs(self[KEY_INST_TO_COMPONENTS]) do - if component[KEY_STARTED] or component[KEY_STARTING] then - task.spawn(component.Stop, component) - end + const ci = Keys.class(self) + + -- Stop watching every instance so no new construction begins mid-destroy. + for instance in ci.watching do + self:_stopWatching(instance) end - -- Destroy the trove which will clean up all connections - self[KEY_TROVE]:Destroy() + -- Tear down all components (started, constructed, or in-flight) via the one path. + for instance, record in ci.pending do + if type(record) == "table" and record.component then + self:_tryDeconstruct(instance, "ClassDestroyed") + end + end + for instance in ci.instToComponents do + self:_tryDeconstruct(instance, "ClassDestroyed") + end - -- Clear all tracking tables - table.clear(self[KEY_INST_TO_COMPONENTS]) - table.clear(self[KEY_COMPONENTS]) - table.clear(self[KEY_LOCK_CONSTRUCT]) + ci.janitor:Destroy() + table.clear(ci.instToComponents) + table.clear(ci.components) + table.clear(ci.lockConstruct) + table.clear(ci.pending) + table.clear(ci.watching) end return Component diff --git a/lib/component/src/scratchpad.luau b/lib/component/src/scratchpad.luau new file mode 100644 index 00000000..de9a3cbd --- /dev/null +++ b/lib/component/src/scratchpad.luau @@ -0,0 +1,19 @@ +--!strict + +local extension = {} +extension.Methods = {} + +function extension.Methods.test(self: CLASS, arg: number): string + return `test: {arg}` +end + +local class = {} +function class.method(self: CLASS, arg: number): string + return `method: {arg}` +end + +function new(config, methods: A): typeof(A) + return nil :: any +end + +local x = new({}, class) diff --git a/lib/component/wally.toml b/lib/component/wally.toml index 1d07f7ce..43d6a776 100644 --- a/lib/component/wally.toml +++ b/lib/component/wally.toml @@ -2,7 +2,7 @@ name = "raild3x/component" description = "A fork of Sleitnick's Component class for Roblox." authors = ["Logan Hunt (Raildex)"] -version = "0.2.0" +version = "1.0.0" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" @@ -16,9 +16,7 @@ docsLink = "Component" [dependencies] -Promise = "evaera/promise@^4.0.0" -Signal = "lucasmzreal/fastsignal@^10.2.1" +Promise = "howmanysmall/typed-promise@^4.0.6" +Signal = "howmanysmall/better-signal@2.1.0" Janitor = "howmanysmall/janitor@^1.16.0" -RailUtil = "raild3x/railutil@^1" -Symbol = "sleitnick/symbol@^2.0.1" -Trove = "sleitnick/trove@1.4.0" \ No newline at end of file +Symbol = "sleitnick/symbol@^2.0.1" \ No newline at end of file diff --git a/stylua.toml b/stylua.toml index 2343da5f..fc665709 100644 --- a/stylua.toml +++ b/stylua.toml @@ -1,4 +1,4 @@ -syntax = "All" +syntax = "Luau" column_width = 120 line_endings = "Unix" indent_type = "Tabs" From 383da7e6caefa176061f625ed7d323ab8ad7f400 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Thu, 23 Jul 2026 21:17:51 -0400 Subject: [PATCH 06/19] Refactor component lifecycle and query engine Reworked component lifecycle execution to a sync-first flow that only creates Promises when hooks actually yield, replacing the old Promise-chain driver. This also switches pending construction tracking to explicit cancel callbacks, preserves teardown signal delivery by deferring class janitor destruction until all teardowns finish, and reuses class-level extension resolution when instance variance is not needed. On the query side, this adds invalidation-backed caches, memoized sub-query evaluation in `get()`, split positive-candidate checks from the rest of matching, and replaces per-attribute janitor fanout with a single filtered `AttributeChanged` connection per candidate to reduce activation overhead. Tests/types were updated to match the API and behavior changes, including removal of sibling-binding coverage from the deleted spec. --- lib/component/src/Keys.luau | 9 + lib/component/src/Lifecycle.luau | 649 ++++++++++++------ lib/component/src/Query.luau | 171 ++++- .../src/Tests/Component.Lifecycle.spec.luau | 27 - .../src/Tests/Component.Siblings.spec.luau | 117 ---- lib/component/src/Tests/Component.types.luau | 4 - lib/component/src/Types.luau | 4 +- lib/component/src/init.luau | 56 +- 8 files changed, 611 insertions(+), 426 deletions(-) delete mode 100644 lib/component/src/Tests/Component.Siblings.spec.luau diff --git a/lib/component/src/Keys.luau b/lib/component/src/Keys.luau index dfa0bc58..f81c83be 100644 --- a/lib/component/src/Keys.luau +++ b/lib/component/src/Keys.luau @@ -70,10 +70,19 @@ export type ClassInternal = { pending: { [Instance]: any }, -- in-flight construction record (or `true` reservation) extensions: { any }, classActiveExtensions: { any }, + -- True when some extension defines `ShouldExtend`, so the active set genuinely + -- varies per instance. When false every instance resolves to + -- `classActiveExtensions` and the per-instance topological sort is skipped. + extensionsVaryPerInstance: boolean, fields: { [string]: any }?, -- config.Fields: deep-copied onto every instance initFields: (() -> { [string]: any })?, -- config.InitFields: called per instance janitor: any, -- class-level Janitor (signals + CollectionService connections) failed: any, -- internal Signal(instance, reason): construction ended without starting + -- Teardowns that have begun but not yet finished. `Destroy` must not tear the + -- class Janitor down while any are outstanding, or it would destroy the + -- `Stopped`/`failed` signals those teardowns still have to fire on. + teardownsInFlight: number, + destroyJanitorWhenIdle: boolean, } local INTERNAL = Symbol("Internal") diff --git a/lib/component/src/Lifecycle.luau b/lib/component/src/Lifecycle.luau index 108ba53c..a3236a6a 100644 --- a/lib/component/src/Lifecycle.luau +++ b/lib/component/src/Lifecycle.luau @@ -7,10 +7,20 @@ The single place a component instance is born and dies. `Run` drives ShouldConstruct -> Constructing -> Construct -> Constructed -> Starting -> - Start -> Started as one cancellable Promise chain; `Teardown` is the single, + Start -> Started as one cancellable sequence; `Teardown` is the single, guaranteed removal path (Stopping -> Stop(reason) -> Stopped -> Janitor destroy) used by every removal route. `init.luau` owns *when* these run (tag/ancestry watching); this module owns *what* running them means. + + Both are driven as straight-line code on a single thread rather than a chain + of Promises: `Run` is already called from a deferred thread, so each step runs + inline and only *parks* (on a Promise) when a hook genuinely yields or returns + one. Nothing about the observable contract changes — construct phases still + hold up the chain, start/stop phases still run off the triggering thread (one + deferral boundary before Starting; teardown defers as a whole), stop hooks + still gate in reverse dependency order, and independent yielding hooks still + run concurrently — but a component that never yields now allocates no Promises + at all. See `invokeSmart` and `runHooks`. ]=] local RunService = game:GetService("RunService") @@ -46,45 +56,92 @@ const function NextRenderName(): string end --[[ - Runs `fn(...)` in its own thread and resolves when it (and any Promise it - returns) finishes. Cancelling the returned Promise cancels the thread and any - chained Promise. This is the unit the phase barrier composes over: a hook that - yields does not block its siblings. + Runs `fn(...)` and reports how it finished, allocating a Promise only when it + actually needs one. This is the unit every phase composes over. + + Returns `(pending, ok, result)`: + - `(nil, true, value)` — finished synchronously; nothing to wait on. + - `(nil, false, err)` — errored synchronously. + - `(promise, ...)` — still running (it yielded) or it returned a Promise: + the caller waits on `promise`. - `deferred` dispatches with `task.defer` instead of `task.spawn`, so the hook - never runs inline on the thread that triggered it (start/stop phases). + The overwhelmingly common case (a hook that neither yields nor returns a + Promise) therefore costs one `coroutine.create/resume` and no allocation + beyond it -- the previous unconditional `Promise.new` + `task.spawn` per hook + was ~12us each and dominated construction with more than a couple extensions. + + When the hook DOES suspend, the already-running thread is adopted by a Promise + with the same cancellation semantics as before: cancelling kills a suspended + thread and cancels a chained Promise. `fn` is `(...any)` on purpose: hooks arrive as typed methods and untyped extension entries alike, and only `any` parameters accept both. ]] -const function invokeAsPromise(deferred: boolean, fn: (...any) -> any, ...: any): PromiseLike - const args = table.pack(...) - const dispatch = if deferred then task.defer else task.spawn - return ( +const function invokeSmart(fn: (...any) -> any, ...: any): (PromiseLike?, boolean, any) + local settled = false + local syncOk: boolean = true + local syncRes: any = nil + -- Set only if the hook yields: the continuation that hands its eventual + -- outcome to the Promise built below. No polling -- the hook's own thread + -- calls it when it finishes. + local finish: ((boolean, any) -> ())? = nil + + -- `resume` returns as soon as the body yields, so `settled` distinguishes + -- "ran to completion" from "parked mid-hook". + const thread = coroutine.create(function(...) + const ok, res = pcall(fn, ...) + settled = true + syncOk, syncRes = ok, res + const continuation = finish + if continuation then + continuation(ok, res) + end + end) + const resumeOk, resumeErr = coroutine.resume(thread, ...) + if not resumeOk then + -- pcall inside the body catches hook errors; reaching here means the + -- thread itself failed (e.g. cancelled out from under us). + return nil, false, resumeErr + end + + if settled then + if not syncOk then + return nil, false, syncRes + end + if not Promise.is(syncRes) then + return nil, true, syncRes + end + -- Returned a Promise: the caller waits on it directly, no wrapper needed. + return syncRes :: PromiseLike, true, nil + end + + -- The hook yielded. Adopt the live thread into a Promise so the caller can + -- wait on it and cancellation can still reach the thread. `Promise.new` runs + -- its executor synchronously, so `finish` is in place before the suspended + -- hook can possibly resume. + const promise = ( Promise.new(function(resolve, reject, onCancel) - local thread: thread? local chained: PromiseLike? local cancelled = false onCancel(function() cancelled = true - if thread and coroutine.status(thread) == "suspended" then + if coroutine.status(thread) == "suspended" then pcall(task.cancel, thread) end if chained then chained:cancel() end end) - thread = task.spawn(function() - const ok, res = pcall(fn, table.unpack(args, 1, args.n)) + finish = function(ok: boolean, res: any) if cancelled then return end if not ok then reject(res) elseif Promise.is(res) then - const promise = res :: PromiseLike - chained = promise - promise:andThen(function(...) + const inner = res :: PromiseLike + chained = inner + inner:andThen(function(...) resolve(...) end, function(...) reject(...) @@ -92,20 +149,33 @@ const function invokeAsPromise(deferred: boolean, fn: (...any) -> any, ...: any) else resolve(res) end - end) + end end) :: unknown ) :: PromiseLike + return promise, true, nil end -const NO_EDGES: { any } = {} +--[[ + Waits for `promise` to settle on the calling (driver) thread. + Returns nil on success, or the error value on rejection/cancellation. +]] +const function awaitSettled(promise: PromiseLike): any? + const awaitable = (promise :: unknown) :: { awaitStatus: (self: unknown) -> (string, ...any) } + const status, result = awaitable:awaitStatus() + if status == Promise.Status.Resolved then + return nil + end + if status == Promise.Status.Cancelled then + return CANCELLED + end + return if result == nil then CANCELLED else result +end export type PhaseOptions = { -- Flip the dependency edges: a dependency's hook runs only once the hooks of -- everything depending on it have finished (teardown). reverse: boolean?, - -- Dispatch each hook with `task.defer` (start/stop phases). - deferred: boolean?, - -- Warn on a hook error instead of rejecting the phase (teardown must finish). + -- Warn on a hook error instead of failing the phase (teardown must finish). warnErrors: boolean?, } @@ -120,25 +190,38 @@ export type PhaseOptions = { already built. An extension with no hook this phase still forwards its gate, so a dependent keeps waiting on its transitive dependencies. - Returns nil when no active extension implements the phase. + The pass is *sync-first*: hooks run inline in topological order, and a hook + that completes without yielding needs no Promise and no gate at all (running + it inline already satisfied every ordering constraint). Only a hook that + actually suspends produces a Promise, and only extensions depending on a + still-pending hook build a gate around theirs. A phase where nothing yields + therefore allocates nothing. + + Returns `(pending, err)`: + - `(nil, nil)` — the whole phase completed synchronously. + - `(promise, nil)` — wait on `promise` for the hooks that are still running. + - `(nil, err)` — a hook errored synchronously (never when `warnErrors`). ]] const function runHooks( activeExtensions: { any }, phaseName: string, component: InstanceAny, options: PhaseOptions -): PromiseLike? +): (PromiseLike?, any?) const reverse = options.reverse == true - const deferred = options.deferred == true + const warnErrors = options.warnErrors == true -- extension -> the extensions whose hooks must settle before its own starts. - const waitsOn: { [any]: { any } } = {} + -- Only built when some extension actually declares dependencies. + local waitsOn: { [any]: { any } }? = nil const function addEdge(after: any, before: any) - const list = waitsOn[after] + const map = waitsOn or {} + waitsOn = map + const list = map[after] if list then table.insert(list, before) else - waitsOn[after] = { before } + map[after] = { before } end end for _, extension in activeExtensions do @@ -163,54 +246,92 @@ const function runHooks( end end - const settled: { [any]: PromiseLike } = {} - const nodes: { PromiseLike } = {} - local hasHook = false + const function warnHookError(err: any) + warn(string.format("[Component] Error in '%s' hook: %s", phaseName, tostring(err))) + end + + -- Only extensions whose hook is STILL RUNNING land here; a hook that finished + -- inline imposes no wait on its dependents. + local pendingOf: { [any]: PromiseLike }? = nil + local nodes: { PromiseLike }? = nil + for _, extension in order do - const gates: { PromiseLike } = {} - for _, other in waitsOn[extension] or NO_EDGES do - const promise = settled[other] - if promise then - table.insert(gates, promise) + const hook = extension[phaseName] + if type(hook) ~= "function" then + continue + end + + -- Gate only on dependencies that have not already finished. + local gates: { PromiseLike }? = nil + const pendingMap = pendingOf + const edges = if waitsOn then waitsOn[extension] else nil + if pendingMap and edges then + for _, other in edges do + const promise = pendingMap[other] + if promise then + const list = gates or {} + gates = list + table.insert(list, promise) + end end end - const gate: PromiseLike? = if #gates == 0 - then nil - elseif #gates == 1 then gates[1] - else promiseAll(gates) - const hook = extension[phaseName] - local node: PromiseLike? = gate - if type(hook) == "function" then - hasHook = true - const invoke = function(): PromiseLike - const promise = invokeAsPromise(deferred, hook, component) - if options.warnErrors then - return promise:catch(function(err) - warn(string.format("[Component] Error in '%s' hook: %s", phaseName, tostring(err))) - end) + local node: PromiseLike? = nil + if gates then + -- A dependency is still in flight: defer this hook behind it. + const gate: PromiseLike = if #gates == 1 then gates[1] else promiseAll(gates) + node = gate:andThen(function() + const pending, ok, err = invokeSmart(hook, component) + if pending then + return pending + end + if not ok then + if warnErrors then + warnHookError(err) + return nil + end + error(err, 0) + end + return nil + end) + if warnErrors then + node = (node :: PromiseLike):catch(warnHookError) + end + else + const pending, ok, err = invokeSmart(hook, component) + if pending then + node = if warnErrors then pending:catch(warnHookError) else pending + elseif not ok then + if warnErrors then + warnHookError(err) + else + return nil, err end - return promise end - node = if gate then gate:andThen(invoke) else invoke() end + if node then - settled[extension] = node - table.insert(nodes, node) + const pendingMapOut = pendingOf or {} + pendingOf = pendingMapOut + pendingMapOut[extension] = node + const nodeList = nodes or {} + nodes = nodeList + table.insert(nodeList, node) end end - if not hasHook then - return nil + if not nodes then + return nil, nil end - return if #nodes == 1 then nodes[1] else promiseAll(nodes) + return (if #nodes == 1 then nodes[1] else promiseAll(nodes)), nil end --- Construction may yield the chain; start/stop hooks are deferred so they never --- run inline on the thread that triggered them, and teardown must always finish. +-- The drivers below already run off the thread that triggered them, so hooks no +-- longer need per-hook deferral; teardown must always finish, so its hook errors +-- warn instead of failing the phase. const CONSTRUCT_PHASE: PhaseOptions = {} -const START_PHASE: PhaseOptions = { deferred = true } -const STOP_PHASE: PhaseOptions = { reverse = true, deferred = true, warnErrors = true } +const START_PHASE: PhaseOptions = {} +const STOP_PHASE: PhaseOptions = { reverse = true, warnErrors = true } --[[ Deep copy for `config.Fields`, so a table default is never shared between @@ -277,21 +398,6 @@ const function untrack(class: ClassAny, instance: Instance) end end -const function callWithoutYielding(fn: (...any) -> any, ...: any): (boolean, ...any) - const args = table.pack(...) - local thread = coroutine.create(function() - return fn(table.unpack(args, 1, args.n)) - end) - const ok, res = coroutine.resume(thread) - if not ok then - return false, res - end - if coroutine.status(thread) ~= "dead" then - error("callWithoutYielding: function yielded", 2) - end - return true, res -end - --[[ teardown - the single, guaranteed removal path. @@ -315,6 +421,11 @@ const function teardown(class: ClassAny, component: InstanceAny, reason: StopRea ic.phase = "Stopping" ic.started = false + -- Claim a slot before deferring: `class:Destroy()` checks this to know it + -- must not tear down the signals this sequence still has to fire on. + const cci = Keys.class(class) + cci.teardownsInFlight += 1 + untrack(class, component.Instance) disconnectUpdates(component) @@ -322,11 +433,21 @@ const function teardown(class: ClassAny, component: InstanceAny, reason: StopRea warn(string.format("[Component] Error during teardown of '%s': %s", tostring(class.Tag), tostring(err))) end - const function invokeSafe(fn: unknown, ...: any): PromiseLike? + -- Runs `fn` to completion on the stop thread, warning (never re-raising) on + -- error: teardown must always reach the Janitor destroy. + const function invokeSafe(fn: unknown, ...: any) if type(fn) ~= "function" then - return nil + return + end + const pending, ok, err = invokeSmart(fn :: (...any) -> any, ...) + if pending then + const failure = awaitSettled(pending) + if failure ~= nil and failure ~= CANCELLED then + warnError(failure) + end + elseif not ok then + warnError(err) end - return invokeAsPromise(true, fn :: (...any) -> any, ...):catch(warnError) end -- The class-level signals may already be destroyed (class:Destroy tears its @@ -337,43 +458,55 @@ const function teardown(class: ClassAny, component: InstanceAny, reason: StopRea end, ...) end - ((Promise.resolve() :: unknown) :: PromiseLike) - :andThen(function() - return runHooks(ic.activeExtensions, "Stopping", component, STOP_PHASE) - end) - :andThen(function() - return invokeSafe(component.Stop, component, reason) - end) - :andThen(function() - return runHooks(ic.activeExtensions, "Stopped", component, STOP_PHASE) - end) - :andThen(function() - const coreJanitor = ic.janitor - if coreJanitor then - ic.janitor = nil - -- Janitor cleanup runs synchronously; isolate it from this thread. - return invokeSafe(function() - coreJanitor:Destroy() - end) - end - return nil - end) - :andThen(function() - ic.phase = "Stopped" - if reachedStart then - fireSafe(class.Stopped, component) - else - fireSafe(Keys.class(class).failed, component.Instance, reason) + -- Deferred so teardown never blocks its caller, then straight-line: the stop + -- sequence only parks when a hook actually yields. + task.defer(function() + const function runStopPhase(phaseName: string) + const pending = runHooks(ic.activeExtensions, phaseName, component, STOP_PHASE) + if pending then + const failure = awaitSettled(pending) + if failure ~= nil and failure ~= CANCELLED then + warnError(failure) + end end - end) - :catch(warnError) + end + + runStopPhase("Stopping") + invokeSafe(component.Stop, component, reason) + runStopPhase("Stopped") + + const coreJanitor = ic.janitor + if coreJanitor then + ic.janitor = nil + -- Janitor cleanup runs synchronously; isolate it so a failing task + -- cannot abort the rest of the sequence. + invokeSafe(function() + coreJanitor:Destroy() + end) + end + + ic.phase = "Stopped" + if reachedStart then + fireSafe(class.Stopped, component) + else + fireSafe(cci.failed, component.Instance, reason) + end + + -- Last one out destroys the class Janitor `Destroy` deferred to us. + cci.teardownsInFlight -= 1 + if cci.teardownsInFlight <= 0 and cci.destroyJanitorWhenIdle then + cci.destroyJanitorWhenIdle = false + cci.janitor:Destroy() + end + end) end --[[ run - drives ShouldConstruct -> Constructing -> Construct -> Constructed -> Starting -> Start -> Started for one component, as a single cancellable - Promise chain. Registers the component at the Constructed phase. Any - non-successful settle routes to `teardown`. + sequence on the calling (already deferred) thread. Registers the component at + the Constructed phase. Any abort — a failed validity check, an explicit + `record.cancel`, or a hook error — routes to `teardown`. ]] const function run(class: ClassAny, instance: Instance, constructId: number) const cci = Keys.class(class) @@ -388,7 +521,11 @@ const function run(class: ClassAny, instance: Instance, constructId: number) phase = "None", started = false, stopReason = nil, - activeExtensions = Extensions.Resolve(component, cci.extensions, false), + -- Shared, never mutated (`runHooks` clones before reversing); only worth + -- re-resolving when `ShouldExtend` can make it differ per instance. + activeExtensions = if cci.extensionsVaryPerInstance + then Extensions.Resolve(component, cci.extensions, false) + else cci.classActiveExtensions, janitor = nil, } componentAny[Keys.Internal] = ic @@ -414,142 +551,204 @@ const function run(class: ClassAny, instance: Instance, constructId: number) return nil end - -- Wraps a function in a barrier that checks the component's validity before - -- and after running it. If the component is no longer valid, the chain is - -- cancelled and the reason is recorded in the internal state for teardown. - const function barrier(check: ((Args...) -> ...any)?, ...: Args...): () -> ...any - const args = {...} - const argCount = select("#", ...) - return function() + -- Register the in-flight record before running the chain so an external + -- teardown can always find and cancel it. + const record: Types.PendingRecord = { component = component, cancel = nil, id = constructId } + cci.pending[instance] = record + + -- The promise the driver is currently parked on, if any. `cancel` wakes it so + -- the next validity check sees the cancellation and routes to teardown. + local parkedOn: PromiseLike? = nil + record.cancel = function(stopReason: StopReason) + ic.stopReason = stopReason + const promise = parkedOn + if promise then + parkedOn = nil + promise:cancel() + end + end + + -- `run` is already called from a `task.defer`red thread (see + -- `_tryConstruct`), so the whole sequence is straight-line code here: it only + -- parks when a hook actually yields, instead of hopping through a Promise per + -- step. Returns a StopReason/error to abort with, or nil on success. + const function drive(): any? + --[[ Validity check that records WHY, so teardown reports the real reason. + An explicit cancel (`record.cancel`) has already stashed its reason and + wins; the effective reason is always returned so the caller can tell an + expected stop apart from a genuine error. ]] + const function checkValidity(): any? const reason = validityReason() - if reason then - ic.stopReason = reason - error(CANCELLED, 0) + if not reason then + return nil end - if check then - -- Cast the callee, NOT the unpack: `f(table.unpack(t) :: any)` is a - -- type assertion on a multi-value expression, which truncates it to - -- one value -- every barrier arg after the first was silently dropped. - return (check :: any)(table.unpack(args, 1, argCount)) + if ic.stopReason == nil then + ic.stopReason = reason end - return nil + return ic.stopReason end - end - -- Runs all hooks of a given phase in dependency order (see `runHooks`), - -- returning a Promise that resolves when all finish. If any hook rejects, the - -- chain is cancelled and the reason is recorded for teardown. - const function runPhase(phaseName: string, options: PhaseOptions): PromiseLike? - return runHooks(ic.activeExtensions, phaseName, component, options) - end + --[[ Waits on `pending` (if any) and then re-checks validity. Returns a + truthy abort value, or nil to continue. ]] + const function settle(pending: PromiseLike?): any? + if pending then + parkedOn = pending + const failure = awaitSettled(pending) + parkedOn = nil + if failure ~= nil then + return failure + end + end + return checkValidity() + end - -- Register the in-flight record before running the chain so an external - -- teardown can always find and cancel it. - const record: Types.PendingRecord = { component = component, promise = nil, id = constructId } - cci.pending[instance] = record + const function runPhase(phaseName: string, options: PhaseOptions): any? + const pending, err = runHooks(ic.activeExtensions, phaseName, component, options) + if err ~= nil then + return err + end + return settle(pending) + end - const chain = ((Promise.resolve() :: unknown) :: PromiseLike) - :andThen(barrier(runPhase, "Constructing", CONSTRUCT_PHASE)) - :andThen(barrier(function() - if type(component.Construct) == "function" then - return invokeAsPromise(false, component.Construct, component) + const function invokeMethod(fn: unknown): any? + if type(fn) ~= "function" then + return checkValidity() end - return nil - end)) - :andThen(barrier(runPhase, "Constructed", CONSTRUCT_PHASE)) - :andThen(barrier(function() - -- Track the component now that it is fully constructed. The pending - -- record is kept until the chain settles so a mid-start teardown can - -- still cancel it. - ic.phase = "Constructed" - cci.instToComponents[instance] = component - table.insert(cci.components, component) - Registry.Register(instance, class, component) - end)) - :andThen(barrier(function() - ic.phase = "Starting" - return runPhase("Starting", START_PHASE) - end)) - :andThen(barrier(function() - if type(component.Start) == "function" then - return invokeAsPromise(true, component.Start, component) + const pending, ok, err = invokeSmart(fn :: (...any) -> any, component) + if not ok then + return if err == nil then CANCELLED else err end - return nil - end)) - :andThen(barrier(runPhase, "Started", START_PHASE)) - :andThen(barrier(function() - -- Connect update loops, mark started, fire Started. - const updateCleanup: { () -> () } = {} - component._updateCleanup = updateCleanup - if type(component.HeartbeatUpdate) == "function" then - const update = component.HeartbeatUpdate :: (self: InstanceAny, dt: number) -> () - const conn = RunService.Heartbeat:Connect(function(dt) + return settle(pending) + end + + local abort: any? = checkValidity() + if abort then + return abort + end + + -- Construct phases run inline and hold up the construction chain. + abort = runPhase("Constructing", CONSTRUCT_PHASE) + if abort then + return abort + end + abort = invokeMethod(component.Construct) + if abort then + return abort + end + abort = runPhase("Constructed", CONSTRUCT_PHASE) + if abort then + return abort + end + + -- Track the component now that it is fully constructed. The pending + -- record is kept until the sequence settles so a mid-start teardown can + -- still cancel it. + ic.phase = "Constructed" + cci.instToComponents[instance] = component + table.insert(cci.components, component) + Registry.Register(instance, class, component) + + -- The one deferral boundary: start phases never run on the thread that + -- drove construction. + const thread = coroutine.running() + task.defer(thread) + coroutine.yield() + + abort = checkValidity() + if abort then + return abort + end + + ic.phase = "Starting" + abort = runPhase("Starting", START_PHASE) + if abort then + return abort + end + abort = invokeMethod(component.Start) + if abort then + return abort + end + abort = runPhase("Started", START_PHASE) + if abort then + return abort + end + + -- Connect update loops, mark started, fire Started. + const updateCleanup: { () -> () } = {} + component._updateCleanup = updateCleanup + if type(component.HeartbeatUpdate) == "function" then + const update = component.HeartbeatUpdate :: (self: InstanceAny, dt: number) -> () + const conn = RunService.Heartbeat:Connect(function(dt) + update(component, dt) + end) + table.insert(updateCleanup, function() + conn:Disconnect() + end) + end + if type(component.SteppedUpdate) == "function" then + const update = component.SteppedUpdate :: (self: InstanceAny, dt: number) -> () + const conn = RunService.Stepped:Connect(function(_, dt) + update(component, dt) + end) + table.insert(updateCleanup, function() + conn:Disconnect() + end) + end + if type(component.RenderSteppedUpdate) == "function" and not IS_SERVER then + const update = component.RenderSteppedUpdate :: (self: InstanceAny, dt: number) -> () + if component.RenderPriority then + const name = NextRenderName() + RunService:BindToRenderStep(name, component.RenderPriority, function(dt) update(component, dt) end) table.insert(updateCleanup, function() - conn:Disconnect() + RunService:UnbindFromRenderStep(name) end) - end - if type(component.SteppedUpdate) == "function" then - const update = component.SteppedUpdate :: (self: InstanceAny, dt: number) -> () - const conn = RunService.Stepped:Connect(function(_, dt) + else + const conn = RunService.RenderStepped:Connect(function(dt) update(component, dt) end) table.insert(updateCleanup, function() conn:Disconnect() end) end - if type(component.RenderSteppedUpdate) == "function" and not IS_SERVER then - const update = component.RenderSteppedUpdate :: (self: InstanceAny, dt: number) -> () - if component.RenderPriority then - const name = NextRenderName() - RunService:BindToRenderStep(name, component.RenderPriority, function(dt) - update(component, dt) - end) - table.insert(updateCleanup, function() - RunService:UnbindFromRenderStep(name) - end) - else - const conn = RunService.RenderStepped:Connect(function(dt) - update(component, dt) - end) - table.insert(updateCleanup, function() - conn:Disconnect() - end) - end - end - ic.phase = "Started" - ic.started = true - class.Started:Fire(component) - end)) + end + ic.phase = "Started" + ic.started = true + class.Started:Fire(component) + return nil + end - record.promise = chain + const ok, abort = pcall(drive) - const settled = chain:finally(function(status) - -- Clear the in-flight record if it is still ours. - if cci.pending[instance] == record then - cci.pending[instance] = nil - end - if status == Promise.Status.Resolved then - return - end - const reason = ic.stopReason or "ConstructionCancelled" - teardown(class, component, reason) - end) + -- Clear the in-flight record if it is still ours. + if cci.pending[instance] == record then + cci.pending[instance] = nil + end + + if ok and abort == nil then + return + end - -- Handle the rejection that `finally` re-raises so it is never "unhandled". - settled:catch(function(err) - if err ~= CANCELLED and typeof(err) == "string" then - warn( - string.format( - "[Component] Error constructing '%s' on '%s':\n%s", - tostring(class.Tag), - instance:GetFullName(), - tostring(err) - ) + -- An expected stop (cancellation, or a validity check that recorded its + -- StopReason) routes straight to teardown; anything else is a genuine + -- construction error worth surfacing. Both table errors (Promise wraps hook + -- errors in a `Promise.Error`) and plain strings are reported -- a + -- string-only filter previously swallowed every wrapped error, which is how a + -- total-failure bug once went silent. + const failure: any = abort + const isExpectedStop = ic.stopReason ~= nil and failure == ic.stopReason + if failure ~= CANCELLED and not isExpectedStop then + warn( + string.format( + "[Component] Error constructing '%s' on '%s':\n%s", + tostring(class.Tag), + instance:GetFullName(), + tostring(failure) ) - end - end) + ) + end + teardown(class, component, ic.stopReason or "ConstructionCancelled") end return { diff --git a/lib/component/src/Query.luau b/lib/component/src/Query.luau index e9409ed6..6a5ecd5f 100644 --- a/lib/component/src/Query.luau +++ b/lib/component/src/Query.luau @@ -131,7 +131,7 @@ type Engine = { observers: { [Observer]: boolean }, janitor: Janitor, positiveSet: { [Instance]: boolean }, -- [Instance]: true while positively bounded (candidate universe) - attrJanitors: { [Instance]: Janitor }, -- attribute subscriptions + attrConns: { [Instance]: ConnectionLike }, -- one AttributeChanged sub per candidate subEngines: { [QueryInternal]: Engine }, } @@ -143,17 +143,28 @@ type QueryInternal = Query & { _predicates: { PredicateRequirement }, _engine: Engine?, _refcount: number, + -- Caches invalidated by every mutator (`_invalidate`). A query is typically + -- built once and read many times, so re-walking the requirement graph on + -- every `get()`/`observe()` was pure waste. + _validated: boolean, + _sources: { Queryable }?, + _invalidate: (self: QueryInternal) -> (), _positiveSources: (self: QueryInternal) -> { Queryable }, _validate: (self: QueryInternal, seen: { [QueryInternal]: boolean }?) -> (), _allReferences: (self: QueryInternal) -> { Queryable }, _positiveCandidate: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, _attributesMatch: (self: QueryInternal, instance: Instance) -> boolean, _predicatesPass: (self: QueryInternal, instance: Instance) -> boolean, + _matchesRest: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, _fullMatch: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, _activate: (self: QueryInternal) -> Engine, _deactivate: (self: QueryInternal) -> (), - _enumerate: (self: QueryInternal, reactive: boolean) -> { [Instance]: boolean }, + _enumerate: ( + self: QueryInternal, + reactive: boolean, + subSets: { [QueryInternal]: { [Instance]: boolean } }? + ) -> { [Instance]: boolean }, } local EXISTS = newproxy(false) -- sentinel: attribute must merely exist @@ -199,12 +210,20 @@ function Query.new(...: Queryable): Query _predicates = {}, _engine = nil, _refcount = 0, + _validated = false, + _sources = nil, }, Query) :: any ) :: QueryInternal self:with(...) return self end +-- Drops the caches every mutator invalidates. +function Query._invalidate(self: QueryInternal) + self._validated = false + self._sources = nil +end + --[=[ @within Query @param ... Queryable @@ -216,6 +235,7 @@ function Query.with(self: QueryInternal, ...: Queryable): Query assertQueryable(req, "with") table.insert(self._positive, req) end + self:_invalidate() return self end @@ -234,6 +254,7 @@ function Query.anyOf(self: QueryInternal, ...: Queryable): Query if #group > 0 then table.insert(self._anyOf, group) end + self:_invalidate() return self end @@ -248,6 +269,7 @@ function Query.without(self: QueryInternal, ...: Queryable): Query assertQueryable(req, "without") table.insert(self._negative, req) end + self:_invalidate() return self end @@ -266,6 +288,7 @@ function Query.withAttribute(self: QueryInternal, name: string, matcher: unknown name = name, matcher = if matcher == nil then EXISTS else matcher, }) + self:_invalidate() return self end @@ -282,6 +305,7 @@ end function Query.where(self: QueryInternal, predicate: (Instance) -> boolean, recheckSignal: unknown?): Query assert(type(predicate) == "function", "[Component] :where() expects a predicate function") table.insert(self._predicates, { fn = predicate, signal = recheckSignal }) + self:_invalidate() return self end @@ -292,6 +316,10 @@ end -- Flattened positive requirements (positional/:with + every :anyOf member). -- These bound the candidate set; a query with none is unbounded and rejected. function Query._positiveSources(self: QueryInternal): { Queryable } + local cached = self._sources + if cached then + return cached + end local sources = {} for _, req in self._positive do table.insert(sources, req) @@ -301,11 +329,15 @@ function Query._positiveSources(self: QueryInternal): { Queryable } table.insert(sources, req) end end + self._sources = sources return sources end -- DFS over sub-query references; errors on an empty query or a dependency cycle. function Query._validate(self: QueryInternal, seen: { [QueryInternal]: boolean }?) + if self._validated and seen == nil then + return + end local seenSet = seen or {} if seenSet[self] then error("[Component] Query dependency cycle detected", 0) @@ -326,6 +358,7 @@ function Query._validate(self: QueryInternal, seen: { [QueryInternal]: boolean } end end seenSet[self] = nil + self._validated = true end -- Every requirement across all clauses (positive, anyOf, negative). @@ -419,10 +452,11 @@ function Query._predicatesPass(self: QueryInternal, instance: Instance): boolean return true end -function Query._fullMatch(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean - if not self:_positiveCandidate(instance, satisfiedFn) then - return false - end +-- Everything a match requires EXCEPT the positive requirements. Split out so +-- callers that already know `instance` is a positive candidate (the reactive +-- engine, and `get()` over a single-source enumeration) do not pay to prove it +-- twice. +function Query._matchesRest(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean for _, req in self._negative do if isSatisfied(satisfiedFn, req, instance) then return false @@ -431,6 +465,13 @@ function Query._fullMatch(self: QueryInternal, instance: Instance, satisfiedFn: return self:_attributesMatch(instance) and self:_predicatesPass(instance) end +function Query._fullMatch(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean + if not self:_positiveCandidate(instance, satisfiedFn) then + return false + end + return self:_matchesRest(instance, satisfiedFn) +end + -------------------------------------------------------------------------------- -- Reactive engine (ref-counted, shared when a query is used more than once) -------------------------------------------------------------------------------- @@ -452,15 +493,15 @@ function Query._activate(self: QueryInternal): Engine observers = {}, janitor = janitor, positiveSet = {}, - attrJanitors = {}, + attrConns = {}, subEngines = {}, } self._engine = engine janitor:Add(function() - for _, attrJanitor in engine.attrJanitors do - attrJanitor:Destroy() + for _, conn in engine.attrConns do + conn:Disconnect() end - table.clear(engine.attrJanitors) + table.clear(engine.attrConns) end) local function subMatches(subQuery: QueryInternal, instance: Instance): boolean @@ -468,7 +509,14 @@ function Query._activate(self: QueryInternal): Engine return subEngine ~= nil and subEngine.matched[instance] == true end + -- One `AttributeChanged` connection per candidate, filtered by name, instead + -- of a Janitor plus a `GetAttributeChangedSignal` connection per attribute: + -- activation over a large candidate set was dominated by that allocation. local hasAttributes = #self._attributes > 0 + local watchedAttributes: { [string]: boolean } = {} + for _, attr in self._attributes do + watchedAttributes[attr.name] = true + end local function reevaluate(instance: Instance?) if not instance then @@ -479,28 +527,23 @@ function Query._activate(self: QueryInternal): Engine -- Track the candidate universe and (only while bounded) attribute subs. if positive then engine.positiveSet[instance] = true - if hasAttributes and not engine.attrJanitors[instance] then - local attrJanitor = Janitor.new() - for _, attr in self._attributes do - attrJanitor:Add( - instance:GetAttributeChangedSignal(attr.name):Connect(function() - reevaluate(instance) - end), - "Disconnect" - ) - end - engine.attrJanitors[instance] = attrJanitor + if hasAttributes and not engine.attrConns[instance] then + engine.attrConns[instance] = instance.AttributeChanged:Connect(function(attrName) + if watchedAttributes[attrName] then + reevaluate(instance) + end + end) :: any end else engine.positiveSet[instance] = nil - local attrJanitor = engine.attrJanitors[instance] - if attrJanitor then - engine.attrJanitors[instance] = nil - attrJanitor:Destroy() + local attrConn = engine.attrConns[instance] + if attrConn then + engine.attrConns[instance] = nil + attrConn:Disconnect() end end - local isMatch = positive and self:_fullMatch(instance, subMatches) + local isMatch = positive and self:_matchesRest(instance, subMatches) local wasMatch = engine.matched[instance] == true if isMatch == wasMatch then return @@ -617,7 +660,11 @@ end -- Enumerate the candidate universe (union of positive sources). When `reactive` -- is true, sub-query membership comes from live engines (already activated); -- otherwise it is computed statically via each sub-query's GetMatches. -function Query._enumerate(self: QueryInternal, reactive: boolean): { [Instance]: boolean } +function Query._enumerate( + self: QueryInternal, + reactive: boolean, + subSets: { [QueryInternal]: { [Instance]: boolean } }? +): { [Instance]: boolean } local set: { [Instance]: boolean } = {} local function addFromRef(req: Queryable) if type(req) == "string" then @@ -635,13 +682,26 @@ function Query._enumerate(self: QueryInternal, reactive: boolean): { [Instance]: end end else - for _, instance in subQuery:get() do - set[instance] = true + -- Reuse the caller's per-call memo when there is one, so a nested + -- sub-query is evaluated once per `get()` rather than per candidate. + local memo = if subSets then subSets[subQuery] else nil + if memo then + for instance in memo do + set[instance] = true + end + else + for _, instance in subQuery:get() do + set[instance] = true + end end end else -- component class + -- Iterate the class's live component array directly when it is one of + -- ours; `GetAll()` clones it purely to be thrown away here. local class = req :: ComponentClassLike - for _, component in class:GetAll() do + local internal = (class :: any)[Keys.Internal] + local components = if internal then internal.components else class:GetAll() + for _, component in components do if Keys.inst(component).phase == "Started" then set[component.Instance] = true end @@ -710,12 +770,55 @@ end ]=] function Query.get(self: QueryInternal): { Instance } self:_validate() - local function staticSub(subQuery: QueryInternal, instance: Instance): boolean - return subQuery:_fullMatch(instance, staticSub) + + -- Each sub-query's match-set is computed ONCE per call and then answered by + -- lookup. Previously every candidate re-ran the whole sub-query (and + -- `_enumerate` ran it again on top), which is quadratic in nested queries. + local subSets: { [QueryInternal]: { [Instance]: boolean } } = {} + local staticSub: SatisfiedFn + local ensureSet: (QueryInternal) -> { [Instance]: boolean } + function ensureSet(subQuery: QueryInternal): { [Instance]: boolean } + local existing = subSets[subQuery] + if existing then + return existing + end + -- Seed the entry before recursing so a cycle (rejected by `_validate`, + -- but cheap to guard) cannot recurse forever. + local set: { [Instance]: boolean } = {} + subSets[subQuery] = set + -- Deepest first, so this sub-query's own enumeration finds its + -- references already memoized. + for _, req in subQuery:_allReferences() do + if isQuery(req) then + ensureSet(req :: QueryInternal) + end + end + for candidate in subQuery:_enumerate(false, subSets) do + if subQuery:_fullMatch(candidate, staticSub) then + set[candidate] = true + end + end + return set + end + function staticSub(subQuery: QueryInternal, instance: Instance): boolean + return ensureSet(subQuery)[instance] == true end + + for _, req in self:_allReferences() do + if isQuery(req) then + ensureSet(req :: QueryInternal) + end + end + + -- A single positive source means enumeration membership already proves the + -- positive half of the match, so only the rest needs checking. + local singleSource = #self._positive == 1 and #self._anyOf == 0 local out: { Instance } = {} - for instance in self:_enumerate(false) do - if self:_fullMatch(instance, staticSub) then + for instance in self:_enumerate(false, subSets) do + local matches = if singleSource + then self:_matchesRest(instance, staticSub) + else self:_fullMatch(instance, staticSub) + if matches then table.insert(out, instance) end end diff --git a/lib/component/src/Tests/Component.Lifecycle.spec.luau b/lib/component/src/Tests/Component.Lifecycle.spec.luau index 9cac5bc8..6c4fc95f 100644 --- a/lib/component/src/Tests/Component.Lifecycle.spec.luau +++ b/lib/component/src/Tests/Component.Lifecycle.spec.luau @@ -83,33 +83,6 @@ return function(t: any) part:Destroy() end) - test("Component.GetComponents returns every class's component on an instance", function() - local A, aTag = makeClass() - local B, bTag = makeClass() - local part = Instance.new("Part") - part.Anchored = true - CollectionService:AddTag(part, aTag) - CollectionService:AddTag(part, bTag) - part.Parent = workspace - - expect(waitUntil(function() - return A:Has(part) and B:Has(part) - end)).is(true) - - local all = Component.GetComponents(part) - local set = {} - for _, c in all do - set[getmetatable(c)] = true - end - expect(#all).is(2) - expect(set[A]).is(true) - expect(set[B]).is(true) - - A:Destroy() - B:Destroy() - part:Destroy() - end) - test("instances outside valid ancestors do not construct", function() local class, tag = makeClass { Ancestors = { workspace } } local part = Instance.new("Part") diff --git a/lib/component/src/Tests/Component.Siblings.spec.luau b/lib/component/src/Tests/Component.Siblings.spec.luau deleted file mode 100644 index 60a8ee06..00000000 --- a/lib/component/src/Tests/Component.Siblings.spec.luau +++ /dev/null @@ -1,117 +0,0 @@ ---!nonstrict ---[[ - Sibling binding: WhileHasComponent / WhileHasComponents run their function - while the sibling component(s) are present on the same instance, and clean up - their Janitor when a sibling stops or the owning component stops. -]] - -local CollectionService = game:GetService("CollectionService") - -return function(t: any) - local H = require(script.Parent.Helpers) - - local describe = t.describe - local test = t.test - local expect = t.expect - - describe("WhileHasComponent", function() - test("runs while the sibling exists and cleans up when it stops", function() - local ran, cleaned = false, false - local B, bTag = H.makeClass() - local A, aTag = H.makeClass { - Start = function(self) - self:WhileHasComponent(B, function(sibling, jani) - ran = sibling ~= nil - jani:Add(function() - cleaned = true - end) - end) - end, - } - - local p = Instance.new("Part") - p.Anchored = true - CollectionService:AddTag(p, aTag) - CollectionService:AddTag(p, bTag) - p.Parent = workspace - - expect(H.waitUntil(function() - return ran - end, 3)).is(true) - - CollectionService:RemoveTag(p, bTag) - expect(H.waitUntil(function() - return cleaned - end, 3)).is(true) - - A:Destroy() - B:Destroy() - p:Destroy() - end) - - test("cleans up when the owning component stops", function() - local cleaned = false - local B, bTag = H.makeClass() - local A, aTag = H.makeClass { - Start = function(self) - self:WhileHasComponent(B, function(_, jani) - jani:Add(function() - cleaned = true - end) - end) - end, - } - - local p = Instance.new("Part") - p.Anchored = true - CollectionService:AddTag(p, aTag) - CollectionService:AddTag(p, bTag) - p.Parent = workspace - - expect(H.waitStarted(A, p, 3)).is(true) - CollectionService:RemoveTag(p, aTag) - expect(H.waitUntil(function() - return cleaned - end, 3)).is(true) - - A:Destroy() - B:Destroy() - p:Destroy() - end) - end) - - describe("WhileHasComponents", function() - test("runs only when all sibling classes are present", function() - local ran = false - local B, bTag = H.makeClass() - local C, cTag = H.makeClass() - local A, aTag = H.makeClass { - Start = function(self) - self:WhileHasComponents({ B, C }, function(components) - ran = #components == 2 - end) - end, - } - - local p = Instance.new("Part") - p.Anchored = true - CollectionService:AddTag(p, aTag) - CollectionService:AddTag(p, bTag) - p.Parent = workspace - - expect(H.waitStarted(A, p, 3)).is(true) - task.wait(0.1) - expect(ran).is(false) -- C not present yet - - CollectionService:AddTag(p, cTag) - expect(H.waitUntil(function() - return ran - end, 3)).is(true) - - A:Destroy() - B:Destroy() - C:Destroy() - p:Destroy() - end) - end) -end diff --git a/lib/component/src/Tests/Component.types.luau b/lib/component/src/Tests/Component.types.luau index e3f06e31..0536f591 100644 --- a/lib/component/src/Tests/Component.types.luau +++ b/lib/component/src/Tests/Component.types.luau @@ -146,10 +146,6 @@ function Methods.UseSibling(self: ti) sibling:HelloExtension(11) -- checked on the sibling's merged type print(inst) end - self:WhileHasComponent(myComponent, function(comp, jani) - comp:HelloComponent("sibling") - print(jani) - end) end function Methods:Test(num: number): string diff --git a/lib/component/src/Types.luau b/lib/component/src/Types.luau index 5b7e376d..80eb9c4a 100644 --- a/lib/component/src/Types.luau +++ b/lib/component/src/Types.luau @@ -323,7 +323,9 @@ export type ComponentInstance_Internal = ComponentClass_Internal_Methods & Compo -- same-frame reservation before the record exists). export type PendingRecord = { component: ComponentInstance_Internal?, - promise: PromiseLike?, + -- Aborts an in-flight construction with the given reason. The driver wakes + -- from whatever it is parked on and routes to teardown. + cancel: ((StopReason) -> ())?, id: number, } diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index 6b53d8af..5956947c 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -45,11 +45,15 @@ resolved. `Construct()`/`Start()` may likewise yield or return a Promise. Only the construct phases (`Constructing` → `Construct()` → `Constructed`) run - inline and hold up the construction chain. The start and stop phases are - dispatched with `task.defer`, so they never run on the thread that triggered - them and teardown never blocks its caller. Stop hooks are gated in *reverse* - dependency order — a dependency stops only after everything depending on it - has stopped. + inline and hold up the construction chain. The start phases run after a + `task.defer` boundary and teardown is deferred as a whole, so neither ever + runs on the thread that triggered it and teardown never blocks its caller. + Stop hooks are gated in *reverse* dependency order — a dependency stops only + after everything depending on it has stopped. + + Hooks that neither yield nor return a Promise run inline in dependency order + and cost no Promise at all; the machinery only materializes for hooks that + actually suspend (see `Lifecycle.luau`). If the instance leaves its valid ancestors, is untagged, or is superseded while a hook is still waiting, the in-flight work is cancelled and the component is @@ -244,10 +248,13 @@ const function componentNew(config: Types.ComponentConfig): Class_Internal pending = {}, extensions = config.Extensions or {}, classActiveExtensions = {}, + extensionsVaryPerInstance = false, fields = config.Fields, initFields = config.InitFields, janitor = janitor, failed = janitor:Add(Signal.new(), "Destroy"), + teardownsInFlight = 0, + destroyJanitorWhenIdle = false, } classAny[Keys.Internal] = ci classAny.Tag = config.Tag @@ -340,15 +347,13 @@ function ComponentClassMethods._tryDeconstruct(self: Class_Internal, instance: I Lifecycle.Untrack(self, instance) ci.pending[instance] = nil - -- If the component is still constructing, cancel the in-flight Promise chain. + -- If the component is still constructing, abort the in-flight driver; it runs + -- teardown itself with the reason we hand it. if type(record) == "table" then const pending = record :: Types.PendingRecord - const promise = pending.promise - if promise and Promise.is(promise) and promise:getStatus() == Promise.Status.Started then - -- Still in flight: cancel it; the chain's `finally` runs teardown with - -- the reason we stash here. - Keys.inst(pending.component).stopReason = reason - promise:cancel() + const cancel = pending.cancel + if cancel then + cancel(reason) return end end @@ -412,8 +417,18 @@ function ComponentClassMethods._setup(self: Class_Internal) end const ci = Keys.class(self) - ci.classActiveExtensions = Extensions.Resolve(self, ci.extensions, true) - Extensions.BindMethods(self, ci.classActiveExtensions) + const classActiveExtensions = Extensions.Resolve(self, ci.extensions, true) + ci.classActiveExtensions = classActiveExtensions + Extensions.BindMethods(self, classActiveExtensions) + + -- Without a `ShouldExtend` anywhere, every instance resolves to exactly this + -- list, so `Lifecycle.Run` can share it instead of re-sorting per instance. + for _, extension in classActiveExtensions do + if type(extension.ShouldExtend) == "function" then + ci.extensionsVaryPerInstance = true + break + end + end ci.janitor:Add(CollectionService:GetInstanceAddedSignal(self.Tag):Connect(function(instance) self:_startWatching(instance) @@ -631,7 +646,6 @@ function ComponentClassMethods.GetComponent(self: Instance_Internal, componentCl return Keys.class(componentClass).instToComponents[self.Instance] end - --[=[ @tag Component Instance @return boolean @@ -700,8 +714,6 @@ function ComponentClassMethods.GetTask(self: Instance_Internal, index: unknown): return Keys.inst(self).janitor:Get(index) end - - --[=[ @tag Component Class @function HeartbeatUpdate @@ -755,7 +767,15 @@ function ComponentClassMethods.Destroy(self: Class_Internal) self:_tryDeconstruct(instance, "ClassDestroyed") end - ci.janitor:Destroy() + -- The teardowns kicked off above are deferred and still have to fire + -- `Stopped` (or `failed`) on this class's signals, which live on this + -- Janitor. Destroying it now would silently swallow those fires, so hand the + -- destroy to whichever teardown finishes last. + if ci.teardownsInFlight > 0 then + ci.destroyJanitorWhenIdle = true + else + ci.janitor:Destroy() + end table.clear(ci.instToComponents) table.clear(ci.components) From 7b2d0ca0f74e4d89d932dbb17f2d3aa900a43969 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Thu, 23 Jul 2026 23:35:01 -0400 Subject: [PATCH 07/19] Optimize Query engine and immutable builders Refactors `Query` to use copy-on-write builder methods (`with`, `anyOf`, `without`, `withAttribute`, `where`) so queries are immutable and chains can branch safely. Adds compiled query plans, requirement selectivity ordering, and cached structural signatures to avoid repeated requirement graph work. `Query` activation now interns engines by signature so equivalent queries share one reactive engine, and matched instances are tracked with a sparse-set (`matched` + `matchedList`) for O(1) membership updates and fast cloning. `get()` now has a live-engine fast path and a much narrower seeded enumeration strategy (class/tag/query/classlike) with optional tag sizing/probe-set optimization, reducing per-candidate overhead significantly. Tests were updated to validate copy-on-write branching behavior instead of self-referential mutation behavior. --- .vscode/settings.json | 3 +- lib/component/src/Query.luau | 697 +++++++++++++++--- .../src/Tests/Component.Query.spec.luau | 27 +- 3 files changed, 633 insertions(+), 94 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index a2b5bba8..f90e4c75 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,8 @@ "types/tiniest_lib.d.luau" ], "luau-lsp.fflags.override": { - "DebugLuauTimeTracing": "false" + "DebugLuauTimeTracing": "false", + "LuauSolverV2": "true" }, "selene.selenePath": "", diff --git a/lib/component/src/Query.luau b/lib/component/src/Query.luau index 6a5ecd5f..6af5c43a 100644 --- a/lib/component/src/Query.luau +++ b/lib/component/src/Query.luau @@ -123,16 +123,55 @@ type Observer = { type ChangedSignal = Signal.Signal<(Instance, boolean) -> (), Instance, boolean> +-- One requirement, compiled: its kind resolved once and, for component classes, +-- the class's live tracking tables captured directly. Checking a class +-- requirement per candidate is then a single table lookup + phase compare +-- instead of a metatable dispatch through `FromInstance` (measured ~2.5x per +-- candidate), and `comps` gives `get()` an O(1)-sized seed source. +type PlanReq = { + kind: "tag" | "class" | "classlike" | "query", + buildIndex: number, -- declaration position; tiebreak for the stable sort + tag: string?, + comps: { any }?, -- class: live components array (seed iteration) + instTo: { [Instance]: any }?, -- class: live instance -> component map + class: ComponentClassLike?, -- foreign class-like: FromInstance fallback + query: QueryInternal?, +} + +-- The compiled shape of a query: requirement lists as PlanReqs plus presence +-- flags so empty clauses cost nothing per candidate. Cached on the query and +-- rebuilt by `_invalidate` (i.e. on any mutation). +type Plan = { + required: { PlanReq }, + anyOf: { { PlanReq } }, + negative: { PlanReq }, + hasNegative: boolean, + hasAttributes: boolean, + hasPredicates: boolean, +} + -- Reactive state backing an activated query (ref-counted, shared when a query -- is used more than once). type Engine = { - matched: { [Instance]: boolean }, + -- Sparse-set pair: `matched[instance]` is its 1-based position in + -- `matchedList` (the map doubles as the membership set), and `matchedList` + -- is the dense, insertion-ordered array reads iterate/clone. Removal is a + -- swap-remove, so both stay O(1) per transition. + matched: { [Instance]: number }, + matchedList: { Instance }, changed: ChangedSignal, observers: { [Observer]: boolean }, janitor: Janitor, positiveSet: { [Instance]: boolean }, -- [Instance]: true while positively bounded (candidate universe) attrConns: { [Instance]: ConnectionLike }, -- one AttributeChanged sub per candidate subEngines: { [QueryInternal]: Engine }, + -- Interning bookkeeping: the canonical signature this engine is registered + -- under, total activations across every equivalent query sharing it, and + -- the queries currently attached (so their `_engine` pointers can be + -- cleared when the engine dies). + signature: string, + refcount: number, + holders: { [QueryInternal]: boolean }, } type QueryInternal = Query & { @@ -143,15 +182,17 @@ type QueryInternal = Query & { _predicates: { PredicateRequirement }, _engine: Engine?, _refcount: number, - -- Caches invalidated by every mutator (`_invalidate`). A query is typically - -- built once and read many times, so re-walking the requirement graph on - -- every `get()`/`observe()` was pure waste. + -- Lazily-built caches. Queries are immutable after construction (builders + -- copy-on-write), so none of these can ever go stale. _validated: boolean, _sources: { Queryable }?, + _planned: Plan?, + _signatureCache: string?, - _invalidate: (self: QueryInternal) -> (), + _plan: (self: QueryInternal) -> Plan, + _signature: (self: QueryInternal) -> string, _positiveSources: (self: QueryInternal) -> { Queryable }, - _validate: (self: QueryInternal, seen: { [QueryInternal]: boolean }?) -> (), + _validate: (self: QueryInternal) -> (), _allReferences: (self: QueryInternal) -> { Queryable }, _positiveCandidate: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, _attributesMatch: (self: QueryInternal, instance: Instance) -> boolean, @@ -168,6 +209,7 @@ type QueryInternal = Query & { } local EXISTS = newproxy(false) -- sentinel: attribute must merely exist +local INTERNAL = Keys.Internal -- hoisted: the plan checks phases in hot loops local Query = {} Query.__index = Query @@ -198,10 +240,10 @@ end Creates a new query whose positional arguments are all required. ]=] -function Query.new(...: Queryable): Query +local function rawNew(): QueryInternal -- Cast through `any`: without it the solver stamps `@metatable` onto the -- table and rejects the internal type (same as TableManager's constructor). - local self = ( + return ( setmetatable({ _positive = {}, _anyOf = {}, @@ -212,65 +254,89 @@ function Query.new(...: Queryable): Query _refcount = 0, _validated = false, _sources = nil, + _planned = nil, + _signatureCache = nil, }, Query) :: any ) :: QueryInternal - self:with(...) - return self end --- Drops the caches every mutator invalidates. -function Query._invalidate(self: QueryInternal) - self._validated = false - self._sources = nil +-- Copy-on-write base for every builder: a fresh query with this one's +-- requirement lists cloned shallowly. The inner entries (anyOf groups, +-- attribute/predicate records) are never mutated after creation, so sharing +-- them is safe. Queries are therefore immutable values: every builder returns +-- a NEW query and the receiver is never changed, so chains branch freely -- +-- `qA:with(qB)` and `qA:without(qC)` are independent and `qA` stays `qA`. +local function derive(self: QueryInternal): QueryInternal + local new = rawNew() + new._positive = table.clone(self._positive) + new._anyOf = table.clone(self._anyOf) + new._negative = table.clone(self._negative) + new._attributes = table.clone(self._attributes) + new._predicates = table.clone(self._predicates) + return new +end + +function Query.new(...: Queryable): Query + local self = rawNew() + for _, req in { ... } do + assertQueryable(req, "with") + table.insert(self._positive, req) + end + return self end --[=[ @within Query @param ... Queryable @return Query - Adds required requirements. `query():with(X)` is equivalent to `query(X)`. + Returns a NEW query with the given required requirements added; the + receiver is unchanged (builders never mutate, so chains branch freely). + `query():with(X)` is equivalent to `query(X)`. ]=] function Query.with(self: QueryInternal, ...: Queryable): Query + local new = derive(self) for _, req in { ... } do assertQueryable(req, "with") - table.insert(self._positive, req) + table.insert(new._positive, req) end - self:_invalidate() - return self + return new end --[=[ @within Query @param ... Queryable @return Query - Adds an "at least one of" group: the instance must satisfy at least one of the - given requirements. Multiple `:anyOf` calls each add an independent group. + Returns a NEW query with an "at least one of" group added: the instance + must satisfy at least one of the given requirements. Multiple `:anyOf` + calls each add an independent group. The receiver is unchanged. ]=] function Query.anyOf(self: QueryInternal, ...: Queryable): Query local group = { ... } for _, req in group do assertQueryable(req, "anyOf") end - if #group > 0 then - table.insert(self._anyOf, group) + if #group == 0 then + return self end - self:_invalidate() - return self + local new = derive(self) + table.insert(new._anyOf, group) + return new end --[=[ @within Query @param ... Queryable @return Query - Adds excluded requirements: the instance must satisfy none of them. + Returns a NEW query with the given excluded requirements added: the + instance must satisfy none of them. The receiver is unchanged. ]=] function Query.without(self: QueryInternal, ...: Queryable): Query + local new = derive(self) for _, req in { ... } do assertQueryable(req, "without") - table.insert(self._negative, req) + table.insert(new._negative, req) end - self:_invalidate() - return self + return new end --[=[ @@ -278,18 +344,19 @@ end @param name string @param matcher any -- a value to equal, a `(value) -> boolean` predicate, or omitted for existence @return Query - Requires an attribute. With no matcher, the attribute must merely exist; with a + Returns a NEW query that additionally requires an attribute; the receiver + is unchanged. With no matcher, the attribute must merely exist; with a function, the function must return true for the attribute's value; otherwise the value must equal `matcher`. Re-evaluated reactively on attribute change. ]=] function Query.withAttribute(self: QueryInternal, name: string, matcher: unknown?): Query assert(type(name) == "string", "[Component] :withAttribute() expects an attribute name string") - table.insert(self._attributes, { + local new = derive(self) + table.insert(new._attributes, { name = name, matcher = if matcher == nil then EXISTS else matcher, }) - self:_invalidate() - return self + return new end --[=[ @@ -298,15 +365,16 @@ end @param recheckSignal Signal? -- fire to force re-evaluation @return Query - Adds an arbitrary predicate. :::caution A predicate has no change signal of its + Returns a NEW query with an arbitrary predicate added; the receiver is + unchanged. :::caution A predicate has no change signal of its own — it is only re-evaluated when another requirement changes, or when the optional `recheckSignal` fires. Without one, its result can go stale. ::: ]=] function Query.where(self: QueryInternal, predicate: (Instance) -> boolean, recheckSignal: unknown?): Query assert(type(predicate) == "function", "[Component] :where() expects a predicate function") - table.insert(self._predicates, { fn = predicate, signal = recheckSignal }) - self:_invalidate() - return self + local new = derive(self) + table.insert(new._predicates, { fn = predicate, signal = recheckSignal }) + return new end -------------------------------------------------------------------------------- @@ -333,15 +401,13 @@ function Query._positiveSources(self: QueryInternal): { Queryable } return sources end --- DFS over sub-query references; errors on an empty query or a dependency cycle. -function Query._validate(self: QueryInternal, seen: { [QueryInternal]: boolean }?) - if self._validated and seen == nil then +-- DFS over sub-query references; errors on an empty query. Cycles are +-- impossible by construction: builders copy-on-write, so a query can only ever +-- reference queries that existed before it did. +function Query._validate(self: QueryInternal) + if self._validated then return end - local seenSet = seen or {} - if seenSet[self] then - error("[Component] Query dependency cycle detected", 0) - end local sources = self:_positiveSources() if #sources == 0 then error( @@ -350,14 +416,11 @@ function Query._validate(self: QueryInternal, seen: { [QueryInternal]: boolean } 0 ) end - seenSet[self] = true for _, req in self:_allReferences() do if isQuery(req) then - local subQuery = req :: QueryInternal - subQuery:_validate(seenSet) + (req :: QueryInternal):_validate() end end - seenSet[self] = nil self._validated = true end @@ -382,28 +445,125 @@ end -- Satisfaction / matching -------------------------------------------------------------------------------- -local function isSatisfied(satisfiedFn: SatisfiedFn, req: Queryable, instance: Instance): boolean +local function compileReq(req: Queryable, buildIndex: number): PlanReq if type(req) == "string" then - return CollectionService:HasTag(instance, req) - elseif isQuery(req) then - return satisfiedFn(req :: QueryInternal, instance) + return { kind = "tag" :: "tag", buildIndex = buildIndex, tag = req } + end + if isQuery(req) then + return { kind = "query" :: "query", buildIndex = buildIndex, query = req :: QueryInternal } + end + local internal = (req :: any)[INTERNAL] + if internal then + -- Our own class: capture its live tracking tables. Both are mutated in + -- place (never replaced) by the lifecycle, so the references stay valid + -- for the class's whole life; `Destroy` clears them, which correctly + -- reads as "no matches". + return { + kind = "class" :: "class", + buildIndex = buildIndex, + comps = internal.components, + instTo = internal.instToComponents, + } + end + return { kind = "classlike" :: "classlike", buildIndex = buildIndex, class = req :: ComponentClassLike } +end + +-- Probe cost by kind, measured per candidate: a class check is one direct table +-- lookup (~30ns), a sub-query check is a memo/engine hash lookup, a class-like +-- goes through `FromInstance` (~80ns), and a tag check is a `HasTag` C-call +-- (~150ns). Every compiled list is sorted cheapest-first so per-candidate +-- evaluation short-circuits on the cheap probes; requirement semantics are +-- order-independent, so this is free. `buildIndex` keeps the sort deterministic +-- (`table.sort` is unstable). +local KIND_COST: { [string]: number } = { class = 1, query = 2, classlike = 3, tag = 4 } + +-- A seed at or below this is narrow enough that hunting for a better one is +-- not worth fetching more tag arrays: remaining probes run at most this many +-- times each. +local TAG_SIZING_EARLY_EXIT = 32 + +-- `get()` sizes every required tag via `GetTagged` (to pick the narrowest seed +-- and order probes most-selective-first) only when the best class seed exceeds +-- this. Below it the candidate set is already small, and sizing a huge tag +-- would cost an array allocation proportional to its population for at most a +-- few hundred cheap probes of savings. +local TAG_SIZING_MIN_SEED = 200 +local function sortBySelectivity(reqs: { PlanReq }) + table.sort(reqs, function(x: PlanReq, y: PlanReq): boolean + local cx = KIND_COST[x.kind] :: number + local cy = KIND_COST[y.kind] :: number + if cx ~= cy then + return cx < cy + end + return x.buildIndex < y.buildIndex + end) +end + +-- Compiled requirement check. `satisfiedFn` is only consulted for sub-query +-- requirements; tag/class checks are direct. +local function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: SatisfiedFn): boolean + local kind = req.kind + if kind == "class" then + local component = (req.instTo :: { [Instance]: any })[instance] + return component ~= nil and component[INTERNAL].phase == "Started" + elseif kind == "tag" then + return CollectionService:HasTag(instance, req.tag :: string) + elseif kind == "query" then + return satisfiedFn(req.query :: QueryInternal, instance) else - local class = req :: ComponentClassLike + local class = req.class :: ComponentClassLike local component = class:FromInstance(instance) return component ~= nil and Keys.inst(component).phase == "Started" end end +function Query._plan(self: QueryInternal): Plan + local cached = self._planned + if cached then + return cached + end + local required: { PlanReq } = {} + for index, req in self._positive do + table.insert(required, compileReq(req, index)) + end + sortBySelectivity(required) + local anyOf: { { PlanReq } } = {} + for _, group in self._anyOf do + local compiled: { PlanReq } = {} + for index, req in group do + table.insert(compiled, compileReq(req, index)) + end + sortBySelectivity(compiled) + table.insert(anyOf, compiled) + end + local negative: { PlanReq } = {} + for index, req in self._negative do + table.insert(negative, compileReq(req, index)) + end + sortBySelectivity(negative) + local plan: Plan = { + required = required, + anyOf = anyOf, + negative = negative, + hasNegative = #negative > 0, + hasAttributes = #self._attributes > 0, + hasPredicates = #self._predicates > 0, + } + self._planned = plan + return plan +end + function Query._positiveCandidate(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean - for _, req in self._positive do - if not isSatisfied(satisfiedFn, req, instance) then + local plan = self:_plan() + for _, req in plan.required do + if not reqSatisfied(req, instance, satisfiedFn) then return false end end - for _, group in self._anyOf do + for _, group in plan.anyOf do local anySatisfied = false for _, req in group do - if isSatisfied(satisfiedFn, req, instance) then + if reqSatisfied(req, instance, satisfiedFn) then anySatisfied = true break end @@ -457,12 +617,21 @@ end -- engine, and `get()` over a single-source enumeration) do not pay to prove it -- twice. function Query._matchesRest(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean - for _, req in self._negative do - if isSatisfied(satisfiedFn, req, instance) then - return false + local plan = self:_plan() + if plan.hasNegative then + for _, req in plan.negative do + if reqSatisfied(req, instance, satisfiedFn) then + return false + end end end - return self:_attributesMatch(instance) and self:_predicatesPass(instance) + if plan.hasAttributes and not self:_attributesMatch(instance) then + return false + end + if plan.hasPredicates and not self:_predicatesPass(instance) then + return false + end + return true end function Query._fullMatch(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean @@ -473,13 +642,116 @@ function Query._fullMatch(self: QueryInternal, instance: Instance, satisfiedFn: end -------------------------------------------------------------------------------- --- Reactive engine (ref-counted, shared when a query is used more than once) +-- Structural signatures + engine interning +-------------------------------------------------------------------------------- + +-- Stable ids for non-primitive signature atoms (classes, predicate/matcher +-- functions, recheck signals). Weak keys: a dead class must not leak here. +local signatureIds: { [any]: number } = setmetatable({}, { __mode = "k" }) :: any +local nextSignatureId = 0 +local function idOf(value: any): string + local existing = signatureIds[value] + if existing then + return tostring(existing) + end + nextSignatureId += 1 + signatureIds[value] = nextSignatureId + return tostring(nextSignatureId) +end + +-- Primitive attribute matchers compare by value, so structurally identical +-- `withAttribute("Team", "Red")` clauses from different modules share; function +-- matchers (and predicates) can only share by identity. +local function matcherToken(matcher: unknown): string + if matcher == EXISTS then + return "*" + end + local kind = type(matcher) + if kind == "string" or kind == "number" or kind == "boolean" then + return kind .. ":" .. tostring(matcher) + end + return "f:" .. idOf(matcher) +end + +--[[ + Canonical structural signature: requirement order never matters, so + `query(A, B)` and `query(B, A)` produce the same key. Sub-queries recurse. + Cached until the query mutates. +]] +function Query._signature(self: QueryInternal): string + local cached = self._signatureCache + if cached then + return cached + end + local function reqToken(req: Queryable): string + if type(req) == "string" then + return "t:" .. req + elseif isQuery(req) then + return "q:(" .. (req :: QueryInternal):_signature() .. ")" + end + return "c:" .. idOf(req) + end + local function sortedTokens(reqs: { Queryable }): string + local tokens = {} + for _, req in reqs do + table.insert(tokens, reqToken(req)) + end + table.sort(tokens) + return table.concat(tokens, ",") + end + local groups = {} + for _, group in self._anyOf do + table.insert(groups, sortedTokens(group)) + end + table.sort(groups) + local attrs = {} + for _, attr in self._attributes do + table.insert(attrs, attr.name .. "=" .. matcherToken(attr.matcher)) + end + table.sort(attrs) + local preds = {} + for _, pred in self._predicates do + table.insert(preds, idOf(pred.fn) .. (if pred.signal ~= nil then ">" .. idOf(pred.signal) else "")) + end + table.sort(preds) + local signature = sortedTokens(self._positive) + .. "|" + .. table.concat(groups, ";") + .. "|" + .. sortedTokens(self._negative) + .. "|" + .. table.concat(attrs, ",") + .. "|" + .. table.concat(preds, ",") + self._signatureCache = signature + return signature +end + +-- Live engines interned by signature: equivalent queries observed anywhere in +-- the process share ONE engine (one set of subscriptions, one matched set, one +-- re-evaluation per event) instead of each maintaining their own. +local activeEngines: { [string]: Engine } = {} + +-------------------------------------------------------------------------------- +-- Reactive engine (ref-counted; shared across all structurally equal queries) -------------------------------------------------------------------------------- function Query._activate(self: QueryInternal): Engine self._refcount += 1 - if self._engine then - return self._engine + local attached = self._engine + if attached then + attached.refcount += 1 + return attached + end + + -- An equivalent query may already maintain this exact engine. + local signature = self:_signature() + local interned = activeEngines[signature] + if interned then + interned.refcount += 1 + interned.holders[self] = true + self._engine = interned + return interned end local janitor = Janitor.new() @@ -489,13 +761,19 @@ function Query._activate(self: QueryInternal): Engine janitor:Add(changed, "Destroy") local engine: Engine = { matched = {}, + matchedList = {}, changed = changed, observers = {}, janitor = janitor, positiveSet = {}, attrConns = {}, subEngines = {}, + signature = signature, + refcount = 1, + holders = {}, } + engine.holders[self] = true + activeEngines[signature] = engine self._engine = engine janitor:Add(function() for _, conn in engine.attrConns do @@ -506,7 +784,7 @@ function Query._activate(self: QueryInternal): Engine local function subMatches(subQuery: QueryInternal, instance: Instance): boolean local subEngine = engine.subEngines[subQuery] - return subEngine ~= nil and subEngine.matched[instance] == true + return subEngine ~= nil and subEngine.matched[instance] ~= nil end -- One `AttributeChanged` connection per candidate, filtered by name, instead @@ -544,19 +822,30 @@ function Query._activate(self: QueryInternal): Engine end local isMatch = positive and self:_matchesRest(instance, subMatches) - local wasMatch = engine.matched[instance] == true + local wasMatch = engine.matched[instance] ~= nil if isMatch == wasMatch then return end + local matchedList = engine.matchedList if isMatch then - engine.matched[instance] = true + local n = #matchedList + 1 + matchedList[n] = instance + engine.matched[instance] = n for obs in engine.observers do local matchJanitor = Janitor.new() obs.janitors[instance] = matchJanitor task.spawn(obs.callback, instance, matchJanitor) end else + -- Swap-remove: move the tail into the vacated slot. When the + -- instance IS the tail, the reassignments are harmless no-ops. + local index = engine.matched[instance] :: number + local lastIndex = #matchedList + local last = matchedList[lastIndex] + matchedList[index] = last + engine.matched[last] = index + matchedList[lastIndex] = nil engine.matched[instance] = nil for obs in engine.observers do local matchJanitor = obs.janitors[instance] @@ -647,13 +936,22 @@ function Query._activate(self: QueryInternal): Engine end function Query._deactivate(self: QueryInternal) - self._refcount -= 1 - if self._refcount <= 0 then - self._refcount = 0 - if self._engine then - self._engine.janitor:Destroy() - self._engine = nil + local engine = self._engine + if not engine then + return + end + self._refcount = math.max(0, self._refcount - 1) + engine.refcount -= 1 + -- Holders stay attached until the engine dies: an attached query keeps + -- serving `get()` straight from the live match set for free. + if engine.refcount <= 0 then + activeEngines[engine.signature] = nil + for holder in engine.holders do + holder._engine = nil + holder._refcount = 0 end + table.clear(engine.holders) + engine.janitor:Destroy() end end @@ -677,7 +975,7 @@ function Query._enumerate( local engine = self._engine :: Engine local subEngine = engine.subEngines[subQuery] if subEngine then - for instance in subEngine.matched do + for _, instance in subEngine.matchedList do set[instance] = true end end @@ -737,8 +1035,10 @@ function Query.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()) local obs: Observer = { callback = callback, janitors = {} } engine.observers[obs] = true - -- Fire for instances already matched at subscribe time. - for instance in engine.matched do + -- Fire for instances already matched at subscribe time. Iterate a SNAPSHOT: + -- under Immediate signal behavior a spawned callback can synchronously + -- retag/untag and mutate the live match set mid-loop. + for _, instance in table.clone(engine.matchedList) do local matchJanitor = Janitor.new() obs.janitors[instance] = matchJanitor task.spawn(callback, instance, matchJanitor) @@ -767,10 +1067,23 @@ end @return { Instance } Returns the instances that match right now. A one-shot read: it sets up no subscriptions. + + While the query is actively observed (any live [Query:observe] connection), + the read is served straight from the reactive engine's maintained match set — + an O(matches) copy, identical to what observers see — making per-frame + `GetMatches` loops cheap enough for ECS-style iteration. ]=] function Query.get(self: QueryInternal): { Instance } self:_validate() + -- Live-engine fast path: the engine already maintains exactly this set. + -- An engine built by any structurally equal query serves just as well -- + -- borrow it read-only via the intern registry. + local engine = self._engine or activeEngines[self:_signature()] + if engine then + return table.clone(engine.matchedList) + end + -- Each sub-query's match-set is computed ONCE per call and then answered by -- lookup. Previously every candidate re-ran the whole sub-query (and -- `_enumerate` ran it again on top), which is quadratic in nested queries. @@ -782,8 +1095,9 @@ function Query.get(self: QueryInternal): { Instance } if existing then return existing end - -- Seed the entry before recursing so a cycle (rejected by `_validate`, - -- but cheap to guard) cannot recurse forever. + -- Seed the entry before recursing so shared sub-queries are computed + -- exactly once (recursion depth is finite: queries are immutable, so + -- the reference graph is a DAG by construction). local set: { [Instance]: boolean } = {} subSets[subQuery] = set -- Deepest first, so this sub-query's own enumeration finds its @@ -810,16 +1124,227 @@ function Query.get(self: QueryInternal): { Instance } end end - -- A single positive source means enumeration membership already proves the - -- positive half of the match, so only the rest needs checking. - local singleSource = #self._positive == 1 and #self._anyOf == 0 + local plan = self:_plan() + local required = plan.required local out: { Instance } = {} - for instance in self:_enumerate(false, subSets) do - local matches = if singleSource - then self:_matchesRest(instance, staticSub) - else self:_fullMatch(instance, staticSub) - if matches then - table.insert(out, instance) + + if #required == 0 then + -- anyOf-only query: the candidate set genuinely is a union, so build it. + for instance in self:_enumerate(false, subSets) do + if self:_fullMatch(instance, staticSub) then + table.insert(out, instance) + end + end + return out + end + + -- Required requirements intersect, so enumerate ONE of them (the narrowest + -- we can size cheaply) and check the rest by direct lookup per candidate. + -- Building the union of every positive source just to intersect it back + -- down was the dominant cost of joins (measured 12x on a 2-class join). + -- Preference: smallest class (its array length is O(1)), else first tag, + -- else first sub-query (its match-set is already memoized), else first + -- foreign class-like. + local seed: PlanReq? = nil + local seedSize = math.huge -- smallest KNOWN population so far + local firstTag: PlanReq? = nil + local firstQuery: PlanReq? = nil + local firstClasslike: PlanReq? = nil + for _, req in required do + local kind = req.kind + if kind == "class" then + local size = #(req.comps :: { any }) + if size < seedSize then + seed, seedSize = req, size + end + elseif kind == "tag" then + firstTag = firstTag or req + elseif kind == "query" then + firstQuery = firstQuery or req + elseif kind == "classlike" then + firstClasslike = firstClasslike or req + end + end + + -- Live tag sizing: population is the selectivity signal declaration order + -- cannot give us. When the candidate set would otherwise be large (or there + -- is no class seed at all), size every required tag; the narrowest source + -- seeds regardless of where the user wrote it, and the fetched arrays are + -- reused for seeding and for probe ordering below. + local tagSizes: { [PlanReq]: number }? = nil + local tagArrays: { [PlanReq]: { Instance } }? = nil + if firstTag and (seed == nil or seedSize > TAG_SIZING_MIN_SEED) then + local sizes: { [PlanReq]: number } = {} + local arrays: { [PlanReq]: { Instance } } = {} + tagSizes, tagArrays = sizes, arrays + for _, req in required do + if req.kind == "tag" then + local instances = CollectionService:GetTagged(req.tag :: string) + arrays[req] = instances + local size = #instances + sizes[req] = size + if size < seedSize then + seed, seedSize = req, size + -- Early exit: the seed is already narrow, so remaining tags + -- will be probed at most `seedSize` times each -- fetching + -- their (possibly huge) arrays just to rank them would cost + -- more than the probes they could save. + if size <= TAG_SIZING_EARLY_EXIT then + break + end + end + end + end + end + local chosenSeed: PlanReq = (seed or firstTag or firstQuery or firstClasslike) :: PlanReq + local seedKind = chosenSeed.kind + + -- Probes = every required requirement except the seed, most-selective-first + -- when populations are known (smaller population rejects more candidates + -- sooner, so each later probe runs against fewer survivors). Unknown counts + -- keep the plan's cheap-kind-first order. + local probes: { PlanReq } = {} + for _, req in required do + if req ~= chosenSeed then + table.insert(probes, req) + end + end + if tagSizes and #probes > 1 then + local sizes = tagSizes :: { [PlanReq]: number } + local function populationOf(req: PlanReq): number + if req.kind == "class" then + return #(req.comps :: { any }) + end + local sized = sizes[req] + if sized then + return sized + end + return math.huge + end + table.sort(probes, function(x: PlanReq, y: PlanReq): boolean + local px, py = populationOf(x), populationOf(y) + if px ~= py then + return px < py + end + return x.buildIndex < y.buildIndex + end) + end + + -- A sized tag whose probe will run many times is cheaper as a hash set than + -- as repeated `HasTag` C-calls: one insert (~45ns) buys back every probe + -- (~150ns -> ~25ns). Convert when the expected probe count (the seed size) + -- makes the build pay for itself; the fetched array is reused, so this only + -- ever spends allocations already made for sizing. + local probeSets: { [PlanReq]: { [Instance]: boolean } }? = nil + if tagArrays and tagSizes then + local arrays = tagArrays :: { [PlanReq]: { Instance } } + local sizes = tagSizes :: { [PlanReq]: number } + for _, req in probes do + local instances = arrays[req] + if instances and seedSize * 3 > (sizes[req] :: number) then + local set: { [Instance]: boolean } = {} + for _, instance in instances do + set[instance] = true + end + local outSets = probeSets or {} + probeSets = outSets + outSets[req] = set + end + end + end + + -- The ECS hot shape — one requirement, nothing else — is a straight dump of + -- the seed source: no per-candidate work at all. + local trivial = #required == 1 + and #plan.anyOf == 0 + and not plan.hasNegative + and not plan.hasAttributes + and not plan.hasPredicates + + -- Checks everything except the seed requirement (the seed's own iteration + -- already proves it) and collects matches. Rest-checks are inlined here so a + -- candidate costs no extra method dispatch. + local anyOf = plan.anyOf + local function consider(instance: Instance) + for _, req in probes do + local set = if probeSets then (probeSets :: { [PlanReq]: { [Instance]: boolean } })[req] else nil + if set then + if not set[instance] then + return + end + elseif not reqSatisfied(req, instance, staticSub) then + return + end + end + for _, group in anyOf do + local anySatisfied = false + for _, req in group do + if reqSatisfied(req, instance, staticSub) then + anySatisfied = true + break + end + end + if not anySatisfied then + return + end + end + if plan.hasNegative then + for _, req in plan.negative do + if reqSatisfied(req, instance, staticSub) then + return + end + end + end + if plan.hasAttributes and not self:_attributesMatch(instance) then + return + end + if plan.hasPredicates and not self:_predicatesPass(instance) then + return + end + table.insert(out, instance) + end + + local n = 0 + if seedKind == "class" then + for _, component in chosenSeed.comps :: { any } do + if component[INTERNAL].phase == "Started" then + if trivial then + n += 1 + out[n] = component.Instance + else + consider(component.Instance) + end + end + end + elseif seedKind == "tag" then + local seedInstances = if tagArrays then (tagArrays :: { [PlanReq]: { Instance } })[chosenSeed] else nil + for _, instance in seedInstances or CollectionService:GetTagged(chosenSeed.tag :: string) do + if trivial then + n += 1 + out[n] = instance + else + consider(instance) + end + end + elseif seedKind == "query" then + for instance in ensureSet(chosenSeed.query :: QueryInternal) do + if trivial then + n += 1 + out[n] = instance + else + consider(instance) + end + end + else + for _, component in (chosenSeed.class :: ComponentClassLike):GetAll() do + if Keys.inst(component).phase == "Started" then + if trivial then + n += 1 + out[n] = component.Instance + else + consider(component.Instance) + end + end end end return out diff --git a/lib/component/src/Tests/Component.Query.spec.luau b/lib/component/src/Tests/Component.Query.spec.luau index bb8bf256..73d18ad0 100644 --- a/lib/component/src/Tests/Component.Query.spec.luau +++ b/lib/component/src/Tests/Component.Query.spec.luau @@ -223,13 +223,26 @@ return function(t: any) end).fails() end) - test("a self-referential query errors", function() - local A = H.makeClass() - local q = Component.query(A) - q:with(q) - expect(function() - q:GetMatches() - end).fails() + test("builders are copy-on-write: chains branch independently", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local qA = Component.query(A) + local withB = qA:with(B) + local withoutB = qA:without(B) + + local p = part { aTag, bTag } + expect(H.waitUntil(function() + return A:Has(p) and B:Has(p) + end, 3)).is(true) + + -- Deriving never reshaped qA, and the two branches are independent. + expect(#qA:GetMatches()).is(1) + expect(#withB:GetMatches()).is(1) + expect(#withoutB:GetMatches()).is(0) + + A:Destroy() + B:Destroy() + p:Destroy() end) end) From 55e740771f90453fb79fb70d7bf084c42229a6e8 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 00:20:12 -0400 Subject: [PATCH 08/19] Refactor lifecycle protocol into Lifecycle module Moves construction/destruction logic (_tryConstruct, _tryDeconstruct, GetLifecycleStatus, Destroy internals) from init.luau into Lifecycle.luau, exposing Request, Release, GetPhase, and DestroyClass. Adds construction-request tests covering dedup, same-frame retag, mid-construct cancel, unpublished supersede, and destroy-while-tearing-down. Also removes classWith type function, Connection type, Registry.Get, Query.withProperty, and the scratchpad file. --- lib/component/src/Keys.luau | 19 +- lib/component/src/Lifecycle.luau | 138 ++++++++++- lib/component/src/Query.luau | 1 - lib/component/src/Registry.luau | 13 +- .../src/Tests/Component.Lifecycle.spec.luau | 215 ++++++++++++++---- lib/component/src/TypeFunctions.luau | 83 ------- lib/component/src/Types.luau | 40 +--- lib/component/src/init.luau | 117 +--------- lib/component/src/scratchpad.luau | 19 -- 9 files changed, 340 insertions(+), 305 deletions(-) delete mode 100644 lib/component/src/scratchpad.luau diff --git a/lib/component/src/Keys.luau b/lib/component/src/Keys.luau index f81c83be..537da39c 100644 --- a/lib/component/src/Keys.luau +++ b/lib/component/src/Keys.luau @@ -62,12 +62,20 @@ export type ComponentInternal = { -- Private state carried by every component CLASS. export type ClassInternal = { + -- Owned by `init.luau` (tag/ancestry watching). ancestors: { Instance }, + watching: { [Instance]: { any } }, -- ancestry-watch connections + -- Written by `Lifecycle.luau` as components are tracked/untracked; read + -- widely (init's lookups, Query's compiled plans). instToComponents: { [Instance]: any }, components: { any }, + --[[ The construction-request protocol. Owned ENTIRELY by `Lifecycle.luau` — + nothing else reads or writes these four. `pending` is a three-state slot + (absent / `true` reservation / `PendingRecord`) and `lockConstruct` is the + monotonic id that supersedes a stale request; keeping them in one module is + what makes that protocol reviewable. ]] lockConstruct: { [Instance]: number }, - watching: { [Instance]: { any } }, -- ancestry-watch connections - pending: { [Instance]: any }, -- in-flight construction record (or `true` reservation) + pending: { [Instance]: any }, extensions: { any }, classActiveExtensions: { any }, -- True when some extension defines `ShouldExtend`, so the active set genuinely @@ -78,9 +86,10 @@ export type ClassInternal = { initFields: (() -> { [string]: any })?, -- config.InitFields: called per instance janitor: any, -- class-level Janitor (signals + CollectionService connections) failed: any, -- internal Signal(instance, reason): construction ended without starting - -- Teardowns that have begun but not yet finished. `Destroy` must not tear the - -- class Janitor down while any are outstanding, or it would destroy the - -- `Stopped`/`failed` signals those teardowns still have to fire on. + -- Also Lifecycle-owned. Teardowns that have begun but not yet finished: + -- `DestroyClass` must not tear the class Janitor down while any are + -- outstanding, or it would destroy the `Stopped`/`failed` signals those + -- teardowns still have to fire on. teardownsInFlight: number, destroyJanitorWhenIdle: boolean, } diff --git a/lib/component/src/Lifecycle.luau b/lib/component/src/Lifecycle.luau index a3236a6a..f31c8806 100644 --- a/lib/component/src/Lifecycle.luau +++ b/lib/component/src/Lifecycle.luau @@ -569,7 +569,7 @@ const function run(class: ClassAny, instance: Instance, constructId: number) end -- `run` is already called from a `task.defer`red thread (see - -- `_tryConstruct`), so the whole sequence is straight-line code here: it only + -- `request`), so the whole sequence is straight-line code here: it only -- parks when a hook actually yields, instead of hopping through a Promise per -- step. Returns a StopReason/error to abort with, or nil on success. const function drive(): any? @@ -751,8 +751,138 @@ const function run(class: ClassAny, instance: Instance, constructId: number) teardown(class, component, ic.stopReason or "ConstructionCancelled") end +--[[ + request - claim `instance`'s construction slot for `class`. + + Reserving the slot synchronously (with `true`, before the record exists) is + what makes a second request in the same frame a no-op; the construct lock is + bumped so any request already in flight becomes stale. The driver itself is + deferred, so a batch of tagged instances processes together. +]] +const function request(class: ClassAny, instance: Instance) + const ci = Keys.class(class) + if ci.instToComponents[instance] or ci.pending[instance] then + return + end + const id = (ci.lockConstruct[instance] or 0) + 1 + ci.lockConstruct[instance] = id + ci.pending[instance] = true + task.defer(function() + if ci.lockConstruct[instance] ~= id then + -- Superseded before we even began. Only clear the slot if it is still + -- the bare reservation: a newer request may already own it. + if ci.pending[instance] == true then + ci.pending[instance] = nil + end + return + end + run(class, instance, id) + end) +end + +--[[ + release - give up `instance`'s slot for `class`, tearing down whatever holds + it with `reason`. + + Bumping the lock first is what supersedes a driver that is parked somewhere + `cancel` cannot reach it — before its record is published, a yielding + `ShouldConstruct` is exactly that case, and the lock check inside `run` is the + only thing that catches it. + + Order is load-bearing: untracking happens before the cancel/teardown handoff + so a same-frame re-tag finds a free slot and can begin a fresh construction. +]] +const function release(class: ClassAny, instance: Instance, reason: StopReason) + const ci = Keys.class(class) + ci.lockConstruct[instance] = (ci.lockConstruct[instance] or 0) + 1 + + const record = ci.pending[instance] + const component = ci.instToComponents[instance] + + untrack(class, instance) + ci.pending[instance] = nil + + -- Still constructing: abort the in-flight driver, which runs teardown itself + -- with the reason we hand it. + if type(record) == "table" then + const cancel = (record :: Types.PendingRecord).cancel + if cancel then + cancel(reason) + return + end + end + if component then + -- Fully constructed / started: tear down directly. + teardown(class, component, reason) + end +end + +--[[ + getPhase - the lifecycle phase of the component `class` has on the given + instance (or of the given component). `"None"` if there is no such component. + + Reads the pending slot as well as the tracking table, so a component that is + still constructing reports its real phase rather than `"None"`. +]] +const function getPhase(class: ClassAny, instanceOrComponent: Instance | Types.AnyComponent): Keys.LifecyclePhase + local component: Types.AnyComponent? = nil + if typeof(instanceOrComponent) == "Instance" then + const ci = Keys.class(class) + component = ci.instToComponents[instanceOrComponent] + if not component then + const record = ci.pending[instanceOrComponent] + -- `unknown` bridge: InstanceAny (an intersection) is not a subtype of + -- the `{[any]: any}` view in the solver's eyes. + component = if type(record) == "table" + then ((record :: Types.PendingRecord).component :: unknown) :: Types.AnyComponent + else nil + end + else + component = instanceOrComponent + end + if type(component) == "table" and component[Keys.Internal] then + return Keys.inst(component).phase + end + return "None" +end + +--[[ + destroyClass - release every slot the class holds and dispose of its Janitor. + + The teardowns started here are deferred and still have to fire `Stopped` (or + `failed`) on signals that live on the class Janitor, so destroying it now + would silently swallow those fires. When any teardown is outstanding the + destroy is handed to whichever one finishes last (see `teardown`). + + The caller is expected to have stopped watching for new instances first. +]] +const function destroyClass(class: ClassAny) + const ci = Keys.class(class) + + for instance, record in ci.pending do + if type(record) == "table" and (record :: Types.PendingRecord).component then + release(class, instance, "ClassDestroyed") + end + end + for instance in ci.instToComponents do + release(class, instance, "ClassDestroyed") + end + + if ci.teardownsInFlight > 0 then + ci.destroyJanitorWhenIdle = true + else + ci.janitor:Destroy() + end + + table.clear(ci.instToComponents) + table.clear(ci.components) + table.clear(ci.lockConstruct) + table.clear(ci.pending) +end + return { - Run = run, - Teardown = teardown, - Untrack = untrack, + Request = request, + Release = release, + GetPhase = getPhase, + DestroyClass = destroyClass, } diff --git a/lib/component/src/Query.luau b/lib/component/src/Query.luau index 6af5c43a..a887bca8 100644 --- a/lib/component/src/Query.luau +++ b/lib/component/src/Query.luau @@ -91,7 +91,6 @@ export type Query = { anyOf: (self: Query, ...Queryable) -> Query, without: (self: Query, ...Queryable) -> Query, withAttribute: (self: Query, name: string, matcher: unknown?) -> Query, - withProperty: (self: Query, name: string, matcher: unknown) -> Query, where: (self: Query, predicate: (Instance) -> boolean, recheckSignal: unknown?) -> Query, observe: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, get: (self: Query) -> { Instance }, diff --git a/lib/component/src/Registry.luau b/lib/component/src/Registry.luau index 45096773..c047fa34 100644 --- a/lib/component/src/Registry.luau +++ b/lib/component/src/Registry.luau @@ -8,8 +8,8 @@ A module-level map of `Roblox Instance -> { [ComponentClass]: componentInstance }` spanning every component class. The original Component only tracked instances per-class, so a cross-class question like "what components does this instance - have?" was unanswerable. This registry backs `Component.GetComponents(instance)` - and the world-level query engine. + have?" was unanswerable. This registry backs + `Component.GetAllComponentsForInstance(instance)`. A component is registered the moment its construction completes (before it starts) and unregistered during teardown, mirroring the per-class tracking in @@ -53,15 +53,6 @@ function Registry.Unregister(instance: Instance, class: ComponentClass) end end ---[=[ - @within ComponentRegistry - Returns the component of `class` bound to `instance`, or `nil`. -]=] -function Registry.Get(instance: Instance, class: ComponentClass): Component? - local classes = instanceToClasses[instance] - return if classes then classes[class] else nil -end - --[=[ @within ComponentRegistry Returns a fresh array of every component bound to `instance`, across all diff --git a/lib/component/src/Tests/Component.Lifecycle.spec.luau b/lib/component/src/Tests/Component.Lifecycle.spec.luau index 6c4fc95f..e8d27c95 100644 --- a/lib/component/src/Tests/Component.Lifecycle.spec.luau +++ b/lib/component/src/Tests/Component.Lifecycle.spec.luau @@ -3,55 +3,26 @@ Core lifecycle coverage: construct -> start -> stop across the real CollectionService + ancestry machinery. Runs in the Open Cloud server context (a real DataModel, so tags, RunService, and task scheduling all behave). + + The "construction request" block covers the scheduling protocol itself — the + reservation that dedupes construction attempts, the lock that supersedes a + stale one, and the class-destroy handshake. Those are pure interface tests: + they assert observable lifecycle behavior, not how the slot is stored. ]] local CollectionService = game:GetService("CollectionService") return function(t: any) local Component = require(script.Parent.Parent :: any) :: any + local Helpers = require(script.Parent.Helpers :: any) :: any local describe = t.describe local test = t.test local expect = t.expect - local tagCounter = 0 - local function uniqueTag(): string - tagCounter += 1 - return `CmpSpec_{tagCounter}_{os.clock()}` - end - - -- Spin the scheduler until `predicate()` is truthy or we time out. - local function waitUntil(predicate, timeout: number?) - local deadline = os.clock() + (timeout or 2) - while os.clock() < deadline do - if predicate() then - return true - end - task.wait() - end - return predicate() - end - - -- Build a tagged part under workspace and its component class; returns both - -- plus a cleanup fn. - local function makeClass(overrides, ancestors) - local tag = uniqueTag() - local class = Component.new { Tag = tag, Ancestors = ancestors or { workspace } } - if overrides then - for k, v in overrides do - class[k] = v - end - end - return class, tag - end - - local function taggedPart(tag: string): Instance - local part = Instance.new("Part") - part.Anchored = true - CollectionService:AddTag(part, tag) - part.Parent = workspace - return part - end + local makeClass = Helpers.makeClass + local taggedPart = Helpers.taggedPart + local waitUntil = Helpers.waitUntil describe("construct -> start", function() test("a tagged instance under a valid ancestor constructs and starts", function() @@ -84,7 +55,7 @@ return function(t: any) end) test("instances outside valid ancestors do not construct", function() - local class, tag = makeClass { Ancestors = { workspace } } + local class, tag = makeClass(nil, { Ancestors = { workspace } }) local part = Instance.new("Part") CollectionService:AddTag(part, tag) part.Parent = game:GetService("ReplicatedStorage") @@ -188,4 +159,170 @@ return function(t: any) part:Destroy() end) end) + + describe("construction request", function() + test("two CreateFromInstance calls in one frame construct one component", function() + local constructs = 0 + local class = makeClass { + Construct = function(self) + constructs += 1 + end, + } + local part = Instance.new("Part") + part.Anchored = true + part.Parent = workspace + + -- Both calls land before the deferred driver runs; the second must + -- find the slot already reserved rather than start a second chain. + class:CreateFromInstance(part) + class:CreateFromInstance(part) + + expect(waitUntil(function() + return class:Has(part) + end, 3)).is(true) + task.wait() + task.wait() + expect(constructs).is(1) + + class:Destroy() + part:Destroy() + end) + + test("a same-frame untag + retag ends with exactly one started component", function() + local constructs = 0 + local class, tag = makeClass { + Construct = function(self) + constructs += 1 + end, + } + local part = taggedPart(tag) + expect(waitUntil(function() + return class:Has(part) + end)).is(true) + expect(constructs).is(1) + + -- Drop and re-add within one frame: the stale reservation must not + -- survive to block the new one, nor let a second chain start. + CollectionService:RemoveTag(part, tag) + CollectionService:AddTag(part, tag) + + expect(waitUntil(function() + return class:Has(part) + end, 3)).is(true) + task.wait() + task.wait() + expect(constructs).is(2) + expect(class:FromInstance(part)).exists() + + class:Destroy() + part:Destroy() + end) + + test("a construction cancelled mid-Construct still runs Stop and cleans its tasks", function() + local cleaned = false + local stopReason = nil + local entered = false + local class, tag = makeClass { + Construct = function(self) + self:AddTask(function() + cleaned = true + end) + entered = true + task.wait(0.25) + end, + Stop = function(self, reason) + stopReason = reason + end, + } + local part = taggedPart(tag) + + expect(waitUntil(function() + return entered + end)).is(true) + expect(class:GetLifecycleStatus(part)).is("Constructing") + + CollectionService:RemoveTag(part, tag) + + expect(waitUntil(function() + return cleaned + end, 3)).is(true) + expect(stopReason).is("Untagged") + + class:Destroy() + part:Destroy() + end) + + test("a construction superseded while unpublished stops with reason Superseded", function() + -- A yielding `ShouldConstruct` parks the driver BEFORE it publishes its + -- pending record, so an untag cannot cancel it directly — only the + -- construct lock can catch that it is stale. + local gate = false + local entered = false + local reasons = {} + local extension = { + ShouldConstruct = function() + entered = true + while not gate do + task.wait() + end + return true + end, + } + local class, tag = makeClass({ + Stop = function(self, reason) + table.insert(reasons, reason) + end, + }, { Extensions = { extension } }) + local part = taggedPart(tag) + + expect(waitUntil(function() + return entered + end)).is(true) + + CollectionService:RemoveTag(part, tag) + gate = true + + expect(waitUntil(function() + return #reasons > 0 + end, 3)).is(true) + expect(reasons[1]).is("Superseded") + + class:Destroy() + part:Destroy() + end) + + test("class:Destroy during an in-flight teardown still fires Stopped", function() + local stoppedFired = false + local class, tag = makeClass { + Stop = function(self) + -- Park the deferred stop sequence so Destroy lands mid-teardown. + task.wait(0.15) + end, + } + local part = taggedPart(tag) + expect(waitUntil(function() + return class:Has(part) + end)).is(true) + + local component = class:FromInstance(part) + class.Stopped:Connect(function() + stoppedFired = true + end) + + CollectionService:RemoveTag(part, tag) + expect(waitUntil(function() + return class:GetLifecycleStatus(component) == "Stopping" + end)).is(true) + + -- The class Janitor owns the `Stopped` signal the parked teardown has + -- still to fire on; destroying it now must be deferred until idle. + class:Destroy() + + expect(waitUntil(function() + return stoppedFired + end, 3)).is(true) + + part:Destroy() + end) + end) end diff --git a/lib/component/src/TypeFunctions.luau b/lib/component/src/TypeFunctions.luau index 384c36fb..60d59311 100644 --- a/lib/component/src/TypeFunctions.luau +++ b/lib/component/src/TypeFunctions.luau @@ -75,87 +75,4 @@ export type function extensionMethods(extensions: type) return result end ---[[ - Builds the type returned by `Component.new`. Both arities route here: - - - One-arg (`Component.new(config)`): pass no `methods`. Result is a copy of - `base` (ComponentClass) with extension methods merged in as checked props, - plus the `[string]: any` indexer for post-hoc methods. - - Two-arg (`Component.new(config, methods)`): pass `methods`. Same merge plus - the user's methods as checked props, and NO indexer — adding methods after - `new` is a type error on this path by design. - - Lifecycle props (Construct/Start/Stop and the update loops) plus every user - method get their `self` rebound to the merged type — that is what makes - extension methods checked on `self` inside `function MyComponent:Start()` - bodies and calls like `class:Greet("hi")` check against the class's own type. - Rebinding EVERY inherited method's self makes the recursive type too complex - for the solver ("Code is too complex to typecheck"), so only lifecycle + - user methods are rebound. Built flat instead of as - `TypedClass>` because that intersection makes - overload resolution at the `new` call site exceed the solver's limit. -]] -export type function classWith(base: type, extensions: type, methods: type) - if not base:is("table") then - print("classWith: base is not a table") - return base - end - - const typed = methods and methods:is("table") - const result = if typed - then types.newtable(nil) - else types.newtable(nil, { index = types.string, readresult = types.any, writeresult = types.any }) - - for key, prop in base:properties() do - result:setproperty(key, prop.read or prop.write) - end - - -- Rebinds a function's self parameter to the merged type so calls on the - -- returned class check against the class's own type. - const function rebound(fn: type) - const copy = types.copy(fn) - const params = copy:parameters() - local head = params.head - if head and #head > 0 then - local newHead = table.clone(head) - newHead[1] = result - -- Drop the solver-inferred `...any` tail; keep genuine typed variadics. - if params.tail and not params.tail:is("any") then - copy:setparameters(newHead, params.tail) - else - copy:setparameters(newHead) - end - end - return copy - end - - for _, name in { "Construct", "Start", "Stop", "HeartbeatUpdate", "SteppedUpdate", "RenderSteppedUpdate" } do - const key = types.singleton(name) - const read = base:readproperty(key) - if read and read:is("function") then - -- print("Rebinding lifecycle method:", name) - result:setproperty(key, rebound(read)) - end - end - - -- Attach extension methods as checked props, with self erased to any so they are callable on any receiver. - for key, prop in extensionMethods(extensions):properties() do - result:setproperty(key, prop.read or prop.write) - end - - if typed then - for key, prop in methods:properties() do - const read = prop.read or prop.write - if read and read:is("function") and #read:generics() == 0 then - -- print("Rebinding method:", key:value()) - result:setproperty(key, rebound(read)) - else - result:setproperty(key, read) - end - end - end - - return result -end - return {} diff --git a/lib/component/src/Types.luau b/lib/component/src/Types.luau index 80eb9c4a..3438e776 100644 --- a/lib/component/src/Types.luau +++ b/lib/component/src/Types.luau @@ -17,7 +17,6 @@ ]=] const Packages = script.Parent.Parent -const Janitor = require(Packages.Janitor) const Signal = require(Packages.Signal) const Promise = require(Packages.Promise) @@ -25,7 +24,6 @@ const Keys = require(script.Parent.Keys) const TypeFunctions = require(script.Parent.TypeFunctions) type Promise = Promise.TypedPromise -type Janitor = Janitor.Janitor export type LifecyclePhase = Keys.LifecyclePhase export type StopReason = Keys.StopReason @@ -164,21 +162,6 @@ export type ComponentConfigOf = { [string]: any, } ---[=[ - @interface Connection - @within Component - .IsConnected boolean - .Disconnect () -> () - .Destroy () -> () - Returned by [Component:WhileHasComponent] / [Component:WhileHasComponents]. - Also callable (`conn()` is `conn:Destroy()`). -]=] -export type Connection = { - IsConnected: boolean, - Disconnect: () -> (), - Destroy: () -> (), -} - --[[ The type returned by `Component.new(config)`: `M` (the config's `Methods`, plus `extensionMethods` when it has Extensions) is merged in, so user @@ -251,27 +234,14 @@ export type TypedInstance = { -- models (a full `TypedInstance<...>` here would need a class -> instance type -- function, and bloats the solver). GetComponent: (self: TypedInstance, componentClass: T) -> (T & { Instance: Instance })?, - -- Generic in the sibling class so the callback sees its real (merged) type. - WhileHasComponent: ( - self: TypedInstance, - componentClass: T, - fn: (component: T & { Instance: Instance }, janitor: Janitor) -> () - ) -> Connection, - WhileHasComponents: ( - self: TypedInstance, - componentClasses: { any }, - fn: (components: { any }, janitor: Janitor) -> () - ) -> Connection, } & TypedClass & F --- Overloads dispatch on arity. The one-argument form returns the type-function- --- merged class (base + checked extension methods + indexer); the two-argument --- form returns the strict merge (base + extension methods + user methods, no --- indexer). `TypedClass` remains the self-annotation alias for the latter. +-- The config's `Methods` (`M`) and its extensions' methods are merged into the +-- returned class as checked props; `Fields`/`InitFields` (`F`/`IF`) land on the +-- instance type. `TypedClass` is the matching self-annotation alias. export type NewFn = ( config: ComponentConfigOf -) -> TypedClass, F & IF> --TypeFunctions.classWith, E, nil>) --- & ((config: ComponentConfigOf) -> TypeFunctions.classWith) +) -> TypedClass, F & IF> -------------------------------------------------------------------------------- -- Internal implementation views @@ -302,8 +272,6 @@ export type ComponentInstance = TypedInstance<{}, {}, Instance> -- the `Keys` accessors and the constructors' `any` build step. type ComponentClass_Internal_Methods = { _isInAncestorList: (self: ComponentClass_Internal, instance: Instance) -> boolean, - _tryConstruct: (self: ComponentClass_Internal, instance: Instance) -> (), - _tryDeconstruct: (self: ComponentClass_Internal, instance: Instance, reason: StopReason) -> (), _startWatching: (self: ComponentClass_Internal, instance: Instance) -> (), _stopWatching: (self: ComponentClass_Internal, instance: Instance) -> (), _setup: (self: ComponentClass_Internal) -> (), diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index 5956947c..b0d02d41 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -111,7 +111,6 @@ export type LifecyclePhase = Keys.LifecyclePhase export type StopReason = Keys.StopReason export type Extension = Types.Extension export type ComponentConfig = Types.ComponentConfig -export type Connection = Types.Connection export type ComponentClass = Types.ComponentClass export type TypedClass = Types.TypedClass export type Query = Query.Query @@ -309,60 +308,6 @@ function ComponentClassMethods._isInAncestorList(self: Class_Internal, instance: return false end ---[[ - Attempts to construct a component for `instance`, unless one already exists or - is in flight. Deferred so a batch of tagged instances processes together. -]] -function ComponentClassMethods._tryConstruct(self: Class_Internal, instance: Instance) - const ci = Keys.class(self) - if ci.instToComponents[instance] or ci.pending[instance] then - return - end - const id = (ci.lockConstruct[instance] or 0) + 1 - ci.lockConstruct[instance] = id - -- Reserve the slot synchronously so a second call in the same frame dedupes. - ci.pending[instance] = true - task.defer(function() - if ci.lockConstruct[instance] ~= id then - if ci.pending[instance] == true then - ci.pending[instance] = nil - end - return - end - Lifecycle.Run(self, instance, id) - end) -end - ---[[ - Stops and removes the component for `instance`, if any, with the given reason. -]] -function ComponentClassMethods._tryDeconstruct(self: Class_Internal, instance: Instance, reason: StopReason) - const ci = Keys.class(self) - ci.lockConstruct[instance] = (ci.lockConstruct[instance] or 0) + 1 - - const record = ci.pending[instance] - const component = ci.instToComponents[instance] - - -- Untrack up front so a same-frame re-tag can begin a fresh construction. - Lifecycle.Untrack(self, instance) - ci.pending[instance] = nil - - -- If the component is still constructing, abort the in-flight driver; it runs - -- teardown itself with the reason we hand it. - if type(record) == "table" then - const pending = record :: Types.PendingRecord - const cancel = pending.cancel - if cancel then - cancel(reason) - return - end - end - if component then - -- Fully constructed / started: tear down directly. - Lifecycle.Teardown(self, component, reason) - end -end - --[[ Begins watching `instance` for ancestry changes, constructing/deconstructing as it enters or leaves the valid ancestor list. Idempotent. @@ -375,10 +320,10 @@ function ComponentClassMethods._startWatching(self: Class_Internal, instance: In const function evaluate() if self:_isInAncestorList(instance) then - self:_tryConstruct(instance) + Lifecycle.Request(self, instance) else const reason: StopReason = if instance:IsDescendantOf(game) then "LeftAncestry" else "InstanceDestroyed" - self:_tryDeconstruct(instance, reason) + Lifecycle.Release(self, instance, reason) end end @@ -388,7 +333,7 @@ function ComponentClassMethods._startWatching(self: Class_Internal, instance: In } if self:_isInAncestorList(instance) then - self:_tryConstruct(instance) + Lifecycle.Request(self, instance) end end @@ -422,7 +367,7 @@ function ComponentClassMethods._setup(self: Class_Internal) Extensions.BindMethods(self, classActiveExtensions) -- Without a `ShouldExtend` anywhere, every instance resolves to exactly this - -- list, so `Lifecycle.Run` can share it instead of re-sorting per instance. + -- list, so the lifecycle engine can share it instead of re-sorting per instance. for _, extension in classActiveExtensions do if type(extension.ShouldExtend) == "function" then ci.extensionsVaryPerInstance = true @@ -435,7 +380,7 @@ function ComponentClassMethods._setup(self: Class_Internal) end)) ci.janitor:Add(CollectionService:GetInstanceRemovedSignal(self.Tag):Connect(function(instance) self:_stopWatching(instance) - self:_tryDeconstruct(instance, "Untagged") + Lifecycle.Release(self, instance, "Untagged") end)) for _, instance in CollectionService:GetTagged(self.Tag) do @@ -492,25 +437,7 @@ function ComponentClassMethods.GetLifecycleStatus( self: Class_Internal, instanceOrComponent: Instance | Types.AnyComponent ): LifecyclePhase - local component: Types.AnyComponent? = nil - if typeof(instanceOrComponent) == "Instance" then - const ci = Keys.class(self) - component = ci.instToComponents[instanceOrComponent] - if not component then - const record = ci.pending[instanceOrComponent] - -- `unknown` bridge: InstanceAny (an intersection) is not a subtype of - -- the `{[any]: any}` view in the solver's eyes. - component = if type(record) == "table" - then ((record :: Types.PendingRecord).component :: unknown) :: Types.AnyComponent - else nil - end - else - component = instanceOrComponent - end - if type(component) == "table" and component[Keys.Internal] then - return Keys.inst(component).phase - end - return "None" + return Lifecycle.GetPhase(self, instanceOrComponent) end --[=[ @@ -581,7 +508,7 @@ function ComponentClassMethods.CreateFromInstance(self: Class_Internal, instance end self:_startWatching(instance) if self:_isInAncestorList(instance) then - self:_tryConstruct(instance) + Lifecycle.Request(self, instance) end end) :: unknown ) :: Types.PromiseLike @@ -750,38 +677,14 @@ function ComponentClassMethods.Destroy(self: Class_Internal) table.remove(UNSETUP_COMPONENTS, idx) end + -- Stop watching every instance first, so no new construction begins mid-destroy. const ci = Keys.class(self) - - -- Stop watching every instance so no new construction begins mid-destroy. for instance in ci.watching do self:_stopWatching(instance) end - - -- Tear down all components (started, constructed, or in-flight) via the one path. - for instance, record in ci.pending do - if type(record) == "table" and record.component then - self:_tryDeconstruct(instance, "ClassDestroyed") - end - end - for instance in ci.instToComponents do - self:_tryDeconstruct(instance, "ClassDestroyed") - end - - -- The teardowns kicked off above are deferred and still have to fire - -- `Stopped` (or `failed`) on this class's signals, which live on this - -- Janitor. Destroying it now would silently swallow those fires, so hand the - -- destroy to whichever teardown finishes last. - if ci.teardownsInFlight > 0 then - ci.destroyJanitorWhenIdle = true - else - ci.janitor:Destroy() - end - - table.clear(ci.instToComponents) - table.clear(ci.components) - table.clear(ci.lockConstruct) - table.clear(ci.pending) table.clear(ci.watching) + + Lifecycle.DestroyClass(self) end return Component diff --git a/lib/component/src/scratchpad.luau b/lib/component/src/scratchpad.luau deleted file mode 100644 index de9a3cbd..00000000 --- a/lib/component/src/scratchpad.luau +++ /dev/null @@ -1,19 +0,0 @@ ---!strict - -local extension = {} -extension.Methods = {} - -function extension.Methods.test(self: CLASS, arg: number): string - return `test: {arg}` -end - -local class = {} -function class.method(self: CLASS, arg: number): string - return `method: {arg}` -end - -function new(config, methods: A): typeof(A) - return nil :: any -end - -local x = new({}, class) From d1597d9d07698066e92fa862ad01ca102a225fd5 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 01:20:55 -0400 Subject: [PATCH 09/19] Inline component registry into lifecycle Moves the cross-class instance/component registry from `Registry.luau` into `Lifecycle.luau` and exposes it as `Lifecycle.GetAllForInstance`, then updates `Component.GetAllComponentsForInstance` to use that API. This keeps registration/unregistration logic at the lifecycle tracking points and removes the extra module. Adds lifecycle-focused tests to verify cross-class aggregation, post-construction visibility, and cleanup during teardown. --- lib/component/src/Lifecycle.luau | 62 +++++++++++-- lib/component/src/Registry.luau | 72 --------------- .../src/Tests/Component.Lifecycle.spec.luau | 87 +++++++++++++++++++ lib/component/src/init.luau | 3 +- 4 files changed, 145 insertions(+), 79 deletions(-) delete mode 100644 lib/component/src/Registry.luau diff --git a/lib/component/src/Lifecycle.luau b/lib/component/src/Lifecycle.luau index f31c8806..4e559ee4 100644 --- a/lib/component/src/Lifecycle.luau +++ b/lib/component/src/Lifecycle.luau @@ -30,7 +30,6 @@ local Promise = require(Packages.Promise) local Janitor = require(Packages.Janitor) local Keys = require(script.Parent.Keys) -local Registry = require(script.Parent.Registry) local Extensions = require(script.Parent.Extensions) local Types = require(script.Parent.Types) @@ -41,6 +40,57 @@ type StopReason = Types.StopReason const IS_SERVER = RunService:IsServer() +--[[ + Cross-class instance registry: a module-level map of + `Roblox Instance -> { [ComponentClass]: componentInstance }` spanning every + component class. Per-class tracking (`ClassInternal.instToComponents`) can + only answer "does THIS class have a component here"; the cross-class question + "what components does this instance have?" needs this index. It backs the + public `Component.GetAllComponentsForInstance`. + + Written only from the tracking sites below — `register` at the Constructed + phase, `unregister` inside `untrack` — so a component appears here from the + moment it finishes constructing and vanishes during teardown. +]] +local instanceToClasses: { [Instance]: { [any]: any } } = {} + +const function register(instance: Instance, class: any, component: any) + local classes = instanceToClasses[instance] + if not classes then + classes = {} + instanceToClasses[instance] = classes + end + classes[class] = component +end + +const function unregister(instance: Instance, class: any) + const classes = instanceToClasses[instance] + if not classes then + return + end + classes[class] = nil + -- Drop the instance entry entirely once its last component is gone, so the + -- registry never retains destroyed instances. + if next(classes) == nil then + instanceToClasses[instance] = nil + end +end + +--[[ + Returns a fresh array of every component bound to `instance`, across all + classes. Backs `Component.GetAllComponentsForInstance`. +]] +const function getAllForInstance(instance: Instance): { any } + const out = {} + const classes = instanceToClasses[instance] + if classes then + for _, component in classes do + table.insert(out, component) + end + end + return out +end + -- Sentinel error used to unwind the lifecycle chain on cancellation (as opposed -- to a genuine construction error). const CANCELLED = newproxy(false) @@ -379,8 +429,9 @@ const function disconnectUpdates(component: InstanceAny) end --[[ - Removes a component from every tracking table (per-class + global registry). - Idempotent; safe to call whether or not the component was ever tracked. + Removes a component from every tracking table: the per-class arrays and the + cross-class registry. Idempotent; safe to call whether or not the component + was ever tracked. ]] const function untrack(class: ClassAny, instance: Instance) const ci = Keys.class(class) @@ -394,7 +445,7 @@ const function untrack(class: ClassAny, instance: Instance) components[index] = components[n] components[n] = nil end - Registry.Unregister(instance, class) + unregister(instance, class) end end @@ -646,7 +697,7 @@ const function run(class: ClassAny, instance: Instance, constructId: number) ic.phase = "Constructed" cci.instToComponents[instance] = component table.insert(cci.components, component) - Registry.Register(instance, class, component) + register(instance, class, component) -- The one deferral boundary: start phases never run on the thread that -- drove construction. @@ -885,4 +936,5 @@ return { Release = release, GetPhase = getPhase, DestroyClass = destroyClass, + GetAllForInstance = getAllForInstance, } diff --git a/lib/component/src/Registry.luau b/lib/component/src/Registry.luau deleted file mode 100644 index c047fa34..00000000 --- a/lib/component/src/Registry.luau +++ /dev/null @@ -1,72 +0,0 @@ ---!strict --- Global cross-class component registry. --- Authors: Logan Hunt [Raildex] ---[=[ - @class ComponentRegistry - @ignore - - A module-level map of `Roblox Instance -> { [ComponentClass]: componentInstance }` - spanning every component class. The original Component only tracked instances - per-class, so a cross-class question like "what components does this instance - have?" was unanswerable. This registry backs - `Component.GetAllComponentsForInstance(instance)`. - - A component is registered the moment its construction completes (before it - starts) and unregistered during teardown, mirroring the per-class tracking in - `init.luau`. -]=] - -type ComponentClass = any -type Component = any - -local instanceToClasses: { [Instance]: { [ComponentClass]: Component } } = {} - -local Registry = {} - ---[=[ - @within ComponentRegistry - Registers a constructed component under its instance and class. -]=] -function Registry.Register(instance: Instance, class: ComponentClass, component: Component) - local classes = instanceToClasses[instance] - if not classes then - classes = {} - instanceToClasses[instance] = classes - end - classes[class] = component -end - ---[=[ - @within ComponentRegistry - Removes a component's registration. Cleans up the instance entry entirely - once it has no remaining components, so the registry never retains destroyed - instances. -]=] -function Registry.Unregister(instance: Instance, class: ComponentClass) - local classes = instanceToClasses[instance] - if not classes then - return - end - classes[class] = nil - if next(classes) == nil then - instanceToClasses[instance] = nil - end -end - ---[=[ - @within ComponentRegistry - Returns a fresh array of every component bound to `instance`, across all - classes. -]=] -function Registry.GetAll(instance: Instance): { Component } - local out = {} - local classes = instanceToClasses[instance] - if classes then - for _, component in classes do - table.insert(out, component) - end - end - return out -end - -return Registry diff --git a/lib/component/src/Tests/Component.Lifecycle.spec.luau b/lib/component/src/Tests/Component.Lifecycle.spec.luau index e8d27c95..98725eba 100644 --- a/lib/component/src/Tests/Component.Lifecycle.spec.luau +++ b/lib/component/src/Tests/Component.Lifecycle.spec.luau @@ -325,4 +325,91 @@ return function(t: any) part:Destroy() end) end) + + describe("GetAllComponentsForInstance", function() + test("returns every component across classes bound to one instance", function() + local classA, tagA = makeClass() + local classB, tagB = makeClass() + local part = Instance.new("Part") + part.Anchored = true + CollectionService:AddTag(part, tagA) + CollectionService:AddTag(part, tagB) + part.Parent = workspace + + expect(waitUntil(function() + return classA:Has(part) and classB:Has(part) + end, 3)).is(true) + + local all = Component.GetAllComponentsForInstance(part) + expect(#all).is(2) + local seen = {} + for _, component in all do + seen[component] = true + end + expect(seen[classA:FromInstance(part)]).is(true) + expect(seen[classB:FromInstance(part)]).is(true) + + classA:Destroy() + classB:Destroy() + part:Destroy() + end) + + test("a component appears only once it finishes constructing", function() + local gate = false + local class, tag = makeClass { + Construct = function(self) + while not gate do + task.wait() + end + end, + } + local part = taggedPart(tag) + + -- Still constructing: not yet globally visible. + expect(waitUntil(function() + return class:GetLifecycleStatus(part) == "Constructing" + end)).is(true) + expect(#Component.GetAllComponentsForInstance(part)).is(0) + + gate = true + expect(waitUntil(function() + return class:Has(part) + end, 3)).is(true) + expect(#Component.GetAllComponentsForInstance(part)).is(1) + + class:Destroy() + part:Destroy() + end) + + test("entries drop on teardown, and the instance clears when its last component goes", function() + local classA, tagA = makeClass() + local classB, tagB = makeClass() + local part = Instance.new("Part") + part.Anchored = true + CollectionService:AddTag(part, tagA) + CollectionService:AddTag(part, tagB) + part.Parent = workspace + + expect(waitUntil(function() + return classA:Has(part) and classB:Has(part) + end, 3)).is(true) + expect(#Component.GetAllComponentsForInstance(part)).is(2) + + CollectionService:RemoveTag(part, tagA) + expect(waitUntil(function() + return not classA:Has(part) + end)).is(true) + expect(#Component.GetAllComponentsForInstance(part)).is(1) + + CollectionService:RemoveTag(part, tagB) + expect(waitUntil(function() + return not classB:Has(part) + end)).is(true) + expect(#Component.GetAllComponentsForInstance(part)).is(0) + + classA:Destroy() + classB:Destroy() + part:Destroy() + end) + end) end diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index b0d02d41..c5d1bc56 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -100,7 +100,6 @@ const Signal = require(Packages.Signal) --// Internal //-- const Keys = require(script.Keys) const Extensions = require(script.Extensions) -const Registry = require(script.Registry) const Query = require(script.Query) const Lifecycle = require(script.Lifecycle) const Types = require(script.Types) @@ -175,7 +174,7 @@ end Returns every component bound to `instance`, across all component classes. A component appears here from the moment it finishes constructing. ]=] -Component.GetAllComponentsForInstance = Registry.GetAll +Component.GetAllComponentsForInstance = Lifecycle.GetAllForInstance -------------------------------------------------------------------------------- -- Construction of a component class From 65b1d00b1f4837b3f24b99c5ee66c2c9e7efefd1 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 04:03:12 -0400 Subject: [PATCH 10/19] Optimize query matching with started sparse set Switch class query checks from component-phase probing to a lifecycle-owned started sparse set (`startedInstances`/`startedList`) so started membership is tracked at start/teardown transitions and reused directly by query planning, seeding, and trivial fast paths. This also updates `Has` to use started membership, adds `Query:iter()` for allocation-free iteration when observed, and expands query tests to cover `iter()` behavior plus constructing/stopped exclusion in cold `GetMatches`. --- lib/component/src/Keys.luau | 8 + lib/component/src/Lifecycle.luau | 24 +- lib/component/src/Query.luau | 264 ++++++++++++------ .../src/Tests/Component.Query.spec.luau | 74 +++++ lib/component/src/init.luau | 5 +- 5 files changed, 280 insertions(+), 95 deletions(-) diff --git a/lib/component/src/Keys.luau b/lib/component/src/Keys.luau index 537da39c..b75871d1 100644 --- a/lib/component/src/Keys.luau +++ b/lib/component/src/Keys.luau @@ -69,6 +69,14 @@ export type ClassInternal = { -- widely (init's lookups, Query's compiled plans). instToComponents: { [Instance]: any }, components: { any }, + -- Started sparse set, Lifecycle-owned: `startedInstances[instance]` is the + -- 1-based index into `startedList` (dense, swap-remove on stop). Membership + -- means "phase is Started" — maintained exactly at the Started/Stopping + -- transitions. Both tables are mutated in place and never replaced, so + -- Query's compiled plans capture them by reference (same guarantee as the + -- two tables above). + startedInstances: { [Instance]: number }, + startedList: { Instance }, --[[ The construction-request protocol. Owned ENTIRELY by `Lifecycle.luau` — nothing else reads or writes these four. `pending` is a three-state slot (absent / `true` reservation / `PendingRecord`) and `lockConstruct` is the diff --git a/lib/component/src/Lifecycle.luau b/lib/component/src/Lifecycle.luau index 4e559ee4..8c46ac08 100644 --- a/lib/component/src/Lifecycle.luau +++ b/lib/component/src/Lifecycle.luau @@ -477,7 +477,21 @@ const function teardown(class: ClassAny, component: InstanceAny, reason: StopRea const cci = Keys.class(class) cci.teardownsInFlight += 1 - untrack(class, component.Instance) + -- Leave the started sparse set synchronously (swap-remove), so probes see + -- the component gone the same moment the phase leaves "Started". + const instance = component.Instance + const startedIndex = cci.startedInstances[instance] + if startedIndex then + const startedList = cci.startedList + const lastIndex = #startedList + const last = startedList[lastIndex] + startedList[startedIndex] = last + cci.startedInstances[last] = startedIndex + startedList[lastIndex] = nil + cci.startedInstances[instance] = nil + end + + untrack(class, instance) disconnectUpdates(component) const function warnError(err: any) @@ -766,6 +780,12 @@ const function run(class: ClassAny, instance: Instance, constructId: number) end ic.phase = "Started" ic.started = true + -- Enter the started sparse set BEFORE firing `Started`, so anything the + -- signal triggers (query re-evaluation) already sees the membership. + const startedList = cci.startedList + const startedIndex = #startedList + 1 + startedList[startedIndex] = instance + cci.startedInstances[instance] = startedIndex class.Started:Fire(component) return nil end @@ -927,6 +947,8 @@ const function destroyClass(class: ClassAny) table.clear(ci.instToComponents) table.clear(ci.components) + table.clear(ci.startedInstances) + table.clear(ci.startedList) table.clear(ci.lockConstruct) table.clear(ci.pending) end diff --git a/lib/component/src/Query.luau b/lib/component/src/Query.luau index a887bca8..caf9b49a 100644 --- a/lib/component/src/Query.luau +++ b/lib/component/src/Query.luau @@ -94,6 +94,7 @@ export type Query = { where: (self: Query, predicate: (Instance) -> boolean, recheckSignal: unknown?) -> Query, observe: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, get: (self: Query) -> { Instance }, + iter: (self: Query) -> () -> Instance?, } -- satisfiedFn(query, instance): is `instance` currently matching `query`? @@ -123,16 +124,16 @@ type Observer = { type ChangedSignal = Signal.Signal<(Instance, boolean) -> (), Instance, boolean> -- One requirement, compiled: its kind resolved once and, for component classes, --- the class's live tracking tables captured directly. Checking a class --- requirement per candidate is then a single table lookup + phase compare --- instead of a metatable dispatch through `FromInstance` (measured ~2.5x per --- candidate), and `comps` gives `get()` an O(1)-sized seed source. +-- the class's live started sparse set captured directly (see +-- `Keys.ClassInternal`). Checking a class requirement per candidate is then a +-- SINGLE table lookup — membership in `startedMap` IS "started" — and +-- `startedList` gives `get()` an O(1)-sized, already-filtered seed source. type PlanReq = { kind: "tag" | "class" | "classlike" | "query", buildIndex: number, -- declaration position; tiebreak for the stable sort tag: string?, - comps: { any }?, -- class: live components array (seed iteration) - instTo: { [Instance]: any }?, -- class: live instance -> component map + startedList: { Instance }?, -- class: live dense started-instance array + startedMap: { [Instance]: number }?, -- class: live instance -> list index class: ComponentClassLike?, -- foreign class-like: FromInstance fallback query: QueryInternal?, } @@ -147,6 +148,9 @@ type Plan = { hasNegative: boolean, hasAttributes: boolean, hasPredicates: boolean, + -- True when any clause references a sub-query; `get()` only builds its + -- per-call memoization machinery (and a real SatisfiedFn) when it is. + hasQueryRefs: boolean, } -- Reactive state backing an activated query (ref-counted, shared when a query @@ -208,7 +212,7 @@ type QueryInternal = Query & { } local EXISTS = newproxy(false) -- sentinel: attribute must merely exist -local INTERNAL = Keys.Internal -- hoisted: the plan checks phases in hot loops +local INTERNAL = Keys.Internal -- hoisted for the plan compiler's class probe local Query = {} Query.__index = Query @@ -453,15 +457,15 @@ local function compileReq(req: Queryable, buildIndex: number): PlanReq end local internal = (req :: any)[INTERNAL] if internal then - -- Our own class: capture its live tracking tables. Both are mutated in - -- place (never replaced) by the lifecycle, so the references stay valid - -- for the class's whole life; `Destroy` clears them, which correctly - -- reads as "no matches". + -- Our own class: capture its live started sparse set. Both tables are + -- mutated in place (never replaced) by the lifecycle, so the references + -- stay valid for the class's whole life; `Destroy` clears them, which + -- correctly reads as "no matches". return { kind = "class" :: "class", buildIndex = buildIndex, - comps = internal.components, - instTo = internal.instToComponents, + startedList = internal.startedList, + startedMap = internal.startedInstances, } end return { kind = "classlike" :: "classlike", buildIndex = buildIndex, class = req :: ComponentClassLike } @@ -503,8 +507,7 @@ end local function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: SatisfiedFn): boolean local kind = req.kind if kind == "class" then - local component = (req.instTo :: { [Instance]: any })[instance] - return component ~= nil and component[INTERNAL].phase == "Started" + return (req.startedMap :: { [Instance]: number })[instance] ~= nil elseif kind == "tag" then return CollectionService:HasTag(instance, req.tag :: string) elseif kind == "query" then @@ -516,6 +519,12 @@ local function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: Satis end end +-- Placeholder SatisfiedFn for plans with no sub-query requirement: nothing can +-- ever call it (`reqSatisfied` only consults satisfiedFn for "query" kinds). +local function neverSub(_query: QueryInternal, _instance: Instance): boolean + return false +end + function Query._plan(self: QueryInternal): Plan local cached = self._planned if cached then @@ -540,6 +549,23 @@ function Query._plan(self: QueryInternal): Plan table.insert(negative, compileReq(req, index)) end sortBySelectivity(negative) + local function anyQueryReq(reqs: { PlanReq }): boolean + for _, req in reqs do + if req.kind == "query" then + return true + end + end + return false + end + local hasQueryRefs = anyQueryReq(required) or anyQueryReq(negative) + if not hasQueryRefs then + for _, group in anyOf do + if anyQueryReq(group) then + hasQueryRefs = true + break + end + end + end local plan: Plan = { required = required, anyOf = anyOf, @@ -547,6 +573,7 @@ function Query._plan(self: QueryInternal): Plan hasNegative = #negative > 0, hasAttributes = #self._attributes > 0, hasPredicates = #self._predicates > 0, + hasQueryRefs = hasQueryRefs, } self._planned = plan return plan @@ -993,14 +1020,20 @@ function Query._enumerate( end end else -- component class - -- Iterate the class's live component array directly when it is one of - -- ours; `GetAll()` clones it purely to be thrown away here. + -- One of ours: its started list already holds exactly the instances + -- this source contributes, pre-filtered. Foreign class-likes fall back + -- to `GetAll()` + a phase check per component. local class = req :: ComponentClassLike local internal = (class :: any)[Keys.Internal] - local components = if internal then internal.components else class:GetAll() - for _, component in components do - if Keys.inst(component).phase == "Started" then - set[component.Instance] = true + if internal then + for _, instance in internal.startedList do + set[instance] = true + end + else + for _, component in class:GetAll() do + if Keys.inst(component).phase == "Started" then + set[component.Instance] = true + end end end end @@ -1083,47 +1116,57 @@ function Query.get(self: QueryInternal): { Instance } return table.clone(engine.matchedList) end + local plan = self:_plan() + -- Each sub-query's match-set is computed ONCE per call and then answered by -- lookup. Previously every candidate re-ran the whole sub-query (and -- `_enumerate` ran it again on top), which is quadratic in nested queries. - local subSets: { [QueryInternal]: { [Instance]: boolean } } = {} - local staticSub: SatisfiedFn - local ensureSet: (QueryInternal) -> { [Instance]: boolean } - function ensureSet(subQuery: QueryInternal): { [Instance]: boolean } - local existing = subSets[subQuery] - if existing then - return existing - end - -- Seed the entry before recursing so shared sub-queries are computed - -- exactly once (recursion depth is finite: queries are immutable, so - -- the reference graph is a DAG by construction). - local set: { [Instance]: boolean } = {} - subSets[subQuery] = set - -- Deepest first, so this sub-query's own enumeration finds its - -- references already memoized. - for _, req in subQuery:_allReferences() do - if isQuery(req) then - ensureSet(req :: QueryInternal) + -- The machinery (memo table + two closures) is only built when some clause + -- actually references a sub-query; `staticSub` is never consulted otherwise. + local subSets: { [QueryInternal]: { [Instance]: boolean } }? = nil + local ensureSet: ((QueryInternal) -> { [Instance]: boolean })? = nil + local staticSub: SatisfiedFn = neverSub + if plan.hasQueryRefs then + local sets: { [QueryInternal]: { [Instance]: boolean } } = {} + subSets = sets + local sub: SatisfiedFn + local ensure: (QueryInternal) -> { [Instance]: boolean } + function ensure(subQuery: QueryInternal): { [Instance]: boolean } + local existing = sets[subQuery] + if existing then + return existing end - end - for candidate in subQuery:_enumerate(false, subSets) do - if subQuery:_fullMatch(candidate, staticSub) then - set[candidate] = true + -- Seed the entry before recursing so shared sub-queries are computed + -- exactly once (recursion depth is finite: queries are immutable, so + -- the reference graph is a DAG by construction). + local set: { [Instance]: boolean } = {} + sets[subQuery] = set + -- Deepest first, so this sub-query's own enumeration finds its + -- references already memoized. + for _, req in subQuery:_allReferences() do + if isQuery(req) then + ensure(req :: QueryInternal) + end end + for candidate in subQuery:_enumerate(false, sets) do + if subQuery:_fullMatch(candidate, sub) then + set[candidate] = true + end + end + return set end - return set - end - function staticSub(subQuery: QueryInternal, instance: Instance): boolean - return ensureSet(subQuery)[instance] == true - end + function sub(subQuery: QueryInternal, instance: Instance): boolean + return ensure(subQuery)[instance] == true + end + ensureSet = ensure + staticSub = sub - for _, req in self:_allReferences() do - if isQuery(req) then - ensureSet(req :: QueryInternal) + for _, req in self:_allReferences() do + if isQuery(req) then + ensure(req :: QueryInternal) + end end end - - local plan = self:_plan() local required = plan.required local out: { Instance } = {} @@ -1152,7 +1195,7 @@ function Query.get(self: QueryInternal): { Instance } for _, req in required do local kind = req.kind if kind == "class" then - local size = #(req.comps :: { any }) + local size = #(req.startedList :: { Instance }) if size < seedSize then seed, seedSize = req, size end @@ -1198,6 +1241,38 @@ function Query.get(self: QueryInternal): { Instance } local chosenSeed: PlanReq = (seed or firstTag or firstQuery or firstClasslike) :: PlanReq local seedKind = chosenSeed.kind + -- The ECS hot shape — one requirement, nothing else — is a straight dump of + -- the seed source, before any per-candidate machinery is even allocated. + if + #required == 1 + and #plan.anyOf == 0 + and not plan.hasNegative + and not plan.hasAttributes + and not plan.hasPredicates + then + if seedKind == "class" then + return table.clone(chosenSeed.startedList :: { Instance }) + elseif seedKind == "tag" then + -- Both sources are arrays freshly allocated for this call, so they + -- are safe to hand out directly. + local sized = if tagArrays then (tagArrays :: { [PlanReq]: { Instance } })[chosenSeed] else nil + return sized or CollectionService:GetTagged(chosenSeed.tag :: string) + elseif seedKind == "query" then + local ensure = ensureSet :: (QueryInternal) -> { [Instance]: boolean } + for instance in ensure(chosenSeed.query :: QueryInternal) do + table.insert(out, instance) + end + return out + else + for _, component in (chosenSeed.class :: ComponentClassLike):GetAll() do + if Keys.inst(component).phase == "Started" then + table.insert(out, component.Instance) + end + end + return out + end + end + -- Probes = every required requirement except the seed, most-selective-first -- when populations are known (smaller population rejects more candidates -- sooner, so each later probe runs against fewer survivors). Unknown counts @@ -1212,7 +1287,7 @@ function Query.get(self: QueryInternal): { Instance } local sizes = tagSizes :: { [PlanReq]: number } local function populationOf(req: PlanReq): number if req.kind == "class" then - return #(req.comps :: { any }) + return #(req.startedList :: { Instance }) end local sized = sizes[req] if sized then @@ -1252,14 +1327,6 @@ function Query.get(self: QueryInternal): { Instance } end end - -- The ECS hot shape — one requirement, nothing else — is a straight dump of - -- the seed source: no per-candidate work at all. - local trivial = #required == 1 - and #plan.anyOf == 0 - and not plan.hasNegative - and not plan.hasAttributes - and not plan.hasPredicates - -- Checks everything except the seed requirement (the seed's own iteration -- already proves it) and collects matches. Rest-checks are inlined here so a -- candidate costs no extra method dispatch. @@ -1303,46 +1370,24 @@ function Query.get(self: QueryInternal): { Instance } table.insert(out, instance) end - local n = 0 if seedKind == "class" then - for _, component in chosenSeed.comps :: { any } do - if component[INTERNAL].phase == "Started" then - if trivial then - n += 1 - out[n] = component.Instance - else - consider(component.Instance) - end - end + for _, instance in chosenSeed.startedList :: { Instance } do + consider(instance) end elseif seedKind == "tag" then local seedInstances = if tagArrays then (tagArrays :: { [PlanReq]: { Instance } })[chosenSeed] else nil for _, instance in seedInstances or CollectionService:GetTagged(chosenSeed.tag :: string) do - if trivial then - n += 1 - out[n] = instance - else - consider(instance) - end + consider(instance) end elseif seedKind == "query" then - for instance in ensureSet(chosenSeed.query :: QueryInternal) do - if trivial then - n += 1 - out[n] = instance - else - consider(instance) - end + local ensure = ensureSet :: (QueryInternal) -> { [Instance]: boolean } + for instance in ensure(chosenSeed.query :: QueryInternal) do + consider(instance) end else for _, component in (chosenSeed.class :: ComponentClassLike):GetAll() do if Keys.inst(component).phase == "Started" then - if trivial then - n += 1 - out[n] = component.Instance - else - consider(component.Instance) - end + consider(component.Instance) end end end @@ -1350,4 +1395,39 @@ function Query.get(self: QueryInternal): { Instance } end Query.GetMatches = Query.get +--[=[ + @within Query + @return () -> Instance? + Iterates the instances that match right now, without copying the match set: + + ```lua + for instance in query:iter() do ... end + ``` + + While the query is actively observed, this walks the reactive engine's live + match list directly (newest match first) — the zero-allocation per-frame read + path; like engine-backed [Query:get] it reflects the reactive view. An + instance that stops matching mid-iteration is handled (it is simply not + visited); other match-set mutations made *during* the loop may re-visit an + already-seen instance. Without a live observer it iterates a fresh + [Query:get] snapshot. + + Trade-off (measured): the per-element iterator call costs more than + [Query:get]'s single `table.clone`, so `get()` + a numeric `for` is faster in + raw throughput; `iter()` is for hot per-frame loops where avoiding the cloned + array's GC garbage matters more than wall time. +]=] +function Query.iter(self: QueryInternal): () -> Instance? + self:_validate() + local engine = self._engine or activeEngines[self:_signature()] + -- Backwards, so the engine's swap-remove (which moves an already-visited + -- tail element into the vacated slot) never skips an unvisited instance. + local list = if engine then engine.matchedList else self:get() + local index = #list + 1 + return function(): Instance? + index -= 1 + return list[index] + end +end + return Query diff --git a/lib/component/src/Tests/Component.Query.spec.luau b/lib/component/src/Tests/Component.Query.spec.luau index 73d18ad0..5b5c866f 100644 --- a/lib/component/src/Tests/Component.Query.spec.luau +++ b/lib/component/src/Tests/Component.Query.spec.luau @@ -215,6 +215,80 @@ return function(t: any) end) end) + describe("iter", function() + test("visits every current match, cold and observed", function() + local A, aTag = H.makeClass() + local q = Component.query(A) + local p1 = part { aTag } + local p2 = part { aTag } + expect(H.waitStarted(A, p1, 3)).is(true) + expect(H.waitStarted(A, p2, 3)).is(true) + + -- Cold: falls back to a get() snapshot. + local seen = {} + local count = 0 + for inst in q:iter() do + seen[inst] = true + count += 1 + end + expect(count).is(2) + expect(seen[p1]).is(true) + expect(seen[p2]).is(true) + + -- Observed: walks the live engine list. + local obs = q:observe(function() end) + seen, count = {}, 0 + for inst in q:iter() do + seen[inst] = true + count += 1 + end + expect(count).is(2) + expect(seen[p1]).is(true) + expect(seen[p2]).is(true) + + obs:Disconnect() + A:Destroy() + p1:Destroy() + p2:Destroy() + end) + end) + + describe("started sparse set", function() + test("cold GetMatches excludes constructing and stopped components", function() + local gate = false + local A, aTag = H.makeClass({ + Construct = function() + while not gate do + task.wait() + end + end, + }) + local q = Component.query(A) + local p = part { aTag } + + -- Constructing (parked in Construct): not a match yet. + expect(H.waitUntil(function() + return A:GetLifecycleStatus(p) == "Constructing" + end, 3)).is(true) + expect(#q:GetMatches()).is(0) + + gate = true + expect(H.waitStarted(A, p, 3)).is(true) + expect(#q:GetMatches()).is(1) + expect(q:GetMatches()[1]).is(p) + + -- Stopped: gone from the match set again. + CollectionService:RemoveTag(p, aTag) + expect(H.waitUntil(function() + return not A:Has(p) + end, 3)).is(true) + expect(#q:GetMatches()).is(0) + + A:Destroy() + p:Destroy() + end) + end) + describe("validation", function() test("a query with no positive requirement errors", function() local Bad = H.makeClass() diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index c5d1bc56..d287d173 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -241,6 +241,8 @@ const function componentNew(config: Types.ComponentConfig): Class_Internal ancestors = config.Ancestors or DEFAULT_ANCESTORS, instToComponents = {}, components = {}, + startedInstances = {}, + startedList = {}, lockConstruct = {}, watching = {}, pending = {}, @@ -421,8 +423,7 @@ end [Component:FromInstance] if a still-constructing component should count. ]=] function ComponentClassMethods.Has(self: Class_Internal, instance: Instance): boolean - const component = Keys.class(self).instToComponents[instance] - return component ~= nil and Keys.inst(component).started == true + return Keys.class(self).startedInstances[instance] ~= nil end --[=[ From 6080b1d1d8dbbeaf4f1f7c3947f378bed3cb808b Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 12:20:52 -0400 Subject: [PATCH 11/19] Add Query contains/count/first terminals Introduces `Query:contains`, `Query:count`, and `Query:first` as one-shot read APIs alongside `get`/`iter`, with docs and type exports updated accordingly. Refactors cold-read logic into shared helpers (`_singleSource`, `_staticSub`, `_collect`) so `get` reuses the same candidate planning and sub-query memoization while enabling early-exit scans for `first` and allocation-free counting. Adds query spec coverage for cold vs observed behavior and join queries to ensure parity with `GetMatches` semantics. --- lib/component/src/Query.luau | 347 ++++++++++++------ .../src/Tests/Component.Query.spec.luau | 119 ++++++ 2 files changed, 351 insertions(+), 115 deletions(-) diff --git a/lib/component/src/Query.luau b/lib/component/src/Query.luau index caf9b49a..ece8dace 100644 --- a/lib/component/src/Query.luau +++ b/lib/component/src/Query.luau @@ -7,7 +7,8 @@ A reusable, reactive query over tagged instances. Built with `Component.query(...)` and refined with chain methods, then either observed - (`:observe`) or read once (`:GetMatches`). + (`:observe`) or read once (`:get`/`:GetMatches`, `:iter`, `:count`, `:first`, + `:contains`). A *requirement* is a **component class** (satisfied while that component is *started* on the instance), a **tag string** (satisfied while the instance has @@ -95,6 +96,9 @@ export type Query = { observe: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, get: (self: Query) -> { Instance }, iter: (self: Query) -> () -> Instance?, + contains: (self: Query, instance: Instance) -> boolean, + count: (self: Query) -> number, + first: (self: Query) -> Instance?, } -- satisfiedFn(query, instance): is `instance` currently matching `query`? @@ -209,6 +213,12 @@ type QueryInternal = Query & { reactive: boolean, subSets: { [QueryInternal]: { [Instance]: boolean } }? ) -> { [Instance]: boolean }, + _singleSource: (self: QueryInternal) -> PlanReq?, + _staticSub: ( + self: QueryInternal, + plan: Plan + ) -> (SatisfiedFn, ((QueryInternal) -> { [Instance]: boolean })?, { [QueryInternal]: { [Instance]: boolean } }?), + _collect: (self: QueryInternal, onMatch: (Instance) -> boolean?) -> (), } local EXISTS = newproxy(false) -- sentinel: attribute must merely exist @@ -1094,90 +1104,96 @@ function Query.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()) return connProxy end ---[=[ - @within Query - @return { Instance } - Returns the instances that match right now. A one-shot read: it sets up no - subscriptions. - - While the query is actively observed (any live [Query:observe] connection), - the read is served straight from the reactive engine's maintained match set — - an O(matches) copy, identical to what observers see — making per-frame - `GetMatches` loops cheap enough for ECS-style iteration. -]=] -function Query.get(self: QueryInternal): { Instance } - self:_validate() - - -- Live-engine fast path: the engine already maintains exactly this set. - -- An engine built by any structurally equal query serves just as well -- - -- borrow it read-only via the intern registry. - local engine = self._engine or activeEngines[self:_signature()] - if engine then - return table.clone(engine.matchedList) - end - +-- The sole positive requirement when the query is the ECS hot shape -- exactly +-- one required requirement and no anyOf / negative / attribute / predicate +-- clause -- so a read can answer straight from that one source. `nil` otherwise. +function Query._singleSource(self: QueryInternal): PlanReq? local plan = self:_plan() + if + #plan.required == 1 + and #plan.anyOf == 0 + and not plan.hasNegative + and not plan.hasAttributes + and not plan.hasPredicates + then + return plan.required[1] + end + return nil +end - -- Each sub-query's match-set is computed ONCE per call and then answered by - -- lookup. Previously every candidate re-ran the whole sub-query (and - -- `_enumerate` ran it again on top), which is quadratic in nested queries. - -- The machinery (memo table + two closures) is only built when some clause - -- actually references a sub-query; `staticSub` is never consulted otherwise. - local subSets: { [QueryInternal]: { [Instance]: boolean } }? = nil - local ensureSet: ((QueryInternal) -> { [Instance]: boolean })? = nil - local staticSub: SatisfiedFn = neverSub - if plan.hasQueryRefs then - local sets: { [QueryInternal]: { [Instance]: boolean } } = {} - subSets = sets - local sub: SatisfiedFn - local ensure: (QueryInternal) -> { [Instance]: boolean } - function ensure(subQuery: QueryInternal): { [Instance]: boolean } - local existing = sets[subQuery] - if existing then - return existing - end - -- Seed the entry before recursing so shared sub-queries are computed - -- exactly once (recursion depth is finite: queries are immutable, so - -- the reference graph is a DAG by construction). - local set: { [Instance]: boolean } = {} - sets[subQuery] = set - -- Deepest first, so this sub-query's own enumeration finds its - -- references already memoized. - for _, req in subQuery:_allReferences() do - if isQuery(req) then - ensure(req :: QueryInternal) - end - end - for candidate in subQuery:_enumerate(false, sets) do - if subQuery:_fullMatch(candidate, sub) then - set[candidate] = true - end - end - return set - end - function sub(subQuery: QueryInternal, instance: Instance): boolean - return ensure(subQuery)[instance] == true +-- Builds the per-call sub-query memoization cold reads use: returns +-- `(satisfiedFn, ensureSet, subSets)`. Each sub-query's match-set is computed +-- ONCE and then answered by lookup (re-running it per candidate is quadratic in +-- nested queries). When the plan references no sub-query, returns the no-op +-- `neverSub` and nils, so callers allocate nothing. +function Query._staticSub( + self: QueryInternal, + plan: Plan +): (SatisfiedFn, ((QueryInternal) -> { [Instance]: boolean })?, { + [QueryInternal]: { [Instance]: boolean }, +}?) + if not plan.hasQueryRefs then + return neverSub, nil, nil + end + local sets: { [QueryInternal]: { [Instance]: boolean } } = {} + local sub: SatisfiedFn + local ensure: (QueryInternal) -> { [Instance]: boolean } + function ensure(subQuery: QueryInternal): { [Instance]: boolean } + local existing = sets[subQuery] + if existing then + return existing end - ensureSet = ensure - staticSub = sub - - for _, req in self:_allReferences() do + -- Seed the entry before recursing so shared sub-queries are computed + -- exactly once (recursion depth is finite: queries are immutable, so + -- the reference graph is a DAG by construction). + local set: { [Instance]: boolean } = {} + sets[subQuery] = set + -- Deepest first, so this sub-query's own enumeration finds its + -- references already memoized. + for _, req in subQuery:_allReferences() do if isQuery(req) then ensure(req :: QueryInternal) end end + for candidate in subQuery:_enumerate(false, sets) do + if subQuery:_fullMatch(candidate, sub) then + set[candidate] = true + end + end + return set end + function sub(subQuery: QueryInternal, instance: Instance): boolean + return ensure(subQuery)[instance] == true + end + for _, req in self:_allReferences() do + if isQuery(req) then + ensure(req :: QueryInternal) + end + end + return sub, ensure, sets +end + +-- General cold scan (no live engine): resolves the narrowest seed, orders probes +-- most-selective first, and invokes `onMatch(instance)` for every match. +-- `onMatch` may return truthy to STOP the scan early -- how `first()` touches +-- one candidate, not all. The live-engine path and the single-requirement ECS +-- shape are cheaper answers the public terminals handle themselves before +-- falling back here, so this deliberately does NOT special-case them. +function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) + local plan = self:_plan() + local staticSub, ensureSet, subSets = self:_staticSub(plan) local required = plan.required - local out: { Instance } = {} if #required == 0 then -- anyOf-only query: the candidate set genuinely is a union, so build it. for instance in self:_enumerate(false, subSets) do if self:_fullMatch(instance, staticSub) then - table.insert(out, instance) + if onMatch(instance) then + return + end end end - return out + return end -- Required requirements intersect, so enumerate ONE of them (the narrowest @@ -1241,38 +1257,6 @@ function Query.get(self: QueryInternal): { Instance } local chosenSeed: PlanReq = (seed or firstTag or firstQuery or firstClasslike) :: PlanReq local seedKind = chosenSeed.kind - -- The ECS hot shape — one requirement, nothing else — is a straight dump of - -- the seed source, before any per-candidate machinery is even allocated. - if - #required == 1 - and #plan.anyOf == 0 - and not plan.hasNegative - and not plan.hasAttributes - and not plan.hasPredicates - then - if seedKind == "class" then - return table.clone(chosenSeed.startedList :: { Instance }) - elseif seedKind == "tag" then - -- Both sources are arrays freshly allocated for this call, so they - -- are safe to hand out directly. - local sized = if tagArrays then (tagArrays :: { [PlanReq]: { Instance } })[chosenSeed] else nil - return sized or CollectionService:GetTagged(chosenSeed.tag :: string) - elseif seedKind == "query" then - local ensure = ensureSet :: (QueryInternal) -> { [Instance]: boolean } - for instance in ensure(chosenSeed.query :: QueryInternal) do - table.insert(out, instance) - end - return out - else - for _, component in (chosenSeed.class :: ComponentClassLike):GetAll() do - if Keys.inst(component).phase == "Started" then - table.insert(out, component.Instance) - end - end - return out - end - end - -- Probes = every required requirement except the seed, most-selective-first -- when populations are known (smaller population rejects more candidates -- sooner, so each later probe runs against fewer survivors). Unknown counts @@ -1328,18 +1312,18 @@ function Query.get(self: QueryInternal): { Instance } end -- Checks everything except the seed requirement (the seed's own iteration - -- already proves it) and collects matches. Rest-checks are inlined here so a - -- candidate costs no extra method dispatch. + -- already proves it). Rest-checks are inlined here so a candidate costs no + -- extra method dispatch. Returns whatever `onMatch` returned (truthy = stop). local anyOf = plan.anyOf - local function consider(instance: Instance) + local function consider(instance: Instance): boolean? for _, req in probes do local set = if probeSets then (probeSets :: { [PlanReq]: { [Instance]: boolean } })[req] else nil if set then if not set[instance] then - return + return nil end elseif not reqSatisfied(req, instance, staticSub) then - return + return nil end end for _, group in anyOf do @@ -1351,50 +1335,183 @@ function Query.get(self: QueryInternal): { Instance } end end if not anySatisfied then - return + return nil end end if plan.hasNegative then for _, req in plan.negative do if reqSatisfied(req, instance, staticSub) then - return + return nil end end end if plan.hasAttributes and not self:_attributesMatch(instance) then - return + return nil end if plan.hasPredicates and not self:_predicatesPass(instance) then - return + return nil end - table.insert(out, instance) + return onMatch(instance) end if seedKind == "class" then for _, instance in chosenSeed.startedList :: { Instance } do - consider(instance) + if consider(instance) then + return + end end elseif seedKind == "tag" then local seedInstances = if tagArrays then (tagArrays :: { [PlanReq]: { Instance } })[chosenSeed] else nil for _, instance in seedInstances or CollectionService:GetTagged(chosenSeed.tag :: string) do - consider(instance) + if consider(instance) then + return + end end elseif seedKind == "query" then local ensure = ensureSet :: (QueryInternal) -> { [Instance]: boolean } for instance in ensure(chosenSeed.query :: QueryInternal) do - consider(instance) + if consider(instance) then + return + end end else for _, component in (chosenSeed.class :: ComponentClassLike):GetAll() do if Keys.inst(component).phase == "Started" then - consider(component.Instance) + if consider(component.Instance) then + return + end + end + end + end +end + +--[=[ + @within Query + @return { Instance } + Returns the instances that match right now. A one-shot read: it sets up no + subscriptions. + + While the query is actively observed (any live [Query:observe] connection), + the read is served straight from the reactive engine's maintained match set — + an O(matches) copy, identical to what observers see — making per-frame + `GetMatches` loops cheap enough for ECS-style iteration. +]=] +function Query.get(self: QueryInternal): { Instance } + self:_validate() + + -- Live-engine fast path: the engine already maintains exactly this set. + -- An engine built by any structurally equal query serves just as well -- + -- borrow it read-only via the intern registry. + local engine = self._engine or activeEngines[self:_signature()] + if engine then + return table.clone(engine.matchedList) + end + + -- The ECS hot shape — one requirement, nothing else — is a straight dump of + -- the seed source, before any per-candidate machinery is even allocated. + local single = self:_singleSource() + if single then + local kind = single.kind + if kind == "class" then + return table.clone(single.startedList :: { Instance }) + elseif kind == "tag" then + return CollectionService:GetTagged(single.tag :: string) + elseif kind == "query" then + local out: { Instance } = {} + local _, ensure = self:_staticSub(self:_plan()) + for instance in (ensure :: (QueryInternal) -> { [Instance]: boolean })(single.query :: QueryInternal) do + table.insert(out, instance) + end + return out + else + local out: { Instance } = {} + for _, component in (single.class :: ComponentClassLike):GetAll() do + if Keys.inst(component).phase == "Started" then + table.insert(out, component.Instance) + end end + return out end end + + local out: { Instance } = {} + self:_collect(function(instance) + table.insert(out, instance) + return nil + end) return out end Query.GetMatches = Query.get +--[=[ + @within Query + @param instance Instance + @return boolean + Whether `instance` matches this query right now. A membership test, not a + scan: while the query is actively observed it is an O(1) lookup in the + reactive engine's match set; cold, it evaluates this one instance against + every clause (no candidate enumeration). +]=] +function Query.contains(self: QueryInternal, instance: Instance): boolean + self:_validate() + local engine = self._engine or activeEngines[self:_signature()] + if engine then + return engine.matched[instance] ~= nil + end + local staticSub = self:_staticSub(self:_plan()) + return self:_fullMatch(instance, staticSub) +end + +--[=[ + @within Query + @return number + How many instances match right now. While the query is actively observed + this is an O(1) read of the engine's match count; cold it scans without + building the match array [Query:get] would allocate. +]=] +function Query.count(self: QueryInternal): number + self:_validate() + local engine = self._engine or activeEngines[self:_signature()] + if engine then + return #engine.matchedList + end + local single = self:_singleSource() + if single and single.kind == "class" then + return #(single.startedList :: { Instance }) + end + local n = 0 + self:_collect(function() + n += 1 + return nil + end) + return n +end + +--[=[ + @within Query + @return Instance? + One instance that matches right now, or `nil` if none do. While the query is + actively observed this is an O(1) read of the engine's first match; cold it + stops at the first matching candidate instead of collecting them all. +]=] +function Query.first(self: QueryInternal): Instance? + self:_validate() + local engine = self._engine or activeEngines[self:_signature()] + if engine then + return engine.matchedList[1] + end + local single = self:_singleSource() + if single and single.kind == "class" then + return (single.startedList :: { Instance })[1] + end + local found: Instance? = nil + self:_collect(function(instance) + found = instance + return true + end) + return found +end + --[=[ @within Query @return () -> Instance? diff --git a/lib/component/src/Tests/Component.Query.spec.luau b/lib/component/src/Tests/Component.Query.spec.luau index 5b5c866f..eae52779 100644 --- a/lib/component/src/Tests/Component.Query.spec.luau +++ b/lib/component/src/Tests/Component.Query.spec.luau @@ -359,4 +359,123 @@ return function(t: any) p:Destroy() end) end) + + describe("contains", function() + test("true only while the instance matches, cold and observed", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local q = Component.query(A):without(B) + local p = part { aTag } + local untagged = part {} + expect(H.waitStarted(A, p, 3)).is(true) + + -- Cold membership test (no candidate enumeration). + expect(q:contains(p)).is(true) + expect(q:contains(untagged)).is(false) + + -- Observed: O(1) engine lookup, same answer. + local obs = q:observe(function() end) + expect(q:contains(p)).is(true) + + -- Adding the excluded component drops it from the match set. + CollectionService:AddTag(p, bTag) + expect(H.waitUntil(function() + return not q:contains(p) + end, 3)).is(true) + + obs:Disconnect() + A:Destroy() + B:Destroy() + p:Destroy() + untagged:Destroy() + end) + end) + + describe("count", function() + test("counts current matches, cold and observed", function() + local A, aTag = H.makeClass() + local q = Component.query(A) + expect(q:count()).is(0) + + local p1 = part { aTag } + local p2 = part { aTag } + expect(H.waitStarted(A, p1, 3)).is(true) + expect(H.waitStarted(A, p2, 3)).is(true) + + -- Cold: single-requirement class fast path (#startedList). + expect(q:count()).is(2) + expect(q:count()).is(#q:GetMatches()) + + -- Observed: O(1) off the engine match list. + local obs = q:observe(function() end) + expect(q:count()).is(2) + + obs:Disconnect() + A:Destroy() + p1:Destroy() + p2:Destroy() + end) + + test("counts a join via the general scan, equal to get()", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local q = Component.query(A, B) + local both = part { aTag, bTag } + local onlyA = part { aTag } + expect(H.waitUntil(function() + return A:Has(both) and B:Has(both) and A:Has(onlyA) + end, 3)).is(true) + + expect(q:count()).is(1) + expect(q:count()).is(#q:GetMatches()) + + A:Destroy() + B:Destroy() + both:Destroy() + onlyA:Destroy() + end) + end) + + describe("first", function() + test("returns a match or nil, cold and observed", function() + local A, aTag = H.makeClass() + local q = Component.query(A) + + -- Empty: nil. + expect(q:first()).never_exists() + + local p = part { aTag } + expect(H.waitStarted(A, p, 3)).is(true) + + -- Cold: single-requirement class fast path (startedList[1]). + expect(q:first()).is(p) + + -- Observed: O(1) off the engine. + local obs = q:observe(function() end) + expect(q:first()).is(p) + + obs:Disconnect() + A:Destroy() + p:Destroy() + end) + + test("returns a matching instance on a join via the general scan", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local q = Component.query(A, B) + local both = part { aTag, bTag } + local onlyA = part { aTag } + expect(H.waitUntil(function() + return A:Has(both) and B:Has(both) and A:Has(onlyA) + end, 3)).is(true) + + -- `both` is the only full match; `onlyA` satisfies just one requirement. + expect(q:first()).is(both) + + A:Destroy() + B:Destroy() + both:Destroy() + onlyA:Destroy() + end) + end) end From d74812ca8ceb255a64cf18f58ecccaa5864864c0 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 13:32:31 -0400 Subject: [PATCH 12/19] Add withProperty and observeUnyielding to Query Extended Component.Query with `:withProperty(...)` matching (existence, explicit nil, value, or predicate) and reactive property-change re-evaluation via `GetPropertyChangedSignal`. Added `:observeUnyielding(...)` for inline, no-yield dispatch with loud contract-violation reporting on yield/error, plus shared observer attachment/dispatch internals and signature/planning updates for property clauses. Also updated tests to cover property matching and unyielding observer behavior, and aligned component type surfaces by replacing `CreateFromInstance` with `GetOrCreateFromInstance`, removing `Has` from `TypedClass`, and dropping the unused internal `PromiseLike` type. --- lib/component/src/Query.luau | 591 +++++++++++++----- .../src/Tests/Component.Query.spec.luau | 185 ++++++ lib/component/src/Tests/Component.types.luau | 2 +- lib/component/src/Types.luau | 19 +- 4 files changed, 608 insertions(+), 189 deletions(-) diff --git a/lib/component/src/Query.luau b/lib/component/src/Query.luau index ece8dace..1280a506 100644 --- a/lib/component/src/Query.luau +++ b/lib/component/src/Query.luau @@ -18,24 +18,25 @@ - every positional / `:with` requirement is satisfied, - every `:anyOf(...)` group has at least one satisfied, - no `:without` requirement is satisfied, - - every `:withAttribute` matches, and + - every `:withAttribute` matches, + - every `:withProperty` matches, and - every `:where` predicate returns true. A query must have at least one positive requirement (component / tag / sub-query in the positional args, `:with`, or `:anyOf`) so its candidate set - is bounded; a query built only from `:without` / `:withAttribute` / `:where` - errors when observed or read. + is bounded; a query built only from `:without` / `:withAttribute` / + `:withProperty` / `:where` errors when observed or read. See the Component `CONTEXT.md` for the glossary and the `README` for examples. ]=] -local CollectionService = game:GetService("CollectionService") +const CollectionService = game:GetService("CollectionService") -local Packages = script.Parent.Parent -local Signal = require(Packages.Signal) -local Janitor = require(Packages.Janitor) +const Packages = script.Parent.Parent +const Signal = require(Packages.Signal) +const Janitor = require(Packages.Janitor) -local Keys = require(script.Parent.Keys) +const Keys = require(script.Parent.Keys) type Janitor = Janitor.Janitor @@ -92,8 +93,10 @@ export type Query = { anyOf: (self: Query, ...Queryable) -> Query, without: (self: Query, ...Queryable) -> Query, withAttribute: (self: Query, name: string, matcher: unknown?) -> Query, + withProperty: (self: Query, name: string, matcher: unknown?) -> Query, where: (self: Query, predicate: (Instance) -> boolean, recheckSignal: unknown?) -> Query, observe: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, + observeUnyielding: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, get: (self: Query) -> { Instance }, iter: (self: Query) -> () -> Instance?, contains: (self: Query, instance: Instance) -> boolean, @@ -113,6 +116,15 @@ type AttributeRequirement = { matcher: unknown, } +-- One `:withProperty` requirement. `matcher` is the EXISTS sentinel (property +-- merely present on the Instance), a `(value) -> boolean` predicate, or a value +-- the property must equal (an explicit `nil` matcher is stored as `nil` and means +-- "property present AND equal to nil"; the omitted form is the EXISTS sentinel). +type PropertyRequirement = { + name: string, + matcher: unknown, +} + -- One `:where` requirement; `signal` is duck-cast to `RecheckSignalView` when -- the engine activates. type PredicateRequirement = { @@ -123,6 +135,9 @@ type PredicateRequirement = { type Observer = { callback: (Instance, Janitor) -> (), janitors: { [Instance]: Janitor }, + -- `observeUnyielding` observers run their callback inline (no per-match + -- thread) and must neither yield nor error; a violation is reported loudly. + unyielding: boolean, } type ChangedSignal = Signal.Signal<(Instance, boolean) -> (), Instance, boolean> @@ -151,6 +166,7 @@ type Plan = { negative: { PlanReq }, hasNegative: boolean, hasAttributes: boolean, + hasProperties: boolean, hasPredicates: boolean, -- True when any clause references a sub-query; `get()` only builds its -- per-call memoization machinery (and a real SatisfiedFn) when it is. @@ -171,6 +187,7 @@ type Engine = { janitor: Janitor, positiveSet: { [Instance]: boolean }, -- [Instance]: true while positively bounded (candidate universe) attrConns: { [Instance]: ConnectionLike }, -- one AttributeChanged sub per candidate + propConns: { [Instance]: { ConnectionLike } }, -- one GetPropertyChangedSignal sub per watched property, per candidate subEngines: { [QueryInternal]: Engine }, -- Interning bookkeeping: the canonical signature this engine is registered -- under, total activations across every equivalent query sharing it, and @@ -186,6 +203,7 @@ type QueryInternal = Query & { _anyOf: { { Queryable } }, -- array of groups _negative: { Queryable }, _attributes: { AttributeRequirement }, + _properties: { PropertyRequirement }, _predicates: { PredicateRequirement }, _engine: Engine?, _refcount: number, @@ -203,6 +221,7 @@ type QueryInternal = Query & { _allReferences: (self: QueryInternal) -> { Queryable }, _positiveCandidate: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, _attributesMatch: (self: QueryInternal, instance: Instance) -> boolean, + _propertiesMatch: (self: QueryInternal, instance: Instance) -> boolean, _predicatesPass: (self: QueryInternal, instance: Instance) -> boolean, _matchesRest: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, _fullMatch: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, @@ -221,24 +240,24 @@ type QueryInternal = Query & { _collect: (self: QueryInternal, onMatch: (Instance) -> boolean?) -> (), } -local EXISTS = newproxy(false) -- sentinel: attribute must merely exist -local INTERNAL = Keys.Internal -- hoisted for the plan compiler's class probe +const EXISTS = newproxy(false) -- sentinel: attribute must merely exist +const INTERNAL = Keys.Internal -- hoisted for the plan compiler's class probe -local Query = {} +const Query = {} Query.__index = Query -local function isQuery(value: unknown): boolean +const function isQuery(value: unknown): boolean return type(value) == "table" and getmetatable(value) == Query end -local function isComponentClass(value: unknown): boolean +const function isComponentClass(value: unknown): boolean if type(value) ~= "table" or isQuery(value) then return false end return type((value :: { read Tag: unknown }).Tag) == "string" end -local function assertQueryable(value: Queryable, method: string) +const function assertQueryable(value: Queryable, method: string) if type(value) == "string" or isQuery(value) or isComponentClass(value) then return end @@ -253,7 +272,7 @@ end Creates a new query whose positional arguments are all required. ]=] -local function rawNew(): QueryInternal +const function rawNew(): QueryInternal -- Cast through `any`: without it the solver stamps `@metatable` onto the -- table and rejects the internal type (same as TableManager's constructor). return ( @@ -262,6 +281,7 @@ local function rawNew(): QueryInternal _anyOf = {}, _negative = {}, _attributes = {}, + _properties = {}, _predicates = {}, _engine = nil, _refcount = 0, @@ -279,18 +299,19 @@ end -- them is safe. Queries are therefore immutable values: every builder returns -- a NEW query and the receiver is never changed, so chains branch freely -- -- `qA:with(qB)` and `qA:without(qC)` are independent and `qA` stays `qA`. -local function derive(self: QueryInternal): QueryInternal - local new = rawNew() +const function derive(self: QueryInternal): QueryInternal + const new = rawNew() new._positive = table.clone(self._positive) new._anyOf = table.clone(self._anyOf) new._negative = table.clone(self._negative) new._attributes = table.clone(self._attributes) + new._properties = table.clone(self._properties) new._predicates = table.clone(self._predicates) return new end function Query.new(...: Queryable): Query - local self = rawNew() + const self = rawNew() for _, req in { ... } do assertQueryable(req, "with") table.insert(self._positive, req) @@ -307,7 +328,7 @@ end `query():with(X)` is equivalent to `query(X)`. ]=] function Query.with(self: QueryInternal, ...: Queryable): Query - local new = derive(self) + const new = derive(self) for _, req in { ... } do assertQueryable(req, "with") table.insert(new._positive, req) @@ -324,14 +345,14 @@ end calls each add an independent group. The receiver is unchanged. ]=] function Query.anyOf(self: QueryInternal, ...: Queryable): Query - local group = { ... } + const group = { ... } for _, req in group do assertQueryable(req, "anyOf") end if #group == 0 then return self end - local new = derive(self) + const new = derive(self) table.insert(new._anyOf, group) return new end @@ -344,7 +365,7 @@ end instance must satisfy none of them. The receiver is unchanged. ]=] function Query.without(self: QueryInternal, ...: Queryable): Query - local new = derive(self) + const new = derive(self) for _, req in { ... } do assertQueryable(req, "without") table.insert(new._negative, req) @@ -364,7 +385,7 @@ end ]=] function Query.withAttribute(self: QueryInternal, name: string, matcher: unknown?): Query assert(type(name) == "string", "[Component] :withAttribute() expects an attribute name string") - local new = derive(self) + const new = derive(self) table.insert(new._attributes, { name = name, matcher = if matcher == nil then EXISTS else matcher, @@ -372,6 +393,36 @@ function Query.withAttribute(self: QueryInternal, name: string, matcher: unknown return new end +--[=[ + @within Query + @param name string + @param matcher any -- a value to equal, a `(value) -> boolean` predicate, or omitted to require the property merely exists + @return Query + Returns a NEW query that additionally requires an Instance property; the + receiver is unchanged. Re-evaluated reactively when the property changes. + + The matcher is REQUIRED but may be an explicit `nil`: + - **omitted** (`:withProperty("Anchored")`) — the property must merely *exist* + on the instance (a candidate whose class lacks it never matches); + - **explicit `nil`** (`:withProperty("Parent", nil)`) — the property must exist + and equal `nil` (e.g. "unparented"); + - **a function** — it must return true for the property's value; + - **any other value** — the property must equal `matcher`. + + Unlike attributes, a property may not exist on every Instance class a query + spans; a class lacking the named property simply does not match. +]=] +function Query.withProperty(self: QueryInternal, name: string, ...: unknown): Query + assert(type(name) == "string", "[Component] :withProperty() expects a property name string") + const new = derive(self) + -- Omitted matcher (void) = existence check via the EXISTS sentinel; an EXPLICIT + -- `nil` is a real matcher meaning "property == nil". `select("#")` (arg count) + -- distinguishes the two, which `matcher == nil` cannot. + const matcher = if select("#", ...) == 0 then EXISTS else (...) + table.insert(new._properties, { name = name, matcher = matcher }) + return new +end + --[=[ @within Query @param predicate (instance: Instance) -> boolean @@ -385,7 +436,7 @@ end ]=] function Query.where(self: QueryInternal, predicate: (Instance) -> boolean, recheckSignal: unknown?): Query assert(type(predicate) == "function", "[Component] :where() expects a predicate function") - local new = derive(self) + const new = derive(self) table.insert(new._predicates, { fn = predicate, signal = recheckSignal }) return new end @@ -397,11 +448,11 @@ end -- Flattened positive requirements (positional/:with + every :anyOf member). -- These bound the candidate set; a query with none is unbounded and rejected. function Query._positiveSources(self: QueryInternal): { Queryable } - local cached = self._sources + const cached = self._sources if cached then return cached end - local sources = {} + const sources = {} for _, req in self._positive do table.insert(sources, req) end @@ -421,7 +472,7 @@ function Query._validate(self: QueryInternal) if self._validated then return end - local sources = self:_positiveSources() + const sources = self:_positiveSources() if #sources == 0 then error( "[Component] Query has no positive requirement (component / tag / sub-query); " @@ -439,7 +490,7 @@ end -- Every requirement across all clauses (positive, anyOf, negative). function Query._allReferences(self: QueryInternal): { Queryable } - local refs = {} + const refs = {} for _, req in self._positive do table.insert(refs, req) end @@ -458,14 +509,14 @@ end -- Satisfaction / matching -------------------------------------------------------------------------------- -local function compileReq(req: Queryable, buildIndex: number): PlanReq +const function compileReq(req: Queryable, buildIndex: number): PlanReq if type(req) == "string" then return { kind = "tag" :: "tag", buildIndex = buildIndex, tag = req } end if isQuery(req) then return { kind = "query" :: "query", buildIndex = buildIndex, query = req :: QueryInternal } end - local internal = (req :: any)[INTERNAL] + const internal = (req :: any)[INTERNAL] if internal then -- Our own class: capture its live started sparse set. Both tables are -- mutated in place (never replaced) by the lifecycle, so the references @@ -488,23 +539,23 @@ end -- evaluation short-circuits on the cheap probes; requirement semantics are -- order-independent, so this is free. `buildIndex` keeps the sort deterministic -- (`table.sort` is unstable). -local KIND_COST: { [string]: number } = { class = 1, query = 2, classlike = 3, tag = 4 } +const KIND_COST: { [string]: number } = { class = 1, query = 2, classlike = 3, tag = 4 } -- A seed at or below this is narrow enough that hunting for a better one is -- not worth fetching more tag arrays: remaining probes run at most this many -- times each. -local TAG_SIZING_EARLY_EXIT = 32 +const TAG_SIZING_EARLY_EXIT = 32 -- `get()` sizes every required tag via `GetTagged` (to pick the narrowest seed -- and order probes most-selective-first) only when the best class seed exceeds -- this. Below it the candidate set is already small, and sizing a huge tag -- would cost an array allocation proportional to its population for at most a -- few hundred cheap probes of savings. -local TAG_SIZING_MIN_SEED = 200 -local function sortBySelectivity(reqs: { PlanReq }) +const TAG_SIZING_MIN_SEED = 200 +const function sortBySelectivity(reqs: { PlanReq }) table.sort(reqs, function(x: PlanReq, y: PlanReq): boolean - local cx = KIND_COST[x.kind] :: number - local cy = KIND_COST[y.kind] :: number + const cx = KIND_COST[x.kind] :: number + const cy = KIND_COST[y.kind] :: number if cx ~= cy then return cx < cy end @@ -514,8 +565,8 @@ end -- Compiled requirement check. `satisfiedFn` is only consulted for sub-query -- requirements; tag/class checks are direct. -local function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: SatisfiedFn): boolean - local kind = req.kind +const function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: SatisfiedFn): boolean + const kind = req.kind if kind == "class" then return (req.startedMap :: { [Instance]: number })[instance] ~= nil elseif kind == "tag" then @@ -523,43 +574,43 @@ local function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: Satis elseif kind == "query" then return satisfiedFn(req.query :: QueryInternal, instance) else - local class = req.class :: ComponentClassLike - local component = class:FromInstance(instance) + const class = req.class :: ComponentClassLike + const component = class:FromInstance(instance) return component ~= nil and Keys.inst(component).phase == "Started" end end -- Placeholder SatisfiedFn for plans with no sub-query requirement: nothing can -- ever call it (`reqSatisfied` only consults satisfiedFn for "query" kinds). -local function neverSub(_query: QueryInternal, _instance: Instance): boolean +const function neverSub(_query: QueryInternal, _instance: Instance): boolean return false end function Query._plan(self: QueryInternal): Plan - local cached = self._planned + const cached = self._planned if cached then return cached end - local required: { PlanReq } = {} + const required: { PlanReq } = {} for index, req in self._positive do table.insert(required, compileReq(req, index)) end sortBySelectivity(required) - local anyOf: { { PlanReq } } = {} + const anyOf: { { PlanReq } } = {} for _, group in self._anyOf do - local compiled: { PlanReq } = {} + const compiled: { PlanReq } = {} for index, req in group do table.insert(compiled, compileReq(req, index)) end sortBySelectivity(compiled) table.insert(anyOf, compiled) end - local negative: { PlanReq } = {} + const negative: { PlanReq } = {} for index, req in self._negative do table.insert(negative, compileReq(req, index)) end sortBySelectivity(negative) - local function anyQueryReq(reqs: { PlanReq }): boolean + const function anyQueryReq(reqs: { PlanReq }): boolean for _, req in reqs do if req.kind == "query" then return true @@ -576,12 +627,13 @@ function Query._plan(self: QueryInternal): Plan end end end - local plan: Plan = { + const plan: Plan = { required = required, anyOf = anyOf, negative = negative, hasNegative = #negative > 0, hasAttributes = #self._attributes > 0, + hasProperties = #self._properties > 0, hasPredicates = #self._predicates > 0, hasQueryRefs = hasQueryRefs, } @@ -590,7 +642,7 @@ function Query._plan(self: QueryInternal): Plan end function Query._positiveCandidate(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean - local plan = self:_plan() + const plan = self:_plan() for _, req in plan.required do if not reqSatisfied(req, instance, satisfiedFn) then return false @@ -613,13 +665,13 @@ end function Query._attributesMatch(self: QueryInternal, instance: Instance): boolean for _, attr in self._attributes do - local value = instance:GetAttribute(attr.name) - local matcher = attr.matcher + const value = instance:GetAttribute(attr.name) + const matcher = attr.matcher local ok: boolean if matcher == EXISTS then ok = value ~= nil elseif type(matcher) == "function" then - local success, result = pcall(matcher :: (unknown) -> unknown, value) + const success, result = pcall(matcher :: (unknown) -> unknown, value) ok = success and result == true if not success then warn(`[Component] Query :withAttribute('{attr.name}') matcher errored: {result}`) @@ -634,9 +686,39 @@ function Query._attributesMatch(self: QueryInternal, instance: Instance): boolea return true end +function Query._propertiesMatch(self: QueryInternal, instance: Instance): boolean + for _, prop in self._properties do + -- A property missing on this Instance's class throws on read; the pcall + -- failing IS the "property absent" signal (there is no reflection API for + -- game code). `exists` distinguishes absent from present-but-nil. + const exists, value = pcall(function() + return (instance :: any)[prop.name] + end) + const matcher = prop.matcher + local ok: boolean + if matcher == EXISTS then + ok = exists + elseif not exists then + ok = false + elseif type(matcher) == "function" then + const success, result = pcall(matcher :: (unknown) -> unknown, value) + ok = success and result == true + if not success then + warn(`[Component] Query :withProperty('{prop.name}') matcher errored: {result}`) + end + else + ok = value == matcher + end + if not ok then + return false + end + end + return true +end + function Query._predicatesPass(self: QueryInternal, instance: Instance): boolean for _, pred in self._predicates do - local success, result = pcall(pred.fn, instance) + const success, result = pcall(pred.fn, instance) if not success then warn(`[Component] Query :where() predicate errored: {result}`) return false @@ -653,7 +735,7 @@ end -- engine, and `get()` over a single-source enumeration) do not pay to prove it -- twice. function Query._matchesRest(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean - local plan = self:_plan() + const plan = self:_plan() if plan.hasNegative then for _, req in plan.negative do if reqSatisfied(req, instance, satisfiedFn) then @@ -664,6 +746,9 @@ function Query._matchesRest(self: QueryInternal, instance: Instance, satisfiedFn if plan.hasAttributes and not self:_attributesMatch(instance) then return false end + if plan.hasProperties and not self:_propertiesMatch(instance) then + return false + end if plan.hasPredicates and not self:_predicatesPass(instance) then return false end @@ -683,10 +768,10 @@ end -- Stable ids for non-primitive signature atoms (classes, predicate/matcher -- functions, recheck signals). Weak keys: a dead class must not leak here. -local signatureIds: { [any]: number } = setmetatable({}, { __mode = "k" }) :: any +const signatureIds: { [any]: number } = setmetatable({}, { __mode = "k" }) :: any local nextSignatureId = 0 -local function idOf(value: any): string - local existing = signatureIds[value] +const function idOf(value: any): string + const existing = signatureIds[value] if existing then return tostring(existing) end @@ -698,11 +783,16 @@ end -- Primitive attribute matchers compare by value, so structurally identical -- `withAttribute("Team", "Red")` clauses from different modules share; function -- matchers (and predicates) can only share by identity. -local function matcherToken(matcher: unknown): string +const function matcherToken(matcher: unknown): string if matcher == EXISTS then return "*" end - local kind = type(matcher) + -- Only reachable from `:withProperty(name, nil)` — an explicit-nil matcher. + -- `idOf(nil)` would error (weak-key table keyed by the value), so short-circuit. + if matcher == nil then + return "nil" + end + const kind = type(matcher) if kind == "string" or kind == "number" or kind == "boolean" then return kind .. ":" .. tostring(matcher) end @@ -715,11 +805,11 @@ end Cached until the query mutates. ]] function Query._signature(self: QueryInternal): string - local cached = self._signatureCache + const cached = self._signatureCache if cached then return cached end - local function reqToken(req: Queryable): string + const function reqToken(req: Queryable): string if type(req) == "string" then return "t:" .. req elseif isQuery(req) then @@ -727,30 +817,35 @@ function Query._signature(self: QueryInternal): string end return "c:" .. idOf(req) end - local function sortedTokens(reqs: { Queryable }): string - local tokens = {} + const function sortedTokens(reqs: { Queryable }): string + const tokens = {} for _, req in reqs do table.insert(tokens, reqToken(req)) end table.sort(tokens) return table.concat(tokens, ",") end - local groups = {} + const groups = {} for _, group in self._anyOf do table.insert(groups, sortedTokens(group)) end table.sort(groups) - local attrs = {} + const attrs = {} for _, attr in self._attributes do table.insert(attrs, attr.name .. "=" .. matcherToken(attr.matcher)) end table.sort(attrs) - local preds = {} + const props = {} + for _, prop in self._properties do + table.insert(props, prop.name .. "=" .. matcherToken(prop.matcher)) + end + table.sort(props) + const preds = {} for _, pred in self._predicates do table.insert(preds, idOf(pred.fn) .. (if pred.signal ~= nil then ">" .. idOf(pred.signal) else "")) end table.sort(preds) - local signature = sortedTokens(self._positive) + const signature = sortedTokens(self._positive) .. "|" .. table.concat(groups, ";") .. "|" @@ -758,6 +853,8 @@ function Query._signature(self: QueryInternal): string .. "|" .. table.concat(attrs, ",") .. "|" + .. table.concat(props, ",") + .. "|" .. table.concat(preds, ",") self._signatureCache = signature return signature @@ -766,7 +863,81 @@ end -- Live engines interned by signature: equivalent queries observed anywhere in -- the process share ONE engine (one set of subscriptions, one matched set, one -- re-evaluation per event) instead of each maintaining their own. -local activeEngines: { [string]: Engine } = {} +const activeEngines: { [string]: Engine } = {} +-- Loudly reports a broken `observeUnyielding` contract WITHOUT unwinding the +-- caller. It is raised on a fresh thread (`task.spawn`) so it surfaces as a red +-- error with a traceback rather than a swallowed warn or an exception that would +-- unwind the engine mid-transition — unwinding there corrupts the shared match +-- set for every observer, i.e. causes the very cross-iteration damage the +-- message warns about. `yieldedThread` is the suspended coroutine for a yield; +-- otherwise `err` carries the caught error. +const function reportUnyieldingViolation(yieldedThread: thread?, err: any) + local message: string + if yieldedThread then + -- Suspended at the yield point; its traceback shows exactly where. The + -- coroutine is abandoned — a callback that broke the contract does not + -- get to finish, and any matches after it in a batch do not dispatch. + const where = debug.traceback(yieldedThread, "unyielding observer callback yielded") + message = + `[Component] Query:observeUnyielding() callback YIELDED — it must run to completion synchronously; yielding here abandons the callback and skips the rest of this dispatch, and may affect other observers reacting to the same change:\n{where}` + else + message = + `[Component] Query:observeUnyielding() callback errored — it ran inline, so this throw may have affected other observers reacting to the same change: {tostring( + err + )}` + end + task.spawn(function() + error(message, 0) + end) +end + +-- Runs one observer's match callback for `instance`. A yield-tolerant observer +-- (`observe`) spawns a thread, so a callback that yields simply parks harmlessly +-- and never blocks the dispatch. An unyielding observer (`observeUnyielding`) +-- runs it inline in a throwaway coroutine and resumes once: an error or a yield +-- is a broken contract, reported loudly (see `reportUnyieldingViolation`). +const function dispatchMatch(obs: Observer, instance: Instance, janitor: Janitor) + if not obs.unyielding then + task.spawn(obs.callback, instance, janitor) + return + end + const thread = coroutine.create(obs.callback) + const ok, err = coroutine.resume(thread, instance, janitor) + if ok and coroutine.status(thread) == "dead" then + return + end + reportUnyieldingViolation(if ok then thread else nil, err) +end + +-- Fires one unyielding observer's callback across MANY instances (its already- +-- matched set at subscribe time) under a SINGLE coroutine, checked once. This is +-- the amortized form of `dispatchMatch`: the yield-detector is a property of the +-- thread, so wrapping the whole loop pays for one `coroutine.create` instead of +-- one per instance (the seed loop was the only place a lone callback ran N +-- times). Each call is still `pcall`-isolated so one erroring match neither +-- aborts the rest nor escapes; a YIELD, though, suspends the shared coroutine +-- and abandons every remaining match in the batch — acceptable because yielding +-- already broke the contract, and it is reported loudly. +const function dispatchSeedUnyielding(obs: Observer, instances: { Instance }) + const callback = obs.callback + const janitors = obs.janitors + const thread = coroutine.create(function() + for _, instance in instances do + const matchJanitor = Janitor.new() + janitors[instance] = matchJanitor + const ok, err = pcall(callback, instance, matchJanitor) + if not ok then + reportUnyieldingViolation(nil, err) + end + end + end) + coroutine.resume(thread) + -- Errors are caught inside; only an (unexpected) escape or a yield leaves the + -- coroutine alive. + if coroutine.status(thread) ~= "dead" then + reportUnyieldingViolation(thread, nil) + end +end -------------------------------------------------------------------------------- -- Reactive engine (ref-counted; shared across all structurally equal queries) @@ -774,15 +945,15 @@ local activeEngines: { [string]: Engine } = {} function Query._activate(self: QueryInternal): Engine self._refcount += 1 - local attached = self._engine + const attached = self._engine if attached then attached.refcount += 1 return attached end -- An equivalent query may already maintain this exact engine. - local signature = self:_signature() - local interned = activeEngines[signature] + const signature = self:_signature() + const interned = activeEngines[signature] if interned then interned.refcount += 1 interned.holders[self] = true @@ -790,12 +961,12 @@ function Query._activate(self: QueryInternal): Engine return interned end - local janitor = Janitor.new() + const janitor = Janitor.new() -- Cast through `unknown`: `Signal.new()` has no inference source for its -- `Function` generic, and Signal's invariant generics reject a direct cast. - local changed = (Signal.new() :: unknown) :: ChangedSignal + const changed = (Signal.new() :: unknown) :: ChangedSignal janitor:Add(changed, "Destroy") - local engine: Engine = { + const engine: Engine = { matched = {}, matchedList = {}, changed = changed, @@ -803,6 +974,7 @@ function Query._activate(self: QueryInternal): Engine janitor = janitor, positiveSet = {}, attrConns = {}, + propConns = {}, subEngines = {}, signature = signature, refcount = 1, @@ -816,29 +988,42 @@ function Query._activate(self: QueryInternal): Engine conn:Disconnect() end table.clear(engine.attrConns) + for _, conns in engine.propConns do + for _, conn in conns do + conn:Disconnect() + end + end + table.clear(engine.propConns) end) - local function subMatches(subQuery: QueryInternal, instance: Instance): boolean - local subEngine = engine.subEngines[subQuery] + const function subMatches(subQuery: QueryInternal, instance: Instance): boolean + const subEngine = engine.subEngines[subQuery] return subEngine ~= nil and subEngine.matched[instance] ~= nil end -- One `AttributeChanged` connection per candidate, filtered by name, instead -- of a Janitor plus a `GetAttributeChangedSignal` connection per attribute: -- activation over a large candidate set was dominated by that allocation. - local hasAttributes = #self._attributes > 0 - local watchedAttributes: { [string]: boolean } = {} + const hasAttributes = #self._attributes > 0 + const watchedAttributes: { [string]: boolean } = {} for _, attr in self._attributes do watchedAttributes[attr.name] = true end - local function reevaluate(instance: Instance?) + -- Properties have no single "any property changed" signal, so each watched + -- property gets its own `GetPropertyChangedSignal` connection per candidate. + const hasProperties = #self._properties > 0 + const watchedProperties: { [string]: boolean } = {} + for _, prop in self._properties do + watchedProperties[prop.name] = true + end + + const function reevaluate(instance: Instance?) if not instance then return end - local positive = self:_positiveCandidate(instance, subMatches) - - -- Track the candidate universe and (only while bounded) attribute subs. + const positive = self:_positiveCandidate(instance, subMatches) + -- Track the candidate universe and (only while bounded) attribute/property subs. if positive then engine.positiveSet[instance] = true if hasAttributes and not engine.attrConns[instance] then @@ -848,43 +1033,70 @@ function Query._activate(self: QueryInternal): Engine end end) :: any end + if hasProperties and not engine.propConns[instance] then + const conns: { ConnectionLike } = {} + for name in watchedProperties do + -- `GetPropertyChangedSignal` throws for a property this Instance's + -- class lacks; such a candidate simply never gets a sub (and the + -- pcall read in `_propertiesMatch` already reports it absent). + const ok, signal = pcall(function() + return instance:GetPropertyChangedSignal(name) + end) + if ok then + table.insert( + conns, + signal:Connect(function() + reevaluate(instance) + end) :: any + ) + end + end + engine.propConns[instance] = conns + end else engine.positiveSet[instance] = nil - local attrConn = engine.attrConns[instance] + const attrConn = engine.attrConns[instance] if attrConn then engine.attrConns[instance] = nil attrConn:Disconnect() end + const propConns = engine.propConns[instance] + if propConns then + engine.propConns[instance] = nil + for _, conn in propConns do + conn:Disconnect() + end + end end - local isMatch = positive and self:_matchesRest(instance, subMatches) - local wasMatch = engine.matched[instance] ~= nil + const isMatch = positive and self:_matchesRest(instance, subMatches) + const wasMatch = engine.matched[instance] ~= nil if isMatch == wasMatch then return end - local matchedList = engine.matchedList + const matchedList = engine.matchedList if isMatch then - local n = #matchedList + 1 + const n = #matchedList + 1 matchedList[n] = instance engine.matched[instance] = n for obs in engine.observers do - local matchJanitor = Janitor.new() + const matchJanitor = Janitor.new() obs.janitors[instance] = matchJanitor - task.spawn(obs.callback, instance, matchJanitor) + dispatchMatch(obs, instance, matchJanitor) end else -- Swap-remove: move the tail into the vacated slot. When the -- instance IS the tail, the reassignments are harmless no-ops. - local index = engine.matched[instance] :: number - local lastIndex = #matchedList - local last = matchedList[lastIndex] + const index = engine.matched[instance] :: number + const lastIndex = #matchedList + const last = matchedList[lastIndex] matchedList[index] = last engine.matched[last] = index matchedList[lastIndex] = nil engine.matched[instance] = nil for obs in engine.observers do - local matchJanitor = obs.janitors[instance] + const matchJanitor = obs.janitors[instance] if matchJanitor then obs.janitors[instance] = nil matchJanitor:Destroy() @@ -895,9 +1107,9 @@ function Query._activate(self: QueryInternal): Engine end -- Subscribe to every referenced input so a change re-evaluates the instance. - local connectedClasses: { [ComponentClassLike]: boolean } = {} - local connectedTags: { [string]: boolean } = {} - local function subscribeRef(req: Queryable) + const connectedClasses: { [ComponentClassLike]: boolean } = {} + const connectedTags: { [string]: boolean } = {} + const function subscribeRef(req: Queryable) if type(req) == "string" then if connectedTags[req] then return @@ -906,11 +1118,11 @@ function Query._activate(self: QueryInternal): Engine janitor:Add(CollectionService:GetInstanceAddedSignal(req):Connect(reevaluate), "Disconnect") janitor:Add(CollectionService:GetInstanceRemovedSignal(req):Connect(reevaluate), "Disconnect") elseif isQuery(req) then - local subQuery = req :: QueryInternal + const subQuery = req :: QueryInternal if engine.subEngines[subQuery] then return end - local subEngine = subQuery:_activate() + const subEngine = subQuery:_activate() engine.subEngines[subQuery] = subEngine janitor:Add(function() subQuery:_deactivate() @@ -922,13 +1134,13 @@ function Query._activate(self: QueryInternal): Engine "Disconnect" ) else -- component class - local class = req :: ComponentClassLike + const class = req :: ComponentClassLike if connectedClasses[class] then return end connectedClasses[class] = true - local started = class.Started :: ClassSignalView - local stopped = class.Stopped :: ClassSignalView + const started = class.Started :: ClassSignalView + const stopped = class.Stopped :: ClassSignalView janitor:Add( started:Connect(function(component) reevaluate(component.Instance) @@ -951,7 +1163,7 @@ function Query._activate(self: QueryInternal): Engine -- `where` recheck signals force a full re-evaluation of bounded instances. for _, pred in self._predicates do if pred.signal ~= nil then - local recheck = pred.signal :: RecheckSignalView + const recheck = pred.signal :: RecheckSignalView janitor:Add( recheck:Connect(function() for instance in engine.positiveSet do @@ -972,7 +1184,7 @@ function Query._activate(self: QueryInternal): Engine end function Query._deactivate(self: QueryInternal) - local engine = self._engine + const engine = self._engine if not engine then return end @@ -999,17 +1211,17 @@ function Query._enumerate( reactive: boolean, subSets: { [QueryInternal]: { [Instance]: boolean } }? ): { [Instance]: boolean } - local set: { [Instance]: boolean } = {} - local function addFromRef(req: Queryable) + const set: { [Instance]: boolean } = {} + const function addFromRef(req: Queryable) if type(req) == "string" then for _, instance in CollectionService:GetTagged(req) do set[instance] = true end elseif isQuery(req) then - local subQuery = req :: QueryInternal + const subQuery = req :: QueryInternal if reactive then - local engine = self._engine :: Engine - local subEngine = engine.subEngines[subQuery] + const engine = self._engine :: Engine + const subEngine = engine.subEngines[subQuery] if subEngine then for _, instance in subEngine.matchedList do set[instance] = true @@ -1018,7 +1230,7 @@ function Query._enumerate( else -- Reuse the caller's per-call memo when there is one, so a nested -- sub-query is evaluated once per `get()` rather than per candidate. - local memo = if subSets then subSets[subQuery] else nil + const memo = if subSets then subSets[subQuery] else nil if memo then for instance in memo do set[instance] = true @@ -1033,8 +1245,8 @@ function Query._enumerate( -- One of ours: its started list already holds exactly the instances -- this source contributes, pre-filtered. Foreign class-likes fall back -- to `GetAll()` + a phase check per component. - local class = req :: ComponentClassLike - local internal = (class :: any)[Keys.Internal] + const class = req :: ComponentClassLike + const internal = (class :: any)[Keys.Internal] if internal then for _, instance in internal.startedList do set[instance] = true @@ -1069,24 +1281,38 @@ end Disconnecting the returned handle destroys all active match janitors and stops watching. ]=] -function Query.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection - assert(type(callback) == "function", "[Component] Query:observe() expects a callback function") +-- Shared body of `observe` / `observeUnyielding`: register an observer, fire it +-- for the current matches, and return a disconnect handle. `unyielding` selects +-- the dispatch strategy (see `dispatchMatch`); `method` names the caller for the +-- assertion message. +const function attachObserver( + self: QueryInternal, + callback: (Instance, Janitor) -> (), + unyielding: boolean, + method: string +): QueryConnection + assert(type(callback) == "function", `[Component] Query:{method}() expects a callback function`) self:_validate() - local engine = self:_activate() - - local obs: Observer = { callback = callback, janitors = {} } + const engine = self:_activate() + const obs: Observer = { callback = callback, janitors = {}, unyielding = unyielding } engine.observers[obs] = true -- Fire for instances already matched at subscribe time. Iterate a SNAPSHOT: - -- under Immediate signal behavior a spawned callback can synchronously - -- retag/untag and mutate the live match set mid-loop. - for _, instance in table.clone(engine.matchedList) do - local matchJanitor = Janitor.new() - obs.janitors[instance] = matchJanitor - task.spawn(callback, instance, matchJanitor) + -- under Immediate signal behavior a dispatched callback can synchronously + -- retag/untag and mutate the live match set mid-loop. Unyielding observers + -- run the whole snapshot under one coroutine (one yield-check for the batch). + const snapshot = table.clone(engine.matchedList) + if unyielding then + dispatchSeedUnyielding(obs, snapshot) + else + for _, instance in snapshot do + const matchJanitor = Janitor.new() + obs.janitors[instance] = matchJanitor + task.spawn(callback, instance, matchJanitor) + end end - local connProxy = {} :: QueryConnection + const connProxy = {} :: QueryConnection connProxy.IsConnected = true function connProxy.Disconnect() if not connProxy.IsConnected then @@ -1104,11 +1330,34 @@ function Query.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()) return connProxy end +function Query.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection + return attachObserver(self, callback, false, "observe") +end + +--[=[ + @within Query + @param callback (instance: Instance, janitor: Janitor) -> () + @return QueryConnection + + Like [Query:observe], but each callback runs INLINE on the thread driving the + match change instead of on its own spawned thread — no per-match thread + allocation, the fast dispatch path for hot bind/unbind work. + + :::danger The callback must run to completion synchronously. If it **yields** + or **errors** it is reported loudly (a red error with a traceback) and + abandoned mid-run; because it shares the dispatch thread, the violation can + also disrupt the other observers and matches reacting to the same change. Use + [Query:observe] for any callback that may yield. ::: +]=] +function Query.observeUnyielding(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection + return attachObserver(self, callback, true, "observeUnyielding") +end + -- The sole positive requirement when the query is the ECS hot shape -- exactly -- one required requirement and no anyOf / negative / attribute / predicate -- clause -- so a read can answer straight from that one source. `nil` otherwise. function Query._singleSource(self: QueryInternal): PlanReq? - local plan = self:_plan() + const plan = self:_plan() if #plan.required == 1 and #plan.anyOf == 0 @@ -1135,18 +1384,18 @@ function Query._staticSub( if not plan.hasQueryRefs then return neverSub, nil, nil end - local sets: { [QueryInternal]: { [Instance]: boolean } } = {} + const sets: { [QueryInternal]: { [Instance]: boolean } } = {} local sub: SatisfiedFn local ensure: (QueryInternal) -> { [Instance]: boolean } function ensure(subQuery: QueryInternal): { [Instance]: boolean } - local existing = sets[subQuery] + const existing = sets[subQuery] if existing then return existing end -- Seed the entry before recursing so shared sub-queries are computed -- exactly once (recursion depth is finite: queries are immutable, so -- the reference graph is a DAG by construction). - local set: { [Instance]: boolean } = {} + const set: { [Instance]: boolean } = {} sets[subQuery] = set -- Deepest first, so this sub-query's own enumeration finds its -- references already memoized. @@ -1180,10 +1429,9 @@ end -- shape are cheaper answers the public terminals handle themselves before -- falling back here, so this deliberately does NOT special-case them. function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) - local plan = self:_plan() - local staticSub, ensureSet, subSets = self:_staticSub(plan) - local required = plan.required - + const plan = self:_plan() + const staticSub, ensureSet, subSets = self:_staticSub(plan) + const required = plan.required if #required == 0 then -- anyOf-only query: the candidate set genuinely is a union, so build it. for instance in self:_enumerate(false, subSets) do @@ -1209,9 +1457,9 @@ function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) local firstQuery: PlanReq? = nil local firstClasslike: PlanReq? = nil for _, req in required do - local kind = req.kind + const kind = req.kind if kind == "class" then - local size = #(req.startedList :: { Instance }) + const size = #(req.startedList :: { Instance }) if size < seedSize then seed, seedSize = req, size end @@ -1232,14 +1480,14 @@ function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) local tagSizes: { [PlanReq]: number }? = nil local tagArrays: { [PlanReq]: { Instance } }? = nil if firstTag and (seed == nil or seedSize > TAG_SIZING_MIN_SEED) then - local sizes: { [PlanReq]: number } = {} - local arrays: { [PlanReq]: { Instance } } = {} + const sizes: { [PlanReq]: number } = {} + const arrays: { [PlanReq]: { Instance } } = {} tagSizes, tagArrays = sizes, arrays for _, req in required do if req.kind == "tag" then - local instances = CollectionService:GetTagged(req.tag :: string) + const instances = CollectionService:GetTagged(req.tag :: string) arrays[req] = instances - local size = #instances + const size = #instances sizes[req] = size if size < seedSize then seed, seedSize = req, size @@ -1254,33 +1502,32 @@ function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) end end end - local chosenSeed: PlanReq = (seed or firstTag or firstQuery or firstClasslike) :: PlanReq - local seedKind = chosenSeed.kind - + const chosenSeed: PlanReq = (seed or firstTag or firstQuery or firstClasslike) :: PlanReq + const seedKind = chosenSeed.kind -- Probes = every required requirement except the seed, most-selective-first -- when populations are known (smaller population rejects more candidates -- sooner, so each later probe runs against fewer survivors). Unknown counts -- keep the plan's cheap-kind-first order. - local probes: { PlanReq } = {} + const probes: { PlanReq } = {} for _, req in required do if req ~= chosenSeed then table.insert(probes, req) end end if tagSizes and #probes > 1 then - local sizes = tagSizes :: { [PlanReq]: number } - local function populationOf(req: PlanReq): number + const sizes = tagSizes :: { [PlanReq]: number } + const function populationOf(req: PlanReq): number if req.kind == "class" then return #(req.startedList :: { Instance }) end - local sized = sizes[req] + const sized = sizes[req] if sized then return sized end return math.huge end table.sort(probes, function(x: PlanReq, y: PlanReq): boolean - local px, py = populationOf(x), populationOf(y) + const px, py = populationOf(x), populationOf(y) if px ~= py then return px < py end @@ -1295,16 +1542,16 @@ function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) -- ever spends allocations already made for sizing. local probeSets: { [PlanReq]: { [Instance]: boolean } }? = nil if tagArrays and tagSizes then - local arrays = tagArrays :: { [PlanReq]: { Instance } } - local sizes = tagSizes :: { [PlanReq]: number } + const arrays = tagArrays :: { [PlanReq]: { Instance } } + const sizes = tagSizes :: { [PlanReq]: number } for _, req in probes do - local instances = arrays[req] + const instances = arrays[req] if instances and seedSize * 3 > (sizes[req] :: number) then - local set: { [Instance]: boolean } = {} + const set: { [Instance]: boolean } = {} for _, instance in instances do set[instance] = true end - local outSets = probeSets or {} + const outSets = probeSets or {} probeSets = outSets outSets[req] = set end @@ -1314,10 +1561,10 @@ function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) -- Checks everything except the seed requirement (the seed's own iteration -- already proves it). Rest-checks are inlined here so a candidate costs no -- extra method dispatch. Returns whatever `onMatch` returned (truthy = stop). - local anyOf = plan.anyOf - local function consider(instance: Instance): boolean? + const anyOf = plan.anyOf + const function consider(instance: Instance): boolean? for _, req in probes do - local set = if probeSets then (probeSets :: { [PlanReq]: { [Instance]: boolean } })[req] else nil + const set = if probeSets then (probeSets :: { [PlanReq]: { [Instance]: boolean } })[req] else nil if set then if not set[instance] then return nil @@ -1348,6 +1595,9 @@ function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) if plan.hasAttributes and not self:_attributesMatch(instance) then return nil end + if plan.hasProperties and not self:_propertiesMatch(instance) then + return nil + end if plan.hasPredicates and not self:_predicatesPass(instance) then return nil end @@ -1361,14 +1611,14 @@ function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) end end elseif seedKind == "tag" then - local seedInstances = if tagArrays then (tagArrays :: { [PlanReq]: { Instance } })[chosenSeed] else nil + const seedInstances = if tagArrays then (tagArrays :: { [PlanReq]: { Instance } })[chosenSeed] else nil for _, instance in seedInstances or CollectionService:GetTagged(chosenSeed.tag :: string) do if consider(instance) then return end end elseif seedKind == "query" then - local ensure = ensureSet :: (QueryInternal) -> { [Instance]: boolean } + const ensure = ensureSet :: (QueryInternal) -> { [Instance]: boolean } for instance in ensure(chosenSeed.query :: QueryInternal) do if consider(instance) then return @@ -1402,29 +1652,29 @@ function Query.get(self: QueryInternal): { Instance } -- Live-engine fast path: the engine already maintains exactly this set. -- An engine built by any structurally equal query serves just as well -- -- borrow it read-only via the intern registry. - local engine = self._engine or activeEngines[self:_signature()] + const engine = self._engine or activeEngines[self:_signature()] if engine then return table.clone(engine.matchedList) end -- The ECS hot shape — one requirement, nothing else — is a straight dump of -- the seed source, before any per-candidate machinery is even allocated. - local single = self:_singleSource() + const single = self:_singleSource() if single then - local kind = single.kind + const kind = single.kind if kind == "class" then return table.clone(single.startedList :: { Instance }) elseif kind == "tag" then return CollectionService:GetTagged(single.tag :: string) elseif kind == "query" then - local out: { Instance } = {} - local _, ensure = self:_staticSub(self:_plan()) + const out: { Instance } = {} + const _, ensure = self:_staticSub(self:_plan()) for instance in (ensure :: (QueryInternal) -> { [Instance]: boolean })(single.query :: QueryInternal) do table.insert(out, instance) end return out else - local out: { Instance } = {} + const out: { Instance } = {} for _, component in (single.class :: ComponentClassLike):GetAll() do if Keys.inst(component).phase == "Started" then table.insert(out, component.Instance) @@ -1434,7 +1684,7 @@ function Query.get(self: QueryInternal): { Instance } end end - local out: { Instance } = {} + const out: { Instance } = {} self:_collect(function(instance) table.insert(out, instance) return nil @@ -1453,12 +1703,13 @@ Query.GetMatches = Query.get every clause (no candidate enumeration). ]=] function Query.contains(self: QueryInternal, instance: Instance): boolean + assert(typeof(instance) == "Instance", "[Component] Query:contains() expects an Instance") self:_validate() - local engine = self._engine or activeEngines[self:_signature()] + const engine = self._engine or activeEngines[self:_signature()] if engine then return engine.matched[instance] ~= nil end - local staticSub = self:_staticSub(self:_plan()) + const staticSub = self:_staticSub(self:_plan()) return self:_fullMatch(instance, staticSub) end @@ -1471,11 +1722,11 @@ end ]=] function Query.count(self: QueryInternal): number self:_validate() - local engine = self._engine or activeEngines[self:_signature()] + const engine = self._engine or activeEngines[self:_signature()] if engine then return #engine.matchedList end - local single = self:_singleSource() + const single = self:_singleSource() if single and single.kind == "class" then return #(single.startedList :: { Instance }) end @@ -1496,11 +1747,11 @@ end ]=] function Query.first(self: QueryInternal): Instance? self:_validate() - local engine = self._engine or activeEngines[self:_signature()] + const engine = self._engine or activeEngines[self:_signature()] if engine then return engine.matchedList[1] end - local single = self:_singleSource() + const single = self:_singleSource() if single and single.kind == "class" then return (single.startedList :: { Instance })[1] end @@ -1536,10 +1787,10 @@ end ]=] function Query.iter(self: QueryInternal): () -> Instance? self:_validate() - local engine = self._engine or activeEngines[self:_signature()] + const engine = self._engine or activeEngines[self:_signature()] -- Backwards, so the engine's swap-remove (which moves an already-visited -- tail element into the vacated slot) never skips an unvisited instance. - local list = if engine then engine.matchedList else self:get() + const list = if engine then engine.matchedList else self:get() local index = #list + 1 return function(): Instance? index -= 1 diff --git a/lib/component/src/Tests/Component.Query.spec.luau b/lib/component/src/Tests/Component.Query.spec.luau index eae52779..1c136ae2 100644 --- a/lib/component/src/Tests/Component.Query.spec.luau +++ b/lib/component/src/Tests/Component.Query.spec.luau @@ -140,6 +140,96 @@ return function(t: any) p:Destroy() end) + test("withProperty matches by value and by predicate, reactively", function() + local A, aTag = H.makeClass() + local namedMatched, clearMatched = {}, {} + local obsName = Component.query(A):withProperty("Name", "Target"):observe(function(i) + namedMatched[i] = true + end) + local obsClear = Component.query(A) + :withProperty("Transparency", function(v) + return type(v) == "number" and v > 0.5 + end) + :observe(function(i) + clearMatched[i] = true + end) + + local p = part { aTag } + p.Name = "Other" + p.Transparency = 0 + expect(H.waitStarted(A, p, 3)).is(true) + task.wait(0.1) + expect(namedMatched[p]).never_exists() + expect(clearMatched[p]).never_exists() + + p.Name = "Target" + p.Transparency = 0.8 + expect(H.waitUntil(function() + return namedMatched[p] and clearMatched[p] + end, 3)).is(true) + + obsName:Disconnect() + obsClear:Disconnect() + A:Destroy() + p:Destroy() + end) + + test("withProperty with no matcher requires the property merely exists", function() + local A, aTag = H.makeClass() + local matched = {} + -- Parts have `Anchored`; the void matcher is a pure existence check. + local obs = Component.query(A):withProperty("Anchored"):observe(function(i) + matched[i] = true + end) + + local p = part { aTag } + expect(H.waitUntil(function() + return matched[p] == true + end, 3)).is(true) + + obs:Disconnect() + A:Destroy() + p:Destroy() + end) + + test("withProperty with an explicit nil matcher matches only while the property is nil", function() + -- Use an ObjectValue whose nillable `.Value` is unrelated to ancestry + -- (nil-parenting a Part would instead stop the component by leaving its + -- ancestor list, muddying what's being tested). + local A, aTag = H.makeClass() + local live = {} + local obs = Component.query(A):withProperty("Value", nil):observe(function(i, jani) + live[i] = true + jani:Add(function() + live[i] = nil + end) + end) + + local ov = Instance.new("ObjectValue") + CollectionService:AddTag(ov, aTag) + local target = Instance.new("Folder") + ov.Value = target -- starts non-nil -> no match + ov.Parent = workspace + expect(H.waitStarted(A, ov, 3)).is(true) + task.wait(0.1) + expect(live[ov]).never_exists() + + ov.Value = nil + expect(H.waitUntil(function() + return live[ov] == true + end, 3)).is(true) + + ov.Value = target + expect(H.waitUntil(function() + return live[ov] == nil + end, 3)).is(true) + + obs:Disconnect() + A:Destroy() + ov:Destroy() + target:Destroy() + end) + test("a sub-query is a valid requirement", function() local A, aTag = H.makeClass() local B, bTag = H.makeClass() @@ -253,6 +343,101 @@ return function(t: any) end) end) + describe("observeUnyielding", function() + -- Captures unhandled errors (including those the reporter raises on a + -- fresh thread) whose message contains `needle`, for the duration of `fn`. + local function captureError(needle, fn) + local ScriptContext = game:GetService("ScriptContext") + local caught = nil + local conn = ScriptContext.Error:Connect(function(message) + if type(message) == "string" and string.find(message, needle, 1, true) then + caught = message + end + end) + fn() + H.waitUntil(function() + return caught ~= nil + end, 2) + conn:Disconnect() + return caught + end + + test("fires inline for current and future matches, cleans on unmatch", function() + local A, aTag = H.makeClass() + local live = {} + local obs = Component.query(A):observeUnyielding(function(instance, jani) + live[instance] = true + jani:Add(function() + live[instance] = nil + end) + end) + + local p = part { aTag } + expect(H.waitUntil(function() + return live[p] == true + end, 3)).is(true) + + CollectionService:RemoveTag(p, aTag) + expect(H.waitUntil(function() + return live[p] == nil + end, 3)).is(true) + + obs:Disconnect() + A:Destroy() + p:Destroy() + end) + + test("a yielding callback is reported loudly and does not break sibling observers", function() + local A, aTag = H.makeClass() + local siblingSaw = false + -- Sibling observer registered FIRST so it dispatches before the + -- offending one on the same match event. + local sibling = Component.query(A):observe(function() + siblingSaw = true + end) + + local message = captureError("observeUnyielding() callback YIELDED", function() + local bad = Component.query(A):observeUnyielding(function() + task.wait(0.05) -- contract violation + end) + local p = part { aTag } + expect(H.waitStarted(A, p, 3)).is(true) + task.wait(0.1) + bad:Disconnect() + p:Destroy() + end) + expect(message).exists() + expect(siblingSaw).is(true) + + sibling:Disconnect() + A:Destroy() + end) + + test("an erroring callback is reported loudly and is isolated", function() + local A, aTag = H.makeClass() + local goodSaw = false + local good = Component.query(A):observeUnyielding(function() + goodSaw = true + end) + + local message = captureError("observeUnyielding() callback errored", function() + local bad = Component.query(A):observeUnyielding(function() + error("boom in observer") + end) + local p = part { aTag } + expect(H.waitStarted(A, p, 3)).is(true) + task.wait(0.1) + bad:Disconnect() + p:Destroy() + end) + expect(message).exists() + expect(goodSaw).is(true) + + good:Disconnect() + A:Destroy() + end) + end) + describe("started sparse set", function() test("cold GetMatches excludes constructing and stopped components", function() local gate = false diff --git a/lib/component/src/Tests/Component.types.luau b/lib/component/src/Tests/Component.types.luau index 0536f591..c73d273b 100644 --- a/lib/component/src/Tests/Component.types.luau +++ b/lib/component/src/Tests/Component.types.luau @@ -138,7 +138,7 @@ function Methods.Start(self) end function Methods.UseSibling(self: ti) - self:CreateFromInstance(Instance.new("Model")) + self:GetOrCreateFromInstance(Instance.new("Model")) local sibling = self:GetComponent(myComponent) if sibling then diff --git a/lib/component/src/Types.luau b/lib/component/src/Types.luau index 3438e776..9cfa43be 100644 --- a/lib/component/src/Types.luau +++ b/lib/component/src/Types.luau @@ -205,7 +205,6 @@ export type TypedClass = { SteppedUpdate: ((self: TypedInstance, dt: number) -> ())?, RenderSteppedUpdate: ((self: TypedInstance, dt: number) -> ())?, - Has: (self: TypedClass, instance: Instance) -> boolean, GetLifecycleStatus: (self: TypedClass, instanceOrComponent: any) -> LifecyclePhase, FromInstance: (self: TypedClass, instance: I & Instance) -> TypedInstance?, WaitForInstance: ( @@ -213,7 +212,7 @@ export type TypedClass = { instance: I & Instance, timeout: number? ) -> Promise>, - CreateFromInstance: (self: TypedClass, instance: I & Instance) -> Promise>, + GetOrCreateFromInstance: (self: TypedClass, instance: I & Instance) -> Promise>, GetAll: (self: T) -> { T }, UpdateAncestors: (self: TypedClass, newAncestors: { Instance }) -> (), GetAncestors: (self: TypedClass) -> { Instance }, @@ -247,22 +246,6 @@ export type NewFn = ( -- Internal implementation views -------------------------------------------------------------------------------- --- Minimal structural view of an evaera Promise. The implementation types its --- promise plumbing against this instead of the vendored modules' inferred / --- generic signatures (which reject our variadic glue); values are cast through --- `unknown` at the vendor boundary. --- Callback params are `...any` by necessity: `...unknown` would require every --- handler to accept arbitrary arguments (contravariance), rejecting plain --- `() -> ()` handlers; only `any` bridges both directions. -export type PromiseLike = { - andThen: (self: PromiseLike, onResolve: (...any) -> ...any, onReject: ((...any) -> ...any)?) -> PromiseLike, - finally: (self: PromiseLike, fn: (...any) -> ...any) -> PromiseLike, - catch: (self: PromiseLike, fn: (...any) -> ...any) -> PromiseLike, - timeout: (self: PromiseLike, seconds: number) -> PromiseLike, - cancel: (self: PromiseLike) -> (), - getStatus: (self: PromiseLike) -> string, -} - export type ComponentClass = TypedClass<{}, {}, Instance> export type ComponentInstance = TypedInstance<{}, {}, Instance> From 150cdd3cbe3f635a7023ebe7e0c5e48340632e24 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 14:56:01 -0400 Subject: [PATCH 13/19] Add Query:track API and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `Query:track()` as a lightweight way to keep a query’s match set live without observer callbacks, returning a `QueryConnection` with idempotent disconnect behavior. This enables cheap repeated reads (`get`/`iter`/`count`/`first`/`contains`) while tracked and reuses the same reactive engine as observers. Adds query tests for live add/remove maintenance, disconnect fallback to cold reads, and engine sharing between tracked and observed query shapes. Also updates a lifecycle race test to use `GetOrCreateFromInstance`, and exports `Janitor`/`Promise` types in `Types.luau` while switching local requires there to package-relative paths. --- lib/component/src/Query.luau | 44 ++++++++++ .../src/Tests/Component.Lifecycle.spec.luau | 4 +- .../src/Tests/Component.Query.spec.luau | 82 +++++++++++++++++++ lib/component/src/Types.luau | 8 +- 4 files changed, 133 insertions(+), 5 deletions(-) diff --git a/lib/component/src/Query.luau b/lib/component/src/Query.luau index 1280a506..326a997d 100644 --- a/lib/component/src/Query.luau +++ b/lib/component/src/Query.luau @@ -97,6 +97,7 @@ export type Query = { where: (self: Query, predicate: (Instance) -> boolean, recheckSignal: unknown?) -> Query, observe: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, observeUnyielding: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, + track: (self: Query) -> QueryConnection, get: (self: Query) -> { Instance }, iter: (self: Query) -> () -> Instance?, contains: (self: Query, instance: Instance) -> boolean, @@ -1353,6 +1354,49 @@ function Query.observeUnyielding(self: QueryInternal, callback: (Instance, Janit return attachObserver(self, callback, true, "observeUnyielding") end +--[=[ + @within Query + @return QueryConnection + + Keeps this query's match set maintained for cheap repeated reads, WITHOUT + running a per-match callback. While tracked, [Query:get] / [Query:iter] / + [Query:count] / [Query:first] / [Query:contains] all answer from the live + reactive engine (an O(matches) clone or O(1) lookup) instead of re-enumerating + the candidate set each call — the read path for an ECS-style system that polls + a join every frame: + + ```lua + local tracked = Component.query(Physics, Velocity):track() + game:GetService("RunService").Heartbeat:Connect(function(dt) + for instance in tracked:iter() do ... end + end) + -- when the system shuts down: + tracked:Disconnect() + ``` + + This is [Query:observe] minus the per-match Janitor and callback: it maintains + the same engine (structurally-equal tracked and observed queries share it), so + tracking is strictly cheaper than observing when you only need to read. The + returned handle MUST be disconnected to release the engine — unlike a one-shot + [Query:get], a tracked query holds live subscriptions until then. +]=] +function Query.track(self: QueryInternal): QueryConnection + self:_validate() + self:_activate() + + const connProxy = {} :: QueryConnection + connProxy.IsConnected = true + function connProxy.Disconnect() + if not connProxy.IsConnected then + return + end + connProxy.IsConnected = false + self:_deactivate() + end + connProxy.Destroy = connProxy.Disconnect + return connProxy +end + -- The sole positive requirement when the query is the ECS hot shape -- exactly -- one required requirement and no anyOf / negative / attribute / predicate -- clause -- so a read can answer straight from that one source. `nil` otherwise. diff --git a/lib/component/src/Tests/Component.Lifecycle.spec.luau b/lib/component/src/Tests/Component.Lifecycle.spec.luau index 98725eba..1ac17de4 100644 --- a/lib/component/src/Tests/Component.Lifecycle.spec.luau +++ b/lib/component/src/Tests/Component.Lifecycle.spec.luau @@ -174,8 +174,8 @@ return function(t: any) -- Both calls land before the deferred driver runs; the second must -- find the slot already reserved rather than start a second chain. - class:CreateFromInstance(part) - class:CreateFromInstance(part) + class:GetOrCreateFromInstance(part) + class:GetOrCreateFromInstance(part) expect(waitUntil(function() return class:Has(part) diff --git a/lib/component/src/Tests/Component.Query.spec.luau b/lib/component/src/Tests/Component.Query.spec.luau index 1c136ae2..a3bb9b17 100644 --- a/lib/component/src/Tests/Component.Query.spec.luau +++ b/lib/component/src/Tests/Component.Query.spec.luau @@ -438,6 +438,88 @@ return function(t: any) end) end) + describe("track", function() + test("a tracked query maintains a join across live adds and removes", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local q = Component.query(A):with(B) + local tracked = q:track() + + local function has(inst) + for _, m in q:GetMatches() do + if m == inst then + return true + end + end + return false + end + + local p = part { aTag, bTag } + expect(H.waitUntil(function() + return has(p) + end, 3)).is(true) + expect(q:contains(p)).is(true) + expect(q:count()).is(1) + + -- Removing a required component drops it from the maintained set. + CollectionService:RemoveTag(p, bTag) + expect(H.waitUntil(function() + return not has(p) + end, 3)).is(true) + expect(q:contains(p)).is(false) + + tracked:Disconnect() + A:Destroy() + B:Destroy() + p:Destroy() + end) + + test("Disconnect releases the engine; reads still work cold", function() + local A, aTag = H.makeClass() + local q = Component.query(A) + local tracked = q:track() + local p = part { aTag } + expect(H.waitUntil(function() + return q:contains(p) + end, 3)).is(true) + + tracked:Disconnect() + expect(tracked.IsConnected).is(false) + -- Cold read (no engine) still reports the current truth. + expect(#q:GetMatches()).is(1) + expect(q:GetMatches()[1]).is(p) + + -- Disconnect is idempotent. + tracked:Disconnect() + + A:Destroy() + p:Destroy() + end) + + test("a tracked query and an observer of the same shape share one engine", function() + local A, aTag = H.makeClass() + local tracked = Component.query(A):track() + local seen = {} + local obs = Component.query(A):observe(function(i) + seen[i] = true + end) + + local p = part { aTag } + -- The observer fires (engine is shared/eager) and the tracked read sees it. + expect(H.waitUntil(function() + return seen[p] == true + end, 3)).is(true) + expect(Component.query(A):contains(p)).is(true) + + obs:Disconnect() + -- Engine survives on the tracker; reads stay correct. + expect(Component.query(A):contains(p)).is(true) + tracked:Disconnect() + A:Destroy() + p:Destroy() + end) + end) + describe("started sparse set", function() test("cold GetMatches excludes constructing and stopped components", function() local gate = false diff --git a/lib/component/src/Types.luau b/lib/component/src/Types.luau index 9cfa43be..bd626411 100644 --- a/lib/component/src/Types.luau +++ b/lib/component/src/Types.luau @@ -19,11 +19,13 @@ const Packages = script.Parent.Parent const Signal = require(Packages.Signal) const Promise = require(Packages.Promise) +const Janitor = require(Packages.Janitor) -const Keys = require(script.Parent.Keys) -const TypeFunctions = require(script.Parent.TypeFunctions) +const Keys = require("./Keys") +const TypeFunctions = require("./TypeFunctions") -type Promise = Promise.TypedPromise +export type Janitor = Janitor.Janitor +export type Promise = Promise.TypedPromise export type LifecyclePhase = Keys.LifecyclePhase export type StopReason = Keys.StopReason From 725e3f4cc2f04992d5de4cdb6b1b26690b612180 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 15:49:38 -0400 Subject: [PATCH 14/19] Change component to use a prototype for its __index --- lib/component/src/init.luau | 128 +++++++++++++++++------------------- 1 file changed, 59 insertions(+), 69 deletions(-) diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index d287d173..edb7a0fc 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -119,7 +119,8 @@ export type Query = Query.Query -- into their typed-path self alias. export type extensionMethods = TypeFunctions.extensionMethods -type Janitor = Janitor.Janitor +type Janitor = Types.Janitor +type Promise = Types.Promise type Class_Internal = Types.ComponentClass_Internal type Instance_Internal = Types.ComponentInstance_Internal @@ -129,12 +130,12 @@ const UNSETUP_COMPONENTS: { Class_Internal } = {} -- Typed view over the vendored `Promise.fromEvent`, whose inferred signature -- rejects our typed signals/predicates; the value bridges through `unknown` once. -type FromEventFn = (event: unknown, predicate: ((T...) -> boolean)?) -> Types.PromiseLike +type FromEventFn = (event: unknown, predicate: ((T...) -> boolean)?) -> Promise const fromEvent = (Promise.fromEvent :: unknown) :: FromEventFn -const ComponentClassMethods = {} const Component = {} -Component.__index = ComponentClassMethods +Component.prototype = {} +Component.__index = Component.prototype --[=[ @within Component @@ -300,7 +301,7 @@ Component.new = (componentNew :: any) :: Types.NewFn --[[ Returns true if `instance` is a descendant of any valid ancestor. ]] -function ComponentClassMethods._isInAncestorList(self: Class_Internal, instance: Instance): boolean +function Component.prototype._isInAncestorList(self: Class_Internal, instance: Instance): boolean for _, parent in Keys.class(self).ancestors do if instance:IsDescendantOf(parent) then return true @@ -313,7 +314,7 @@ end Begins watching `instance` for ancestry changes, constructing/deconstructing as it enters or leaves the valid ancestor list. Idempotent. ]] -function ComponentClassMethods._startWatching(self: Class_Internal, instance: Instance) +function Component.prototype._startWatching(self: Class_Internal, instance: Instance) const ci = Keys.class(self) if ci.watching[instance] then return @@ -338,7 +339,7 @@ function ComponentClassMethods._startWatching(self: Class_Internal, instance: In end end -function ComponentClassMethods._stopWatching(self: Class_Internal, instance: Instance) +function Component.prototype._stopWatching(self: Class_Internal, instance: Instance) const ci = Keys.class(self) const connections = ci.watching[instance] if connections then @@ -353,7 +354,7 @@ end Wires the component class to CollectionService and begins processing tagged instances. Called automatically unless `DelaySetup` is set. ]] -function ComponentClassMethods._setup(self: Class_Internal) +function Component.prototype._setup(self: Class_Internal) const idx = table.find(UNSETUP_COMPONENTS, self) if idx then table.remove(UNSETUP_COMPONENTS, idx) @@ -400,7 +401,7 @@ end @return {Component} Returns a copy of all active component instances of this class. ]=] -function ComponentClassMethods.GetAll(self: Class_Internal): { Instance_Internal } +function Component.prototype.GetAll(self: Class_Internal): { Instance_Internal } return table.clone(Keys.class(self).components) end @@ -411,7 +412,7 @@ end Returns the component of this class bound to `instance`, or nil. The component may still be constructing; use [Component:GetLifecycleStatus] to check. ]=] -function ComponentClassMethods.FromInstance(self: Class_Internal, instance: Instance): Instance_Internal? +function Component.prototype.FromInstance(self: Class_Internal, instance: Instance): Instance_Internal? return Keys.class(self).instToComponents[instance] end @@ -422,7 +423,7 @@ end Whether a *started* component of this class is bound to `instance`. Use [Component:FromInstance] if a still-constructing component should count. ]=] -function ComponentClassMethods.Has(self: Class_Internal, instance: Instance): boolean +function Component.prototype.Has(self: Class_Internal, instance: Instance): boolean return Keys.class(self).startedInstances[instance] ~= nil end @@ -433,7 +434,7 @@ end Returns the lifecycle phase of the component bound to the given instance (or the phase of the given component). `"None"` if there is no such component. ]=] -function ComponentClassMethods.GetLifecycleStatus( +function Component.prototype.GetLifecycleStatus( self: Class_Internal, instanceOrComponent: Instance | Types.AnyComponent ): LifecyclePhase @@ -448,14 +449,14 @@ end Resolves once a *started* component of this class exists on `instance`. Defaults to a 60 second timeout. ]=] -function ComponentClassMethods.WaitForInstance( +function Component.prototype.WaitForInstance( self: Class_Internal, instance: Instance, timeout: number? -): Types.PromiseLike +): Promise const componentInstance = self:FromInstance(instance) if componentInstance and Keys.inst(componentInstance).started then - return (Promise.resolve(componentInstance) :: unknown) :: Types.PromiseLike + return Promise.resolve(componentInstance) end return fromEvent(self.Started, function(c: Instance_Internal) return c.Instance == instance @@ -471,57 +472,46 @@ end round-trip). Rejects if construction is vetoed by `ShouldConstruct` or is stopped before it starts. ]=] -function ComponentClassMethods.CreateFromInstance(self: Class_Internal, instance: Instance): Types.PromiseLike - return ( - Promise.new(function(resolve, reject, onCancel) - const existing = self:FromInstance(instance) - if existing and Keys.inst(existing).started then - resolve(existing) - return - end +function Component.prototype.GetCreateFromInstance(self: Class_Internal, instance: Instance): Promise + return Promise.new(function(resolve, reject, onCancel) + const existing = self:FromInstance(instance) + if existing and Keys.inst(existing).started then + resolve(existing) + return + end - const janitor = Janitor.new() - onCancel(function() + const janitor = Janitor.new() + onCancel(function() + janitor:Destroy() + end) + janitor:Add(self.Started:Connect(function(component) + if component.Instance == instance then janitor:Destroy() - end) - janitor:Add( - self.Started:Connect(function(component) - if component.Instance == instance then - janitor:Destroy() - resolve(component) - end - end), - "Disconnect" - ) - janitor:Add( - Keys.class(self).failed:Connect(function(failedInstance, reason) - if failedInstance == instance then - janitor:Destroy() - reject(`Component '{self.Tag}' did not start on instance: {reason}`) - end - end), - "Disconnect" - ) - - if not CollectionService:HasTag(instance, self.Tag) then - CollectionService:AddTag(instance, self.Tag) + resolve(component) end - self:_startWatching(instance) - if self:_isInAncestorList(instance) then - Lifecycle.Request(self, instance) + end)) + janitor:Add(Keys.class(self).failed:Connect(function(failedInstance, reason) + if failedInstance == instance then + janitor:Destroy() + reject(`Component '{self.Tag}' did not start on instance: {reason}`) end - end) :: unknown - ) :: Types.PromiseLike -end + end)) ---- @deprecated v1.0.0 -- Renamed to [Component:CreateFromInstance]. -ComponentClassMethods.GetOrCreateFromInstance = ComponentClassMethods.CreateFromInstance + if not CollectionService:HasTag(instance, self.Tag) then + CollectionService:AddTag(instance, self.Tag) + end + self:_startWatching(instance) + if self:_isInAncestorList(instance) then + Lifecycle.Request(self, instance) + end + end) +end --[=[ @tag Component Class Updates the valid ancestors of this class and re-evaluates watched instances. ]=] -function ComponentClassMethods.UpdateAncestors(self: Class_Internal, newAncestors: { Instance }) +function Component.prototype.UpdateAncestors(self: Class_Internal, newAncestors: { Instance }) const ci = Keys.class(self) const lastAncestors = ci.ancestors ci.ancestors = newAncestors @@ -532,7 +522,7 @@ end @tag Component Class Returns a copy of the current valid ancestors. ]=] -function ComponentClassMethods.GetAncestors(self: Class_Internal): { Instance } +function Component.prototype.GetAncestors(self: Class_Internal): { Instance } return table.clone(Keys.class(self).ancestors) end @@ -541,14 +531,14 @@ end Called before the component starts, to initialize it. May yield or return a Promise. ]=] -function ComponentClassMethods.Construct(_self: Instance_Internal) end +function Component.prototype.Construct(_self: Instance_Internal) end --[=[ @tag Component Class Called when the component starts. Sibling components on the same instance are safe to access here. May yield or return a Promise. ]=] -function ComponentClassMethods.Start(_self: Instance_Internal) end +function Component.prototype.Start(_self: Instance_Internal) end --[=[ @tag Component Class @@ -557,7 +547,7 @@ function ComponentClassMethods.Start(_self: Instance_Internal) end `reason`. Anything added via `self:AddTask` is cleaned up automatically after this returns. ]=] -function ComponentClassMethods.Stop(_self: Instance_Internal, _reason: StopReason) end +function Component.prototype.Stop(_self: Instance_Internal, _reason: StopReason) end -------------------------------------------------------------------------------- -- Public instance API @@ -569,7 +559,7 @@ function ComponentClassMethods.Stop(_self: Instance_Internal, _reason: StopReaso @return Component? Retrieves another component bound to the same Roblox instance. ]=] -function ComponentClassMethods.GetComponent(self: Instance_Internal, componentClass: Class_Internal): Instance_Internal? +function Component.prototype.GetComponent(self: Instance_Internal, componentClass: Class_Internal): Instance_Internal? return Keys.class(componentClass).instToComponents[self.Instance] end @@ -578,7 +568,7 @@ end @return boolean Whether the component has fully started. ]=] -function ComponentClassMethods.IsStarted(self: Instance_Internal): boolean +function Component.prototype.IsStarted(self: Instance_Internal): boolean return Keys.inst(self).started == true end @@ -591,7 +581,7 @@ end Adds a task to the component's core Janitor, cleaned up when the component stops (on every teardown path). Returns the task. ]=] -function ComponentClassMethods.AddTask( +function Component.prototype.AddTask( self: Instance_Internal, task_: T, cleanupMethod: (string | boolean)?, @@ -608,11 +598,11 @@ end Adds a Promise to the component's core Janitor. An optional string `index` names it so it can be removed/cancelled via [Component:RemoveTask]. ]=] -function ComponentClassMethods.AddPromise( +function Component.prototype.AddPromise( self: Instance_Internal, - promise: Types.PromiseLike, + promise: Promise, index: unknown? -): Types.PromiseLike +): Promise return Keys.inst(self).janitor:AddPromise(promise, index) end @@ -622,7 +612,7 @@ end @param dontClean boolean? Removes a task from the core Janitor, cleaning it unless `dontClean` is true. ]=] -function ComponentClassMethods.RemoveTask(self: Instance_Internal, index: unknown, dontClean: boolean?) +function Component.prototype.RemoveTask(self: Instance_Internal, index: unknown, dontClean: boolean?) const janitor = Keys.inst(self).janitor if dontClean then janitor:RemoveNoClean(index) @@ -637,7 +627,7 @@ end @return any Gets a task previously added with an index. ]=] -function ComponentClassMethods.GetTask(self: Instance_Internal, index: unknown): unknown +function Component.prototype.GetTask(self: Instance_Internal, index: unknown): unknown return Keys.inst(self).janitor:Get(index) end @@ -671,7 +661,7 @@ end Destroys the component class: stops all its components (with reason `"ClassDestroyed"`), disconnects from CollectionService, and clears all state. ]=] -function ComponentClassMethods.Destroy(self: Class_Internal) +function Component.prototype.Destroy(self: Class_Internal) const idx = table.find(UNSETUP_COMPONENTS, self) if idx then table.remove(UNSETUP_COMPONENTS, idx) From e6e461b6aec198a15d2ed67d5a20dd5871c884bd Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 18:11:53 -0400 Subject: [PATCH 15/19] Flatten Query Logic --- lib/component/src/Query.luau | 1220 ++++++++++++++--- .../src/Tests/Component.Query.spec.luau | 395 +++++- lib/component/src/Types.luau | 9 + lib/component/src/init.luau | 29 +- 4 files changed, 1439 insertions(+), 214 deletions(-) diff --git a/lib/component/src/Query.luau b/lib/component/src/Query.luau index 326a997d..72ebfbbd 100644 --- a/lib/component/src/Query.luau +++ b/lib/component/src/Query.luau @@ -12,20 +12,54 @@ A *requirement* is a **component class** (satisfied while that component is *started* on the instance), a **tag string** (satisfied while the instance has - the raw CollectionService tag), or **another Query** (satisfied while the - instance matches it). An instance *matches* while: + the raw CollectionService tag), **another Query** (satisfied while the + instance matches it), a **filter node** (`Query.Attr` / `Query.Prop` / + `Query.Pred` — satisfied while the value test passes), or a **combinator + node** (`Query.And` / `Query.Or` / `Query.Not` — boolean composition of any + requirements, nesting freely). An instance *matches* while: - every positional / `:with` requirement is satisfied, - - every `:anyOf(...)` group has at least one satisfied, + - every `:withAny(...)` group has at least one satisfied, - no `:without` requirement is satisfied, - every `:withAttribute` matches, - every `:withProperty` matches, and - every `:where` predicate returns true. - A query must have at least one positive requirement (component / tag / - sub-query in the positional args, `:with`, or `:anyOf`) so its candidate set - is bounded; a query built only from `:without` / `:withAttribute` / - `:withProperty` / `:where` errors when observed or read. + A query must have at least one ENUMERABLE positive requirement (component / + tag / sub-query — or an `And`/`Or` of them) so its candidate set is bounded; + filter nodes and `Not` only refine, and a query with no enumerable source + errors when observed or read. + + ## Composition: by value, normalized at build time + + Every requirement is canonicalized as it enters a query, so one logical + shape has ONE internal form — and therefore one signature, one plan, and one + interned engine — no matter how it was spelled: + + - `Query(q1):with(B)` is **literally** `q1:with(B)` (a raw `Query` composes + by value: its clauses are spliced in positive position, or lowered to an + equivalent node in `withAny`/`without`, where the query must stay atomic); + - `with(Query.And(a, b))` == `with(a, b)`; `with(Query.Or(a, b))` == + `withAny(a, b)`; `with(Query.Not(x))` == `without(x)`; + - `with(Query.Attr/Prop/Pred(...))` == `withAttribute` / `withProperty` / + `where`; duplicated requirements do not split signatures. + + `Query.Sub(q)` is the one deliberate exception: it composes **by + reference**, keeping `q` as a live nested sub-query with its own (shared) + reactive engine — see [Query.Sub] for when that is worth it. + + Boolean recipes compose from `And`/`Or`/`Not` (a functionally complete + basis; variadic `Not(...)` is "none of" — i.e. NOR). E.g. exclusive-or, + "B or C but not both": + + ```lua + Component.query(A):withAny(B, C):without(Query.And(B, C)) + ``` + + :::caution Canonicalization can change the ORDER (and short-circuit count) + in which user predicate functions run relative to the exact spelling used. + Match results are unaffected; do not rely on side effects inside `Pred` / + `where` / function matchers. ::: See the Component `CONTEXT.md` for the glossary and the `README` for examples. ]=] @@ -72,7 +106,54 @@ type RecheckSignalView = { Connect: (self: RecheckSignalView, fn: () -> ()) -> ConnectionLike, } -export type Queryable = ComponentClassLike | string | Query +-- A single matcher for an attribute/property value: either a value the value +-- must EQUAL, or a predicate `(instance, value) -> boolean`. `unknown` because a +-- value can be anything; the predicate case is duck-detected via `type == "function"`. +export type Matcher = unknown + +-- Caller-owned signal that forces a re-evaluation of all bounded instances when +-- fired. Structural so it accepts both better-signal `Signal` and RBXScriptSignal. +export type RecheckSignal = { + Connect: (self: RecheckSignal, callback: () -> ()) -> { Disconnect: (self: any) -> () }, +} + +-- ── Composable nodes ───────────────────────────────────────────────────────── +-- A Filter is a leaf refinement: it tests ONE instance and contributes NO +-- candidate source, so it is valid only nested inside a bounded query (via +-- with / withAny / without / a combinator), never as a query's sole bound. +-- Built by Query.Attr / Query.Prop / Query.Pred; never constructed by hand. +export type Filter = { + _node: "filter", + op: "attr" | "prop" | "where", + name: string?, -- attr / prop + -- attr/prop: the compiled MatchSpec (existence flag + packed any-of matcher + -- list; explicit `nil` matchers survive via table.pack). Opaque to users. + spec: unknown?, + fn: ((instance: Instance) -> boolean)?, -- where + signal: RecheckSignal?, -- where: optional recheck +} + +-- A Combinator composes child Queryables under a boolean operator. `not`, and +-- an `or` with any unbounded child, are refinement-only; `and` is bounded when +-- any child is (see the boundedness rules in `_validate`). Built by +-- Query.And / Query.Or / Query.Not. +export type Combinator = { + _node: "combinator", + op: "not" | "or" | "and", + children: { Queryable }, +} + +-- Compose-BY-REFERENCE marker: `Query.Sub(q)` keeps `q` as a live nested +-- sub-query (own reactive engine, shared by every parent referencing an +-- equivalent shape) instead of lowering its clauses into the parent. A raw +-- `Query` passed anywhere composes BY VALUE (its clauses are spliced/lowered +-- at build time); `Sub` is the only spelling that nests. +export type Sub = { + _node: "sub", + query: Query, +} + +export type Queryable = ComponentClassLike | string | Query | Filter | Combinator | Sub --[=[ @interface QueryConnection @@ -90,11 +171,11 @@ export type QueryConnection = { export type Query = { with: (self: Query, ...Queryable) -> Query, - anyOf: (self: Query, ...Queryable) -> Query, + withAny: (self: Query, ...Queryable) -> Query, without: (self: Query, ...Queryable) -> Query, - withAttribute: (self: Query, name: string, matcher: unknown?) -> Query, - withProperty: (self: Query, name: string, matcher: unknown?) -> Query, - where: (self: Query, predicate: (Instance) -> boolean, recheckSignal: unknown?) -> Query, + withAttribute: (self: Query, name: string, ...Matcher) -> Query, + withProperty: (self: Query, name: string, ...Matcher) -> Query, + where: (self: Query, predicate: (Instance) -> boolean, recheckSignal: RecheckSignal?) -> Query, observe: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, observeUnyielding: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, track: (self: Query) -> QueryConnection, @@ -110,27 +191,23 @@ export type Query = { -- engine (sub-query engines) and the one-shot GetMatches (static membership). type SatisfiedFn = (QueryInternal, Instance) -> boolean --- One `:withAttribute` requirement. `matcher` is the EXISTS sentinel, a --- `(value) -> boolean` predicate, or a value the attribute must equal. -type AttributeRequirement = { +-- Unified attribute/property clause used by BOTH the fast-path lists +-- (`_attributes` / `_properties`) and compiled attr/prop nodes. `exists` +-- short-circuits to an existence check (0-matcher form); otherwise match = +-- value satisfies ANY matcher (any-of). The list may legitimately hold `nil` +-- matchers ("value == nil"), so `count` (table.pack's `n`) sizes it, never `#`. +type MatchSpec = { name: string, - matcher: unknown, -} - --- One `:withProperty` requirement. `matcher` is the EXISTS sentinel (property --- merely present on the Instance), a `(value) -> boolean` predicate, or a value --- the property must equal (an explicit `nil` matcher is stored as `nil` and means --- "property present AND equal to nil"; the omitted form is the EXISTS sentinel). -type PropertyRequirement = { - name: string, - matcher: unknown, + exists: boolean, + matchers: { Matcher }, + count: number, } -- One `:where` requirement; `signal` is duck-cast to `RecheckSignalView` when -- the engine activates. type PredicateRequirement = { fn: (Instance) -> boolean, - signal: unknown?, + signal: RecheckSignal?, } type Observer = { @@ -149,13 +226,17 @@ type ChangedSignal = Signal.Signal<(Instance, boolean) -> (), Instance, boolean> -- SINGLE table lookup — membership in `startedMap` IS "started" — and -- `startedList` gives `get()` an O(1)-sized, already-filtered seed source. type PlanReq = { - kind: "tag" | "class" | "classlike" | "query", + kind: "tag" | "class" | "classlike" | "query" | "attr" | "prop" | "where" | "not" | "or" | "and", buildIndex: number, -- declaration position; tiebreak for the stable sort tag: string?, startedList: { Instance }?, -- class: live dense started-instance array startedMap: { [Instance]: number }?, -- class: live instance -> list index class: ComponentClassLike?, -- foreign class-like: FromInstance fallback query: QueryInternal?, + spec: MatchSpec?, -- attr / prop node + fn: ((Instance) -> boolean)?, -- where node + signal: RecheckSignal?, -- where node + children: { PlanReq }?, -- not / or / and (compiled recursively) } -- The compiled shape of a query: requirement lists as PlanReqs plus presence @@ -203,8 +284,8 @@ type QueryInternal = Query & { _positive: { Queryable }, _anyOf: { { Queryable } }, -- array of groups _negative: { Queryable }, - _attributes: { AttributeRequirement }, - _properties: { PropertyRequirement }, + _attributes: { MatchSpec }, + _properties: { MatchSpec }, _predicates: { PredicateRequirement }, _engine: Engine?, _refcount: number, @@ -221,6 +302,7 @@ type QueryInternal = Query & { _validate: (self: QueryInternal) -> (), _allReferences: (self: QueryInternal) -> { Queryable }, _positiveCandidate: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, + _universeCandidate: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, _attributesMatch: (self: QueryInternal, instance: Instance) -> boolean, _propertiesMatch: (self: QueryInternal, instance: Instance) -> boolean, _predicatesPass: (self: QueryInternal, instance: Instance) -> boolean, @@ -241,28 +323,66 @@ type QueryInternal = Query & { _collect: (self: QueryInternal, onMatch: (Instance) -> boolean?) -> (), } -const EXISTS = newproxy(false) -- sentinel: attribute must merely exist const INTERNAL = Keys.Internal -- hoisted for the plan compiler's class probe +-- Module table: static constructors (`new`, `Attr`, `Prop`, `Pred`, `And`, +-- `Or`, `Not`) plus the instance `prototype`. Made callable (`Query(...)` == +-- `Query.new(...)`) at the bottom of the file. const Query = {} -Query.__index = Query +-- Instance methods live on `prototype` (mirrors `Component.prototype`), keeping +-- the static namespace free — `and`/`or`/`not` are reserved words, so combinators +-- must be statics, and statics must not collide with method names. +const prototype = {} +prototype.__index = prototype +Query.prototype = prototype + +-- Node prototypes: Filters/Combinators are tagged tables built through these so +-- a later fluent matcher DSL can attach methods without changing representation. +const FilterProto = {} +FilterProto.__index = FilterProto +const ComboProto = {} +ComboProto.__index = ComboProto +const SubProto = {} +SubProto.__index = SubProto const function isQuery(value: unknown): boolean - return type(value) == "table" and getmetatable(value) == Query + return type(value) == "table" and getmetatable(value) == prototype +end + +const function isFilter(value: unknown): boolean + return type(value) == "table" and getmetatable(value) == FilterProto +end + +const function isCombinator(value: unknown): boolean + return type(value) == "table" and getmetatable(value) == ComboProto +end + +const function isSub(value: unknown): boolean + return type(value) == "table" and getmetatable(value) == SubProto end const function isComponentClass(value: unknown): boolean - if type(value) ~= "table" or isQuery(value) then + if type(value) ~= "table" or isQuery(value) or isFilter(value) or isCombinator(value) or isSub(value) then return false end return type((value :: { read Tag: unknown }).Tag) == "string" end const function assertQueryable(value: Queryable, method: string) - if type(value) == "string" or isQuery(value) or isComponentClass(value) then + if + type(value) == "string" + or isQuery(value) + or isFilter(value) + or isCombinator(value) + or isSub(value) + or isComponentClass(value) + then return end - error(`[Component] :{method}() expects a component class, tag string, or Query`, 3) + error( + `[Component] :{method}() expects a component class, tag string, Query, or Query.Attr/Prop/Pred/And/Or/Not/Sub node`, + 3 + ) end --[=[ @@ -290,7 +410,7 @@ const function rawNew(): QueryInternal _sources = nil, _planned = nil, _signatureCache = nil, - }, Query) :: any + }, prototype) :: any ) :: QueryInternal end @@ -311,11 +431,297 @@ const function derive(self: QueryInternal): QueryInternal return new end +-- Packs a variadic matcher list into a MatchSpec. 0 matchers = existence check; +-- otherwise the value must satisfy ANY entry (value-equality, or a predicate +-- `(instance, value) -> boolean`). `table.pack` so explicit `nil` matchers +-- ("value == nil") survive; `select("#")` distinguishes omitted from nil. +const function makeMatchSpec(name: string, ...: Matcher): MatchSpec + const count = select("#", ...) + return { + name = name, + exists = count == 0, + matchers = table.pack(...) :: { Matcher }, + count = count, + } +end + +-------------------------------------------------------------------------------- +-- Static node constructors (composable Queryables) +-------------------------------------------------------------------------------- + +--[=[ + @within Query + @function Attr + @param name string + @param ... any -- values and/or `(instance, value) -> boolean` predicates; ANY may match. Omit all for existence. + @return Filter + An attribute filter node, usable anywhere a Queryable is accepted + (`query(...)`, `:with`, `:withAny`, `:without`, or nested in `Query.And/Or/Not`). + Same matcher semantics as [Query:withAttribute]. +]=] +function Query.Attr(name: string, ...: Matcher): Filter + assert(type(name) == "string", "[Component] Query.Attr() expects an attribute name string") + const spec = makeMatchSpec(name, ...) + return (setmetatable({ _node = "filter", op = "attr", name = name, spec = spec }, FilterProto) :: any) :: Filter +end + +--[=[ + @within Query + @function Prop + @param name string + @param ... any -- values and/or `(instance, value) -> boolean` predicates; ANY may match. Omit all for existence; explicit `nil` means "== nil". + @return Filter + A property filter node. Same matcher semantics as [Query:withProperty]. +]=] +function Query.Prop(name: string, ...: Matcher): Filter + assert(type(name) == "string", "[Component] Query.Prop() expects a property name string") + const spec = makeMatchSpec(name, ...) + return (setmetatable({ _node = "filter", op = "prop", name = name, spec = spec }, FilterProto) :: any) :: Filter +end + +--[=[ + @within Query + @function Pred + @param fn (instance: Instance) -> boolean + @param recheckSignal RecheckSignal? -- fire to force re-evaluation + @return Filter + A predicate filter node. Same semantics (and staleness caveat) as [Query:where]. +]=] +function Query.Pred(fn: (Instance) -> boolean, recheckSignal: RecheckSignal?): Filter + assert(type(fn) == "function", "[Component] Query.Pred() expects a predicate function") + return ( + setmetatable({ _node = "filter", op = "where", fn = fn, signal = recheckSignal }, FilterProto) :: any + ) :: Filter +end + +const function makeCombinator(op: "not" | "or" | "and", method: string, ...: Queryable): Combinator + const children = { ... } + assert(#children > 0, `[Component] Query.{method}() expects at least one Queryable`) + for _, child in children do + assertQueryable(child, method) + end + return (setmetatable({ _node = "combinator", op = op, children = children }, ComboProto) :: any) :: Combinator +end + +--[=[ + @within Query + @function And + @param ... Queryable + @return Combinator + Satisfied when ALL children are. Bounded (usable as a candidate source) when + any child is bounded. +]=] +function Query.And(...: Queryable): Combinator + return makeCombinator("and", "And", ...) +end + +--[=[ + @within Query + @function Or + @param ... Queryable + @return Combinator + Satisfied when AT LEAST ONE child is. Bounded only when every child is + bounded (an unbounded branch would make the candidate set unbounded). +]=] +function Query.Or(...: Queryable): Combinator + return makeCombinator("or", "Or", ...) +end + +--[=[ + @within Query + @function Not + @param ... Queryable + @return Combinator + Satisfied when NONE of the children are (variadic "none of", mirroring + [Query:without]). Refinement-only: contributes no candidates. +]=] +function Query.Not(...: Queryable): Combinator + return makeCombinator("not", "Not", ...) +end + +--[=[ + @within Query + @function Sub + @param query Query + @return Sub + + Composes `query` **by reference**: the parent keeps it as a live nested + sub-query with its own reactive engine (shared with every other parent + referencing an equivalent shape) instead of lowering its clauses into the + parent's plan. + + A raw `Query` passed to `query(...)` / `:with` / `:withAny` / `:without` + composes **by value** — its clauses are spliced (or wrapped as an `And` node + in `withAny`/`without`) at build time, so `Query(q1):with(B)` is literally + `q1:with(B)`. Use `Sub` when you specifically want the nested form: + - the sub-query's clauses are expensive per candidate (heavy `Pred`s, many + property reads) and it is shared by many active parents — one shared + evaluation instead of per-parent probes; + - you want the sub-query validated standalone (a `Sub` of an unbounded + query errors; a spliced one can be bounded by the parent's other sources); + - you rely on the sub-query's own evaluation order for side-effectful + predicates. +]=] +function Query.Sub(query: Query): Sub + assert(isQuery(query), "[Component] Query.Sub() expects a Query") + return (setmetatable({ _node = "sub", query = query }, SubProto) :: any) :: Sub +end + +-- Rebuilds a Filter node from an already-packed MatchSpec (the public +-- `Query.Attr`/`Query.Prop` pack fresh varargs; lowering reuses stored specs — +-- they are immutable after creation, so sharing is safe). +const function filterFromSpec(op: "attr" | "prop", spec: MatchSpec): Filter + return (setmetatable({ _node = "filter", op = op, name = spec.name, spec = spec }, FilterProto) :: any) :: Filter +end + +const function predToFilter(pred: PredicateRequirement): Filter + return ( + setmetatable({ _node = "filter", op = "where", fn = pred.fn, signal = pred.signal }, FilterProto) :: any + ) :: Filter +end + +-- Lowers a whole query to a single equivalent node for ATOMIC positions +-- (`withAny` members, `without` entries), where splicing would change meaning +-- (De Morgan): the node is satisfied exactly while the query matches. Positive +-- positions splice instead (see `addPositive`). +const function queryToNode(q: QueryInternal, method: string): Queryable + const parts: { Queryable } = {} + for _, req in q._positive do + table.insert(parts, req) + end + for _, group in q._anyOf do + table.insert(parts, makeCombinator("or", "Or", table.unpack(group))) + end + if #q._negative > 0 then + table.insert(parts, makeCombinator("not", "Not", table.unpack(q._negative))) + end + for _, spec in q._attributes do + table.insert(parts, filterFromSpec("attr", spec)) + end + for _, spec in q._properties do + table.insert(parts, filterFromSpec("prop", spec)) + end + for _, pred in q._predicates do + table.insert(parts, predToFilter(pred)) + end + if #parts == 0 then + error(`[Component] :{method}() cannot compose an empty query (it has no requirements)`, 3) + end + if #parts == 1 then + return parts[1] + end + return makeCombinator("and", "And", table.unpack(parts)) +end + +-------------------------------------------------------------------------------- +-- Build-time normalization +-- +-- Every requirement is canonicalized as it enters a query, so ONE logical shape +-- has ONE internal form (and therefore one signature, one plan, one interned +-- engine) no matter how it was spelled: +-- with(rawQuery) == splicing its clauses (compose by value) +-- with(And(a, b)) == with(a, b) +-- with(Or(a, b)) == withAny(a, b) +-- with(Not(x)) == without(x) +-- with(Attr/Prop/...) == withAttribute / withProperty / where +-- withAny(x) == with(x) (a 1-member group is required) +-- `Query.Sub(q)` is the deliberate exception: it composes by REFERENCE and is +-- stored as-is. Normalization happens only here in the builders; stored queries +-- are always already canonical, so splices never recurse. +-------------------------------------------------------------------------------- + +local normalizeAnyMember: (req: Queryable, method: string) -> Queryable + +-- Canonical entry of one requirement into REQUIRED (AND) position. +const function addPositive(new: QueryInternal, req: Queryable, method: string) + if isQuery(req) then + -- Compose by value: splice the query's (already canonical) clauses. + const q = req :: QueryInternal + for _, entry in q._positive do + table.insert(new._positive, entry) + end + for _, group in q._anyOf do + table.insert(new._anyOf, group) + end + for _, entry in q._negative do + table.insert(new._negative, entry) + end + for _, spec in q._attributes do + table.insert(new._attributes, spec) + end + for _, spec in q._properties do + table.insert(new._properties, spec) + end + for _, pred in q._predicates do + table.insert(new._predicates, pred) + end + return + end + if isCombinator(req) then + const combo = req :: Combinator + const op = combo.op + if op == "and" then + for _, child in combo.children do + addPositive(new, child, method) + end + return + elseif op == "or" then + const group: { Queryable } = {} + for _, child in combo.children do + table.insert(group, normalizeAnyMember(child, method)) + end + if #group == 1 then + addPositive(new, group[1], method) + else + table.insert(new._anyOf, group) + end + return + else -- not: required "none of" == excluded + for _, child in combo.children do + table.insert(new._negative, normalizeAnyMember(child, method)) + end + return + end + end + if isFilter(req) then + const filter = req :: Filter + const op = filter.op + if op == "attr" then + table.insert(new._attributes, filter.spec :: MatchSpec) + elseif op == "prop" then + table.insert(new._properties, filter.spec :: MatchSpec) + else + table.insert(new._predicates, { fn = filter.fn :: (Instance) -> boolean, signal = filter.signal }) + end + return + end + -- Sub node / class / tag / class-like: a plain required requirement. + table.insert(new._positive, req) +end + +-- Canonical form of one requirement in an ATOMIC position (a `withAny` group +-- member or a `without` entry), where the requirement must stand as a single +-- testable unit: raw queries lower to an equivalent node (De Morgan forbids +-- splicing here); nested single-child wrappers collapse; everything else is +-- kept as-is. +function normalizeAnyMember(req: Queryable, method: string): Queryable + if isQuery(req) then + return queryToNode(req :: QueryInternal, method) + end + if isCombinator(req) then + const combo = req :: Combinator + if #combo.children == 1 and combo.op ~= "not" then + return normalizeAnyMember(combo.children[1], method) + end + end + return req +end + function Query.new(...: Queryable): Query const self = rawNew() for _, req in { ... } do assertQueryable(req, "with") - table.insert(self._positive, req) + addPositive(self, req, "with") end return self end @@ -328,11 +734,11 @@ end receiver is unchanged (builders never mutate, so chains branch freely). `query():with(X)` is equivalent to `query(X)`. ]=] -function Query.with(self: QueryInternal, ...: Queryable): Query +function prototype.with(self: QueryInternal, ...: Queryable): Query const new = derive(self) for _, req in { ... } do assertQueryable(req, "with") - table.insert(new._positive, req) + addPositive(new, req, "with") end return new end @@ -342,19 +748,35 @@ end @param ... Queryable @return Query Returns a NEW query with an "at least one of" group added: the instance - must satisfy at least one of the given requirements. Multiple `:anyOf` + must satisfy at least one of the given requirements. Multiple `:withAny` calls each add an independent group. The receiver is unchanged. ]=] -function Query.anyOf(self: QueryInternal, ...: Queryable): Query - const group = { ... } - for _, req in group do - assertQueryable(req, "anyOf") - end - if #group == 0 then +function prototype.withAny(self: QueryInternal, ...: Queryable): Query + const raw = { ... } + if #raw == 0 then return self end const new = derive(self) - table.insert(new._anyOf, group) + const group: { Queryable } = {} + for _, req in raw do + assertQueryable(req, "withAny") + -- A nested Or is the same disjunction: flatten its members into this + -- group so `withAny(Or(a, b), c)` == `withAny(a, b, c)`. + const normalized = normalizeAnyMember(req, "withAny") + if isCombinator(normalized) and (normalized :: Combinator).op == "or" then + for _, child in (normalized :: Combinator).children do + table.insert(group, normalizeAnyMember(child, "withAny")) + end + else + table.insert(group, normalized) + end + end + if #group == 1 then + -- "At least one of {x}" is just "x": required position. + addPositive(new, group[1], "withAny") + else + table.insert(new._anyOf, group) + end return new end @@ -365,11 +787,11 @@ end Returns a NEW query with the given excluded requirements added: the instance must satisfy none of them. The receiver is unchanged. ]=] -function Query.without(self: QueryInternal, ...: Queryable): Query +function prototype.without(self: QueryInternal, ...: Queryable): Query const new = derive(self) for _, req in { ... } do assertQueryable(req, "without") - table.insert(new._negative, req) + table.insert(new._negative, normalizeAnyMember(req, "without")) end return new end @@ -377,57 +799,51 @@ end --[=[ @within Query @param name string - @param matcher any -- a value to equal, a `(value) -> boolean` predicate, or omitted for existence + @param ... any -- values to equal and/or `(instance, value) -> boolean` predicates; matches if ANY does. Omit all for existence. @return Query Returns a NEW query that additionally requires an attribute; the receiver - is unchanged. With no matcher, the attribute must merely exist; with a - function, the function must return true for the attribute's value; otherwise the - value must equal `matcher`. Re-evaluated reactively on attribute change. + is unchanged. With no matchers, the attribute must merely exist; otherwise + the attribute's value must satisfy at least one matcher (equal a value, or a + predicate returning true). Re-evaluated reactively on attribute change. ]=] -function Query.withAttribute(self: QueryInternal, name: string, matcher: unknown?): Query +function prototype.withAttribute(self: QueryInternal, name: string, ...: Matcher): Query assert(type(name) == "string", "[Component] :withAttribute() expects an attribute name string") const new = derive(self) - table.insert(new._attributes, { - name = name, - matcher = if matcher == nil then EXISTS else matcher, - }) + table.insert(new._attributes, makeMatchSpec(name, ...)) return new end --[=[ @within Query @param name string - @param matcher any -- a value to equal, a `(value) -> boolean` predicate, or omitted to require the property merely exists + @param ... any -- values to equal and/or `(instance, value) -> boolean` predicates; matches if ANY does. Omit all for existence. @return Query Returns a NEW query that additionally requires an Instance property; the receiver is unchanged. Re-evaluated reactively when the property changes. - The matcher is REQUIRED but may be an explicit `nil`: + Matcher forms: - **omitted** (`:withProperty("Anchored")`) — the property must merely *exist* on the instance (a candidate whose class lacks it never matches); - **explicit `nil`** (`:withProperty("Parent", nil)`) — the property must exist and equal `nil` (e.g. "unparented"); - - **a function** — it must return true for the property's value; - - **any other value** — the property must equal `matcher`. + - **functions** — must return true for `(instance, value)`; + - **any other value** — the property must equal it; + - **several matchers** — the value must satisfy at least one. Unlike attributes, a property may not exist on every Instance class a query spans; a class lacking the named property simply does not match. ]=] -function Query.withProperty(self: QueryInternal, name: string, ...: unknown): Query +function prototype.withProperty(self: QueryInternal, name: string, ...: Matcher): Query assert(type(name) == "string", "[Component] :withProperty() expects a property name string") const new = derive(self) - -- Omitted matcher (void) = existence check via the EXISTS sentinel; an EXPLICIT - -- `nil` is a real matcher meaning "property == nil". `select("#")` (arg count) - -- distinguishes the two, which `matcher == nil` cannot. - const matcher = if select("#", ...) == 0 then EXISTS else (...) - table.insert(new._properties, { name = name, matcher = matcher }) + table.insert(new._properties, makeMatchSpec(name, ...)) return new end --[=[ @within Query @param predicate (instance: Instance) -> boolean - @param recheckSignal Signal? -- fire to force re-evaluation + @param recheckSignal RecheckSignal? -- fire to force re-evaluation @return Query Returns a NEW query with an arbitrary predicate added; the receiver is @@ -435,7 +851,7 @@ end own — it is only re-evaluated when another requirement changes, or when the optional `recheckSignal` fires. Without one, its result can go stale. ::: ]=] -function Query.where(self: QueryInternal, predicate: (Instance) -> boolean, recheckSignal: unknown?): Query +function prototype.where(self: QueryInternal, predicate: (Instance) -> boolean, recheckSignal: RecheckSignal?): Query assert(type(predicate) == "function", "[Component] :where() expects a predicate function") const new = derive(self) table.insert(new._predicates, { fn = predicate, signal = recheckSignal }) @@ -446,9 +862,9 @@ end -- Validation -------------------------------------------------------------------------------- --- Flattened positive requirements (positional/:with + every :anyOf member). --- These bound the candidate set; a query with none is unbounded and rejected. -function Query._positiveSources(self: QueryInternal): { Queryable } +-- Flattened positive requirements (positional/:with + every :withAny member). +-- These bound the candidate set; a query with no enumerable one is rejected. +function prototype._positiveSources(self: QueryInternal): { Queryable } const cached = self._sources if cached then return cached @@ -466,18 +882,78 @@ function Query._positiveSources(self: QueryInternal): { Queryable } return sources end --- DFS over sub-query references; errors on an empty query. Cycles are --- impossible by construction: builders copy-on-write, so a query can only ever --- reference queries that existed before it did. -function Query._validate(self: QueryInternal) +-- Whether a requirement can ENUMERATE a finite candidate set: classes, tags, +-- and sub-queries can; filters and `Not` cannot (they only test); an `And` can +-- when any child can (that child's members are a superset of the And's); an +-- `Or` only when every child can (its members are the union of the children's). +const function isEnumerable(req: Queryable): boolean + if type(req) == "string" or isQuery(req) or isSub(req) then + return true + end + if isFilter(req) then + return false + end + if isCombinator(req) then + const combo = req :: Combinator + const op = combo.op + if op == "not" then + return false + elseif op == "and" then + for _, child in combo.children do + if isEnumerable(child) then + return true + end + end + return false + else -- or + for _, child in combo.children do + if not isEnumerable(child) then + return false + end + end + return true + end + end + return true -- component class / class-like +end + +-- DFS over sub-query references; errors on an unbounded query. A query is +-- bounded iff a required requirement is enumerable, or (with no enumerable +-- required requirement) some `withAny` group is enumerable throughout — every +-- match satisfies each group, so a fully-enumerable group's union bounds the +-- candidate set. Cycles are impossible by construction: builders copy-on-write, +-- so a query can only ever reference queries that existed before it did. +function prototype._validate(self: QueryInternal) if self._validated then return end - const sources = self:_positiveSources() - if #sources == 0 then + local bounded = false + for _, req in self._positive do + if isEnumerable(req) then + bounded = true + break + end + end + if not bounded then + for _, group in self._anyOf do + local groupEnumerable = #group > 0 + for _, req in group do + if not isEnumerable(req) then + groupEnumerable = false + break + end + end + if groupEnumerable then + bounded = true + break + end + end + end + if not bounded then error( - "[Component] Query has no positive requirement (component / tag / sub-query); " - .. "add one via query(...), :with(), or :anyOf() so its candidate set is bounded", + "[Component] Query has no enumerable positive requirement (component / tag / sub-query, " + .. "or an And/Or of them); filters and Not() only refine — add a bounded source via " + .. "query(...), :with(), or :withAny() so the candidate set is finite", 0 ) end @@ -489,19 +965,34 @@ function Query._validate(self: QueryInternal) self._validated = true end --- Every requirement across all clauses (positive, anyOf, negative). -function Query._allReferences(self: QueryInternal): { Queryable } - const refs = {} +-- Every requirement across all clauses (positive, anyOf, negative), flattened: +-- combinators are unwrapped recursively and `Sub` nodes unwrap to their inner +-- Query, so the list holds only leaf requirements (classes, tags, sub-queries, +-- filters). Consumers subscribe / validate / memoize from this without knowing +-- about nesting. +function prototype._allReferences(self: QueryInternal): { Queryable } + const refs: { Queryable } = {} + const function add(req: Queryable) + if isCombinator(req) then + for _, child in (req :: Combinator).children do + add(child) + end + elseif isSub(req) then + table.insert(refs, (req :: Sub).query) + else + table.insert(refs, req) + end + end for _, req in self._positive do - table.insert(refs, req) + add(req) end for _, group in self._anyOf do for _, req in group do - table.insert(refs, req) + add(req) end end for _, req in self._negative do - table.insert(refs, req) + add(req) end return refs end @@ -510,13 +1001,86 @@ end -- Satisfaction / matching -------------------------------------------------------------------------------- +-- Probe cost by kind, measured per candidate: a class check is one direct table +-- lookup (~30ns), a sub-query check is a memo/engine hash lookup, a class-like +-- goes through `FromInstance` (~80ns), and a tag check is a `HasTag` C-call +-- (~150ns). Node kinds follow: an attr read is one `GetAttribute` C-call, a prop +-- read a pcall'd index, `where` a user pcall, and combinators recurse into an +-- unknown number of children — probed last. Every compiled list is sorted +-- cheapest-first so per-candidate evaluation short-circuits on the cheap probes; +-- requirement semantics are order-independent, so this is free. `buildIndex` +-- keeps the sort deterministic (`table.sort` is unstable). +const KIND_COST: { [string]: number } = { + class = 1, + query = 2, + classlike = 3, + tag = 4, + attr = 5, + prop = 6, + where = 7, + ["not"] = 8, + ["or"] = 8, + ["and"] = 8, +} + +-- A seed at or below this is narrow enough that hunting for a better one is +-- not worth fetching more tag arrays: remaining probes run at most this many +-- times each. +const TAG_SIZING_EARLY_EXIT = 32 + +-- `get()` sizes every required tag via `GetTagged` (to pick the narrowest seed +-- and order probes most-selective-first) only when the best class seed exceeds +-- this. Below it the candidate set is already small, and sizing a huge tag +-- would cost an array allocation proportional to its population for at most a +-- few hundred cheap probes of savings. +const TAG_SIZING_MIN_SEED = 200 +const function sortBySelectivity(reqs: { PlanReq }) + table.sort(reqs, function(x: PlanReq, y: PlanReq): boolean + const cx = KIND_COST[x.kind] :: number + const cy = KIND_COST[y.kind] :: number + if cx ~= cy then + return cx < cy + end + return x.buildIndex < y.buildIndex + end) +end + const function compileReq(req: Queryable, buildIndex: number): PlanReq if type(req) == "string" then return { kind = "tag" :: "tag", buildIndex = buildIndex, tag = req } end + if isSub(req) then + return { kind = "query" :: "query", buildIndex = buildIndex, query = (req :: Sub).query :: QueryInternal } + end if isQuery(req) then + -- Defensive: normalization never stores a raw Query, but compile it as a + -- sub-query rather than misclassifying if one ever slips through. return { kind = "query" :: "query", buildIndex = buildIndex, query = req :: QueryInternal } end + if isFilter(req) then + const filter = req :: Filter + if filter.op == "where" then + return { kind = "where" :: "where", buildIndex = buildIndex, fn = filter.fn, signal = filter.signal } + end + return { + kind = (if filter.op == "attr" then "attr" else "prop") :: "attr" | "prop", + buildIndex = buildIndex, + spec = filter.spec :: MatchSpec, + } + end + if isCombinator(req) then + const combo = req :: Combinator + const children: { PlanReq } = {} + for index, child in combo.children do + table.insert(children, compileReq(child, index)) + end + sortBySelectivity(children) + return { + kind = combo.op :: "not" | "or" | "and", + buildIndex = buildIndex, + children = children, + } + end const internal = (req :: any)[INTERNAL] if internal then -- Our own class: capture its live started sparse set. Both tables are @@ -533,39 +1097,59 @@ const function compileReq(req: Queryable, buildIndex: number): PlanReq return { kind = "classlike" :: "classlike", buildIndex = buildIndex, class = req :: ComponentClassLike } end --- Probe cost by kind, measured per candidate: a class check is one direct table --- lookup (~30ns), a sub-query check is a memo/engine hash lookup, a class-like --- goes through `FromInstance` (~80ns), and a tag check is a `HasTag` C-call --- (~150ns). Every compiled list is sorted cheapest-first so per-candidate --- evaluation short-circuits on the cheap probes; requirement semantics are --- order-independent, so this is free. `buildIndex` keeps the sort deterministic --- (`table.sort` is unstable). -const KIND_COST: { [string]: number } = { class = 1, query = 2, classlike = 3, tag = 4 } +-- Evaluates one MatchSpec. `present` is whether the attribute/property exists at +-- all on this instance (attribute: value ~= nil; property: the read didn't +-- throw); `label` names the calling surface for matcher-error warnings. THE +-- single implementation of matcher semantics — the fast-path clause lists and +-- the compiled attr/prop nodes both route here. +const function matchSpecSatisfied( + instance: Instance, + present: boolean, + value: unknown, + spec: MatchSpec, + label: string +): boolean + if spec.exists then + return present + end + if not present then + return false + end + const matchers = spec.matchers + for i = 1, spec.count do + const matcher = matchers[i] + if type(matcher) == "function" then + const success, result = pcall(matcher :: (Instance, unknown) -> unknown, instance, value) + if success and result == true then + return true + end + if not success then + warn(`[Component] Query {label}('{spec.name}') matcher errored: {result}`) + end + elseif value == matcher then + return true + end + end + return false +end --- A seed at or below this is narrow enough that hunting for a better one is --- not worth fetching more tag arrays: remaining probes run at most this many --- times each. -const TAG_SIZING_EARLY_EXIT = 32 +const function attrSatisfies(instance: Instance, spec: MatchSpec): boolean + const value = instance:GetAttribute(spec.name) + return matchSpecSatisfied(instance, value ~= nil, value, spec, "Attr") +end --- `get()` sizes every required tag via `GetTagged` (to pick the narrowest seed --- and order probes most-selective-first) only when the best class seed exceeds --- this. Below it the candidate set is already small, and sizing a huge tag --- would cost an array allocation proportional to its population for at most a --- few hundred cheap probes of savings. -const TAG_SIZING_MIN_SEED = 200 -const function sortBySelectivity(reqs: { PlanReq }) - table.sort(reqs, function(x: PlanReq, y: PlanReq): boolean - const cx = KIND_COST[x.kind] :: number - const cy = KIND_COST[y.kind] :: number - if cx ~= cy then - return cx < cy - end - return x.buildIndex < y.buildIndex +const function propSatisfies(instance: Instance, spec: MatchSpec): boolean + -- A property missing on this Instance's class throws on read; the pcall + -- failing IS the "property absent" signal (there is no reflection API for + -- game code). `present` distinguishes absent from present-but-nil. + const present, value = pcall(function() + return (instance :: any)[spec.name] end) + return matchSpecSatisfied(instance, present, if present then value else nil, spec, "Prop") end -- Compiled requirement check. `satisfiedFn` is only consulted for sub-query --- requirements; tag/class checks are direct. +-- requirements; all other kinds are direct (combinators recurse). const function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: SatisfiedFn): boolean const kind = req.kind if kind == "class" then @@ -574,6 +1158,38 @@ const function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: Satis return CollectionService:HasTag(instance, req.tag :: string) elseif kind == "query" then return satisfiedFn(req.query :: QueryInternal, instance) + elseif kind == "attr" then + return attrSatisfies(instance, req.spec :: MatchSpec) + elseif kind == "prop" then + return propSatisfies(instance, req.spec :: MatchSpec) + elseif kind == "where" then + const success, result = pcall(req.fn :: (Instance) -> boolean, instance) + if not success then + warn(`[Component] Query.Pred() predicate errored: {result}`) + return false + end + return result == true + elseif kind == "not" then + for _, child in req.children :: { PlanReq } do + if reqSatisfied(child, instance, satisfiedFn) then + return false + end + end + return true + elseif kind == "or" then + for _, child in req.children :: { PlanReq } do + if reqSatisfied(child, instance, satisfiedFn) then + return true + end + end + return false + elseif kind == "and" then + for _, child in req.children :: { PlanReq } do + if not reqSatisfied(child, instance, satisfiedFn) then + return false + end + end + return true else const class = req.class :: ComponentClassLike const component = class:FromInstance(instance) @@ -581,13 +1197,41 @@ const function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: Satis end end +-- Loose per-requirement check used to track the candidate UNIVERSE: value +-- filters (attr/prop/where) and negations are vacuously TRUE here, so an +-- instance stays tracked (positiveSet membership, attribute/property change +-- subscriptions) while it satisfies the SOURCE requirements alone — a filter +-- that is currently false must not tear down the very subscription that would +-- re-evaluate it when it flips true. The strict check decides actual matching. +const function reqInUniverse(req: PlanReq, instance: Instance, satisfiedFn: SatisfiedFn): boolean + const kind = req.kind + if kind == "attr" or kind == "prop" or kind == "where" or kind == "not" then + return true + elseif kind == "or" then + for _, child in req.children :: { PlanReq } do + if reqInUniverse(child, instance, satisfiedFn) then + return true + end + end + return false + elseif kind == "and" then + for _, child in req.children :: { PlanReq } do + if not reqInUniverse(child, instance, satisfiedFn) then + return false + end + end + return true + end + return reqSatisfied(req, instance, satisfiedFn) +end + -- Placeholder SatisfiedFn for plans with no sub-query requirement: nothing can -- ever call it (`reqSatisfied` only consults satisfiedFn for "query" kinds). const function neverSub(_query: QueryInternal, _instance: Instance): boolean return false end -function Query._plan(self: QueryInternal): Plan +function prototype._plan(self: QueryInternal): Plan const cached = self._planned if cached then return cached @@ -616,6 +1260,10 @@ function Query._plan(self: QueryInternal): Plan if req.kind == "query" then return true end + const children = req.children + if children and anyQueryReq(children) then + return true + end end return false end @@ -642,7 +1290,7 @@ function Query._plan(self: QueryInternal): Plan return plan end -function Query._positiveCandidate(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean +function prototype._positiveCandidate(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean const plan = self:_plan() for _, req in plan.required do if not reqSatisfied(req, instance, satisfiedFn) then @@ -664,60 +1312,49 @@ function Query._positiveCandidate(self: QueryInternal, instance: Instance, satis return true end -function Query._attributesMatch(self: QueryInternal, instance: Instance): boolean - for _, attr in self._attributes do - const value = instance:GetAttribute(attr.name) - const matcher = attr.matcher - local ok: boolean - if matcher == EXISTS then - ok = value ~= nil - elseif type(matcher) == "function" then - const success, result = pcall(matcher :: (unknown) -> unknown, value) - ok = success and result == true - if not success then - warn(`[Component] Query :withAttribute('{attr.name}') matcher errored: {result}`) +-- Universe (subscription-tracking) variant of `_positiveCandidate`: value +-- filters count as vacuously satisfied (see `reqInUniverse`). +function prototype._universeCandidate(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean + const plan = self:_plan() + for _, req in plan.required do + if not reqInUniverse(req, instance, satisfiedFn) then + return false + end + end + for _, group in plan.anyOf do + local anySatisfied = false + for _, req in group do + if reqInUniverse(req, instance, satisfiedFn) then + anySatisfied = true + break end - else - ok = value == matcher end - if not ok then + if not anySatisfied then return false end end return true end -function Query._propertiesMatch(self: QueryInternal, instance: Instance): boolean - for _, prop in self._properties do - -- A property missing on this Instance's class throws on read; the pcall - -- failing IS the "property absent" signal (there is no reflection API for - -- game code). `exists` distinguishes absent from present-but-nil. - const exists, value = pcall(function() - return (instance :: any)[prop.name] - end) - const matcher = prop.matcher - local ok: boolean - if matcher == EXISTS then - ok = exists - elseif not exists then - ok = false - elseif type(matcher) == "function" then - const success, result = pcall(matcher :: (unknown) -> unknown, value) - ok = success and result == true - if not success then - warn(`[Component] Query :withProperty('{prop.name}') matcher errored: {result}`) - end - else - ok = value == matcher +function prototype._attributesMatch(self: QueryInternal, instance: Instance): boolean + for _, spec in self._attributes do + if not attrSatisfies(instance, spec) then + return false end - if not ok then + end + return true +end + +function prototype._propertiesMatch(self: QueryInternal, instance: Instance): boolean + for _, spec in self._properties do + if not propSatisfies(instance, spec) then return false end end return true end -function Query._predicatesPass(self: QueryInternal, instance: Instance): boolean +function prototype._predicatesPass(self: QueryInternal, instance: Instance): boolean for _, pred in self._predicates do const success, result = pcall(pred.fn, instance) if not success then @@ -735,7 +1372,7 @@ end -- callers that already know `instance` is a positive candidate (the reactive -- engine, and `get()` over a single-source enumeration) do not pay to prove it -- twice. -function Query._matchesRest(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean +function prototype._matchesRest(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean const plan = self:_plan() if plan.hasNegative then for _, req in plan.negative do @@ -756,7 +1393,7 @@ function Query._matchesRest(self: QueryInternal, instance: Instance, satisfiedFn return true end -function Query._fullMatch(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean +function prototype._fullMatch(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean if not self:_positiveCandidate(instance, satisfiedFn) then return false end @@ -785,11 +1422,8 @@ end -- `withAttribute("Team", "Red")` clauses from different modules share; function -- matchers (and predicates) can only share by identity. const function matcherToken(matcher: unknown): string - if matcher == EXISTS then - return "*" - end - -- Only reachable from `:withProperty(name, nil)` — an explicit-nil matcher. - -- `idOf(nil)` would error (weak-key table keyed by the value), so short-circuit. + -- An explicit-nil matcher ("value == nil"). `idOf(nil)` would error + -- (weak-key table keyed by the value), so short-circuit. if matcher == nil then return "nil" end @@ -800,12 +1434,45 @@ const function matcherToken(matcher: unknown): string return "f:" .. idOf(matcher) end +-- Canonical token for one MatchSpec: `*` for existence, else the SORTED any-of +-- matcher tokens (matcher order never matters, so reordered lists share). +const function specToken(spec: MatchSpec): string + if spec.exists then + return "*" + end + const tokens = {} + for i = 1, spec.count do + table.insert(tokens, matcherToken(spec.matchers[i])) + end + table.sort(tokens) + return table.concat(tokens, "/") +end + +-- Sorted token lists are deduped so duplicated requirements never split a +-- signature: `query(A, A)` == `query(A)`, and clauses duplicated by splicing +-- two overlapping queries still intern. +const function dedupeSorted(tokens: { string }): { string } + local write = 0 + local previous: string? = nil + for _, token in tokens do + if token ~= previous then + write += 1 + tokens[write] = token + previous = token + end + end + for index = #tokens, write + 1, -1 do + tokens[index] = nil + end + return tokens +end + --[[ Canonical structural signature: requirement order never matters, so `query(A, B)` and `query(B, A)` produce the same key. Sub-queries recurse. Cached until the query mutates. ]] -function Query._signature(self: QueryInternal): string +function prototype._signature(self: QueryInternal): string const cached = self._signatureCache if cached then return cached @@ -813,8 +1480,30 @@ function Query._signature(self: QueryInternal): string const function reqToken(req: Queryable): string if type(req) == "string" then return "t:" .. req + elseif isSub(req) then + return "q:(" .. ((req :: Sub).query :: QueryInternal):_signature() .. ")" elseif isQuery(req) then return "q:(" .. (req :: QueryInternal):_signature() .. ")" + elseif isFilter(req) then + const filter = req :: Filter + if filter.op == "where" then + return "w:" .. idOf(filter.fn) .. (if filter.signal ~= nil then ">" .. idOf(filter.signal) else "") + end + const spec = filter.spec :: MatchSpec + return (if filter.op == "attr" then "a:" else "p:") .. spec.name .. "=" .. specToken(spec) + elseif isCombinator(req) then + -- Child order never matters, so sorted tokens make reordered + -- combinators (and their whole queries) intern to the same engine. + const combo = req :: Combinator + const tokens = {} + for _, child in combo.children do + table.insert(tokens, reqToken(child)) + end + table.sort(tokens) + dedupeSorted(tokens) + const op = combo.op + const sym = if op == "not" then "!" elseif op == "or" then "|" else "&" + return sym .. "(" .. table.concat(tokens, ",") .. ")" end return "c:" .. idOf(req) end @@ -824,6 +1513,7 @@ function Query._signature(self: QueryInternal): string table.insert(tokens, reqToken(req)) end table.sort(tokens) + dedupeSorted(tokens) return table.concat(tokens, ",") end const groups = {} @@ -831,21 +1521,25 @@ function Query._signature(self: QueryInternal): string table.insert(groups, sortedTokens(group)) end table.sort(groups) + dedupeSorted(groups) const attrs = {} for _, attr in self._attributes do - table.insert(attrs, attr.name .. "=" .. matcherToken(attr.matcher)) + table.insert(attrs, attr.name .. "=" .. specToken(attr)) end table.sort(attrs) + dedupeSorted(attrs) const props = {} for _, prop in self._properties do - table.insert(props, prop.name .. "=" .. matcherToken(prop.matcher)) + table.insert(props, prop.name .. "=" .. specToken(prop)) end table.sort(props) + dedupeSorted(props) const preds = {} for _, pred in self._predicates do table.insert(preds, idOf(pred.fn) .. (if pred.signal ~= nil then ">" .. idOf(pred.signal) else "")) end table.sort(preds) + dedupeSorted(preds) const signature = sortedTokens(self._positive) .. "|" .. table.concat(groups, ";") @@ -944,7 +1638,7 @@ end -- Reactive engine (ref-counted; shared across all structurally equal queries) -------------------------------------------------------------------------------- -function Query._activate(self: QueryInternal): Engine +function prototype._activate(self: QueryInternal): Engine self._refcount += 1 const attached = self._engine if attached then @@ -1005,7 +1699,10 @@ function Query._activate(self: QueryInternal): Engine -- One `AttributeChanged` connection per candidate, filtered by name, instead -- of a Janitor plus a `GetAttributeChangedSignal` connection per attribute: -- activation over a large candidate set was dominated by that allocation. - const hasAttributes = #self._attributes > 0 + -- Watched names come from the chain-method lists AND from every attr/prop + -- filter node anywhere in the requirement tree (`_allReferences` flattens + -- combinator nesting). + local hasAttributes = #self._attributes > 0 const watchedAttributes: { [string]: boolean } = {} for _, attr in self._attributes do watchedAttributes[attr.name] = true @@ -1013,18 +1710,36 @@ function Query._activate(self: QueryInternal): Engine -- Properties have no single "any property changed" signal, so each watched -- property gets its own `GetPropertyChangedSignal` connection per candidate. - const hasProperties = #self._properties > 0 + local hasProperties = #self._properties > 0 const watchedProperties: { [string]: boolean } = {} for _, prop in self._properties do watchedProperties[prop.name] = true end + const allReferences = self:_allReferences() + for _, req in allReferences do + if isFilter(req) then + const filter = req :: Filter + const op = filter.op + if op == "attr" then + hasAttributes = true + watchedAttributes[filter.name :: string] = true + elseif op == "prop" then + hasProperties = true + watchedProperties[filter.name :: string] = true + end + end + end + const function reevaluate(instance: Instance?) if not instance then return end - const positive = self:_positiveCandidate(instance, subMatches) - -- Track the candidate universe and (only while bounded) attribute/property subs. + -- Universe membership (source requirements only) gates tracking and the + -- attribute/property subscriptions: a filter that is currently FALSE must + -- not tear down the very subscription that would re-evaluate it when it + -- flips true. Full positive candidacy (filters included) gates matching. + const positive = self:_universeCandidate(instance, subMatches) if positive then engine.positiveSet[instance] = true if hasAttributes and not engine.attrConns[instance] then @@ -1070,7 +1785,9 @@ function Query._activate(self: QueryInternal): Engine end end - const isMatch = positive and self:_matchesRest(instance, subMatches) + const isMatch = positive + and self:_positiveCandidate(instance, subMatches) + and self:_matchesRest(instance, subMatches) const wasMatch = engine.matched[instance] ~= nil if isMatch == wasMatch then return @@ -1111,6 +1828,11 @@ function Query._activate(self: QueryInternal): Engine const connectedClasses: { [ComponentClassLike]: boolean } = {} const connectedTags: { [string]: boolean } = {} const function subscribeRef(req: Queryable) + if isFilter(req) then + -- Attr/prop names already fed the watched sets above; a `where` node's + -- recheck signal is connected with the chain predicates below. + return + end if type(req) == "string" then if connectedTags[req] then return @@ -1157,22 +1879,42 @@ function Query._activate(self: QueryInternal): Engine end end - for _, req in self:_allReferences() do + for _, req in allReferences do subscribeRef(req) end - -- `where` recheck signals force a full re-evaluation of bounded instances. + -- `where` recheck signals (chain predicates AND Pred nodes) force a full + -- re-evaluation of tracked instances. + -- Deduped by signal identity: the same recheck signal reachable through + -- several clauses (e.g. one predicate spliced in from two source queries) + -- must trigger ONE sweep, not one per reference. + const connectedRechecks: { [any]: boolean } = {} + const function connectRecheck(signal: unknown) + if connectedRechecks[signal] then + return + end + connectedRechecks[signal] = true + const recheck = signal :: RecheckSignalView + janitor:Add( + recheck:Connect(function() + for instance in engine.positiveSet do + reevaluate(instance) + end + end), + "Disconnect" + ) + end for _, pred in self._predicates do if pred.signal ~= nil then - const recheck = pred.signal :: RecheckSignalView - janitor:Add( - recheck:Connect(function() - for instance in engine.positiveSet do - reevaluate(instance) - end - end), - "Disconnect" - ) + connectRecheck(pred.signal) + end + end + for _, req in allReferences do + if isFilter(req) then + const filter = req :: Filter + if filter.op == "where" and filter.signal ~= nil then + connectRecheck(filter.signal) + end end end @@ -1184,7 +1926,7 @@ function Query._activate(self: QueryInternal): Engine return engine end -function Query._deactivate(self: QueryInternal) +function prototype._deactivate(self: QueryInternal) const engine = self._engine if not engine then return @@ -1207,7 +1949,7 @@ end -- Enumerate the candidate universe (union of positive sources). When `reactive` -- is true, sub-query membership comes from live engines (already activated); -- otherwise it is computed statically via each sub-query's GetMatches. -function Query._enumerate( +function prototype._enumerate( self: QueryInternal, reactive: boolean, subSets: { [QueryInternal]: { [Instance]: boolean } }? @@ -1218,8 +1960,8 @@ function Query._enumerate( for _, instance in CollectionService:GetTagged(req) do set[instance] = true end - elseif isQuery(req) then - const subQuery = req :: QueryInternal + elseif isSub(req) or isQuery(req) then + const subQuery = (if isSub(req) then (req :: Sub).query else req) :: QueryInternal if reactive then const engine = self._engine :: Engine const subEngine = engine.subEngines[subQuery] @@ -1242,6 +1984,51 @@ function Query._enumerate( end end end + elseif isFilter(req) then + -- Refinement-only: contributes no candidates. + return + elseif isCombinator(req) then + const combo = req :: Combinator + const op = combo.op + if op == "not" then + return -- exclusion: contributes no candidates + elseif op == "and" then + -- Any one enumerable child's members are a superset of the And's + -- satisfiers, so ONE child bounds it. Prefer the narrowest child + -- whose population is known O(1) (a class's started list length), + -- mirroring top-level seed selection; otherwise first enumerable. + local best: Queryable? = nil + local bestSize = math.huge + for _, child in combo.children do + if isEnumerable(child) then + local startedList: { Instance }? = nil + if type(child) == "table" and not isCombinator(child) and not isSub(child) then + const internal = (child :: any)[INTERNAL] :: any + if internal ~= nil then + startedList = internal.startedList + end + end + if startedList ~= nil then + const size = #startedList + if size < bestSize then + best, bestSize = child, size + end + elseif best == nil then + best = child + end + end + end + if best ~= nil then + addFromRef(best) + end + return + else -- or: the union of all children (only valid fully enumerable) + if isEnumerable(req) then + for _, child in combo.children do + addFromRef(child) + end + end + end else -- component class -- One of ours: its started list already holds exactly the instances -- this source contributes, pre-filtered. Foreign class-likes fall back @@ -1331,7 +2118,7 @@ const function attachObserver( return connProxy end -function Query.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection +function prototype.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection return attachObserver(self, callback, false, "observe") end @@ -1350,7 +2137,7 @@ end also disrupt the other observers and matches reacting to the same change. Use [Query:observe] for any callback that may yield. ::: ]=] -function Query.observeUnyielding(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection +function prototype.observeUnyielding(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection return attachObserver(self, callback, true, "observeUnyielding") end @@ -1380,7 +2167,7 @@ end returned handle MUST be disconnected to release the engine — unlike a one-shot [Query:get], a tracked query holds live subscriptions until then. ]=] -function Query.track(self: QueryInternal): QueryConnection +function prototype.track(self: QueryInternal): QueryConnection self:_validate() self:_activate() @@ -1398,18 +2185,25 @@ function Query.track(self: QueryInternal): QueryConnection end -- The sole positive requirement when the query is the ECS hot shape -- exactly --- one required requirement and no anyOf / negative / attribute / predicate --- clause -- so a read can answer straight from that one source. `nil` otherwise. -function Query._singleSource(self: QueryInternal): PlanReq? +-- one required SOURCE requirement (class / tag / sub-query / class-like; node +-- kinds are refinements, not dumpable sources) and no anyOf / negative / +-- attribute / property / predicate clause -- so a read can answer straight from +-- that one source. `nil` otherwise. +function prototype._singleSource(self: QueryInternal): PlanReq? const plan = self:_plan() if #plan.required == 1 and #plan.anyOf == 0 and not plan.hasNegative and not plan.hasAttributes + and not plan.hasProperties and not plan.hasPredicates then - return plan.required[1] + const req = plan.required[1] + const kind = req.kind + if kind == "class" or kind == "tag" or kind == "query" or kind == "classlike" then + return req + end end return nil end @@ -1419,7 +2213,7 @@ end -- ONCE and then answered by lookup (re-running it per candidate is quadratic in -- nested queries). When the plan references no sub-query, returns the no-op -- `neverSub` and nils, so callers allocate nothing. -function Query._staticSub( +function prototype._staticSub( self: QueryInternal, plan: Plan ): (SatisfiedFn, ((QueryInternal) -> { [Instance]: boolean })?, { @@ -1472,7 +2266,7 @@ end -- one candidate, not all. The live-engine path and the single-requirement ECS -- shape are cheaper answers the public terminals handle themselves before -- falling back here, so this deliberately does NOT special-case them. -function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) +function prototype._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) const plan = self:_plan() const staticSub, ensureSet, subSets = self:_staticSub(plan) const required = plan.required @@ -1546,7 +2340,21 @@ function Query._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) end end end - const chosenSeed: PlanReq = (seed or firstTag or firstQuery or firstClasslike) :: PlanReq + -- No direct source among the required reqs (they are all filter/combinator + -- nodes; validation guarantees an enumerable one is nested somewhere): fall + -- back to the union scan, same as the anyOf-only shape. + const fallbackSeed = seed or firstTag or firstQuery or firstClasslike + if not fallbackSeed then + for instance in self:_enumerate(false, subSets) do + if self:_fullMatch(instance, staticSub) then + if onMatch(instance) then + return + end + end + end + return + end + const chosenSeed: PlanReq = fallbackSeed :: PlanReq const seedKind = chosenSeed.kind -- Probes = every required requirement except the seed, most-selective-first -- when populations are known (smaller population rejects more candidates @@ -1690,7 +2498,7 @@ end an O(matches) copy, identical to what observers see — making per-frame `GetMatches` loops cheap enough for ECS-style iteration. ]=] -function Query.get(self: QueryInternal): { Instance } +function prototype.get(self: QueryInternal): { Instance } self:_validate() -- Live-engine fast path: the engine already maintains exactly this set. @@ -1735,7 +2543,7 @@ function Query.get(self: QueryInternal): { Instance } end) return out end -Query.GetMatches = Query.get +prototype.GetMatches = prototype.get --[=[ @within Query @@ -1746,7 +2554,7 @@ Query.GetMatches = Query.get reactive engine's match set; cold, it evaluates this one instance against every clause (no candidate enumeration). ]=] -function Query.contains(self: QueryInternal, instance: Instance): boolean +function prototype.contains(self: QueryInternal, instance: Instance): boolean assert(typeof(instance) == "Instance", "[Component] Query:contains() expects an Instance") self:_validate() const engine = self._engine or activeEngines[self:_signature()] @@ -1764,7 +2572,7 @@ end this is an O(1) read of the engine's match count; cold it scans without building the match array [Query:get] would allocate. ]=] -function Query.count(self: QueryInternal): number +function prototype.count(self: QueryInternal): number self:_validate() const engine = self._engine or activeEngines[self:_signature()] if engine then @@ -1789,7 +2597,7 @@ end actively observed this is an O(1) read of the engine's first match; cold it stops at the first matching candidate instead of collecting them all. ]=] -function Query.first(self: QueryInternal): Instance? +function prototype.first(self: QueryInternal): Instance? self:_validate() const engine = self._engine or activeEngines[self:_signature()] if engine then @@ -1829,7 +2637,7 @@ end raw throughput; `iter()` is for hot per-frame loops where avoiding the cloned array's GC garbage matters more than wall time. ]=] -function Query.iter(self: QueryInternal): () -> Instance? +function prototype.iter(self: QueryInternal): () -> Instance? self:_validate() const engine = self._engine or activeEngines[self:_signature()] -- Backwards, so the engine's swap-remove (which moves an already-visited @@ -1842,4 +2650,12 @@ function Query.iter(self: QueryInternal): () -> Instance? end end +-- Callable module: `Query(...)` == `Query.new(...)`. Query INSTANCES are +-- unaffected — their metatable is `prototype`; this metatable is the module's. +setmetatable(Query, { + __call = function(_, ...: Queryable): Query + return Query.new(...) + end, +}) + return Query diff --git a/lib/component/src/Tests/Component.Query.spec.luau b/lib/component/src/Tests/Component.Query.spec.luau index a3bb9b17..242dff07 100644 --- a/lib/component/src/Tests/Component.Query.spec.luau +++ b/lib/component/src/Tests/Component.Query.spec.luau @@ -113,7 +113,7 @@ return function(t: any) redMatched[i] = true end) local obsHp = Component.query(A) - :withAttribute("Hp", function(v) + :withAttribute("Hp", function(_, v) return type(v) == "number" and v > 0 end) :observe(function(i) @@ -147,7 +147,7 @@ return function(t: any) namedMatched[i] = true end) local obsClear = Component.query(A) - :withProperty("Transparency", function(v) + :withProperty("Transparency", function(_, v) return type(v) == "number" and v > 0.5 end) :observe(function(i) @@ -523,13 +523,13 @@ return function(t: any) describe("started sparse set", function() test("cold GetMatches excludes constructing and stopped components", function() local gate = false - local A, aTag = H.makeClass({ + local A, aTag = H.makeClass { Construct = function() while not gate do task.wait() end end, - }) + } local q = Component.query(A) local p = part { aTag } @@ -556,6 +556,393 @@ return function(t: any) end) end) + describe("composable nodes", function() + local Query = Component.Query + + test("the module is callable and exposes static node constructors", function() + local A, aTag = H.makeClass() + local p = part { aTag } + expect(H.waitStarted(A, p, 3)).is(true) + + -- Query(...) == Query.new(...); Component.query is the same module. + expect(#Query(A):get()).is(1) + expect(Component.query).is(Query) + expect(type(Query.Attr)).is("function") + expect(type(Query.Or)).is("function") + expect(type(Query.Not)).is("function") + + A:Destroy() + p:Destroy() + end) + + test("Attr nodes work in with/without/withAny, reactively", function() + local A, aTag = H.makeClass() + local redLive, notRedLive, eitherLive = {}, {}, {} + local obsRed = Query(A):with(Query.Attr("Team", "Red")):observe(function(i, jani) + redLive[i] = true + jani:Add(function() + redLive[i] = nil + end) + end) + local obsNotRed = Query(A):without(Query.Attr("Team", "Red")):observe(function(i, jani) + notRedLive[i] = true + jani:Add(function() + notRedLive[i] = nil + end) + end) + local obsEither = Query(A) + :withAny(Query.Attr("Team", "Red"), Query.Attr("Team", "Blue")) + :observe(function(i, jani) + eitherLive[i] = true + jani:Add(function() + eitherLive[i] = nil + end) + end) + + local p = part { aTag } + p:SetAttribute("Team", "Green") + expect(H.waitStarted(A, p, 3)).is(true) + task.wait(0.1) + expect(redLive[p]).never_exists() + expect(notRedLive[p]).is(true) -- Green is not Red + expect(eitherLive[p]).never_exists() + + p:SetAttribute("Team", "Red") + expect(H.waitUntil(function() + return redLive[p] and eitherLive[p] and not notRedLive[p] + end, 3)).is(true) + + p:SetAttribute("Team", "Blue") + expect(H.waitUntil(function() + return not redLive[p] and eitherLive[p] and notRedLive[p] + end, 3)).is(true) + + obsRed:Disconnect() + obsNotRed:Disconnect() + obsEither:Disconnect() + A:Destroy() + p:Destroy() + end) + + test("without(Prop) expresses property ABSENCE (class lacks the property)", function() + local A, aTag = H.makeClass() + local live = {} + -- Parts have no PrimaryPart property; Models do (even when nil-valued). + local obs = Query(A):without(Query.Prop("PrimaryPart")):observe(function(i, jani) + live[i] = true + jani:Add(function() + live[i] = nil + end) + end) + + local p = part { aTag } + local model = Instance.new("Model") + CollectionService:AddTag(model, aTag) + model.Parent = workspace + expect(H.waitUntil(function() + return A:Has(p) and A:Has(model) + end, 3)).is(true) + task.wait(0.1) + + expect(live[p]).is(true) -- Part lacks PrimaryPart -> absence matches + expect(live[model]).never_exists() -- Model HAS the property (nil-valued counts as present) + + obs:Disconnect() + A:Destroy() + p:Destroy() + model:Destroy() + end) + + test("any-of matcher lists pass (instance, value) to predicates", function() + local A, aTag = H.makeClass() + local sawInstance, sawValue = nil, nil + local live = {} + local obs = Query(A):withProperty("Name", "Alpha", function(i, v) + sawInstance, sawValue = i, v + return v == "Zed" + end):observe(function(i, jani) + live[i] = true + jani:Add(function() + live[i] = nil + end) + end) + + local p = part { aTag } + p.Name = "Other" + expect(H.waitStarted(A, p, 3)).is(true) + task.wait(0.1) + expect(live[p]).never_exists() + -- The predicate branch ran and received both arguments. + expect(sawInstance).is(p) + expect(sawValue).is("Other") + + p.Name = "Alpha" -- first value matcher + expect(H.waitUntil(function() + return live[p] == true + end, 3)).is(true) + p.Name = "Zed" -- predicate matcher + task.wait(0.1) + expect(live[p]).is(true) + p.Name = "Miss" + expect(H.waitUntil(function() + return live[p] == nil + end, 3)).is(true) + + obs:Disconnect() + A:Destroy() + p:Destroy() + end) + + test("And/Or/Not nest and react to nested clause flips", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local sectionLive, orLive = {}, {} + -- Negate a whole section: A minus (B AND Locked=true). + local obsSection = Query(A):without(Query.And(B, Query.Attr("Locked", true))):observe(function(i, jani) + sectionLive[i] = true + jani:Add(function() + sectionLive[i] = nil + end) + end) + -- Or mixes a filter with a component requirement. + local obsOr = Query(A):with(Query.Or(Query.Attr("Flag", 1), B)):observe(function(i, jani) + orLive[i] = true + jani:Add(function() + orLive[i] = nil + end) + end) + + local p = part { aTag, bTag } + expect(H.waitUntil(function() + return A:Has(p) and B:Has(p) + end, 3)).is(true) + task.wait(0.1) + expect(sectionLive[p]).is(true) -- B started but not Locked + expect(orLive[p]).is(true) -- B satisfies the Or + + p:SetAttribute("Locked", true) + expect(H.waitUntil(function() + return sectionLive[p] == nil + end, 3)).is(true) + + CollectionService:RemoveTag(p, bTag) + expect(H.waitUntil(function() + return orLive[p] == nil -- B gone and Flag unset -> Or fails + end, 3)).is(true) + p:SetAttribute("Flag", 1) + expect(H.waitUntil(function() + return orLive[p] == true -- attr branch of the Or + end, 3)).is(true) + + obsSection:Disconnect() + obsOr:Disconnect() + A:Destroy() + B:Destroy() + p:Destroy() + end) + + test("structurally equal composed queries share one interned engine", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local live1, live2 = {}, {} + -- Same requirements, reordered combinator children AND reordered matchers. + local q1 = Query(A):with(Query.Or(Query.Attr("Team", "Red", "Blue"), B)) + local q2 = Query(A):with(Query.Or(B, Query.Attr("Team", "Blue", "Red"))) + local obs1 = q1:observe(function(i, jani) + live1[i] = true + jani:Add(function() + live1[i] = nil + end) + end) + local obs2 = q2:observe(function(i, jani) + live2[i] = true + jani:Add(function() + live2[i] = nil + end) + end) + + local p = part { aTag, bTag } + expect(H.waitUntil(function() + return live1[p] == true and live2[p] == true + end, 3)).is(true) + + -- Shared engine: disconnecting one observer must not kill the other's + -- reactivity (refcounted interning). + obs1:Disconnect() + p:SetAttribute("Team", "Red") + CollectionService:RemoveTag(p, bTag) + expect(H.waitUntil(function() + return live2[p] == true -- attr branch still satisfies the Or + end, 3)).is(true) + p:SetAttribute("Team", "Green") + expect(H.waitUntil(function() + return live2[p] == nil + end, 3)).is(true) + + obs2:Disconnect() + A:Destroy() + B:Destroy() + p:Destroy() + end) + + test("queries with no enumerable source error", function() + local A = H.makeClass() + -- Filter-only, Not-only, and Or-with-unbounded-branch are all unbounded. + expect(function() + Query(Query.Attr("X", 1)):get() + end).fails() + expect(function() + Query(Query.Not(A)):get() + end).fails() + expect(function() + Query(Query.Or(A, Query.Attr("X", 1))):get() + end).fails() + -- An And with one bounded child is fine. + expect(#Query(Query.And(A, Query.Attr("X", 1))):get()).is(0) + A:Destroy() + end) + end) + + describe("composition unification", function() + local Query = Component.Query + + test("Query(q1):with(B) is literally q1:with(B) (compose by value)", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local q1 = Query(A) + local nestedSpelling = Query(q1):with(B) + local chainedSpelling = q1:with(B) + + -- One internal form: identical signatures, so they intern together. + expect(nestedSpelling:_signature()).is(chainedSpelling:_signature()) + + local pBoth = part { aTag, bTag } + local pA = part { aTag } + expect(H.waitUntil(function() + return A:Has(pBoth) and B:Has(pBoth) and A:Has(pA) + end, 3)).is(true) + + for _, q in { nestedSpelling, chainedSpelling } do + expect(q:contains(pBoth)).is(true) + expect(q:contains(pA)).is(false) + expect(q:count()).is(1) + end + + A:Destroy() + B:Destroy() + pBoth:Destroy() + pA:Destroy() + end) + + test("equivalent spellings converge to one signature", function() + local A = H.makeClass() + local B = H.makeClass() + local C = H.makeClass() + + expect(Query(A, Query.And(B, C)):_signature()).is(Query(A, B, C):_signature()) + expect(Query(A):with(Query.Or(B, C)):_signature()).is(Query(A):withAny(B, C):_signature()) + expect(Query(A):with(Query.Not(B)):_signature()).is(Query(A):without(B):_signature()) + expect(Query(A, Query.Attr("D", 1)):_signature()).is(Query(A):withAttribute("D", 1):_signature()) + expect(Query(A, A):_signature()).is(Query(A):_signature()) + + A:Destroy() + B:Destroy() + C:Destroy() + end) + + test("a query in without/withAny stays atomic (De Morgan)", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local C, cTag = H.makeClass() + local dTag = H.uniqueTag() + local sub = Query(B):with(C) + + local pB = part { aTag, bTag } + local pBC = part { aTag, bTag, cTag } + local pD = part { aTag, dTag } + expect(H.waitUntil(function() + return A:Has(pB) and B:Has(pB) and A:Has(pBC) and B:Has(pBC) and C:Has(pBC) and A:Has(pD) + end, 3)).is(true) + + -- without(sub) excludes only instances matching ALL of sub, not any part. + local qWithout = Query(A):without(sub) + expect(qWithout:contains(pB)).is(true) -- has B alone: not (B and C) + expect(qWithout:contains(pBC)).is(false) + + -- withAny(sub, tag): sub is one atomic alternative. + local qAny = Query(A):withAny(sub, dTag) + expect(qAny:contains(pBC)).is(true) + expect(qAny:contains(pD)).is(true) + expect(qAny:contains(pB)).is(false) + + A:Destroy() + B:Destroy() + C:Destroy() + pB:Destroy() + pBC:Destroy() + pD:Destroy() + end) + + test("splicing can bound an otherwise unbounded query", function() + local A, aTag = H.makeClass() + local filterOnly = Query():withAttribute("Powered", true) + -- Standalone it is unbounded and errors... + expect(function() + filterOnly:get() + end).fails() + -- ...but composed by value, the class bounds the spliced clauses. + local q = Query(filterOnly):with(A) + local pOn = part { aTag } + pOn:SetAttribute("Powered", true) + local pOff = part { aTag } + expect(H.waitUntil(function() + return A:Has(pOn) and A:Has(pOff) + end, 3)).is(true) + expect(q:contains(pOn)).is(true) + expect(q:contains(pOff)).is(false) + + A:Destroy() + pOn:Destroy() + pOff:Destroy() + end) + + test("Query.Sub composes by reference with identical matching, reactively", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local C, cTag = H.makeClass() + local sub = Query(B):with(C) + local byValue = Query(A):with(sub) + local byRef = Query(A):with(Query.Sub(sub)) + + local live = {} + local obs = byRef:observe(function(i, jani) + live[i] = true + jani:Add(function() + live[i] = nil + end) + end) + + local p = part { aTag, bTag, cTag } + expect(H.waitUntil(function() + return live[p] == true + end, 3)).is(true) + expect(byValue:contains(p)).is(true) + + -- Sub-driven unmatch propagates to the by-reference parent. + CollectionService:RemoveTag(p, cTag) + expect(H.waitUntil(function() + return live[p] == nil + end, 3)).is(true) + expect(byValue:contains(p)).is(false) + + obs:Disconnect() + A:Destroy() + B:Destroy() + C:Destroy() + p:Destroy() + end) + end) + describe("validation", function() test("a query with no positive requirement errors", function() local Bad = H.makeClass() diff --git a/lib/component/src/Types.luau b/lib/component/src/Types.luau index bd626411..9ab878f6 100644 --- a/lib/component/src/Types.luau +++ b/lib/component/src/Types.luau @@ -26,6 +26,15 @@ const TypeFunctions = require("./TypeFunctions") export type Janitor = Janitor.Janitor export type Promise = Promise.TypedPromise + +-- Structural view of a promise, used by Lifecycle's hook plumbing. Callbacks +-- are `(...any) -> ...any` deliberately: `...unknown` params reject `() -> ()` +-- handlers via contravariance. Vendor promises bridge through `unknown` once. +export type PromiseLike = { + andThen: (self: PromiseLike, onResolve: (...any) -> ...any, onReject: ((...any) -> ...any)?) -> PromiseLike, + catch: (self: PromiseLike, onReject: (...any) -> ...any) -> PromiseLike, + cancel: (self: PromiseLike) -> (), +} export type LifecyclePhase = Keys.LifecyclePhase export type StopReason = Keys.StopReason diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index edb7a0fc..45cd41b0 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -113,6 +113,11 @@ export type ComponentConfig = Types.ComponentConfig export type ComponentClass = Types.ComponentClass export type TypedClass = Types.TypedClass export type Query = Query.Query +export type Queryable = Query.Queryable +export type Filter = Query.Filter +export type Combinator = Query.Combinator +export type Matcher = Query.Matcher +export type RecheckSignal = Query.RecheckSignal -- Type functions (NEW type solver only) live in TypeFunctions.luau; re-exported -- here so users can compose `Component.extensionMethods` @@ -151,11 +156,17 @@ Component.DelaySetup = script:GetAttribute("DelaySetup") or false --[=[ @within Component @function query - @param ... Queryable -- component classes, tag strings, and/or sub-queries + @param ... Queryable -- component classes, tag strings, sub-queries, and/or Query.Attr/Prop/Pred/And/Or/Not nodes @return Query Creates a world-level [Query] over tagged instances. See the [Query] class. + + `Component.query` / `Component.Query` are the callable Query module itself: + `Component.Query(A, B)` builds a query, and the static node constructors + (`Component.Query.Attr`, `.Prop`, `.Pred`, `.And`, `.Or`, `.Not`) build + composable requirement nodes. ]=] -Component.query = Query.new +Component.query = Query +Component.Query = Query --[=[ @tag Component @@ -472,7 +483,10 @@ end round-trip). Rejects if construction is vetoed by `ShouldConstruct` or is stopped before it starts. ]=] -function Component.prototype.GetCreateFromInstance(self: Class_Internal, instance: Instance): Promise +function Component.prototype.GetCreateFromInstance( + self: Class_Internal, + instance: Instance +): Promise return Promise.new(function(resolve, reject, onCancel) const existing = self:FromInstance(instance) if existing and Keys.inst(existing).started then @@ -507,6 +521,9 @@ function Component.prototype.GetCreateFromInstance(self: Class_Internal, instanc end) end +--- @deprecated v1.0.0 -- Renamed to [Component:GetCreateFromInstance]. +Component.prototype.GetOrCreateFromInstance = Component.prototype.GetCreateFromInstance + --[=[ @tag Component Class Updates the valid ancestors of this class and re-evaluates watched instances. @@ -598,11 +615,7 @@ end Adds a Promise to the component's core Janitor. An optional string `index` names it so it can be removed/cancelled via [Component:RemoveTask]. ]=] -function Component.prototype.AddPromise( - self: Instance_Internal, - promise: Promise, - index: unknown? -): Promise +function Component.prototype.AddPromise(self: Instance_Internal, promise: Promise, index: unknown?): Promise return Keys.inst(self).janitor:AddPromise(promise, index) end From 769edfff0bd40487e089aa4fef903c0c84e42095 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 22:00:09 -0400 Subject: [PATCH 16/19] Reformatted Query --- lib/component/src/Docs/CO_Extensions.luau | 116 + .../src/Docs/CO_Getting_Started.luau | 120 + .../src/Docs/CO_Lifecycle_And_Cleanup.luau | 117 + lib/component/src/Docs/CO_Queries.luau | 144 + lib/component/src/Query.luau | 2661 ----------------- lib/component/src/Query/Build.luau | 560 ++++ lib/component/src/Query/Plan.luau | 570 ++++ lib/component/src/Query/Runtime.luau | 1316 ++++++++ lib/component/src/Query/Types.luau | 263 ++ lib/component/src/Query/init.luau | 102 + moonwave.toml | 11 +- 11 files changed, 3318 insertions(+), 2662 deletions(-) create mode 100644 lib/component/src/Docs/CO_Extensions.luau create mode 100644 lib/component/src/Docs/CO_Getting_Started.luau create mode 100644 lib/component/src/Docs/CO_Lifecycle_And_Cleanup.luau create mode 100644 lib/component/src/Docs/CO_Queries.luau delete mode 100644 lib/component/src/Query.luau create mode 100644 lib/component/src/Query/Build.luau create mode 100644 lib/component/src/Query/Plan.luau create mode 100644 lib/component/src/Query/Runtime.luau create mode 100644 lib/component/src/Query/Types.luau create mode 100644 lib/component/src/Query/init.luau diff --git a/lib/component/src/Docs/CO_Extensions.luau b/lib/component/src/Docs/CO_Extensions.luau new file mode 100644 index 00000000..9824f446 --- /dev/null +++ b/lib/component/src/Docs/CO_Extensions.luau @@ -0,0 +1,116 @@ +--[=[ + @class CO Extensions + + An **extension** is a table that hooks into the [Component](/api/Component) + lifecycle. Where a component class *is* one behavior, an extension is a slice of + behavior you attach to *many* classes — logging, replication, pooling, a debug + overlay — without editing any of them. Extensions contribute per-phase hooks, + class methods, a construction veto, and their own dependencies. + + ### 1. Attach an extension + + Pass extensions in the config's `Extensions` array. Every instance of the class + then runs the extension's hooks around its own lifecycle. + + ```lua + local LogExtension = { + Starting = function(component) + print("starting", component.Instance:GetFullName()) + end, + Stopped = function(component) + print("stopped", component.Instance) + end, + } + + local Enemy = Component.new({ + Tag = "Enemy", + Extensions = { LogExtension }, + }) + ``` + + ### 2. The six hooks + + An extension may define any of these. Each receives the component instance. + They mirror the component's own lifecycle methods but run *around* them, and one + extension's hooks fire for every class that lists it. + + | Hook | Runs | Relative to the component's own method | + | --- | --- | --- | + | `Constructing` | before construction | before `Construct` | + | `Constructed` | after construction | after `Construct` — the instance is now tracked | + | `Starting` | before startup | before `Start` | + | `Started` | after startup | after `Start` | + | `Stopping` | before teardown | before `Stop` | + | `Stopped` | after teardown | after `Stop`, before the Janitor destroy | + + `Constructing` / `Constructed` may **yield or return a Promise**; the construct + chain waits for them. `Starting` / `Started` / `Stopping` / `Stopped` are plain + callbacks. See [CO Lifecycle & Cleanup](/api/CO%20Lifecycle%20&%20Cleanup) for + exactly when each phase runs and how yielding is coordinated. + + ### 3. Veto construction with ShouldConstruct + + `ShouldConstruct` runs before anything else and returns `false` to skip this + instance entirely — no component is built. Every extension's `ShouldConstruct` + must pass; a single `false` vetoes. + + ```lua + local ServerOnly = { + ShouldConstruct = function(component) + return game:GetService("RunService"):IsServer() + end, + } + ``` + + `ShouldExtend` is the finer, per-instance toggle: it decides whether *this + extension* applies to *this* instance (evaluated once), while the component + still constructs. + + ### 4. Contribute methods + + An extension's `Methods` are merged onto the component **class**, so instances + call them like any other method. A name collision with a class member or another + extension's method is an error — methods never silently shadow. + + ```lua + local Healthful = { + Methods = { + Heal = function(component, amount) + component.Health = math.min(component.MaxHealth, component.Health + amount) + end, + }, + } + + local Enemy = Component.new({ Tag = "Enemy", Extensions = { Healthful } }) + -- later, on a component instance: + enemy:Heal(10) + ``` + + ### 5. Depend on other extensions + + An extension may list its own dependencies in a nested `Extensions` array. They + are resolved in **topological order** — a dependency's hook for a phase finishes + before the dependent's hook for that phase starts — and shared dependencies are + included once. + + ```lua + local Replicated = { + Extensions = { NetworkIdentity }, -- Replicated's hooks run after NetworkIdentity's + Started = function(component) + replicate(component.NetworkId) -- set up by NetworkIdentity.Started + end, + } + ``` + + :::info Order guarantees, not a scheduler + Only edges you declare are ordered. Two extensions with no dependency between + them run **concurrently** within a phase — don't rely on array position for + ordering; add a dependency edge if one truly needs the other first. + ::: + + --- + ### See also + + - **[CO Lifecycle & Cleanup](/api/CO%20Lifecycle%20&%20Cleanup)** — when each hook phase runs, and how the phase barrier coordinates yielding hooks. + - **[CO Getting Started](/api/CO%20Getting%20Started)** — defining the component classes extensions attach to. +]=] diff --git a/lib/component/src/Docs/CO_Getting_Started.luau b/lib/component/src/Docs/CO_Getting_Started.luau new file mode 100644 index 00000000..65ff368f --- /dev/null +++ b/lib/component/src/Docs/CO_Getting_Started.luau @@ -0,0 +1,120 @@ +--[=[ + @class CO Getting Started + + [Component](/api/Component) binds a reusable, class-based behavior to every + Roblox instance carrying a CollectionService tag, and gives each one a + yield-tolerant lifecycle and a guaranteed cleanup path. Tag an instance and its + component *constructs*; untag it (or destroy it, or move it out of the world) + and the component *tears down* — automatically. This guide walks the minimal + end-to-end setup. For the deeper topics, see the guides linked at the bottom. + + ### 1. Require the module + + ```lua + local Component = require(Packages.Component) + ``` + + ### 2. Define a component class + + `Component.new` takes one config table. The only required field is `Tag` — the + CollectionService tag whose instances this class binds to. Add the lifecycle + methods (`Construct` / `Start` / `Stop`) to the returned class; each runs on a + fresh **component instance** with `self.Instance` pointing at the tagged Roblox + instance. + + ```lua + local Lava = Component.new({ + Tag = "Lava", + Ancestors = { workspace }, -- only bind instances under here (default: {workspace, Players}) + }) + + function Lava:Construct() + -- Set up state. Runs first, before the instance is "live". Do not touch + -- other components here — they may not have started yet. + self.Touched = 0 + end + + function Lava:Start() + -- The instance is live. Connect events, start loops, read other components. + print(self.Instance:GetFullName(), "is now hot") + end + + function Lava:Stop(reason) + -- Teardown. `reason` says why (see the Lifecycle guide). Anything you + -- registered with self:AddTask is already being cleaned up for you. + print("cooling down:", reason) + end + ``` + + :::tip Typed classes + `Construct` / `Start` / `Stop` may be defined post-hoc as above. Custom methods + and fields, though, go through the config's `Methods` / `Fields` so the new type + solver can check them — the class type has no `[string]: any` escape hatch by + design. See [Component.new](/api/Component#new) for the typed `Methods` / + `Fields` / `InitFields` pattern. + ::: + + ### 3. Bind an instance + + Nothing else to call — binding is by tag. Add the `Lava` tag to any instance + under a valid ancestor (in Studio's Tag Editor, or from code) and `Construct` + then `Start` run for it: + + ```lua + local CollectionService = game:GetService("CollectionService") + CollectionService:AddTag(somePart, "Lava") + ``` + + Every tagged instance gets its own independent component instance. Remove the + tag and that instance's component tears down. + + ### 4. Reach a component from its instance + + Given the Roblox instance, `FromInstance` returns its started component instance + (or `nil` if it has none). This is how systems talk to each other — collision + handlers, other components, UI: + + ```lua + somePart.Touched:Connect(function(hit) + local lava = Lava:FromInstance(somePart) + if lava then + lava.Touched += 1 + end + end) + ``` + + Enumerate every live instance of a class with `GetAll`: + + ```lua + for _, lava in Lava:GetAll() do + print(lava.Instance) + end + ``` + + ### 5. Clean up with AddTask + + Never disconnect by hand. Register anything cleanup-worthy — connections, + Instances, Janitors, Promises — on the component's **core Janitor** via + `self:AddTask`, and it is torn down for you on *every* removal path. + + ```lua + function Lava:Start() + self:AddTask(self.Instance.Touched:Connect(function(hit) + -- ... + end)) + + -- Second arg names the cleanup method for non-connection tasks: + self:AddTask(Instance.new("Fire", self.Instance), "Destroy") + end + ``` + + This is the framework's core guarantee: if `Construct` ran, `Stop` runs and the + Janitor is destroyed — no matter how the instance goes away. + + --- + ### See also + + - **[CO Lifecycle & Cleanup](/api/CO%20Lifecycle%20&%20Cleanup)** — the full phase sequence, yielding, the phase barrier, and every teardown path. + - **[CO Extensions](/api/CO%20Extensions)** — share behavior across classes with lifecycle hooks and merged methods. + - **[CO Queries](/api/CO%20Queries)** — find and react to instances across the whole world with `Component.query`. +]=] diff --git a/lib/component/src/Docs/CO_Lifecycle_And_Cleanup.luau b/lib/component/src/Docs/CO_Lifecycle_And_Cleanup.luau new file mode 100644 index 00000000..8d2da2c4 --- /dev/null +++ b/lib/component/src/Docs/CO_Lifecycle_And_Cleanup.luau @@ -0,0 +1,117 @@ +--[=[ + @class CO Lifecycle & Cleanup + + Every [Component](/api/Component) instance moves through a fixed sequence of + **phases**, and always leaves through the same teardown path. This guide covers + that sequence, what "yield-tolerant" actually means (the phase barrier), and the + cleanup guarantee that lets you stop writing disconnect code. + + ### 1. The phases + + A component instance is always in one of these, reported by + `self:GetLifecycleStatus()`: + + ``` + None → Constructing → Constructed → Starting → Started + ↓ + Stopped ← Stopping ← (teardown, any removal) + ``` + + The methods and hooks run in this order: + + 1. **ShouldConstruct** — extensions may veto (sync). See [CO Extensions](/api/CO%20Extensions). + 2. **Constructing** — extension hooks. + 3. **Construct()** — your class's initialization. + 4. **Constructed** — extension hooks. The instance is now tracked and findable via `FromInstance`. + 5. **Starting** — extension hooks. + 6. **Start()** — your class's startup. + 7. **Started** — extension hooks, then the `Started` signal fires. + + :::tip Where to put what + `Construct` sets up state and must not assume other components exist yet. + `Start` is where the instance is fully live — connect events, read other + components, begin loops. Reading a sibling component in `Construct` is the most + common lifecycle mistake. + ::: + + ### 2. Yielding and the phase barrier + + Lifecycle methods and hooks **may yield or return a Promise**. Within one phase, + each active extension's hook is gated on dependency order: a hook does not start + until the hooks it depends on have *finished*. Hooks with no dependency edge + between them run **concurrently**, so an unrelated sibling never blocks you. The + phase completes — and the next begins — only once every hook has finished and + every returned Promise has resolved. This coordination point is the **phase + barrier**. + + ```lua + local Preloaded = { + Constructing = function(component) + -- The Constructed phase waits for this Promise before anyone starts. + return preloadAssets(component.Instance) + end, + } + ``` + + Only the construct phases run **inline** and hold up the construction chain. The + start phases run after a `task.defer` boundary, and teardown is deferred as a + whole — so neither ever runs on the thread that triggered it, and teardown never + blocks its caller. Hooks that neither yield nor return a Promise cost no Promise + at all; the async machinery only materializes for hooks that actually suspend. + + ### 3. Teardown always runs, in order + + Removal — untag, ancestry exit, instance destroyed, class destroyed, or a + cancelled/superseded construction — funnels through one path once construction + has begun: + + ``` + untrack → Stopping hooks → Stop(reason) → Stopped hooks → core Janitor destroy + ``` + + Stop hooks gate in **reverse** dependency order (a dependency stops only after + everything depending on it has). `Stop` receives a **reason**: + + | Reason | Cause | + | --- | --- | + | `Untagged` | the CollectionService tag was removed | + | `LeftAncestry` | the instance left the valid ancestor list | + | `InstanceDestroyed` | the bound instance was destroyed | + | `ClassDestroyed` | the component class was destroyed | + | `ConstructionCancelled` | torn down before it finished constructing | + | `Superseded` | a newer construction attempt replaced this one | + + ### 4. The cleanup guarantee + + Because teardown runs `Stop` and destroys the core Janitor on *every* path, + anything you register with `self:AddTask` is guaranteed to be cleaned up. This + is the whole point: you never write matching disconnect logic. + + ```lua + function Enemy:Start() + -- connections clean up on teardown, no manual disconnect + self:AddTask(RunService.Heartbeat:Connect(function(dt) + self:think(dt) + end)) + + -- name a task to remove it early via self:RemoveTask("aggro") + self:AddTask(startAggroLoop(self), true, "aggro") + + -- Promises are cancelled on teardown + self:AddPromise(chaseNearestPlayer(self)) + end + ``` + + :::info If the instance goes away mid-start + If the instance leaves its ancestors, is untagged, or is superseded while a hook + is still waiting at the barrier, the in-flight work is cancelled and the + component tears down — you will still get `Stop` (with the matching reason) for + anything that had begun constructing. + ::: + + --- + ### See also + + - **[CO Getting Started](/api/CO%20Getting%20Started)** — the minimal class and `AddTask` basics. + - **[CO Extensions](/api/CO%20Extensions)** — the hooks that run at each phase, and how their dependencies order. +]=] diff --git a/lib/component/src/Docs/CO_Queries.luau b/lib/component/src/Docs/CO_Queries.luau new file mode 100644 index 00000000..6fd2a626 --- /dev/null +++ b/lib/component/src/Docs/CO_Queries.luau @@ -0,0 +1,144 @@ +--[=[ + @class CO Queries + + A [Query](/api/Query) is a reusable, reactive description of "the instances + matching these requirements". Where [FromInstance](/api/Component#FromInstance) + answers *about one instance*, a query answers *about the whole world* — and + keeps answering as instances come and go. Build one with + [Component.query](/api/Component#query), refine it with chain methods, then + either observe it (reactive) or read it once. + + ### 1. Build a query + + Every argument to `query(...)` is a **requirement** the instance must satisfy. A + requirement is a component class (satisfied while that component is *started* on + the instance), a tag string, another Query, a filter node, or a combinator. + + ```lua + local Component = require(Packages.Component) + + -- instances that currently have BOTH the Physics and Velocity components started + local moving = Component.query(Physics, Velocity) + + -- a raw tag works too, and mixes freely with classes + local hazards = Component.query("Hazard") + ``` + + :::caution Every query needs a bounded source + A query must have at least one **enumerable** positive requirement — a + component, a tag, or a sub-query (or an `And`/`Or` of them) — so its candidate + set is finite. Filter nodes and `Not` only *refine*; a query built from those + alone errors when read or observed. Add a real source with `query(...)`, + `:with`, or `:withAny`. + ::: + + ### 2. Refine it + + Builders return a **new** query (queries are immutable values), so chains branch + freely and the receiver is never mutated. + + ```lua + local q = Component.query(Enemy) + :with(Targetable) -- also require this (AND) + :withAny(Ranged, Melee) -- at least one of these + :without(Stunned) -- none of these + :withAttribute("Team", "Red") -- attribute equals a value + :withProperty("Anchored", false) + :where(function(instance) -- arbitrary predicate + return instance:GetPivot().Position.Y > 0 + end) + ``` + + Matcher lists (for `:withAttribute` / `:withProperty` and the `Attr` / `Prop` + nodes) are **any-of**: pass several values or `(instance, value) -> boolean` + predicates and the value need satisfy just one. Pass none to require mere + existence. + + ```lua + :withAttribute("Rarity", "Epic", "Legendary") -- Epic OR Legendary + :withAttribute("Level", function(_, v) return v >= 10 end) -- predicate + :withProperty("Adornee") -- must merely exist + ``` + + ### 3. Compose with nodes + + `Query.Attr` / `Query.Prop` / `Query.Pred` are filter nodes, and + `Query.And` / `Query.Or` / `Query.Not` combine any requirements (they must be + constructors — `and`/`or`/`not` are reserved words). They nest freely and go + anywhere a requirement is accepted: + + ```lua + -- "B or C, but not both" (exclusive or) + Component.query(A):withAny(B, C):without(Query.And(B, C)) + + -- a nested boolean requirement + Component.query(Enemy):with(Query.Or(Ranged, Query.And(Melee, Enraged))) + ``` + + ### 4. Observe it (reactive) + + `:observe` runs your callback for every instance matching now, and for every one + that matches later — each with a fresh [Janitor](/api/Janitor) that is cleaned + up the moment the instance stops matching. Fetch the matched component inside + with `FromInstance`. + + ```lua + local connection = Component.query(Enemy, Targetable):observe(function(instance, janitor) + local enemy = Enemy:FromInstance(instance) + janitor:Add(highlight(instance), "Destroy") -- removed when it stops matching + end) + + -- stop watching (destroys all active match janitors): + connection:Disconnect() + ``` + + Use `:track()` when you only need cheap repeated reads and no per-match + callback; it keeps the match set maintained so the reads below are O(1) / + O(matches). `:observeUnyielding` is the same as `:observe` but runs its callback + inline on the change thread — the fast path for hot bind/unbind work, at the + cost that the callback must not yield. + + ### 5. Read it once + + The one-shot reads take no subscription. While the query is being observed (or + tracked) anywhere in the game, they answer straight from the live engine; + otherwise they compute on the spot. + + ```lua + local all = q:get() -- { Instance } of current matches (alias: GetMatches) + local n = q:count() -- how many match + local one = q:first() -- one match, or nil + local hit = q:contains(inst) -- does this instance match right now? + + for instance in q:iter() do -- zero-allocation iteration for hot loops + -- ... + end + ``` + + :::tip Structurally-equal queries share one engine + `query(A, B)` and `query(B, A)` normalize to the same thing, so observing either + anywhere in the process shares a single set of subscriptions and one match set. + Duplicated requirements collapse too. You don't manage this — just build the + query you mean. + ::: + + ### 6. Compose by value, and `Sub` + + Passing a raw `Query` into another composes it **by value** — its clauses are + spliced in, so `Component.query(q1):with(B)` is literally `q1:with(B)`. When you + instead want a query to stay a live *nested* sub-query with its own shared + engine (worth it for an expensive shared sub-query, or to validate it + standalone), wrap it with `Query.Sub`: + + ```lua + local expensive = Component.query(Physics):where(heavyPredicate) + Component.query(Enemy):with(Query.Sub(expensive)) -- kept by reference + ``` + + --- + ### See also + + - **[Query](/api/Query)** — the full API reference: every builder, node, and terminal. + - **[CO Getting Started](/api/CO%20Getting%20Started)** — defining the component classes a query matches against. + - **[CO Lifecycle & Cleanup](/api/CO%20Lifecycle%20&%20Cleanup)** — why a class requirement means *started*, and how teardown removes a match. +]=] diff --git a/lib/component/src/Query.luau b/lib/component/src/Query.luau deleted file mode 100644 index 72ebfbbd..00000000 --- a/lib/component/src/Query.luau +++ /dev/null @@ -1,2661 +0,0 @@ ---!strict --- World-level component query engine. --- Authors: Logan Hunt [Raildex] ---[=[ - @class Query - @ignore - - A reusable, reactive query over tagged instances. Built with - `Component.query(...)` and refined with chain methods, then either observed - (`:observe`) or read once (`:get`/`:GetMatches`, `:iter`, `:count`, `:first`, - `:contains`). - - A *requirement* is a **component class** (satisfied while that component is - *started* on the instance), a **tag string** (satisfied while the instance has - the raw CollectionService tag), **another Query** (satisfied while the - instance matches it), a **filter node** (`Query.Attr` / `Query.Prop` / - `Query.Pred` — satisfied while the value test passes), or a **combinator - node** (`Query.And` / `Query.Or` / `Query.Not` — boolean composition of any - requirements, nesting freely). An instance *matches* while: - - - every positional / `:with` requirement is satisfied, - - every `:withAny(...)` group has at least one satisfied, - - no `:without` requirement is satisfied, - - every `:withAttribute` matches, - - every `:withProperty` matches, and - - every `:where` predicate returns true. - - A query must have at least one ENUMERABLE positive requirement (component / - tag / sub-query — or an `And`/`Or` of them) so its candidate set is bounded; - filter nodes and `Not` only refine, and a query with no enumerable source - errors when observed or read. - - ## Composition: by value, normalized at build time - - Every requirement is canonicalized as it enters a query, so one logical - shape has ONE internal form — and therefore one signature, one plan, and one - interned engine — no matter how it was spelled: - - - `Query(q1):with(B)` is **literally** `q1:with(B)` (a raw `Query` composes - by value: its clauses are spliced in positive position, or lowered to an - equivalent node in `withAny`/`without`, where the query must stay atomic); - - `with(Query.And(a, b))` == `with(a, b)`; `with(Query.Or(a, b))` == - `withAny(a, b)`; `with(Query.Not(x))` == `without(x)`; - - `with(Query.Attr/Prop/Pred(...))` == `withAttribute` / `withProperty` / - `where`; duplicated requirements do not split signatures. - - `Query.Sub(q)` is the one deliberate exception: it composes **by - reference**, keeping `q` as a live nested sub-query with its own (shared) - reactive engine — see [Query.Sub] for when that is worth it. - - Boolean recipes compose from `And`/`Or`/`Not` (a functionally complete - basis; variadic `Not(...)` is "none of" — i.e. NOR). E.g. exclusive-or, - "B or C but not both": - - ```lua - Component.query(A):withAny(B, C):without(Query.And(B, C)) - ``` - - :::caution Canonicalization can change the ORDER (and short-circuit count) - in which user predicate functions run relative to the exact spelling used. - Match results are unaffected; do not rely on side effects inside `Pred` / - `where` / function matchers. ::: - - See the Component `CONTEXT.md` for the glossary and the `README` for examples. -]=] - -const CollectionService = game:GetService("CollectionService") - -const Packages = script.Parent.Parent -const Signal = require(Packages.Signal) -const Janitor = require(Packages.Janitor) - -const Keys = require(script.Parent.Keys) - -type Janitor = Janitor.Janitor - --- Minimal structural view of a component class and its instances (see --- `ComponentClass` / `TypedClass` in `init.luau`); declared here so real --- classes are subtypes without a cyclic require of `init.luau`. Props are --- `read` (covariant) and the class API is self-generic, matching the real --- types, so both class variants satisfy this view. -type ComponentLike = { read Instance: Instance } -type ComponentClassLike = { - read Tag: string, - read Instance: Instance, - -- `unknown`, not a structural signal type: `Signal`'s generics are - -- invariant (via `Fire`), so no one signal type accepts every class's - -- signal. Cast to `ClassSignalView` at the connect site. - read Started: unknown, - read Stopped: unknown, - read FromInstance: (self: T, instance: Instance) -> T?, - read GetAll: (self: T) -> { T }, -} - --- Runtime connection shape shared by better-signal and RBXScriptSignal. -type ConnectionLike = { Disconnect: (self: ConnectionLike) -> () } - --- Connect-only view a class's Started/Stopped signal is cast to. -type ClassSignalView = { - Connect: (self: ClassSignalView, fn: (ComponentLike) -> ()) -> ConnectionLike, -} - --- Connect-only view a `:where` recheck signal is duck-cast to; accepts any --- signal-like value (better-signal, RBXScriptSignal, ...) with `:Connect`. -type RecheckSignalView = { - Connect: (self: RecheckSignalView, fn: () -> ()) -> ConnectionLike, -} - --- A single matcher for an attribute/property value: either a value the value --- must EQUAL, or a predicate `(instance, value) -> boolean`. `unknown` because a --- value can be anything; the predicate case is duck-detected via `type == "function"`. -export type Matcher = unknown - --- Caller-owned signal that forces a re-evaluation of all bounded instances when --- fired. Structural so it accepts both better-signal `Signal` and RBXScriptSignal. -export type RecheckSignal = { - Connect: (self: RecheckSignal, callback: () -> ()) -> { Disconnect: (self: any) -> () }, -} - --- ── Composable nodes ───────────────────────────────────────────────────────── --- A Filter is a leaf refinement: it tests ONE instance and contributes NO --- candidate source, so it is valid only nested inside a bounded query (via --- with / withAny / without / a combinator), never as a query's sole bound. --- Built by Query.Attr / Query.Prop / Query.Pred; never constructed by hand. -export type Filter = { - _node: "filter", - op: "attr" | "prop" | "where", - name: string?, -- attr / prop - -- attr/prop: the compiled MatchSpec (existence flag + packed any-of matcher - -- list; explicit `nil` matchers survive via table.pack). Opaque to users. - spec: unknown?, - fn: ((instance: Instance) -> boolean)?, -- where - signal: RecheckSignal?, -- where: optional recheck -} - --- A Combinator composes child Queryables under a boolean operator. `not`, and --- an `or` with any unbounded child, are refinement-only; `and` is bounded when --- any child is (see the boundedness rules in `_validate`). Built by --- Query.And / Query.Or / Query.Not. -export type Combinator = { - _node: "combinator", - op: "not" | "or" | "and", - children: { Queryable }, -} - --- Compose-BY-REFERENCE marker: `Query.Sub(q)` keeps `q` as a live nested --- sub-query (own reactive engine, shared by every parent referencing an --- equivalent shape) instead of lowering its clauses into the parent. A raw --- `Query` passed anywhere composes BY VALUE (its clauses are spliced/lowered --- at build time); `Sub` is the only spelling that nests. -export type Sub = { - _node: "sub", - query: Query, -} - -export type Queryable = ComponentClassLike | string | Query | Filter | Combinator | Sub - ---[=[ - @interface QueryConnection - @within Query - .IsConnected boolean - .Disconnect () -> () - .Destroy () -> () - Returned by [Query:observe]. -]=] -export type QueryConnection = { - IsConnected: boolean, - Disconnect: () -> (), - Destroy: () -> (), -} - -export type Query = { - with: (self: Query, ...Queryable) -> Query, - withAny: (self: Query, ...Queryable) -> Query, - without: (self: Query, ...Queryable) -> Query, - withAttribute: (self: Query, name: string, ...Matcher) -> Query, - withProperty: (self: Query, name: string, ...Matcher) -> Query, - where: (self: Query, predicate: (Instance) -> boolean, recheckSignal: RecheckSignal?) -> Query, - observe: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, - observeUnyielding: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, - track: (self: Query) -> QueryConnection, - get: (self: Query) -> { Instance }, - iter: (self: Query) -> () -> Instance?, - contains: (self: Query, instance: Instance) -> boolean, - count: (self: Query) -> number, - first: (self: Query) -> Instance?, -} - --- satisfiedFn(query, instance): is `instance` currently matching `query`? --- Supplied by caller so the same predicate logic serves both the reactive --- engine (sub-query engines) and the one-shot GetMatches (static membership). -type SatisfiedFn = (QueryInternal, Instance) -> boolean - --- Unified attribute/property clause used by BOTH the fast-path lists --- (`_attributes` / `_properties`) and compiled attr/prop nodes. `exists` --- short-circuits to an existence check (0-matcher form); otherwise match = --- value satisfies ANY matcher (any-of). The list may legitimately hold `nil` --- matchers ("value == nil"), so `count` (table.pack's `n`) sizes it, never `#`. -type MatchSpec = { - name: string, - exists: boolean, - matchers: { Matcher }, - count: number, -} - --- One `:where` requirement; `signal` is duck-cast to `RecheckSignalView` when --- the engine activates. -type PredicateRequirement = { - fn: (Instance) -> boolean, - signal: RecheckSignal?, -} - -type Observer = { - callback: (Instance, Janitor) -> (), - janitors: { [Instance]: Janitor }, - -- `observeUnyielding` observers run their callback inline (no per-match - -- thread) and must neither yield nor error; a violation is reported loudly. - unyielding: boolean, -} - -type ChangedSignal = Signal.Signal<(Instance, boolean) -> (), Instance, boolean> - --- One requirement, compiled: its kind resolved once and, for component classes, --- the class's live started sparse set captured directly (see --- `Keys.ClassInternal`). Checking a class requirement per candidate is then a --- SINGLE table lookup — membership in `startedMap` IS "started" — and --- `startedList` gives `get()` an O(1)-sized, already-filtered seed source. -type PlanReq = { - kind: "tag" | "class" | "classlike" | "query" | "attr" | "prop" | "where" | "not" | "or" | "and", - buildIndex: number, -- declaration position; tiebreak for the stable sort - tag: string?, - startedList: { Instance }?, -- class: live dense started-instance array - startedMap: { [Instance]: number }?, -- class: live instance -> list index - class: ComponentClassLike?, -- foreign class-like: FromInstance fallback - query: QueryInternal?, - spec: MatchSpec?, -- attr / prop node - fn: ((Instance) -> boolean)?, -- where node - signal: RecheckSignal?, -- where node - children: { PlanReq }?, -- not / or / and (compiled recursively) -} - --- The compiled shape of a query: requirement lists as PlanReqs plus presence --- flags so empty clauses cost nothing per candidate. Cached on the query and --- rebuilt by `_invalidate` (i.e. on any mutation). -type Plan = { - required: { PlanReq }, - anyOf: { { PlanReq } }, - negative: { PlanReq }, - hasNegative: boolean, - hasAttributes: boolean, - hasProperties: boolean, - hasPredicates: boolean, - -- True when any clause references a sub-query; `get()` only builds its - -- per-call memoization machinery (and a real SatisfiedFn) when it is. - hasQueryRefs: boolean, -} - --- Reactive state backing an activated query (ref-counted, shared when a query --- is used more than once). -type Engine = { - -- Sparse-set pair: `matched[instance]` is its 1-based position in - -- `matchedList` (the map doubles as the membership set), and `matchedList` - -- is the dense, insertion-ordered array reads iterate/clone. Removal is a - -- swap-remove, so both stay O(1) per transition. - matched: { [Instance]: number }, - matchedList: { Instance }, - changed: ChangedSignal, - observers: { [Observer]: boolean }, - janitor: Janitor, - positiveSet: { [Instance]: boolean }, -- [Instance]: true while positively bounded (candidate universe) - attrConns: { [Instance]: ConnectionLike }, -- one AttributeChanged sub per candidate - propConns: { [Instance]: { ConnectionLike } }, -- one GetPropertyChangedSignal sub per watched property, per candidate - subEngines: { [QueryInternal]: Engine }, - -- Interning bookkeeping: the canonical signature this engine is registered - -- under, total activations across every equivalent query sharing it, and - -- the queries currently attached (so their `_engine` pointers can be - -- cleared when the engine dies). - signature: string, - refcount: number, - holders: { [QueryInternal]: boolean }, -} - -type QueryInternal = Query & { - _positive: { Queryable }, - _anyOf: { { Queryable } }, -- array of groups - _negative: { Queryable }, - _attributes: { MatchSpec }, - _properties: { MatchSpec }, - _predicates: { PredicateRequirement }, - _engine: Engine?, - _refcount: number, - -- Lazily-built caches. Queries are immutable after construction (builders - -- copy-on-write), so none of these can ever go stale. - _validated: boolean, - _sources: { Queryable }?, - _planned: Plan?, - _signatureCache: string?, - - _plan: (self: QueryInternal) -> Plan, - _signature: (self: QueryInternal) -> string, - _positiveSources: (self: QueryInternal) -> { Queryable }, - _validate: (self: QueryInternal) -> (), - _allReferences: (self: QueryInternal) -> { Queryable }, - _positiveCandidate: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, - _universeCandidate: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, - _attributesMatch: (self: QueryInternal, instance: Instance) -> boolean, - _propertiesMatch: (self: QueryInternal, instance: Instance) -> boolean, - _predicatesPass: (self: QueryInternal, instance: Instance) -> boolean, - _matchesRest: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, - _fullMatch: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, - _activate: (self: QueryInternal) -> Engine, - _deactivate: (self: QueryInternal) -> (), - _enumerate: ( - self: QueryInternal, - reactive: boolean, - subSets: { [QueryInternal]: { [Instance]: boolean } }? - ) -> { [Instance]: boolean }, - _singleSource: (self: QueryInternal) -> PlanReq?, - _staticSub: ( - self: QueryInternal, - plan: Plan - ) -> (SatisfiedFn, ((QueryInternal) -> { [Instance]: boolean })?, { [QueryInternal]: { [Instance]: boolean } }?), - _collect: (self: QueryInternal, onMatch: (Instance) -> boolean?) -> (), -} - -const INTERNAL = Keys.Internal -- hoisted for the plan compiler's class probe - --- Module table: static constructors (`new`, `Attr`, `Prop`, `Pred`, `And`, --- `Or`, `Not`) plus the instance `prototype`. Made callable (`Query(...)` == --- `Query.new(...)`) at the bottom of the file. -const Query = {} --- Instance methods live on `prototype` (mirrors `Component.prototype`), keeping --- the static namespace free — `and`/`or`/`not` are reserved words, so combinators --- must be statics, and statics must not collide with method names. -const prototype = {} -prototype.__index = prototype -Query.prototype = prototype - --- Node prototypes: Filters/Combinators are tagged tables built through these so --- a later fluent matcher DSL can attach methods without changing representation. -const FilterProto = {} -FilterProto.__index = FilterProto -const ComboProto = {} -ComboProto.__index = ComboProto -const SubProto = {} -SubProto.__index = SubProto - -const function isQuery(value: unknown): boolean - return type(value) == "table" and getmetatable(value) == prototype -end - -const function isFilter(value: unknown): boolean - return type(value) == "table" and getmetatable(value) == FilterProto -end - -const function isCombinator(value: unknown): boolean - return type(value) == "table" and getmetatable(value) == ComboProto -end - -const function isSub(value: unknown): boolean - return type(value) == "table" and getmetatable(value) == SubProto -end - -const function isComponentClass(value: unknown): boolean - if type(value) ~= "table" or isQuery(value) or isFilter(value) or isCombinator(value) or isSub(value) then - return false - end - return type((value :: { read Tag: unknown }).Tag) == "string" -end - -const function assertQueryable(value: Queryable, method: string) - if - type(value) == "string" - or isQuery(value) - or isFilter(value) - or isCombinator(value) - or isSub(value) - or isComponentClass(value) - then - return - end - error( - `[Component] :{method}() expects a component class, tag string, Query, or Query.Attr/Prop/Pred/And/Or/Not/Sub node`, - 3 - ) -end - ---[=[ - @within Component - @function query - @param ... Queryable -- component classes, tag strings, and/or sub-queries - @return Query - - Creates a new query whose positional arguments are all required. -]=] -const function rawNew(): QueryInternal - -- Cast through `any`: without it the solver stamps `@metatable` onto the - -- table and rejects the internal type (same as TableManager's constructor). - return ( - setmetatable({ - _positive = {}, - _anyOf = {}, - _negative = {}, - _attributes = {}, - _properties = {}, - _predicates = {}, - _engine = nil, - _refcount = 0, - _validated = false, - _sources = nil, - _planned = nil, - _signatureCache = nil, - }, prototype) :: any - ) :: QueryInternal -end - --- Copy-on-write base for every builder: a fresh query with this one's --- requirement lists cloned shallowly. The inner entries (anyOf groups, --- attribute/predicate records) are never mutated after creation, so sharing --- them is safe. Queries are therefore immutable values: every builder returns --- a NEW query and the receiver is never changed, so chains branch freely -- --- `qA:with(qB)` and `qA:without(qC)` are independent and `qA` stays `qA`. -const function derive(self: QueryInternal): QueryInternal - const new = rawNew() - new._positive = table.clone(self._positive) - new._anyOf = table.clone(self._anyOf) - new._negative = table.clone(self._negative) - new._attributes = table.clone(self._attributes) - new._properties = table.clone(self._properties) - new._predicates = table.clone(self._predicates) - return new -end - --- Packs a variadic matcher list into a MatchSpec. 0 matchers = existence check; --- otherwise the value must satisfy ANY entry (value-equality, or a predicate --- `(instance, value) -> boolean`). `table.pack` so explicit `nil` matchers --- ("value == nil") survive; `select("#")` distinguishes omitted from nil. -const function makeMatchSpec(name: string, ...: Matcher): MatchSpec - const count = select("#", ...) - return { - name = name, - exists = count == 0, - matchers = table.pack(...) :: { Matcher }, - count = count, - } -end - --------------------------------------------------------------------------------- --- Static node constructors (composable Queryables) --------------------------------------------------------------------------------- - ---[=[ - @within Query - @function Attr - @param name string - @param ... any -- values and/or `(instance, value) -> boolean` predicates; ANY may match. Omit all for existence. - @return Filter - An attribute filter node, usable anywhere a Queryable is accepted - (`query(...)`, `:with`, `:withAny`, `:without`, or nested in `Query.And/Or/Not`). - Same matcher semantics as [Query:withAttribute]. -]=] -function Query.Attr(name: string, ...: Matcher): Filter - assert(type(name) == "string", "[Component] Query.Attr() expects an attribute name string") - const spec = makeMatchSpec(name, ...) - return (setmetatable({ _node = "filter", op = "attr", name = name, spec = spec }, FilterProto) :: any) :: Filter -end - ---[=[ - @within Query - @function Prop - @param name string - @param ... any -- values and/or `(instance, value) -> boolean` predicates; ANY may match. Omit all for existence; explicit `nil` means "== nil". - @return Filter - A property filter node. Same matcher semantics as [Query:withProperty]. -]=] -function Query.Prop(name: string, ...: Matcher): Filter - assert(type(name) == "string", "[Component] Query.Prop() expects a property name string") - const spec = makeMatchSpec(name, ...) - return (setmetatable({ _node = "filter", op = "prop", name = name, spec = spec }, FilterProto) :: any) :: Filter -end - ---[=[ - @within Query - @function Pred - @param fn (instance: Instance) -> boolean - @param recheckSignal RecheckSignal? -- fire to force re-evaluation - @return Filter - A predicate filter node. Same semantics (and staleness caveat) as [Query:where]. -]=] -function Query.Pred(fn: (Instance) -> boolean, recheckSignal: RecheckSignal?): Filter - assert(type(fn) == "function", "[Component] Query.Pred() expects a predicate function") - return ( - setmetatable({ _node = "filter", op = "where", fn = fn, signal = recheckSignal }, FilterProto) :: any - ) :: Filter -end - -const function makeCombinator(op: "not" | "or" | "and", method: string, ...: Queryable): Combinator - const children = { ... } - assert(#children > 0, `[Component] Query.{method}() expects at least one Queryable`) - for _, child in children do - assertQueryable(child, method) - end - return (setmetatable({ _node = "combinator", op = op, children = children }, ComboProto) :: any) :: Combinator -end - ---[=[ - @within Query - @function And - @param ... Queryable - @return Combinator - Satisfied when ALL children are. Bounded (usable as a candidate source) when - any child is bounded. -]=] -function Query.And(...: Queryable): Combinator - return makeCombinator("and", "And", ...) -end - ---[=[ - @within Query - @function Or - @param ... Queryable - @return Combinator - Satisfied when AT LEAST ONE child is. Bounded only when every child is - bounded (an unbounded branch would make the candidate set unbounded). -]=] -function Query.Or(...: Queryable): Combinator - return makeCombinator("or", "Or", ...) -end - ---[=[ - @within Query - @function Not - @param ... Queryable - @return Combinator - Satisfied when NONE of the children are (variadic "none of", mirroring - [Query:without]). Refinement-only: contributes no candidates. -]=] -function Query.Not(...: Queryable): Combinator - return makeCombinator("not", "Not", ...) -end - ---[=[ - @within Query - @function Sub - @param query Query - @return Sub - - Composes `query` **by reference**: the parent keeps it as a live nested - sub-query with its own reactive engine (shared with every other parent - referencing an equivalent shape) instead of lowering its clauses into the - parent's plan. - - A raw `Query` passed to `query(...)` / `:with` / `:withAny` / `:without` - composes **by value** — its clauses are spliced (or wrapped as an `And` node - in `withAny`/`without`) at build time, so `Query(q1):with(B)` is literally - `q1:with(B)`. Use `Sub` when you specifically want the nested form: - - the sub-query's clauses are expensive per candidate (heavy `Pred`s, many - property reads) and it is shared by many active parents — one shared - evaluation instead of per-parent probes; - - you want the sub-query validated standalone (a `Sub` of an unbounded - query errors; a spliced one can be bounded by the parent's other sources); - - you rely on the sub-query's own evaluation order for side-effectful - predicates. -]=] -function Query.Sub(query: Query): Sub - assert(isQuery(query), "[Component] Query.Sub() expects a Query") - return (setmetatable({ _node = "sub", query = query }, SubProto) :: any) :: Sub -end - --- Rebuilds a Filter node from an already-packed MatchSpec (the public --- `Query.Attr`/`Query.Prop` pack fresh varargs; lowering reuses stored specs — --- they are immutable after creation, so sharing is safe). -const function filterFromSpec(op: "attr" | "prop", spec: MatchSpec): Filter - return (setmetatable({ _node = "filter", op = op, name = spec.name, spec = spec }, FilterProto) :: any) :: Filter -end - -const function predToFilter(pred: PredicateRequirement): Filter - return ( - setmetatable({ _node = "filter", op = "where", fn = pred.fn, signal = pred.signal }, FilterProto) :: any - ) :: Filter -end - --- Lowers a whole query to a single equivalent node for ATOMIC positions --- (`withAny` members, `without` entries), where splicing would change meaning --- (De Morgan): the node is satisfied exactly while the query matches. Positive --- positions splice instead (see `addPositive`). -const function queryToNode(q: QueryInternal, method: string): Queryable - const parts: { Queryable } = {} - for _, req in q._positive do - table.insert(parts, req) - end - for _, group in q._anyOf do - table.insert(parts, makeCombinator("or", "Or", table.unpack(group))) - end - if #q._negative > 0 then - table.insert(parts, makeCombinator("not", "Not", table.unpack(q._negative))) - end - for _, spec in q._attributes do - table.insert(parts, filterFromSpec("attr", spec)) - end - for _, spec in q._properties do - table.insert(parts, filterFromSpec("prop", spec)) - end - for _, pred in q._predicates do - table.insert(parts, predToFilter(pred)) - end - if #parts == 0 then - error(`[Component] :{method}() cannot compose an empty query (it has no requirements)`, 3) - end - if #parts == 1 then - return parts[1] - end - return makeCombinator("and", "And", table.unpack(parts)) -end - --------------------------------------------------------------------------------- --- Build-time normalization --- --- Every requirement is canonicalized as it enters a query, so ONE logical shape --- has ONE internal form (and therefore one signature, one plan, one interned --- engine) no matter how it was spelled: --- with(rawQuery) == splicing its clauses (compose by value) --- with(And(a, b)) == with(a, b) --- with(Or(a, b)) == withAny(a, b) --- with(Not(x)) == without(x) --- with(Attr/Prop/...) == withAttribute / withProperty / where --- withAny(x) == with(x) (a 1-member group is required) --- `Query.Sub(q)` is the deliberate exception: it composes by REFERENCE and is --- stored as-is. Normalization happens only here in the builders; stored queries --- are always already canonical, so splices never recurse. --------------------------------------------------------------------------------- - -local normalizeAnyMember: (req: Queryable, method: string) -> Queryable - --- Canonical entry of one requirement into REQUIRED (AND) position. -const function addPositive(new: QueryInternal, req: Queryable, method: string) - if isQuery(req) then - -- Compose by value: splice the query's (already canonical) clauses. - const q = req :: QueryInternal - for _, entry in q._positive do - table.insert(new._positive, entry) - end - for _, group in q._anyOf do - table.insert(new._anyOf, group) - end - for _, entry in q._negative do - table.insert(new._negative, entry) - end - for _, spec in q._attributes do - table.insert(new._attributes, spec) - end - for _, spec in q._properties do - table.insert(new._properties, spec) - end - for _, pred in q._predicates do - table.insert(new._predicates, pred) - end - return - end - if isCombinator(req) then - const combo = req :: Combinator - const op = combo.op - if op == "and" then - for _, child in combo.children do - addPositive(new, child, method) - end - return - elseif op == "or" then - const group: { Queryable } = {} - for _, child in combo.children do - table.insert(group, normalizeAnyMember(child, method)) - end - if #group == 1 then - addPositive(new, group[1], method) - else - table.insert(new._anyOf, group) - end - return - else -- not: required "none of" == excluded - for _, child in combo.children do - table.insert(new._negative, normalizeAnyMember(child, method)) - end - return - end - end - if isFilter(req) then - const filter = req :: Filter - const op = filter.op - if op == "attr" then - table.insert(new._attributes, filter.spec :: MatchSpec) - elseif op == "prop" then - table.insert(new._properties, filter.spec :: MatchSpec) - else - table.insert(new._predicates, { fn = filter.fn :: (Instance) -> boolean, signal = filter.signal }) - end - return - end - -- Sub node / class / tag / class-like: a plain required requirement. - table.insert(new._positive, req) -end - --- Canonical form of one requirement in an ATOMIC position (a `withAny` group --- member or a `without` entry), where the requirement must stand as a single --- testable unit: raw queries lower to an equivalent node (De Morgan forbids --- splicing here); nested single-child wrappers collapse; everything else is --- kept as-is. -function normalizeAnyMember(req: Queryable, method: string): Queryable - if isQuery(req) then - return queryToNode(req :: QueryInternal, method) - end - if isCombinator(req) then - const combo = req :: Combinator - if #combo.children == 1 and combo.op ~= "not" then - return normalizeAnyMember(combo.children[1], method) - end - end - return req -end - -function Query.new(...: Queryable): Query - const self = rawNew() - for _, req in { ... } do - assertQueryable(req, "with") - addPositive(self, req, "with") - end - return self -end - ---[=[ - @within Query - @param ... Queryable - @return Query - Returns a NEW query with the given required requirements added; the - receiver is unchanged (builders never mutate, so chains branch freely). - `query():with(X)` is equivalent to `query(X)`. -]=] -function prototype.with(self: QueryInternal, ...: Queryable): Query - const new = derive(self) - for _, req in { ... } do - assertQueryable(req, "with") - addPositive(new, req, "with") - end - return new -end - ---[=[ - @within Query - @param ... Queryable - @return Query - Returns a NEW query with an "at least one of" group added: the instance - must satisfy at least one of the given requirements. Multiple `:withAny` - calls each add an independent group. The receiver is unchanged. -]=] -function prototype.withAny(self: QueryInternal, ...: Queryable): Query - const raw = { ... } - if #raw == 0 then - return self - end - const new = derive(self) - const group: { Queryable } = {} - for _, req in raw do - assertQueryable(req, "withAny") - -- A nested Or is the same disjunction: flatten its members into this - -- group so `withAny(Or(a, b), c)` == `withAny(a, b, c)`. - const normalized = normalizeAnyMember(req, "withAny") - if isCombinator(normalized) and (normalized :: Combinator).op == "or" then - for _, child in (normalized :: Combinator).children do - table.insert(group, normalizeAnyMember(child, "withAny")) - end - else - table.insert(group, normalized) - end - end - if #group == 1 then - -- "At least one of {x}" is just "x": required position. - addPositive(new, group[1], "withAny") - else - table.insert(new._anyOf, group) - end - return new -end - ---[=[ - @within Query - @param ... Queryable - @return Query - Returns a NEW query with the given excluded requirements added: the - instance must satisfy none of them. The receiver is unchanged. -]=] -function prototype.without(self: QueryInternal, ...: Queryable): Query - const new = derive(self) - for _, req in { ... } do - assertQueryable(req, "without") - table.insert(new._negative, normalizeAnyMember(req, "without")) - end - return new -end - ---[=[ - @within Query - @param name string - @param ... any -- values to equal and/or `(instance, value) -> boolean` predicates; matches if ANY does. Omit all for existence. - @return Query - Returns a NEW query that additionally requires an attribute; the receiver - is unchanged. With no matchers, the attribute must merely exist; otherwise - the attribute's value must satisfy at least one matcher (equal a value, or a - predicate returning true). Re-evaluated reactively on attribute change. -]=] -function prototype.withAttribute(self: QueryInternal, name: string, ...: Matcher): Query - assert(type(name) == "string", "[Component] :withAttribute() expects an attribute name string") - const new = derive(self) - table.insert(new._attributes, makeMatchSpec(name, ...)) - return new -end - ---[=[ - @within Query - @param name string - @param ... any -- values to equal and/or `(instance, value) -> boolean` predicates; matches if ANY does. Omit all for existence. - @return Query - Returns a NEW query that additionally requires an Instance property; the - receiver is unchanged. Re-evaluated reactively when the property changes. - - Matcher forms: - - **omitted** (`:withProperty("Anchored")`) — the property must merely *exist* - on the instance (a candidate whose class lacks it never matches); - - **explicit `nil`** (`:withProperty("Parent", nil)`) — the property must exist - and equal `nil` (e.g. "unparented"); - - **functions** — must return true for `(instance, value)`; - - **any other value** — the property must equal it; - - **several matchers** — the value must satisfy at least one. - - Unlike attributes, a property may not exist on every Instance class a query - spans; a class lacking the named property simply does not match. -]=] -function prototype.withProperty(self: QueryInternal, name: string, ...: Matcher): Query - assert(type(name) == "string", "[Component] :withProperty() expects a property name string") - const new = derive(self) - table.insert(new._properties, makeMatchSpec(name, ...)) - return new -end - ---[=[ - @within Query - @param predicate (instance: Instance) -> boolean - @param recheckSignal RecheckSignal? -- fire to force re-evaluation - @return Query - - Returns a NEW query with an arbitrary predicate added; the receiver is - unchanged. :::caution A predicate has no change signal of its - own — it is only re-evaluated when another requirement changes, or when the - optional `recheckSignal` fires. Without one, its result can go stale. ::: -]=] -function prototype.where(self: QueryInternal, predicate: (Instance) -> boolean, recheckSignal: RecheckSignal?): Query - assert(type(predicate) == "function", "[Component] :where() expects a predicate function") - const new = derive(self) - table.insert(new._predicates, { fn = predicate, signal = recheckSignal }) - return new -end - --------------------------------------------------------------------------------- --- Validation --------------------------------------------------------------------------------- - --- Flattened positive requirements (positional/:with + every :withAny member). --- These bound the candidate set; a query with no enumerable one is rejected. -function prototype._positiveSources(self: QueryInternal): { Queryable } - const cached = self._sources - if cached then - return cached - end - const sources = {} - for _, req in self._positive do - table.insert(sources, req) - end - for _, group in self._anyOf do - for _, req in group do - table.insert(sources, req) - end - end - self._sources = sources - return sources -end - --- Whether a requirement can ENUMERATE a finite candidate set: classes, tags, --- and sub-queries can; filters and `Not` cannot (they only test); an `And` can --- when any child can (that child's members are a superset of the And's); an --- `Or` only when every child can (its members are the union of the children's). -const function isEnumerable(req: Queryable): boolean - if type(req) == "string" or isQuery(req) or isSub(req) then - return true - end - if isFilter(req) then - return false - end - if isCombinator(req) then - const combo = req :: Combinator - const op = combo.op - if op == "not" then - return false - elseif op == "and" then - for _, child in combo.children do - if isEnumerable(child) then - return true - end - end - return false - else -- or - for _, child in combo.children do - if not isEnumerable(child) then - return false - end - end - return true - end - end - return true -- component class / class-like -end - --- DFS over sub-query references; errors on an unbounded query. A query is --- bounded iff a required requirement is enumerable, or (with no enumerable --- required requirement) some `withAny` group is enumerable throughout — every --- match satisfies each group, so a fully-enumerable group's union bounds the --- candidate set. Cycles are impossible by construction: builders copy-on-write, --- so a query can only ever reference queries that existed before it did. -function prototype._validate(self: QueryInternal) - if self._validated then - return - end - local bounded = false - for _, req in self._positive do - if isEnumerable(req) then - bounded = true - break - end - end - if not bounded then - for _, group in self._anyOf do - local groupEnumerable = #group > 0 - for _, req in group do - if not isEnumerable(req) then - groupEnumerable = false - break - end - end - if groupEnumerable then - bounded = true - break - end - end - end - if not bounded then - error( - "[Component] Query has no enumerable positive requirement (component / tag / sub-query, " - .. "or an And/Or of them); filters and Not() only refine — add a bounded source via " - .. "query(...), :with(), or :withAny() so the candidate set is finite", - 0 - ) - end - for _, req in self:_allReferences() do - if isQuery(req) then - (req :: QueryInternal):_validate() - end - end - self._validated = true -end - --- Every requirement across all clauses (positive, anyOf, negative), flattened: --- combinators are unwrapped recursively and `Sub` nodes unwrap to their inner --- Query, so the list holds only leaf requirements (classes, tags, sub-queries, --- filters). Consumers subscribe / validate / memoize from this without knowing --- about nesting. -function prototype._allReferences(self: QueryInternal): { Queryable } - const refs: { Queryable } = {} - const function add(req: Queryable) - if isCombinator(req) then - for _, child in (req :: Combinator).children do - add(child) - end - elseif isSub(req) then - table.insert(refs, (req :: Sub).query) - else - table.insert(refs, req) - end - end - for _, req in self._positive do - add(req) - end - for _, group in self._anyOf do - for _, req in group do - add(req) - end - end - for _, req in self._negative do - add(req) - end - return refs -end - --------------------------------------------------------------------------------- --- Satisfaction / matching --------------------------------------------------------------------------------- - --- Probe cost by kind, measured per candidate: a class check is one direct table --- lookup (~30ns), a sub-query check is a memo/engine hash lookup, a class-like --- goes through `FromInstance` (~80ns), and a tag check is a `HasTag` C-call --- (~150ns). Node kinds follow: an attr read is one `GetAttribute` C-call, a prop --- read a pcall'd index, `where` a user pcall, and combinators recurse into an --- unknown number of children — probed last. Every compiled list is sorted --- cheapest-first so per-candidate evaluation short-circuits on the cheap probes; --- requirement semantics are order-independent, so this is free. `buildIndex` --- keeps the sort deterministic (`table.sort` is unstable). -const KIND_COST: { [string]: number } = { - class = 1, - query = 2, - classlike = 3, - tag = 4, - attr = 5, - prop = 6, - where = 7, - ["not"] = 8, - ["or"] = 8, - ["and"] = 8, -} - --- A seed at or below this is narrow enough that hunting for a better one is --- not worth fetching more tag arrays: remaining probes run at most this many --- times each. -const TAG_SIZING_EARLY_EXIT = 32 - --- `get()` sizes every required tag via `GetTagged` (to pick the narrowest seed --- and order probes most-selective-first) only when the best class seed exceeds --- this. Below it the candidate set is already small, and sizing a huge tag --- would cost an array allocation proportional to its population for at most a --- few hundred cheap probes of savings. -const TAG_SIZING_MIN_SEED = 200 -const function sortBySelectivity(reqs: { PlanReq }) - table.sort(reqs, function(x: PlanReq, y: PlanReq): boolean - const cx = KIND_COST[x.kind] :: number - const cy = KIND_COST[y.kind] :: number - if cx ~= cy then - return cx < cy - end - return x.buildIndex < y.buildIndex - end) -end - -const function compileReq(req: Queryable, buildIndex: number): PlanReq - if type(req) == "string" then - return { kind = "tag" :: "tag", buildIndex = buildIndex, tag = req } - end - if isSub(req) then - return { kind = "query" :: "query", buildIndex = buildIndex, query = (req :: Sub).query :: QueryInternal } - end - if isQuery(req) then - -- Defensive: normalization never stores a raw Query, but compile it as a - -- sub-query rather than misclassifying if one ever slips through. - return { kind = "query" :: "query", buildIndex = buildIndex, query = req :: QueryInternal } - end - if isFilter(req) then - const filter = req :: Filter - if filter.op == "where" then - return { kind = "where" :: "where", buildIndex = buildIndex, fn = filter.fn, signal = filter.signal } - end - return { - kind = (if filter.op == "attr" then "attr" else "prop") :: "attr" | "prop", - buildIndex = buildIndex, - spec = filter.spec :: MatchSpec, - } - end - if isCombinator(req) then - const combo = req :: Combinator - const children: { PlanReq } = {} - for index, child in combo.children do - table.insert(children, compileReq(child, index)) - end - sortBySelectivity(children) - return { - kind = combo.op :: "not" | "or" | "and", - buildIndex = buildIndex, - children = children, - } - end - const internal = (req :: any)[INTERNAL] - if internal then - -- Our own class: capture its live started sparse set. Both tables are - -- mutated in place (never replaced) by the lifecycle, so the references - -- stay valid for the class's whole life; `Destroy` clears them, which - -- correctly reads as "no matches". - return { - kind = "class" :: "class", - buildIndex = buildIndex, - startedList = internal.startedList, - startedMap = internal.startedInstances, - } - end - return { kind = "classlike" :: "classlike", buildIndex = buildIndex, class = req :: ComponentClassLike } -end - --- Evaluates one MatchSpec. `present` is whether the attribute/property exists at --- all on this instance (attribute: value ~= nil; property: the read didn't --- throw); `label` names the calling surface for matcher-error warnings. THE --- single implementation of matcher semantics — the fast-path clause lists and --- the compiled attr/prop nodes both route here. -const function matchSpecSatisfied( - instance: Instance, - present: boolean, - value: unknown, - spec: MatchSpec, - label: string -): boolean - if spec.exists then - return present - end - if not present then - return false - end - const matchers = spec.matchers - for i = 1, spec.count do - const matcher = matchers[i] - if type(matcher) == "function" then - const success, result = pcall(matcher :: (Instance, unknown) -> unknown, instance, value) - if success and result == true then - return true - end - if not success then - warn(`[Component] Query {label}('{spec.name}') matcher errored: {result}`) - end - elseif value == matcher then - return true - end - end - return false -end - -const function attrSatisfies(instance: Instance, spec: MatchSpec): boolean - const value = instance:GetAttribute(spec.name) - return matchSpecSatisfied(instance, value ~= nil, value, spec, "Attr") -end - -const function propSatisfies(instance: Instance, spec: MatchSpec): boolean - -- A property missing on this Instance's class throws on read; the pcall - -- failing IS the "property absent" signal (there is no reflection API for - -- game code). `present` distinguishes absent from present-but-nil. - const present, value = pcall(function() - return (instance :: any)[spec.name] - end) - return matchSpecSatisfied(instance, present, if present then value else nil, spec, "Prop") -end - --- Compiled requirement check. `satisfiedFn` is only consulted for sub-query --- requirements; all other kinds are direct (combinators recurse). -const function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: SatisfiedFn): boolean - const kind = req.kind - if kind == "class" then - return (req.startedMap :: { [Instance]: number })[instance] ~= nil - elseif kind == "tag" then - return CollectionService:HasTag(instance, req.tag :: string) - elseif kind == "query" then - return satisfiedFn(req.query :: QueryInternal, instance) - elseif kind == "attr" then - return attrSatisfies(instance, req.spec :: MatchSpec) - elseif kind == "prop" then - return propSatisfies(instance, req.spec :: MatchSpec) - elseif kind == "where" then - const success, result = pcall(req.fn :: (Instance) -> boolean, instance) - if not success then - warn(`[Component] Query.Pred() predicate errored: {result}`) - return false - end - return result == true - elseif kind == "not" then - for _, child in req.children :: { PlanReq } do - if reqSatisfied(child, instance, satisfiedFn) then - return false - end - end - return true - elseif kind == "or" then - for _, child in req.children :: { PlanReq } do - if reqSatisfied(child, instance, satisfiedFn) then - return true - end - end - return false - elseif kind == "and" then - for _, child in req.children :: { PlanReq } do - if not reqSatisfied(child, instance, satisfiedFn) then - return false - end - end - return true - else - const class = req.class :: ComponentClassLike - const component = class:FromInstance(instance) - return component ~= nil and Keys.inst(component).phase == "Started" - end -end - --- Loose per-requirement check used to track the candidate UNIVERSE: value --- filters (attr/prop/where) and negations are vacuously TRUE here, so an --- instance stays tracked (positiveSet membership, attribute/property change --- subscriptions) while it satisfies the SOURCE requirements alone — a filter --- that is currently false must not tear down the very subscription that would --- re-evaluate it when it flips true. The strict check decides actual matching. -const function reqInUniverse(req: PlanReq, instance: Instance, satisfiedFn: SatisfiedFn): boolean - const kind = req.kind - if kind == "attr" or kind == "prop" or kind == "where" or kind == "not" then - return true - elseif kind == "or" then - for _, child in req.children :: { PlanReq } do - if reqInUniverse(child, instance, satisfiedFn) then - return true - end - end - return false - elseif kind == "and" then - for _, child in req.children :: { PlanReq } do - if not reqInUniverse(child, instance, satisfiedFn) then - return false - end - end - return true - end - return reqSatisfied(req, instance, satisfiedFn) -end - --- Placeholder SatisfiedFn for plans with no sub-query requirement: nothing can --- ever call it (`reqSatisfied` only consults satisfiedFn for "query" kinds). -const function neverSub(_query: QueryInternal, _instance: Instance): boolean - return false -end - -function prototype._plan(self: QueryInternal): Plan - const cached = self._planned - if cached then - return cached - end - const required: { PlanReq } = {} - for index, req in self._positive do - table.insert(required, compileReq(req, index)) - end - sortBySelectivity(required) - const anyOf: { { PlanReq } } = {} - for _, group in self._anyOf do - const compiled: { PlanReq } = {} - for index, req in group do - table.insert(compiled, compileReq(req, index)) - end - sortBySelectivity(compiled) - table.insert(anyOf, compiled) - end - const negative: { PlanReq } = {} - for index, req in self._negative do - table.insert(negative, compileReq(req, index)) - end - sortBySelectivity(negative) - const function anyQueryReq(reqs: { PlanReq }): boolean - for _, req in reqs do - if req.kind == "query" then - return true - end - const children = req.children - if children and anyQueryReq(children) then - return true - end - end - return false - end - local hasQueryRefs = anyQueryReq(required) or anyQueryReq(negative) - if not hasQueryRefs then - for _, group in anyOf do - if anyQueryReq(group) then - hasQueryRefs = true - break - end - end - end - const plan: Plan = { - required = required, - anyOf = anyOf, - negative = negative, - hasNegative = #negative > 0, - hasAttributes = #self._attributes > 0, - hasProperties = #self._properties > 0, - hasPredicates = #self._predicates > 0, - hasQueryRefs = hasQueryRefs, - } - self._planned = plan - return plan -end - -function prototype._positiveCandidate(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean - const plan = self:_plan() - for _, req in plan.required do - if not reqSatisfied(req, instance, satisfiedFn) then - return false - end - end - for _, group in plan.anyOf do - local anySatisfied = false - for _, req in group do - if reqSatisfied(req, instance, satisfiedFn) then - anySatisfied = true - break - end - end - if not anySatisfied then - return false - end - end - return true -end - --- Universe (subscription-tracking) variant of `_positiveCandidate`: value --- filters count as vacuously satisfied (see `reqInUniverse`). -function prototype._universeCandidate(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean - const plan = self:_plan() - for _, req in plan.required do - if not reqInUniverse(req, instance, satisfiedFn) then - return false - end - end - for _, group in plan.anyOf do - local anySatisfied = false - for _, req in group do - if reqInUniverse(req, instance, satisfiedFn) then - anySatisfied = true - break - end - end - if not anySatisfied then - return false - end - end - return true -end - -function prototype._attributesMatch(self: QueryInternal, instance: Instance): boolean - for _, spec in self._attributes do - if not attrSatisfies(instance, spec) then - return false - end - end - return true -end - -function prototype._propertiesMatch(self: QueryInternal, instance: Instance): boolean - for _, spec in self._properties do - if not propSatisfies(instance, spec) then - return false - end - end - return true -end - -function prototype._predicatesPass(self: QueryInternal, instance: Instance): boolean - for _, pred in self._predicates do - const success, result = pcall(pred.fn, instance) - if not success then - warn(`[Component] Query :where() predicate errored: {result}`) - return false - end - if result ~= true then - return false - end - end - return true -end - --- Everything a match requires EXCEPT the positive requirements. Split out so --- callers that already know `instance` is a positive candidate (the reactive --- engine, and `get()` over a single-source enumeration) do not pay to prove it --- twice. -function prototype._matchesRest(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean - const plan = self:_plan() - if plan.hasNegative then - for _, req in plan.negative do - if reqSatisfied(req, instance, satisfiedFn) then - return false - end - end - end - if plan.hasAttributes and not self:_attributesMatch(instance) then - return false - end - if plan.hasProperties and not self:_propertiesMatch(instance) then - return false - end - if plan.hasPredicates and not self:_predicatesPass(instance) then - return false - end - return true -end - -function prototype._fullMatch(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean - if not self:_positiveCandidate(instance, satisfiedFn) then - return false - end - return self:_matchesRest(instance, satisfiedFn) -end - --------------------------------------------------------------------------------- --- Structural signatures + engine interning --------------------------------------------------------------------------------- - --- Stable ids for non-primitive signature atoms (classes, predicate/matcher --- functions, recheck signals). Weak keys: a dead class must not leak here. -const signatureIds: { [any]: number } = setmetatable({}, { __mode = "k" }) :: any -local nextSignatureId = 0 -const function idOf(value: any): string - const existing = signatureIds[value] - if existing then - return tostring(existing) - end - nextSignatureId += 1 - signatureIds[value] = nextSignatureId - return tostring(nextSignatureId) -end - --- Primitive attribute matchers compare by value, so structurally identical --- `withAttribute("Team", "Red")` clauses from different modules share; function --- matchers (and predicates) can only share by identity. -const function matcherToken(matcher: unknown): string - -- An explicit-nil matcher ("value == nil"). `idOf(nil)` would error - -- (weak-key table keyed by the value), so short-circuit. - if matcher == nil then - return "nil" - end - const kind = type(matcher) - if kind == "string" or kind == "number" or kind == "boolean" then - return kind .. ":" .. tostring(matcher) - end - return "f:" .. idOf(matcher) -end - --- Canonical token for one MatchSpec: `*` for existence, else the SORTED any-of --- matcher tokens (matcher order never matters, so reordered lists share). -const function specToken(spec: MatchSpec): string - if spec.exists then - return "*" - end - const tokens = {} - for i = 1, spec.count do - table.insert(tokens, matcherToken(spec.matchers[i])) - end - table.sort(tokens) - return table.concat(tokens, "/") -end - --- Sorted token lists are deduped so duplicated requirements never split a --- signature: `query(A, A)` == `query(A)`, and clauses duplicated by splicing --- two overlapping queries still intern. -const function dedupeSorted(tokens: { string }): { string } - local write = 0 - local previous: string? = nil - for _, token in tokens do - if token ~= previous then - write += 1 - tokens[write] = token - previous = token - end - end - for index = #tokens, write + 1, -1 do - tokens[index] = nil - end - return tokens -end - ---[[ - Canonical structural signature: requirement order never matters, so - `query(A, B)` and `query(B, A)` produce the same key. Sub-queries recurse. - Cached until the query mutates. -]] -function prototype._signature(self: QueryInternal): string - const cached = self._signatureCache - if cached then - return cached - end - const function reqToken(req: Queryable): string - if type(req) == "string" then - return "t:" .. req - elseif isSub(req) then - return "q:(" .. ((req :: Sub).query :: QueryInternal):_signature() .. ")" - elseif isQuery(req) then - return "q:(" .. (req :: QueryInternal):_signature() .. ")" - elseif isFilter(req) then - const filter = req :: Filter - if filter.op == "where" then - return "w:" .. idOf(filter.fn) .. (if filter.signal ~= nil then ">" .. idOf(filter.signal) else "") - end - const spec = filter.spec :: MatchSpec - return (if filter.op == "attr" then "a:" else "p:") .. spec.name .. "=" .. specToken(spec) - elseif isCombinator(req) then - -- Child order never matters, so sorted tokens make reordered - -- combinators (and their whole queries) intern to the same engine. - const combo = req :: Combinator - const tokens = {} - for _, child in combo.children do - table.insert(tokens, reqToken(child)) - end - table.sort(tokens) - dedupeSorted(tokens) - const op = combo.op - const sym = if op == "not" then "!" elseif op == "or" then "|" else "&" - return sym .. "(" .. table.concat(tokens, ",") .. ")" - end - return "c:" .. idOf(req) - end - const function sortedTokens(reqs: { Queryable }): string - const tokens = {} - for _, req in reqs do - table.insert(tokens, reqToken(req)) - end - table.sort(tokens) - dedupeSorted(tokens) - return table.concat(tokens, ",") - end - const groups = {} - for _, group in self._anyOf do - table.insert(groups, sortedTokens(group)) - end - table.sort(groups) - dedupeSorted(groups) - const attrs = {} - for _, attr in self._attributes do - table.insert(attrs, attr.name .. "=" .. specToken(attr)) - end - table.sort(attrs) - dedupeSorted(attrs) - const props = {} - for _, prop in self._properties do - table.insert(props, prop.name .. "=" .. specToken(prop)) - end - table.sort(props) - dedupeSorted(props) - const preds = {} - for _, pred in self._predicates do - table.insert(preds, idOf(pred.fn) .. (if pred.signal ~= nil then ">" .. idOf(pred.signal) else "")) - end - table.sort(preds) - dedupeSorted(preds) - const signature = sortedTokens(self._positive) - .. "|" - .. table.concat(groups, ";") - .. "|" - .. sortedTokens(self._negative) - .. "|" - .. table.concat(attrs, ",") - .. "|" - .. table.concat(props, ",") - .. "|" - .. table.concat(preds, ",") - self._signatureCache = signature - return signature -end - --- Live engines interned by signature: equivalent queries observed anywhere in --- the process share ONE engine (one set of subscriptions, one matched set, one --- re-evaluation per event) instead of each maintaining their own. -const activeEngines: { [string]: Engine } = {} --- Loudly reports a broken `observeUnyielding` contract WITHOUT unwinding the --- caller. It is raised on a fresh thread (`task.spawn`) so it surfaces as a red --- error with a traceback rather than a swallowed warn or an exception that would --- unwind the engine mid-transition — unwinding there corrupts the shared match --- set for every observer, i.e. causes the very cross-iteration damage the --- message warns about. `yieldedThread` is the suspended coroutine for a yield; --- otherwise `err` carries the caught error. -const function reportUnyieldingViolation(yieldedThread: thread?, err: any) - local message: string - if yieldedThread then - -- Suspended at the yield point; its traceback shows exactly where. The - -- coroutine is abandoned — a callback that broke the contract does not - -- get to finish, and any matches after it in a batch do not dispatch. - const where = debug.traceback(yieldedThread, "unyielding observer callback yielded") - message = - `[Component] Query:observeUnyielding() callback YIELDED — it must run to completion synchronously; yielding here abandons the callback and skips the rest of this dispatch, and may affect other observers reacting to the same change:\n{where}` - else - message = - `[Component] Query:observeUnyielding() callback errored — it ran inline, so this throw may have affected other observers reacting to the same change: {tostring( - err - )}` - end - task.spawn(function() - error(message, 0) - end) -end - --- Runs one observer's match callback for `instance`. A yield-tolerant observer --- (`observe`) spawns a thread, so a callback that yields simply parks harmlessly --- and never blocks the dispatch. An unyielding observer (`observeUnyielding`) --- runs it inline in a throwaway coroutine and resumes once: an error or a yield --- is a broken contract, reported loudly (see `reportUnyieldingViolation`). -const function dispatchMatch(obs: Observer, instance: Instance, janitor: Janitor) - if not obs.unyielding then - task.spawn(obs.callback, instance, janitor) - return - end - const thread = coroutine.create(obs.callback) - const ok, err = coroutine.resume(thread, instance, janitor) - if ok and coroutine.status(thread) == "dead" then - return - end - reportUnyieldingViolation(if ok then thread else nil, err) -end - --- Fires one unyielding observer's callback across MANY instances (its already- --- matched set at subscribe time) under a SINGLE coroutine, checked once. This is --- the amortized form of `dispatchMatch`: the yield-detector is a property of the --- thread, so wrapping the whole loop pays for one `coroutine.create` instead of --- one per instance (the seed loop was the only place a lone callback ran N --- times). Each call is still `pcall`-isolated so one erroring match neither --- aborts the rest nor escapes; a YIELD, though, suspends the shared coroutine --- and abandons every remaining match in the batch — acceptable because yielding --- already broke the contract, and it is reported loudly. -const function dispatchSeedUnyielding(obs: Observer, instances: { Instance }) - const callback = obs.callback - const janitors = obs.janitors - const thread = coroutine.create(function() - for _, instance in instances do - const matchJanitor = Janitor.new() - janitors[instance] = matchJanitor - const ok, err = pcall(callback, instance, matchJanitor) - if not ok then - reportUnyieldingViolation(nil, err) - end - end - end) - coroutine.resume(thread) - -- Errors are caught inside; only an (unexpected) escape or a yield leaves the - -- coroutine alive. - if coroutine.status(thread) ~= "dead" then - reportUnyieldingViolation(thread, nil) - end -end - --------------------------------------------------------------------------------- --- Reactive engine (ref-counted; shared across all structurally equal queries) --------------------------------------------------------------------------------- - -function prototype._activate(self: QueryInternal): Engine - self._refcount += 1 - const attached = self._engine - if attached then - attached.refcount += 1 - return attached - end - - -- An equivalent query may already maintain this exact engine. - const signature = self:_signature() - const interned = activeEngines[signature] - if interned then - interned.refcount += 1 - interned.holders[self] = true - self._engine = interned - return interned - end - - const janitor = Janitor.new() - -- Cast through `unknown`: `Signal.new()` has no inference source for its - -- `Function` generic, and Signal's invariant generics reject a direct cast. - const changed = (Signal.new() :: unknown) :: ChangedSignal - janitor:Add(changed, "Destroy") - const engine: Engine = { - matched = {}, - matchedList = {}, - changed = changed, - observers = {}, - janitor = janitor, - positiveSet = {}, - attrConns = {}, - propConns = {}, - subEngines = {}, - signature = signature, - refcount = 1, - holders = {}, - } - engine.holders[self] = true - activeEngines[signature] = engine - self._engine = engine - janitor:Add(function() - for _, conn in engine.attrConns do - conn:Disconnect() - end - table.clear(engine.attrConns) - for _, conns in engine.propConns do - for _, conn in conns do - conn:Disconnect() - end - end - table.clear(engine.propConns) - end) - - const function subMatches(subQuery: QueryInternal, instance: Instance): boolean - const subEngine = engine.subEngines[subQuery] - return subEngine ~= nil and subEngine.matched[instance] ~= nil - end - - -- One `AttributeChanged` connection per candidate, filtered by name, instead - -- of a Janitor plus a `GetAttributeChangedSignal` connection per attribute: - -- activation over a large candidate set was dominated by that allocation. - -- Watched names come from the chain-method lists AND from every attr/prop - -- filter node anywhere in the requirement tree (`_allReferences` flattens - -- combinator nesting). - local hasAttributes = #self._attributes > 0 - const watchedAttributes: { [string]: boolean } = {} - for _, attr in self._attributes do - watchedAttributes[attr.name] = true - end - - -- Properties have no single "any property changed" signal, so each watched - -- property gets its own `GetPropertyChangedSignal` connection per candidate. - local hasProperties = #self._properties > 0 - const watchedProperties: { [string]: boolean } = {} - for _, prop in self._properties do - watchedProperties[prop.name] = true - end - - const allReferences = self:_allReferences() - for _, req in allReferences do - if isFilter(req) then - const filter = req :: Filter - const op = filter.op - if op == "attr" then - hasAttributes = true - watchedAttributes[filter.name :: string] = true - elseif op == "prop" then - hasProperties = true - watchedProperties[filter.name :: string] = true - end - end - end - - const function reevaluate(instance: Instance?) - if not instance then - return - end - -- Universe membership (source requirements only) gates tracking and the - -- attribute/property subscriptions: a filter that is currently FALSE must - -- not tear down the very subscription that would re-evaluate it when it - -- flips true. Full positive candidacy (filters included) gates matching. - const positive = self:_universeCandidate(instance, subMatches) - if positive then - engine.positiveSet[instance] = true - if hasAttributes and not engine.attrConns[instance] then - engine.attrConns[instance] = instance.AttributeChanged:Connect(function(attrName) - if watchedAttributes[attrName] then - reevaluate(instance) - end - end) :: any - end - if hasProperties and not engine.propConns[instance] then - const conns: { ConnectionLike } = {} - for name in watchedProperties do - -- `GetPropertyChangedSignal` throws for a property this Instance's - -- class lacks; such a candidate simply never gets a sub (and the - -- pcall read in `_propertiesMatch` already reports it absent). - const ok, signal = pcall(function() - return instance:GetPropertyChangedSignal(name) - end) - if ok then - table.insert( - conns, - signal:Connect(function() - reevaluate(instance) - end) :: any - ) - end - end - engine.propConns[instance] = conns - end - else - engine.positiveSet[instance] = nil - const attrConn = engine.attrConns[instance] - if attrConn then - engine.attrConns[instance] = nil - attrConn:Disconnect() - end - const propConns = engine.propConns[instance] - if propConns then - engine.propConns[instance] = nil - for _, conn in propConns do - conn:Disconnect() - end - end - end - - const isMatch = positive - and self:_positiveCandidate(instance, subMatches) - and self:_matchesRest(instance, subMatches) - const wasMatch = engine.matched[instance] ~= nil - if isMatch == wasMatch then - return - end - - const matchedList = engine.matchedList - if isMatch then - const n = #matchedList + 1 - matchedList[n] = instance - engine.matched[instance] = n - for obs in engine.observers do - const matchJanitor = Janitor.new() - obs.janitors[instance] = matchJanitor - dispatchMatch(obs, instance, matchJanitor) - end - else - -- Swap-remove: move the tail into the vacated slot. When the - -- instance IS the tail, the reassignments are harmless no-ops. - const index = engine.matched[instance] :: number - const lastIndex = #matchedList - const last = matchedList[lastIndex] - matchedList[index] = last - engine.matched[last] = index - matchedList[lastIndex] = nil - engine.matched[instance] = nil - for obs in engine.observers do - const matchJanitor = obs.janitors[instance] - if matchJanitor then - obs.janitors[instance] = nil - matchJanitor:Destroy() - end - end - end - engine.changed:Fire(instance, isMatch) - end - - -- Subscribe to every referenced input so a change re-evaluates the instance. - const connectedClasses: { [ComponentClassLike]: boolean } = {} - const connectedTags: { [string]: boolean } = {} - const function subscribeRef(req: Queryable) - if isFilter(req) then - -- Attr/prop names already fed the watched sets above; a `where` node's - -- recheck signal is connected with the chain predicates below. - return - end - if type(req) == "string" then - if connectedTags[req] then - return - end - connectedTags[req] = true - janitor:Add(CollectionService:GetInstanceAddedSignal(req):Connect(reevaluate), "Disconnect") - janitor:Add(CollectionService:GetInstanceRemovedSignal(req):Connect(reevaluate), "Disconnect") - elseif isQuery(req) then - const subQuery = req :: QueryInternal - if engine.subEngines[subQuery] then - return - end - const subEngine = subQuery:_activate() - engine.subEngines[subQuery] = subEngine - janitor:Add(function() - subQuery:_deactivate() - end) - janitor:Add( - subEngine.changed:Connect(function(instance, _isMatch) - reevaluate(instance) - end), - "Disconnect" - ) - else -- component class - const class = req :: ComponentClassLike - if connectedClasses[class] then - return - end - connectedClasses[class] = true - const started = class.Started :: ClassSignalView - const stopped = class.Stopped :: ClassSignalView - janitor:Add( - started:Connect(function(component) - reevaluate(component.Instance) - end), - "Disconnect" - ) - janitor:Add( - stopped:Connect(function(component) - reevaluate(component.Instance) - end), - "Disconnect" - ) - end - end - - for _, req in allReferences do - subscribeRef(req) - end - - -- `where` recheck signals (chain predicates AND Pred nodes) force a full - -- re-evaluation of tracked instances. - -- Deduped by signal identity: the same recheck signal reachable through - -- several clauses (e.g. one predicate spliced in from two source queries) - -- must trigger ONE sweep, not one per reference. - const connectedRechecks: { [any]: boolean } = {} - const function connectRecheck(signal: unknown) - if connectedRechecks[signal] then - return - end - connectedRechecks[signal] = true - const recheck = signal :: RecheckSignalView - janitor:Add( - recheck:Connect(function() - for instance in engine.positiveSet do - reevaluate(instance) - end - end), - "Disconnect" - ) - end - for _, pred in self._predicates do - if pred.signal ~= nil then - connectRecheck(pred.signal) - end - end - for _, req in allReferences do - if isFilter(req) then - const filter = req :: Filter - if filter.op == "where" and filter.signal ~= nil then - connectRecheck(filter.signal) - end - end - end - - -- Seed from the current members of every positive source. - for instance in self:_enumerate(true) do - reevaluate(instance) - end - - return engine -end - -function prototype._deactivate(self: QueryInternal) - const engine = self._engine - if not engine then - return - end - self._refcount = math.max(0, self._refcount - 1) - engine.refcount -= 1 - -- Holders stay attached until the engine dies: an attached query keeps - -- serving `get()` straight from the live match set for free. - if engine.refcount <= 0 then - activeEngines[engine.signature] = nil - for holder in engine.holders do - holder._engine = nil - holder._refcount = 0 - end - table.clear(engine.holders) - engine.janitor:Destroy() - end -end - --- Enumerate the candidate universe (union of positive sources). When `reactive` --- is true, sub-query membership comes from live engines (already activated); --- otherwise it is computed statically via each sub-query's GetMatches. -function prototype._enumerate( - self: QueryInternal, - reactive: boolean, - subSets: { [QueryInternal]: { [Instance]: boolean } }? -): { [Instance]: boolean } - const set: { [Instance]: boolean } = {} - const function addFromRef(req: Queryable) - if type(req) == "string" then - for _, instance in CollectionService:GetTagged(req) do - set[instance] = true - end - elseif isSub(req) or isQuery(req) then - const subQuery = (if isSub(req) then (req :: Sub).query else req) :: QueryInternal - if reactive then - const engine = self._engine :: Engine - const subEngine = engine.subEngines[subQuery] - if subEngine then - for _, instance in subEngine.matchedList do - set[instance] = true - end - end - else - -- Reuse the caller's per-call memo when there is one, so a nested - -- sub-query is evaluated once per `get()` rather than per candidate. - const memo = if subSets then subSets[subQuery] else nil - if memo then - for instance in memo do - set[instance] = true - end - else - for _, instance in subQuery:get() do - set[instance] = true - end - end - end - elseif isFilter(req) then - -- Refinement-only: contributes no candidates. - return - elseif isCombinator(req) then - const combo = req :: Combinator - const op = combo.op - if op == "not" then - return -- exclusion: contributes no candidates - elseif op == "and" then - -- Any one enumerable child's members are a superset of the And's - -- satisfiers, so ONE child bounds it. Prefer the narrowest child - -- whose population is known O(1) (a class's started list length), - -- mirroring top-level seed selection; otherwise first enumerable. - local best: Queryable? = nil - local bestSize = math.huge - for _, child in combo.children do - if isEnumerable(child) then - local startedList: { Instance }? = nil - if type(child) == "table" and not isCombinator(child) and not isSub(child) then - const internal = (child :: any)[INTERNAL] :: any - if internal ~= nil then - startedList = internal.startedList - end - end - if startedList ~= nil then - const size = #startedList - if size < bestSize then - best, bestSize = child, size - end - elseif best == nil then - best = child - end - end - end - if best ~= nil then - addFromRef(best) - end - return - else -- or: the union of all children (only valid fully enumerable) - if isEnumerable(req) then - for _, child in combo.children do - addFromRef(child) - end - end - end - else -- component class - -- One of ours: its started list already holds exactly the instances - -- this source contributes, pre-filtered. Foreign class-likes fall back - -- to `GetAll()` + a phase check per component. - const class = req :: ComponentClassLike - const internal = (class :: any)[Keys.Internal] - if internal then - for _, instance in internal.startedList do - set[instance] = true - end - else - for _, component in class:GetAll() do - if Keys.inst(component).phase == "Started" then - set[component.Instance] = true - end - end - end - end - end - for _, req in self:_positiveSources() do - addFromRef(req) - end - return set -end - --------------------------------------------------------------------------------- --- Public terminals --------------------------------------------------------------------------------- - ---[=[ - @within Query - @param callback (instance: Instance, janitor: Janitor) -> () - @return QueryConnection - - Runs `callback` for every instance that currently matches, and for every - instance that matches later, each with a fresh Janitor cleaned up when that - instance stops matching. Fetch matched components with `Class:FromInstance`. - Disconnecting the returned handle destroys all active match janitors and stops - watching. -]=] --- Shared body of `observe` / `observeUnyielding`: register an observer, fire it --- for the current matches, and return a disconnect handle. `unyielding` selects --- the dispatch strategy (see `dispatchMatch`); `method` names the caller for the --- assertion message. -const function attachObserver( - self: QueryInternal, - callback: (Instance, Janitor) -> (), - unyielding: boolean, - method: string -): QueryConnection - assert(type(callback) == "function", `[Component] Query:{method}() expects a callback function`) - self:_validate() - const engine = self:_activate() - const obs: Observer = { callback = callback, janitors = {}, unyielding = unyielding } - engine.observers[obs] = true - - -- Fire for instances already matched at subscribe time. Iterate a SNAPSHOT: - -- under Immediate signal behavior a dispatched callback can synchronously - -- retag/untag and mutate the live match set mid-loop. Unyielding observers - -- run the whole snapshot under one coroutine (one yield-check for the batch). - const snapshot = table.clone(engine.matchedList) - if unyielding then - dispatchSeedUnyielding(obs, snapshot) - else - for _, instance in snapshot do - const matchJanitor = Janitor.new() - obs.janitors[instance] = matchJanitor - task.spawn(callback, instance, matchJanitor) - end - end - - const connProxy = {} :: QueryConnection - connProxy.IsConnected = true - function connProxy.Disconnect() - if not connProxy.IsConnected then - return - end - connProxy.IsConnected = false - engine.observers[obs] = nil - for _, matchJanitor in obs.janitors do - matchJanitor:Destroy() - end - table.clear(obs.janitors) - self:_deactivate() - end - connProxy.Destroy = connProxy.Disconnect - return connProxy -end - -function prototype.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection - return attachObserver(self, callback, false, "observe") -end - ---[=[ - @within Query - @param callback (instance: Instance, janitor: Janitor) -> () - @return QueryConnection - - Like [Query:observe], but each callback runs INLINE on the thread driving the - match change instead of on its own spawned thread — no per-match thread - allocation, the fast dispatch path for hot bind/unbind work. - - :::danger The callback must run to completion synchronously. If it **yields** - or **errors** it is reported loudly (a red error with a traceback) and - abandoned mid-run; because it shares the dispatch thread, the violation can - also disrupt the other observers and matches reacting to the same change. Use - [Query:observe] for any callback that may yield. ::: -]=] -function prototype.observeUnyielding(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection - return attachObserver(self, callback, true, "observeUnyielding") -end - ---[=[ - @within Query - @return QueryConnection - - Keeps this query's match set maintained for cheap repeated reads, WITHOUT - running a per-match callback. While tracked, [Query:get] / [Query:iter] / - [Query:count] / [Query:first] / [Query:contains] all answer from the live - reactive engine (an O(matches) clone or O(1) lookup) instead of re-enumerating - the candidate set each call — the read path for an ECS-style system that polls - a join every frame: - - ```lua - local tracked = Component.query(Physics, Velocity):track() - game:GetService("RunService").Heartbeat:Connect(function(dt) - for instance in tracked:iter() do ... end - end) - -- when the system shuts down: - tracked:Disconnect() - ``` - - This is [Query:observe] minus the per-match Janitor and callback: it maintains - the same engine (structurally-equal tracked and observed queries share it), so - tracking is strictly cheaper than observing when you only need to read. The - returned handle MUST be disconnected to release the engine — unlike a one-shot - [Query:get], a tracked query holds live subscriptions until then. -]=] -function prototype.track(self: QueryInternal): QueryConnection - self:_validate() - self:_activate() - - const connProxy = {} :: QueryConnection - connProxy.IsConnected = true - function connProxy.Disconnect() - if not connProxy.IsConnected then - return - end - connProxy.IsConnected = false - self:_deactivate() - end - connProxy.Destroy = connProxy.Disconnect - return connProxy -end - --- The sole positive requirement when the query is the ECS hot shape -- exactly --- one required SOURCE requirement (class / tag / sub-query / class-like; node --- kinds are refinements, not dumpable sources) and no anyOf / negative / --- attribute / property / predicate clause -- so a read can answer straight from --- that one source. `nil` otherwise. -function prototype._singleSource(self: QueryInternal): PlanReq? - const plan = self:_plan() - if - #plan.required == 1 - and #plan.anyOf == 0 - and not plan.hasNegative - and not plan.hasAttributes - and not plan.hasProperties - and not plan.hasPredicates - then - const req = plan.required[1] - const kind = req.kind - if kind == "class" or kind == "tag" or kind == "query" or kind == "classlike" then - return req - end - end - return nil -end - --- Builds the per-call sub-query memoization cold reads use: returns --- `(satisfiedFn, ensureSet, subSets)`. Each sub-query's match-set is computed --- ONCE and then answered by lookup (re-running it per candidate is quadratic in --- nested queries). When the plan references no sub-query, returns the no-op --- `neverSub` and nils, so callers allocate nothing. -function prototype._staticSub( - self: QueryInternal, - plan: Plan -): (SatisfiedFn, ((QueryInternal) -> { [Instance]: boolean })?, { - [QueryInternal]: { [Instance]: boolean }, -}?) - if not plan.hasQueryRefs then - return neverSub, nil, nil - end - const sets: { [QueryInternal]: { [Instance]: boolean } } = {} - local sub: SatisfiedFn - local ensure: (QueryInternal) -> { [Instance]: boolean } - function ensure(subQuery: QueryInternal): { [Instance]: boolean } - const existing = sets[subQuery] - if existing then - return existing - end - -- Seed the entry before recursing so shared sub-queries are computed - -- exactly once (recursion depth is finite: queries are immutable, so - -- the reference graph is a DAG by construction). - const set: { [Instance]: boolean } = {} - sets[subQuery] = set - -- Deepest first, so this sub-query's own enumeration finds its - -- references already memoized. - for _, req in subQuery:_allReferences() do - if isQuery(req) then - ensure(req :: QueryInternal) - end - end - for candidate in subQuery:_enumerate(false, sets) do - if subQuery:_fullMatch(candidate, sub) then - set[candidate] = true - end - end - return set - end - function sub(subQuery: QueryInternal, instance: Instance): boolean - return ensure(subQuery)[instance] == true - end - for _, req in self:_allReferences() do - if isQuery(req) then - ensure(req :: QueryInternal) - end - end - return sub, ensure, sets -end - --- General cold scan (no live engine): resolves the narrowest seed, orders probes --- most-selective first, and invokes `onMatch(instance)` for every match. --- `onMatch` may return truthy to STOP the scan early -- how `first()` touches --- one candidate, not all. The live-engine path and the single-requirement ECS --- shape are cheaper answers the public terminals handle themselves before --- falling back here, so this deliberately does NOT special-case them. -function prototype._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) - const plan = self:_plan() - const staticSub, ensureSet, subSets = self:_staticSub(plan) - const required = plan.required - if #required == 0 then - -- anyOf-only query: the candidate set genuinely is a union, so build it. - for instance in self:_enumerate(false, subSets) do - if self:_fullMatch(instance, staticSub) then - if onMatch(instance) then - return - end - end - end - return - end - - -- Required requirements intersect, so enumerate ONE of them (the narrowest - -- we can size cheaply) and check the rest by direct lookup per candidate. - -- Building the union of every positive source just to intersect it back - -- down was the dominant cost of joins (measured 12x on a 2-class join). - -- Preference: smallest class (its array length is O(1)), else first tag, - -- else first sub-query (its match-set is already memoized), else first - -- foreign class-like. - local seed: PlanReq? = nil - local seedSize = math.huge -- smallest KNOWN population so far - local firstTag: PlanReq? = nil - local firstQuery: PlanReq? = nil - local firstClasslike: PlanReq? = nil - for _, req in required do - const kind = req.kind - if kind == "class" then - const size = #(req.startedList :: { Instance }) - if size < seedSize then - seed, seedSize = req, size - end - elseif kind == "tag" then - firstTag = firstTag or req - elseif kind == "query" then - firstQuery = firstQuery or req - elseif kind == "classlike" then - firstClasslike = firstClasslike or req - end - end - - -- Live tag sizing: population is the selectivity signal declaration order - -- cannot give us. When the candidate set would otherwise be large (or there - -- is no class seed at all), size every required tag; the narrowest source - -- seeds regardless of where the user wrote it, and the fetched arrays are - -- reused for seeding and for probe ordering below. - local tagSizes: { [PlanReq]: number }? = nil - local tagArrays: { [PlanReq]: { Instance } }? = nil - if firstTag and (seed == nil or seedSize > TAG_SIZING_MIN_SEED) then - const sizes: { [PlanReq]: number } = {} - const arrays: { [PlanReq]: { Instance } } = {} - tagSizes, tagArrays = sizes, arrays - for _, req in required do - if req.kind == "tag" then - const instances = CollectionService:GetTagged(req.tag :: string) - arrays[req] = instances - const size = #instances - sizes[req] = size - if size < seedSize then - seed, seedSize = req, size - -- Early exit: the seed is already narrow, so remaining tags - -- will be probed at most `seedSize` times each -- fetching - -- their (possibly huge) arrays just to rank them would cost - -- more than the probes they could save. - if size <= TAG_SIZING_EARLY_EXIT then - break - end - end - end - end - end - -- No direct source among the required reqs (they are all filter/combinator - -- nodes; validation guarantees an enumerable one is nested somewhere): fall - -- back to the union scan, same as the anyOf-only shape. - const fallbackSeed = seed or firstTag or firstQuery or firstClasslike - if not fallbackSeed then - for instance in self:_enumerate(false, subSets) do - if self:_fullMatch(instance, staticSub) then - if onMatch(instance) then - return - end - end - end - return - end - const chosenSeed: PlanReq = fallbackSeed :: PlanReq - const seedKind = chosenSeed.kind - -- Probes = every required requirement except the seed, most-selective-first - -- when populations are known (smaller population rejects more candidates - -- sooner, so each later probe runs against fewer survivors). Unknown counts - -- keep the plan's cheap-kind-first order. - const probes: { PlanReq } = {} - for _, req in required do - if req ~= chosenSeed then - table.insert(probes, req) - end - end - if tagSizes and #probes > 1 then - const sizes = tagSizes :: { [PlanReq]: number } - const function populationOf(req: PlanReq): number - if req.kind == "class" then - return #(req.startedList :: { Instance }) - end - const sized = sizes[req] - if sized then - return sized - end - return math.huge - end - table.sort(probes, function(x: PlanReq, y: PlanReq): boolean - const px, py = populationOf(x), populationOf(y) - if px ~= py then - return px < py - end - return x.buildIndex < y.buildIndex - end) - end - - -- A sized tag whose probe will run many times is cheaper as a hash set than - -- as repeated `HasTag` C-calls: one insert (~45ns) buys back every probe - -- (~150ns -> ~25ns). Convert when the expected probe count (the seed size) - -- makes the build pay for itself; the fetched array is reused, so this only - -- ever spends allocations already made for sizing. - local probeSets: { [PlanReq]: { [Instance]: boolean } }? = nil - if tagArrays and tagSizes then - const arrays = tagArrays :: { [PlanReq]: { Instance } } - const sizes = tagSizes :: { [PlanReq]: number } - for _, req in probes do - const instances = arrays[req] - if instances and seedSize * 3 > (sizes[req] :: number) then - const set: { [Instance]: boolean } = {} - for _, instance in instances do - set[instance] = true - end - const outSets = probeSets or {} - probeSets = outSets - outSets[req] = set - end - end - end - - -- Checks everything except the seed requirement (the seed's own iteration - -- already proves it). Rest-checks are inlined here so a candidate costs no - -- extra method dispatch. Returns whatever `onMatch` returned (truthy = stop). - const anyOf = plan.anyOf - const function consider(instance: Instance): boolean? - for _, req in probes do - const set = if probeSets then (probeSets :: { [PlanReq]: { [Instance]: boolean } })[req] else nil - if set then - if not set[instance] then - return nil - end - elseif not reqSatisfied(req, instance, staticSub) then - return nil - end - end - for _, group in anyOf do - local anySatisfied = false - for _, req in group do - if reqSatisfied(req, instance, staticSub) then - anySatisfied = true - break - end - end - if not anySatisfied then - return nil - end - end - if plan.hasNegative then - for _, req in plan.negative do - if reqSatisfied(req, instance, staticSub) then - return nil - end - end - end - if plan.hasAttributes and not self:_attributesMatch(instance) then - return nil - end - if plan.hasProperties and not self:_propertiesMatch(instance) then - return nil - end - if plan.hasPredicates and not self:_predicatesPass(instance) then - return nil - end - return onMatch(instance) - end - - if seedKind == "class" then - for _, instance in chosenSeed.startedList :: { Instance } do - if consider(instance) then - return - end - end - elseif seedKind == "tag" then - const seedInstances = if tagArrays then (tagArrays :: { [PlanReq]: { Instance } })[chosenSeed] else nil - for _, instance in seedInstances or CollectionService:GetTagged(chosenSeed.tag :: string) do - if consider(instance) then - return - end - end - elseif seedKind == "query" then - const ensure = ensureSet :: (QueryInternal) -> { [Instance]: boolean } - for instance in ensure(chosenSeed.query :: QueryInternal) do - if consider(instance) then - return - end - end - else - for _, component in (chosenSeed.class :: ComponentClassLike):GetAll() do - if Keys.inst(component).phase == "Started" then - if consider(component.Instance) then - return - end - end - end - end -end - ---[=[ - @within Query - @return { Instance } - Returns the instances that match right now. A one-shot read: it sets up no - subscriptions. - - While the query is actively observed (any live [Query:observe] connection), - the read is served straight from the reactive engine's maintained match set — - an O(matches) copy, identical to what observers see — making per-frame - `GetMatches` loops cheap enough for ECS-style iteration. -]=] -function prototype.get(self: QueryInternal): { Instance } - self:_validate() - - -- Live-engine fast path: the engine already maintains exactly this set. - -- An engine built by any structurally equal query serves just as well -- - -- borrow it read-only via the intern registry. - const engine = self._engine or activeEngines[self:_signature()] - if engine then - return table.clone(engine.matchedList) - end - - -- The ECS hot shape — one requirement, nothing else — is a straight dump of - -- the seed source, before any per-candidate machinery is even allocated. - const single = self:_singleSource() - if single then - const kind = single.kind - if kind == "class" then - return table.clone(single.startedList :: { Instance }) - elseif kind == "tag" then - return CollectionService:GetTagged(single.tag :: string) - elseif kind == "query" then - const out: { Instance } = {} - const _, ensure = self:_staticSub(self:_plan()) - for instance in (ensure :: (QueryInternal) -> { [Instance]: boolean })(single.query :: QueryInternal) do - table.insert(out, instance) - end - return out - else - const out: { Instance } = {} - for _, component in (single.class :: ComponentClassLike):GetAll() do - if Keys.inst(component).phase == "Started" then - table.insert(out, component.Instance) - end - end - return out - end - end - - const out: { Instance } = {} - self:_collect(function(instance) - table.insert(out, instance) - return nil - end) - return out -end -prototype.GetMatches = prototype.get - ---[=[ - @within Query - @param instance Instance - @return boolean - Whether `instance` matches this query right now. A membership test, not a - scan: while the query is actively observed it is an O(1) lookup in the - reactive engine's match set; cold, it evaluates this one instance against - every clause (no candidate enumeration). -]=] -function prototype.contains(self: QueryInternal, instance: Instance): boolean - assert(typeof(instance) == "Instance", "[Component] Query:contains() expects an Instance") - self:_validate() - const engine = self._engine or activeEngines[self:_signature()] - if engine then - return engine.matched[instance] ~= nil - end - const staticSub = self:_staticSub(self:_plan()) - return self:_fullMatch(instance, staticSub) -end - ---[=[ - @within Query - @return number - How many instances match right now. While the query is actively observed - this is an O(1) read of the engine's match count; cold it scans without - building the match array [Query:get] would allocate. -]=] -function prototype.count(self: QueryInternal): number - self:_validate() - const engine = self._engine or activeEngines[self:_signature()] - if engine then - return #engine.matchedList - end - const single = self:_singleSource() - if single and single.kind == "class" then - return #(single.startedList :: { Instance }) - end - local n = 0 - self:_collect(function() - n += 1 - return nil - end) - return n -end - ---[=[ - @within Query - @return Instance? - One instance that matches right now, or `nil` if none do. While the query is - actively observed this is an O(1) read of the engine's first match; cold it - stops at the first matching candidate instead of collecting them all. -]=] -function prototype.first(self: QueryInternal): Instance? - self:_validate() - const engine = self._engine or activeEngines[self:_signature()] - if engine then - return engine.matchedList[1] - end - const single = self:_singleSource() - if single and single.kind == "class" then - return (single.startedList :: { Instance })[1] - end - local found: Instance? = nil - self:_collect(function(instance) - found = instance - return true - end) - return found -end - ---[=[ - @within Query - @return () -> Instance? - Iterates the instances that match right now, without copying the match set: - - ```lua - for instance in query:iter() do ... end - ``` - - While the query is actively observed, this walks the reactive engine's live - match list directly (newest match first) — the zero-allocation per-frame read - path; like engine-backed [Query:get] it reflects the reactive view. An - instance that stops matching mid-iteration is handled (it is simply not - visited); other match-set mutations made *during* the loop may re-visit an - already-seen instance. Without a live observer it iterates a fresh - [Query:get] snapshot. - - Trade-off (measured): the per-element iterator call costs more than - [Query:get]'s single `table.clone`, so `get()` + a numeric `for` is faster in - raw throughput; `iter()` is for hot per-frame loops where avoiding the cloned - array's GC garbage matters more than wall time. -]=] -function prototype.iter(self: QueryInternal): () -> Instance? - self:_validate() - const engine = self._engine or activeEngines[self:_signature()] - -- Backwards, so the engine's swap-remove (which moves an already-visited - -- tail element into the vacated slot) never skips an unvisited instance. - const list = if engine then engine.matchedList else self:get() - local index = #list + 1 - return function(): Instance? - index -= 1 - return list[index] - end -end - --- Callable module: `Query(...)` == `Query.new(...)`. Query INSTANCES are --- unaffected — their metatable is `prototype`; this metatable is the module's. -setmetatable(Query, { - __call = function(_, ...: Queryable): Query - return Query.new(...) - end, -}) - -return Query diff --git a/lib/component/src/Query/Build.luau b/lib/component/src/Query/Build.luau new file mode 100644 index 00000000..0f92e553 --- /dev/null +++ b/lib/component/src/Query/Build.luau @@ -0,0 +1,560 @@ +--!strict +-- Query construction: the instance `prototype`, node prototypes, type guards, +-- the static node constructors (Attr/Prop/Pred/And/Or/Not/Sub), build-time +-- normalization, and the copy-on-write builder methods. Owns the one prototype +-- table every Query instance is stamped with; Plan and Runtime attach their +-- methods to `Build.prototype` at require time. +-- Authors: Logan Hunt [Raildex] + +const Types = require(script.Parent.Types) + +type Queryable = Types.Queryable +type QueryInternal = Types.QueryInternal +type Query = Types.Query +type Filter = Types.Filter +type Combinator = Types.Combinator +type Sub = Types.Sub +type Matcher = Types.Matcher +type MatchSpec = Types.MatchSpec +type PredicateRequirement = Types.PredicateRequirement + +const Build = {} + +-- Instance methods live on `prototype` (mirrors `Component.prototype`), keeping +-- the static namespace free — `and`/`or`/`not` are reserved words, so combinators +-- must be statics, and statics must not collide with method names. +const prototype = {} +prototype.__index = prototype +Build.prototype = prototype + +-- Node prototypes: Filters/Combinators are tagged tables built through these so +-- a later fluent matcher DSL can attach methods without changing representation. +const FilterProto = {} +FilterProto.__index = FilterProto +const ComboProto = {} +ComboProto.__index = ComboProto +const SubProto = {} +SubProto.__index = SubProto + +const function isQuery(value: unknown): boolean + return type(value) == "table" and getmetatable(value) == prototype +end +Build.isQuery = isQuery + +const function isFilter(value: unknown): boolean + return type(value) == "table" and getmetatable(value) == FilterProto +end +Build.isFilter = isFilter + +const function isCombinator(value: unknown): boolean + return type(value) == "table" and getmetatable(value) == ComboProto +end +Build.isCombinator = isCombinator + +const function isSub(value: unknown): boolean + return type(value) == "table" and getmetatable(value) == SubProto +end +Build.isSub = isSub + +const function isComponentClass(value: unknown): boolean + if type(value) ~= "table" or isQuery(value) or isFilter(value) or isCombinator(value) or isSub(value) then + return false + end + return type((value :: { read Tag: unknown }).Tag) == "string" +end + +const function assertQueryable(value: Queryable, method: string) + if + type(value) == "string" + or isQuery(value) + or isFilter(value) + or isCombinator(value) + or isSub(value) + or isComponentClass(value) + then + return + end + error( + `[Component] :{method}() expects a component class, tag string, Query, or Query.Attr/Prop/Pred/And/Or/Not/Sub node`, + 3 + ) +end + +--[=[ + @within Component + @function query + @param ... Queryable -- component classes, tag strings, and/or sub-queries + @return Query + + Creates a new query whose positional arguments are all required. +]=] +const function rawNew(): QueryInternal + -- Cast through `any`: without it the solver stamps `@metatable` onto the + -- table and rejects the internal type (same as TableManager's constructor). + return ( + setmetatable({ + _positive = {}, + _anyOf = {}, + _negative = {}, + _attributes = {}, + _properties = {}, + _predicates = {}, + _engine = nil, + _refcount = 0, + _validated = false, + _sources = nil, + _planned = nil, + _signatureCache = nil, + }, prototype) :: any + ) :: QueryInternal +end + +-- Copy-on-write base for every builder: a fresh query with this one's +-- requirement lists cloned shallowly. The inner entries (anyOf groups, +-- attribute/predicate records) are never mutated after creation, so sharing +-- them is safe. Queries are therefore immutable values: every builder returns +-- a NEW query and the receiver is never changed, so chains branch freely -- +-- `qA:with(qB)` and `qA:without(qC)` are independent and `qA` stays `qA`. +const function derive(self: QueryInternal): QueryInternal + const new = rawNew() + new._positive = table.clone(self._positive) + new._anyOf = table.clone(self._anyOf) + new._negative = table.clone(self._negative) + new._attributes = table.clone(self._attributes) + new._properties = table.clone(self._properties) + new._predicates = table.clone(self._predicates) + return new +end + +-- Packs a variadic matcher list into a MatchSpec. 0 matchers = existence check; +-- otherwise the value must satisfy ANY entry (value-equality, or a predicate +-- `(instance, value) -> boolean`). `table.pack` so explicit `nil` matchers +-- ("value == nil") survive; `select("#")` distinguishes omitted from nil. +const function makeMatchSpec(name: string, ...: Matcher): MatchSpec + const count = select("#", ...) + return { + name = name, + exists = count == 0, + matchers = table.pack(...) :: { Matcher }, + count = count, + } +end + +-------------------------------------------------------------------------------- +-- Static node constructors (composable Queryables) +-------------------------------------------------------------------------------- + +--[=[ + @within Query + @function Attr + @param name string + @param ... any -- values and/or `(instance, value) -> boolean` predicates; ANY may match. Omit all for existence. + @return Filter + An attribute filter node, usable anywhere a Queryable is accepted + (`query(...)`, `:with`, `:withAny`, `:without`, or nested in `Query.And/Or/Not`). + Same matcher semantics as [Query:withAttribute]. +]=] +function Build.Attr(name: string, ...: Matcher): Filter + assert(type(name) == "string", "[Component] Query.Attr() expects an attribute name string") + const spec = makeMatchSpec(name, ...) + return (setmetatable({ _node = "filter", op = "attr", name = name, spec = spec }, FilterProto) :: any) :: Filter +end + +--[=[ + @within Query + @function Prop + @param name string + @param ... any -- values and/or `(instance, value) -> boolean` predicates; ANY may match. Omit all for existence; explicit `nil` means "== nil". + @return Filter + A property filter node. Same matcher semantics as [Query:withProperty]. +]=] +function Build.Prop(name: string, ...: Matcher): Filter + assert(type(name) == "string", "[Component] Query.Prop() expects a property name string") + const spec = makeMatchSpec(name, ...) + return (setmetatable({ _node = "filter", op = "prop", name = name, spec = spec }, FilterProto) :: any) :: Filter +end + +--[=[ + @within Query + @function Pred + @param fn (instance: Instance) -> boolean + @param recheckSignal RecheckSignal? -- fire to force re-evaluation + @return Filter + A predicate filter node. Same semantics (and staleness caveat) as [Query:where]. +]=] +function Build.Pred(fn: (Instance) -> boolean, recheckSignal: Types.RecheckSignal?): Filter + assert(type(fn) == "function", "[Component] Query.Pred() expects a predicate function") + return ( + setmetatable({ _node = "filter", op = "where", fn = fn, signal = recheckSignal }, FilterProto) :: any + ) :: Filter +end + +const function makeCombinator(op: "not" | "or" | "and", method: string, ...: Queryable): Combinator + const children = { ... } + assert(#children > 0, `[Component] Query.{method}() expects at least one Queryable`) + for _, child in children do + assertQueryable(child, method) + end + return (setmetatable({ _node = "combinator", op = op, children = children }, ComboProto) :: any) :: Combinator +end + +--[=[ + @within Query + @function And + @param ... Queryable + @return Combinator + Satisfied when ALL children are. Bounded (usable as a candidate source) when + any child is bounded. +]=] +function Build.And(...: Queryable): Combinator + return makeCombinator("and", "And", ...) +end + +--[=[ + @within Query + @function Or + @param ... Queryable + @return Combinator + Satisfied when AT LEAST ONE child is. Bounded only when every child is + bounded (an unbounded branch would make the candidate set unbounded). +]=] +function Build.Or(...: Queryable): Combinator + return makeCombinator("or", "Or", ...) +end + +--[=[ + @within Query + @function Not + @param ... Queryable + @return Combinator + Satisfied when NONE of the children are (variadic "none of", mirroring + [Query:without]). Refinement-only: contributes no candidates. +]=] +function Build.Not(...: Queryable): Combinator + return makeCombinator("not", "Not", ...) +end + +--[=[ + @within Query + @function Sub + @param query Query + @return Sub + + Composes `query` **by reference**: the parent keeps it as a live nested + sub-query with its own reactive engine (shared with every other parent + referencing an equivalent shape) instead of lowering its clauses into the + parent's plan. + + A raw `Query` passed to `query(...)` / `:with` / `:withAny` / `:without` + composes **by value** — its clauses are spliced (or wrapped as an `And` node + in `withAny`/`without`) at build time, so `Query(q1):with(B)` is literally + `q1:with(B)`. Use `Sub` when you specifically want the nested form: + - the sub-query's clauses are expensive per candidate (heavy `Pred`s, many + property reads) and it is shared by many active parents — one shared + evaluation instead of per-parent probes; + - you want the sub-query validated standalone (a `Sub` of an unbounded + query errors; a spliced one can be bounded by the parent's other sources); + - you rely on the sub-query's own evaluation order for side-effectful + predicates. +]=] +function Build.Sub(query: Query): Sub + assert(isQuery(query), "[Component] Query.Sub() expects a Query") + return (setmetatable({ _node = "sub", query = query }, SubProto) :: any) :: Sub +end + +-- Rebuilds a Filter node from an already-packed MatchSpec (the public +-- `Query.Attr`/`Query.Prop` pack fresh varargs; lowering reuses stored specs — +-- they are immutable after creation, so sharing is safe). +const function filterFromSpec(op: "attr" | "prop", spec: MatchSpec): Filter + return (setmetatable({ _node = "filter", op = op, name = spec.name, spec = spec }, FilterProto) :: any) :: Filter +end + +const function predToFilter(pred: PredicateRequirement): Filter + return ( + setmetatable({ _node = "filter", op = "where", fn = pred.fn, signal = pred.signal }, FilterProto) :: any + ) :: Filter +end + +-- Lowers a whole query to a single equivalent node for ATOMIC positions +-- (`withAny` members, `without` entries), where splicing would change meaning +-- (De Morgan): the node is satisfied exactly while the query matches. Positive +-- positions splice instead (see `addPositive`). +const function queryToNode(q: QueryInternal, method: string): Queryable + const parts: { Queryable } = {} + for _, req in q._positive do + table.insert(parts, req) + end + for _, group in q._anyOf do + table.insert(parts, makeCombinator("or", "Or", table.unpack(group))) + end + if #q._negative > 0 then + table.insert(parts, makeCombinator("not", "Not", table.unpack(q._negative))) + end + for _, spec in q._attributes do + table.insert(parts, filterFromSpec("attr", spec)) + end + for _, spec in q._properties do + table.insert(parts, filterFromSpec("prop", spec)) + end + for _, pred in q._predicates do + table.insert(parts, predToFilter(pred)) + end + if #parts == 0 then + error(`[Component] :{method}() cannot compose an empty query (it has no requirements)`, 3) + end + if #parts == 1 then + return parts[1] + end + return makeCombinator("and", "And", table.unpack(parts)) +end + +-------------------------------------------------------------------------------- +-- Build-time normalization +-- +-- Every requirement is canonicalized as it enters a query, so ONE logical shape +-- has ONE internal form (and therefore one signature, one plan, one interned +-- engine) no matter how it was spelled: +-- with(rawQuery) == splicing its clauses (compose by value) +-- with(And(a, b)) == with(a, b) +-- with(Or(a, b)) == withAny(a, b) +-- with(Not(x)) == without(x) +-- with(Attr/Prop/...) == withAttribute / withProperty / where +-- withAny(x) == with(x) (a 1-member group is required) +-- `Query.Sub(q)` is the deliberate exception: it composes by REFERENCE and is +-- stored as-is. Normalization happens only here in the builders; stored queries +-- are always already canonical, so splices never recurse. +-------------------------------------------------------------------------------- + +local normalizeAnyMember: (req: Queryable, method: string) -> Queryable + +-- Canonical entry of one requirement into REQUIRED (AND) position. +const function addPositive(new: QueryInternal, req: Queryable, method: string) + if isQuery(req) then + -- Compose by value: splice the query's (already canonical) clauses. + const q = req :: QueryInternal + for _, entry in q._positive do + table.insert(new._positive, entry) + end + for _, group in q._anyOf do + table.insert(new._anyOf, group) + end + for _, entry in q._negative do + table.insert(new._negative, entry) + end + for _, spec in q._attributes do + table.insert(new._attributes, spec) + end + for _, spec in q._properties do + table.insert(new._properties, spec) + end + for _, pred in q._predicates do + table.insert(new._predicates, pred) + end + return + end + if isCombinator(req) then + const combo = req :: Combinator + const op = combo.op + if op == "and" then + for _, child in combo.children do + addPositive(new, child, method) + end + return + elseif op == "or" then + const group: { Queryable } = {} + for _, child in combo.children do + table.insert(group, normalizeAnyMember(child, method)) + end + if #group == 1 then + addPositive(new, group[1], method) + else + table.insert(new._anyOf, group) + end + return + else -- not: required "none of" == excluded + for _, child in combo.children do + table.insert(new._negative, normalizeAnyMember(child, method)) + end + return + end + end + if isFilter(req) then + const filter = req :: Filter + const op = filter.op + if op == "attr" then + table.insert(new._attributes, filter.spec :: MatchSpec) + elseif op == "prop" then + table.insert(new._properties, filter.spec :: MatchSpec) + else + table.insert(new._predicates, { fn = filter.fn :: (Instance) -> boolean, signal = filter.signal }) + end + return + end + -- Sub node / class / tag / class-like: a plain required requirement. + table.insert(new._positive, req) +end + +-- Canonical form of one requirement in an ATOMIC position (a `withAny` group +-- member or a `without` entry), where the requirement must stand as a single +-- testable unit: raw queries lower to an equivalent node (De Morgan forbids +-- splicing here); nested single-child wrappers collapse; everything else is +-- kept as-is. +function normalizeAnyMember(req: Queryable, method: string): Queryable + if isQuery(req) then + return queryToNode(req :: QueryInternal, method) + end + if isCombinator(req) then + const combo = req :: Combinator + if #combo.children == 1 and combo.op ~= "not" then + return normalizeAnyMember(combo.children[1], method) + end + end + return req +end + +function Build.new(...: Queryable): Query + const self = rawNew() + for _, req in { ... } do + assertQueryable(req, "with") + addPositive(self, req, "with") + end + return self +end + +--[=[ + @within Query + @param ... Queryable + @return Query + Returns a NEW query with the given required requirements added; the + receiver is unchanged (builders never mutate, so chains branch freely). + `query():with(X)` is equivalent to `query(X)`. +]=] +function prototype.with(self: QueryInternal, ...: Queryable): Query + const new = derive(self) + for _, req in { ... } do + assertQueryable(req, "with") + addPositive(new, req, "with") + end + return new +end + +--[=[ + @within Query + @param ... Queryable + @return Query + Returns a NEW query with an "at least one of" group added: the instance + must satisfy at least one of the given requirements. Multiple `:withAny` + calls each add an independent group. The receiver is unchanged. +]=] +function prototype.withAny(self: QueryInternal, ...: Queryable): Query + const raw = { ... } + if #raw == 0 then + return self + end + const new = derive(self) + const group: { Queryable } = {} + for _, req in raw do + assertQueryable(req, "withAny") + -- A nested Or is the same disjunction: flatten its members into this + -- group so `withAny(Or(a, b), c)` == `withAny(a, b, c)`. + const normalized = normalizeAnyMember(req, "withAny") + if isCombinator(normalized) and (normalized :: Combinator).op == "or" then + for _, child in (normalized :: Combinator).children do + table.insert(group, normalizeAnyMember(child, "withAny")) + end + else + table.insert(group, normalized) + end + end + if #group == 1 then + -- "At least one of {x}" is just "x": required position. + addPositive(new, group[1], "withAny") + else + table.insert(new._anyOf, group) + end + return new +end + +--[=[ + @within Query + @param ... Queryable + @return Query + Returns a NEW query with the given excluded requirements added: the + instance must satisfy none of them. The receiver is unchanged. +]=] +function prototype.without(self: QueryInternal, ...: Queryable): Query + const new = derive(self) + for _, req in { ... } do + assertQueryable(req, "without") + table.insert(new._negative, normalizeAnyMember(req, "without")) + end + return new +end + +--[=[ + @within Query + @param name string + @param ... any -- values to equal and/or `(instance, value) -> boolean` predicates; matches if ANY does. Omit all for existence. + @return Query + Returns a NEW query that additionally requires an attribute; the receiver + is unchanged. With no matchers, the attribute must merely exist; otherwise + the attribute's value must satisfy at least one matcher (equal a value, or a + predicate returning true). Re-evaluated reactively on attribute change. +]=] +function prototype.withAttribute(self: QueryInternal, name: string, ...: Matcher): Query + assert(type(name) == "string", "[Component] :withAttribute() expects an attribute name string") + const new = derive(self) + table.insert(new._attributes, makeMatchSpec(name, ...)) + return new +end + +--[=[ + @within Query + @param name string + @param ... any -- values to equal and/or `(instance, value) -> boolean` predicates; matches if ANY does. Omit all for existence. + @return Query + Returns a NEW query that additionally requires an Instance property; the + receiver is unchanged. Re-evaluated reactively when the property changes. + + Matcher forms: + - **omitted** (`:withProperty("Anchored")`) — the property must merely *exist* + on the instance (a candidate whose class lacks it never matches); + - **explicit `nil`** (`:withProperty("Parent", nil)`) — the property must exist + and equal `nil` (e.g. "unparented"); + - **functions** — must return true for `(instance, value)`; + - **any other value** — the property must equal it; + - **several matchers** — the value must satisfy at least one. + + Unlike attributes, a property may not exist on every Instance class a query + spans; a class lacking the named property simply does not match. +]=] +function prototype.withProperty(self: QueryInternal, name: string, ...: Matcher): Query + assert(type(name) == "string", "[Component] :withProperty() expects a property name string") + const new = derive(self) + table.insert(new._properties, makeMatchSpec(name, ...)) + return new +end + +--[=[ + @within Query + @param predicate (instance: Instance) -> boolean + @param recheckSignal RecheckSignal? -- fire to force re-evaluation + @return Query + + Returns a NEW query with an arbitrary predicate added; the receiver is + unchanged. :::caution A predicate has no change signal of its + own — it is only re-evaluated when another requirement changes, or when the + optional `recheckSignal` fires. Without one, its result can go stale. ::: +]=] +function prototype.where( + self: QueryInternal, + predicate: (Instance) -> boolean, + recheckSignal: Types.RecheckSignal? +): Query + assert(type(predicate) == "function", "[Component] :where() expects a predicate function") + const new = derive(self) + table.insert(new._predicates, { fn = predicate, signal = recheckSignal }) + return new +end + +return Build diff --git a/lib/component/src/Query/Plan.luau b/lib/component/src/Query/Plan.luau new file mode 100644 index 00000000..438d64ec --- /dev/null +++ b/lib/component/src/Query/Plan.luau @@ -0,0 +1,570 @@ +--!strict +-- Query validation, plan compilation, and the per-instance matching engine. +-- Attaches its methods (`_validate`, `_plan`, `_fullMatch`, ...) to the shared +-- `Build.prototype`. ADR-0002: the plan compiler reads a component class's live +-- started sparse set (`Keys.Internal` → `startedList` / `startedInstances`) +-- directly; that coupling to the lifecycle's field layout stays put. +-- Authors: Logan Hunt [Raildex] + +const CollectionService = game:GetService("CollectionService") + +const Types = require(script.Parent.Types) +const Build = require(script.Parent.Build) +const Keys = require(script.Parent.Parent.Keys) + +type Queryable = Types.Queryable +type QueryInternal = Types.QueryInternal +type Filter = Types.Filter +type Combinator = Types.Combinator +type Sub = Types.Sub +type MatchSpec = Types.MatchSpec +type PlanReq = Types.PlanReq +type Plan = Types.Plan +type SatisfiedFn = Types.SatisfiedFn +type ComponentClassLike = Types.ComponentClassLike + +const prototype = Build.prototype :: any +const isQuery = Build.isQuery +const isFilter = Build.isFilter +const isCombinator = Build.isCombinator +const isSub = Build.isSub + +const INTERNAL = Keys.Internal -- hoisted for the plan compiler's class probe + +const Plan = {} + +-------------------------------------------------------------------------------- +-- Validation +-------------------------------------------------------------------------------- + +-- Flattened positive requirements (positional/:with + every :withAny member). +-- These bound the candidate set; a query with no enumerable one is rejected. +function prototype._positiveSources(self: QueryInternal): { Queryable } + const cached = self._sources + if cached then + return cached + end + const sources = {} + for _, req in self._positive do + table.insert(sources, req) + end + for _, group in self._anyOf do + for _, req in group do + table.insert(sources, req) + end + end + self._sources = sources + return sources +end + +-- Whether a requirement can ENUMERATE a finite candidate set: classes, tags, +-- and sub-queries can; filters and `Not` cannot (they only test); an `And` can +-- when any child can (that child's members are a superset of the And's); an +-- `Or` only when every child can (its members are the union of the children's). +const function isEnumerable(req: Queryable): boolean + if type(req) == "string" or isQuery(req) or isSub(req) then + return true + end + if isFilter(req) then + return false + end + if isCombinator(req) then + const combo = req :: Combinator + const op = combo.op + if op == "not" then + return false + elseif op == "and" then + for _, child in combo.children do + if isEnumerable(child) then + return true + end + end + return false + else -- or + for _, child in combo.children do + if not isEnumerable(child) then + return false + end + end + return true + end + end + return true -- component class / class-like +end +Plan.isEnumerable = isEnumerable + +-- DFS over sub-query references; errors on an unbounded query. A query is +-- bounded iff a required requirement is enumerable, or (with no enumerable +-- required requirement) some `withAny` group is enumerable throughout — every +-- match satisfies each group, so a fully-enumerable group's union bounds the +-- candidate set. Cycles are impossible by construction: builders copy-on-write, +-- so a query can only ever reference queries that existed before it did. +function prototype._validate(self: QueryInternal) + if self._validated then + return + end + local bounded = false + for _, req in self._positive do + if isEnumerable(req) then + bounded = true + break + end + end + if not bounded then + for _, group in self._anyOf do + local groupEnumerable = #group > 0 + for _, req in group do + if not isEnumerable(req) then + groupEnumerable = false + break + end + end + if groupEnumerable then + bounded = true + break + end + end + end + if not bounded then + error( + "[Component] Query has no enumerable positive requirement (component / tag / sub-query, " + .. "or an And/Or of them); filters and Not() only refine — add a bounded source via " + .. "query(...), :with(), or :withAny() so the candidate set is finite", + 0 + ) + end + for _, req in self:_allReferences() do + if isQuery(req) then + (req :: QueryInternal):_validate() + end + end + self._validated = true +end + +-- Every requirement across all clauses (positive, anyOf, negative), flattened: +-- combinators are unwrapped recursively and `Sub` nodes unwrap to their inner +-- Query, so the list holds only leaf requirements (classes, tags, sub-queries, +-- filters). Consumers subscribe / validate / memoize from this without knowing +-- about nesting. +function prototype._allReferences(self: QueryInternal): { Queryable } + const refs: { Queryable } = {} + const function add(req: Queryable) + if isCombinator(req) then + for _, child in (req :: Combinator).children do + add(child) + end + elseif isSub(req) then + table.insert(refs, (req :: Sub).query) + else + table.insert(refs, req) + end + end + for _, req in self._positive do + add(req) + end + for _, group in self._anyOf do + for _, req in group do + add(req) + end + end + for _, req in self._negative do + add(req) + end + return refs +end + +-------------------------------------------------------------------------------- +-- Satisfaction / matching +-------------------------------------------------------------------------------- + +-- Probe cost by kind, measured per candidate: a class check is one direct table +-- lookup (~30ns), a sub-query check is a memo/engine hash lookup, a class-like +-- goes through `FromInstance` (~80ns), and a tag check is a `HasTag` C-call +-- (~150ns). Node kinds follow: an attr read is one `GetAttribute` C-call, a prop +-- read a pcall'd index, `where` a user pcall, and combinators recurse into an +-- unknown number of children — probed last. Every compiled list is sorted +-- cheapest-first so per-candidate evaluation short-circuits on the cheap probes; +-- requirement semantics are order-independent, so this is free. `buildIndex` +-- keeps the sort deterministic (`table.sort` is unstable). +const KIND_COST: { [string]: number } = { + class = 1, + query = 2, + classlike = 3, + tag = 4, + attr = 5, + prop = 6, + where = 7, + ["not"] = 8, + ["or"] = 8, + ["and"] = 8, +} + +const function sortBySelectivity(reqs: { PlanReq }) + table.sort(reqs, function(x: PlanReq, y: PlanReq): boolean + const cx = KIND_COST[x.kind] :: number + const cy = KIND_COST[y.kind] :: number + if cx ~= cy then + return cx < cy + end + return x.buildIndex < y.buildIndex + end) +end + +const function compileReq(req: Queryable, buildIndex: number): PlanReq + if type(req) == "string" then + return { kind = "tag" :: "tag", buildIndex = buildIndex, tag = req } + end + if isSub(req) then + return { kind = "query" :: "query", buildIndex = buildIndex, query = (req :: Sub).query :: QueryInternal } + end + if isQuery(req) then + -- Defensive: normalization never stores a raw Query, but compile it as a + -- sub-query rather than misclassifying if one ever slips through. + return { kind = "query" :: "query", buildIndex = buildIndex, query = req :: QueryInternal } + end + if isFilter(req) then + const filter = req :: Filter + if filter.op == "where" then + return { kind = "where" :: "where", buildIndex = buildIndex, fn = filter.fn, signal = filter.signal } + end + return { + kind = (if filter.op == "attr" then "attr" else "prop") :: "attr" | "prop", + buildIndex = buildIndex, + spec = filter.spec :: MatchSpec, + } + end + if isCombinator(req) then + const combo = req :: Combinator + const children: { PlanReq } = {} + for index, child in combo.children do + table.insert(children, compileReq(child, index)) + end + sortBySelectivity(children) + return { + kind = combo.op :: "not" | "or" | "and", + buildIndex = buildIndex, + children = children, + } + end + const internal = (req :: any)[INTERNAL] + if internal then + -- Our own class: capture its live started sparse set. Both tables are + -- mutated in place (never replaced) by the lifecycle, so the references + -- stay valid for the class's whole life; `Destroy` clears them, which + -- correctly reads as "no matches". + return { + kind = "class" :: "class", + buildIndex = buildIndex, + startedList = internal.startedList, + startedMap = internal.startedInstances, + } + end + return { kind = "classlike" :: "classlike", buildIndex = buildIndex, class = req :: ComponentClassLike } +end + +-- Evaluates one MatchSpec. `present` is whether the attribute/property exists at +-- all on this instance (attribute: value ~= nil; property: the read didn't +-- throw); `label` names the calling surface for matcher-error warnings. THE +-- single implementation of matcher semantics — the fast-path clause lists and +-- the compiled attr/prop nodes both route here. +const function matchSpecSatisfied( + instance: Instance, + present: boolean, + value: unknown, + spec: MatchSpec, + label: string +): boolean + if spec.exists then + return present + end + if not present then + return false + end + const matchers = spec.matchers + for i = 1, spec.count do + const matcher = matchers[i] + if type(matcher) == "function" then + const success, result = pcall(matcher :: (Instance, unknown) -> unknown, instance, value) + if success and result == true then + return true + end + if not success then + warn(`[Component] Query {label}('{spec.name}') matcher errored: {result}`) + end + elseif value == matcher then + return true + end + end + return false +end + +const function attrSatisfies(instance: Instance, spec: MatchSpec): boolean + const value = instance:GetAttribute(spec.name) + return matchSpecSatisfied(instance, value ~= nil, value, spec, "Attr") +end + +const function propSatisfies(instance: Instance, spec: MatchSpec): boolean + -- A property missing on this Instance's class throws on read; the pcall + -- failing IS the "property absent" signal (there is no reflection API for + -- game code). `present` distinguishes absent from present-but-nil. + const present, value = pcall(function() + return (instance :: any)[spec.name] + end) + return matchSpecSatisfied(instance, present, if present then value else nil, spec, "Prop") +end + +-- Compiled requirement check. `satisfiedFn` is only consulted for sub-query +-- requirements; all other kinds are direct (combinators recurse). +const function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: SatisfiedFn): boolean + const kind = req.kind + if kind == "class" then + return (req.startedMap :: { [Instance]: number })[instance] ~= nil + elseif kind == "tag" then + return CollectionService:HasTag(instance, req.tag :: string) + elseif kind == "query" then + return satisfiedFn(req.query :: QueryInternal, instance) + elseif kind == "attr" then + return attrSatisfies(instance, req.spec :: MatchSpec) + elseif kind == "prop" then + return propSatisfies(instance, req.spec :: MatchSpec) + elseif kind == "where" then + const success, result = pcall(req.fn :: (Instance) -> boolean, instance) + if not success then + warn(`[Component] Query.Pred() predicate errored: {result}`) + return false + end + return result == true + elseif kind == "not" then + for _, child in req.children :: { PlanReq } do + if reqSatisfied(child, instance, satisfiedFn) then + return false + end + end + return true + elseif kind == "or" then + for _, child in req.children :: { PlanReq } do + if reqSatisfied(child, instance, satisfiedFn) then + return true + end + end + return false + elseif kind == "and" then + for _, child in req.children :: { PlanReq } do + if not reqSatisfied(child, instance, satisfiedFn) then + return false + end + end + return true + else + const class = req.class :: ComponentClassLike + const component = class:FromInstance(instance) + return component ~= nil and Keys.inst(component).phase == "Started" + end +end +Plan.reqSatisfied = reqSatisfied + +-- Loose per-requirement check used to track the candidate UNIVERSE: value +-- filters (attr/prop/where) and negations are vacuously TRUE here, so an +-- instance stays tracked (positiveSet membership, attribute/property change +-- subscriptions) while it satisfies the SOURCE requirements alone — a filter +-- that is currently false must not tear down the very subscription that would +-- re-evaluate it when it flips true. The strict check decides actual matching. +const function reqInUniverse(req: PlanReq, instance: Instance, satisfiedFn: SatisfiedFn): boolean + const kind = req.kind + if kind == "attr" or kind == "prop" or kind == "where" or kind == "not" then + return true + elseif kind == "or" then + for _, child in req.children :: { PlanReq } do + if reqInUniverse(child, instance, satisfiedFn) then + return true + end + end + return false + elseif kind == "and" then + for _, child in req.children :: { PlanReq } do + if not reqInUniverse(child, instance, satisfiedFn) then + return false + end + end + return true + end + return reqSatisfied(req, instance, satisfiedFn) +end + +-- Placeholder SatisfiedFn for plans with no sub-query requirement: nothing can +-- ever call it (`reqSatisfied` only consults satisfiedFn for "query" kinds). +const function neverSub(_query: QueryInternal, _instance: Instance): boolean + return false +end +Plan.neverSub = neverSub + +function prototype._plan(self: QueryInternal): Plan + const cached = self._planned + if cached then + return cached + end + const required: { PlanReq } = {} + for index, req in self._positive do + table.insert(required, compileReq(req, index)) + end + sortBySelectivity(required) + const anyOf: { { PlanReq } } = {} + for _, group in self._anyOf do + const compiled: { PlanReq } = {} + for index, req in group do + table.insert(compiled, compileReq(req, index)) + end + sortBySelectivity(compiled) + table.insert(anyOf, compiled) + end + const negative: { PlanReq } = {} + for index, req in self._negative do + table.insert(negative, compileReq(req, index)) + end + sortBySelectivity(negative) + const function anyQueryReq(reqs: { PlanReq }): boolean + for _, req in reqs do + if req.kind == "query" then + return true + end + const children = req.children + if children and anyQueryReq(children) then + return true + end + end + return false + end + local hasQueryRefs = anyQueryReq(required) or anyQueryReq(negative) + if not hasQueryRefs then + for _, group in anyOf do + if anyQueryReq(group) then + hasQueryRefs = true + break + end + end + end + const plan: Plan = { + required = required, + anyOf = anyOf, + negative = negative, + hasNegative = #negative > 0, + hasAttributes = #self._attributes > 0, + hasProperties = #self._properties > 0, + hasPredicates = #self._predicates > 0, + hasQueryRefs = hasQueryRefs, + } + self._planned = plan + return plan +end + +function prototype._positiveCandidate(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean + const plan = self:_plan() + for _, req in plan.required do + if not reqSatisfied(req, instance, satisfiedFn) then + return false + end + end + for _, group in plan.anyOf do + local anySatisfied = false + for _, req in group do + if reqSatisfied(req, instance, satisfiedFn) then + anySatisfied = true + break + end + end + if not anySatisfied then + return false + end + end + return true +end + +-- Universe (subscription-tracking) variant of `_positiveCandidate`: value +-- filters count as vacuously satisfied (see `reqInUniverse`). +function prototype._universeCandidate(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean + const plan = self:_plan() + for _, req in plan.required do + if not reqInUniverse(req, instance, satisfiedFn) then + return false + end + end + for _, group in plan.anyOf do + local anySatisfied = false + for _, req in group do + if reqInUniverse(req, instance, satisfiedFn) then + anySatisfied = true + break + end + end + if not anySatisfied then + return false + end + end + return true +end + +function prototype._attributesMatch(self: QueryInternal, instance: Instance): boolean + for _, spec in self._attributes do + if not attrSatisfies(instance, spec) then + return false + end + end + return true +end + +function prototype._propertiesMatch(self: QueryInternal, instance: Instance): boolean + for _, spec in self._properties do + if not propSatisfies(instance, spec) then + return false + end + end + return true +end + +function prototype._predicatesPass(self: QueryInternal, instance: Instance): boolean + for _, pred in self._predicates do + const success, result = pcall(pred.fn, instance) + if not success then + warn(`[Component] Query :where() predicate errored: {result}`) + return false + end + if result ~= true then + return false + end + end + return true +end + +-- Everything a match requires EXCEPT the positive requirements. Split out so +-- callers that already know `instance` is a positive candidate (the reactive +-- engine, and `get()` over a single-source enumeration) do not pay to prove it +-- twice. +function prototype._matchesRest(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean + const plan = self:_plan() + if plan.hasNegative then + for _, req in plan.negative do + if reqSatisfied(req, instance, satisfiedFn) then + return false + end + end + end + if plan.hasAttributes and not self:_attributesMatch(instance) then + return false + end + if plan.hasProperties and not self:_propertiesMatch(instance) then + return false + end + if plan.hasPredicates and not self:_predicatesPass(instance) then + return false + end + return true +end + +function prototype._fullMatch(self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn): boolean + if not self:_positiveCandidate(instance, satisfiedFn) then + return false + end + return self:_matchesRest(instance, satisfiedFn) +end + +return Plan diff --git a/lib/component/src/Query/Runtime.luau b/lib/component/src/Query/Runtime.luau new file mode 100644 index 00000000..d95642cc --- /dev/null +++ b/lib/component/src/Query/Runtime.luau @@ -0,0 +1,1316 @@ +--!strict +-- Query runtime: structural signatures + engine interning, the ref-counted +-- reactive engine (`_activate` / `_deactivate`), candidate enumeration, the +-- subscription terminals (`observe` / `observeUnyielding` / `track`), and the +-- one-shot read terminals (`get` / `iter` / `contains` / `count` / `first`). +-- Owns the module-level intern registry and signature-id state. Attaches its +-- methods to the shared `Build.prototype`. +-- Authors: Logan Hunt [Raildex] + +const CollectionService = game:GetService("CollectionService") + +const Packages = script.Parent.Parent.Parent +const Signal = require(Packages.Signal) +const Janitor = require(Packages.Janitor) + +const Types = require(script.Parent.Types) +const Build = require(script.Parent.Build) +const Plan = require(script.Parent.Plan) +const Keys = require(script.Parent.Parent.Keys) + +type Queryable = Types.Queryable +type QueryInternal = Types.QueryInternal +type Filter = Types.Filter +type Combinator = Types.Combinator +type Sub = Types.Sub +type MatchSpec = Types.MatchSpec +type PlanReq = Types.PlanReq +type Plan = Types.Plan +type Engine = Types.Engine +type Observer = Types.Observer +type ChangedSignal = Types.ChangedSignal +type SatisfiedFn = Types.SatisfiedFn +type QueryConnection = Types.QueryConnection +type ComponentClassLike = Types.ComponentClassLike +type ClassSignalView = Types.ClassSignalView +type RecheckSignalView = Types.RecheckSignalView +type ConnectionLike = Types.ConnectionLike +type Janitor = Types.Janitor + +const prototype = Build.prototype :: any +const isQuery = Build.isQuery +const isFilter = Build.isFilter +const isCombinator = Build.isCombinator +const isSub = Build.isSub + +const isEnumerable = Plan.isEnumerable +const reqSatisfied = Plan.reqSatisfied +const neverSub = Plan.neverSub + +const INTERNAL = Keys.Internal + +-- A seed at or below this is narrow enough that hunting for a better one is +-- not worth fetching more tag arrays: remaining probes run at most this many +-- times each. +const TAG_SIZING_EARLY_EXIT = 32 + +-- `get()` sizes every required tag via `GetTagged` (to pick the narrowest seed +-- and order probes most-selective-first) only when the best class seed exceeds +-- this. Below it the candidate set is already small, and sizing a huge tag +-- would cost an array allocation proportional to its population for at most a +-- few hundred cheap probes of savings. +const TAG_SIZING_MIN_SEED = 200 + +const Runtime = {} + +-------------------------------------------------------------------------------- +-- Structural signatures + engine interning +-------------------------------------------------------------------------------- + +-- Stable ids for non-primitive signature atoms (classes, predicate/matcher +-- functions, recheck signals). Weak keys: a dead class must not leak here. +const signatureIds: { [any]: number } = setmetatable({}, { __mode = "k" }) :: any +local nextSignatureId = 0 +const function idOf(value: any): string + const existing = signatureIds[value] + if existing then + return tostring(existing) + end + nextSignatureId += 1 + signatureIds[value] = nextSignatureId + return tostring(nextSignatureId) +end + +-- Primitive attribute matchers compare by value, so structurally identical +-- `withAttribute("Team", "Red")` clauses from different modules share; function +-- matchers (and predicates) can only share by identity. +const function matcherToken(matcher: unknown): string + -- An explicit-nil matcher ("value == nil"). `idOf(nil)` would error + -- (weak-key table keyed by the value), so short-circuit. + if matcher == nil then + return "nil" + end + const kind = type(matcher) + if kind == "string" or kind == "number" or kind == "boolean" then + return kind .. ":" .. tostring(matcher) + end + return "f:" .. idOf(matcher) +end + +-- Canonical token for one MatchSpec: `*` for existence, else the SORTED any-of +-- matcher tokens (matcher order never matters, so reordered lists share). +const function specToken(spec: MatchSpec): string + if spec.exists then + return "*" + end + const tokens = {} + for i = 1, spec.count do + table.insert(tokens, matcherToken(spec.matchers[i])) + end + table.sort(tokens) + return table.concat(tokens, "/") +end + +-- Sorted token lists are deduped so duplicated requirements never split a +-- signature: `query(A, A)` == `query(A)`, and clauses duplicated by splicing +-- two overlapping queries still intern. +const function dedupeSorted(tokens: { string }): { string } + local write = 0 + local previous: string? = nil + for _, token in tokens do + if token ~= previous then + write += 1 + tokens[write] = token + previous = token + end + end + for index = #tokens, write + 1, -1 do + tokens[index] = nil + end + return tokens +end + +--[[ + Canonical structural signature: requirement order never matters, so + `query(A, B)` and `query(B, A)` produce the same key. Sub-queries recurse. + Cached until the query mutates. +]] +function prototype._signature(self: QueryInternal): string + const cached = self._signatureCache + if cached then + return cached + end + const function reqToken(req: Queryable): string + if type(req) == "string" then + return "t:" .. req + elseif isSub(req) then + return "q:(" .. ((req :: Sub).query :: QueryInternal):_signature() .. ")" + elseif isQuery(req) then + return "q:(" .. (req :: QueryInternal):_signature() .. ")" + elseif isFilter(req) then + const filter = req :: Filter + if filter.op == "where" then + return "w:" .. idOf(filter.fn) .. (if filter.signal ~= nil then ">" .. idOf(filter.signal) else "") + end + const spec = filter.spec :: MatchSpec + return (if filter.op == "attr" then "a:" else "p:") .. spec.name .. "=" .. specToken(spec) + elseif isCombinator(req) then + -- Child order never matters, so sorted tokens make reordered + -- combinators (and their whole queries) intern to the same engine. + const combo = req :: Combinator + const tokens = {} + for _, child in combo.children do + table.insert(tokens, reqToken(child)) + end + table.sort(tokens) + dedupeSorted(tokens) + const op = combo.op + const sym = if op == "not" then "!" elseif op == "or" then "|" else "&" + return sym .. "(" .. table.concat(tokens, ",") .. ")" + end + return "c:" .. idOf(req) + end + const function sortedTokens(reqs: { Queryable }): string + const tokens = {} + for _, req in reqs do + table.insert(tokens, reqToken(req)) + end + table.sort(tokens) + dedupeSorted(tokens) + return table.concat(tokens, ",") + end + const groups = {} + for _, group in self._anyOf do + table.insert(groups, sortedTokens(group)) + end + table.sort(groups) + dedupeSorted(groups) + const attrs = {} + for _, attr in self._attributes do + table.insert(attrs, attr.name .. "=" .. specToken(attr)) + end + table.sort(attrs) + dedupeSorted(attrs) + const props = {} + for _, prop in self._properties do + table.insert(props, prop.name .. "=" .. specToken(prop)) + end + table.sort(props) + dedupeSorted(props) + const preds = {} + for _, pred in self._predicates do + table.insert(preds, idOf(pred.fn) .. (if pred.signal ~= nil then ">" .. idOf(pred.signal) else "")) + end + table.sort(preds) + dedupeSorted(preds) + const signature = sortedTokens(self._positive) + .. "|" + .. table.concat(groups, ";") + .. "|" + .. sortedTokens(self._negative) + .. "|" + .. table.concat(attrs, ",") + .. "|" + .. table.concat(props, ",") + .. "|" + .. table.concat(preds, ",") + self._signatureCache = signature + return signature +end + +-- Live engines interned by signature: equivalent queries observed anywhere in +-- the process share ONE engine (one set of subscriptions, one matched set, one +-- re-evaluation per event) instead of each maintaining their own. +const activeEngines: { [string]: Engine } = {} +-- Loudly reports a broken `observeUnyielding` contract WITHOUT unwinding the +-- caller. It is raised on a fresh thread (`task.spawn`) so it surfaces as a red +-- error with a traceback rather than a swallowed warn or an exception that would +-- unwind the engine mid-transition — unwinding there corrupts the shared match +-- set for every observer, i.e. causes the very cross-iteration damage the +-- message warns about. `yieldedThread` is the suspended coroutine for a yield; +-- otherwise `err` carries the caught error. +const function reportUnyieldingViolation(yieldedThread: thread?, err: any) + local message: string + if yieldedThread then + -- Suspended at the yield point; its traceback shows exactly where. The + -- coroutine is abandoned — a callback that broke the contract does not + -- get to finish, and any matches after it in a batch do not dispatch. + const where = debug.traceback(yieldedThread, "unyielding observer callback yielded") + message = + `[Component] Query:observeUnyielding() callback YIELDED — it must run to completion synchronously; yielding here abandons the callback and skips the rest of this dispatch, and may affect other observers reacting to the same change:\n{where}` + else + message = + `[Component] Query:observeUnyielding() callback errored — it ran inline, so this throw may have affected other observers reacting to the same change: {tostring( + err + )}` + end + task.spawn(function() + error(message, 0) + end) +end + +-- Runs one observer's match callback for `instance`. A yield-tolerant observer +-- (`observe`) spawns a thread, so a callback that yields simply parks harmlessly +-- and never blocks the dispatch. An unyielding observer (`observeUnyielding`) +-- runs it inline in a throwaway coroutine and resumes once: an error or a yield +-- is a broken contract, reported loudly (see `reportUnyieldingViolation`). +const function dispatchMatch(obs: Observer, instance: Instance, janitor: Janitor) + if not obs.unyielding then + task.spawn(obs.callback, instance, janitor) + return + end + const thread = coroutine.create(obs.callback) + const ok, err = coroutine.resume(thread, instance, janitor) + if ok and coroutine.status(thread) == "dead" then + return + end + reportUnyieldingViolation(if ok then thread else nil, err) +end + +-- Fires one unyielding observer's callback across MANY instances (its already- +-- matched set at subscribe time) under a SINGLE coroutine, checked once. This is +-- the amortized form of `dispatchMatch`: the yield-detector is a property of the +-- thread, so wrapping the whole loop pays for one `coroutine.create` instead of +-- one per instance (the seed loop was the only place a lone callback ran N +-- times). Each call is still `pcall`-isolated so one erroring match neither +-- aborts the rest nor escapes; a YIELD, though, suspends the shared coroutine +-- and abandons every remaining match in the batch — acceptable because yielding +-- already broke the contract, and it is reported loudly. +const function dispatchSeedUnyielding(obs: Observer, instances: { Instance }) + const callback = obs.callback + const janitors = obs.janitors + const thread = coroutine.create(function() + for _, instance in instances do + const matchJanitor = Janitor.new() + janitors[instance] = matchJanitor + const ok, err = pcall(callback, instance, matchJanitor) + if not ok then + reportUnyieldingViolation(nil, err) + end + end + end) + coroutine.resume(thread) + -- Errors are caught inside; only an (unexpected) escape or a yield leaves the + -- coroutine alive. + if coroutine.status(thread) ~= "dead" then + reportUnyieldingViolation(thread, nil) + end +end + +-------------------------------------------------------------------------------- +-- Reactive engine (ref-counted; shared across all structurally equal queries) +-------------------------------------------------------------------------------- + +function prototype._activate(self: QueryInternal): Engine + self._refcount += 1 + const attached = self._engine + if attached then + attached.refcount += 1 + return attached + end + + -- An equivalent query may already maintain this exact engine. + const signature = self:_signature() + const interned = activeEngines[signature] + if interned then + interned.refcount += 1 + interned.holders[self] = true + self._engine = interned + return interned + end + + const janitor = Janitor.new() + -- Cast through `unknown`: `Signal.new()` has no inference source for its + -- `Function` generic, and Signal's invariant generics reject a direct cast. + const changed = (Signal.new() :: unknown) :: ChangedSignal + janitor:Add(changed, "Destroy") + const engine: Engine = { + matched = {}, + matchedList = {}, + changed = changed, + observers = {}, + janitor = janitor, + positiveSet = {}, + attrConns = {}, + propConns = {}, + subEngines = {}, + signature = signature, + refcount = 1, + holders = {}, + } + engine.holders[self] = true + activeEngines[signature] = engine + self._engine = engine + janitor:Add(function() + for _, conn in engine.attrConns do + conn:Disconnect() + end + table.clear(engine.attrConns) + for _, conns in engine.propConns do + for _, conn in conns do + conn:Disconnect() + end + end + table.clear(engine.propConns) + end) + + const function subMatches(subQuery: QueryInternal, instance: Instance): boolean + const subEngine = engine.subEngines[subQuery] + return subEngine ~= nil and subEngine.matched[instance] ~= nil + end + + -- One `AttributeChanged` connection per candidate, filtered by name, instead + -- of a Janitor plus a `GetAttributeChangedSignal` connection per attribute: + -- activation over a large candidate set was dominated by that allocation. + -- Watched names come from the chain-method lists AND from every attr/prop + -- filter node anywhere in the requirement tree (`_allReferences` flattens + -- combinator nesting). + local hasAttributes = #self._attributes > 0 + const watchedAttributes: { [string]: boolean } = {} + for _, attr in self._attributes do + watchedAttributes[attr.name] = true + end + + -- Properties have no single "any property changed" signal, so each watched + -- property gets its own `GetPropertyChangedSignal` connection per candidate. + local hasProperties = #self._properties > 0 + const watchedProperties: { [string]: boolean } = {} + for _, prop in self._properties do + watchedProperties[prop.name] = true + end + + const allReferences = self:_allReferences() + for _, req in allReferences do + if isFilter(req) then + const filter = req :: Filter + const op = filter.op + if op == "attr" then + hasAttributes = true + watchedAttributes[filter.name :: string] = true + elseif op == "prop" then + hasProperties = true + watchedProperties[filter.name :: string] = true + end + end + end + + const function reevaluate(instance: Instance?) + if not instance then + return + end + -- Universe membership (source requirements only) gates tracking and the + -- attribute/property subscriptions: a filter that is currently FALSE must + -- not tear down the very subscription that would re-evaluate it when it + -- flips true. Full positive candidacy (filters included) gates matching. + const positive = self:_universeCandidate(instance, subMatches) + if positive then + engine.positiveSet[instance] = true + if hasAttributes and not engine.attrConns[instance] then + engine.attrConns[instance] = instance.AttributeChanged:Connect(function(attrName) + if watchedAttributes[attrName] then + reevaluate(instance) + end + end) :: any + end + if hasProperties and not engine.propConns[instance] then + const conns: { ConnectionLike } = {} + for name in watchedProperties do + -- `GetPropertyChangedSignal` throws for a property this Instance's + -- class lacks; such a candidate simply never gets a sub (and the + -- pcall read in `_propertiesMatch` already reports it absent). + const ok, signal = pcall(function() + return instance:GetPropertyChangedSignal(name) + end) + if ok then + table.insert( + conns, + signal:Connect(function() + reevaluate(instance) + end) :: any + ) + end + end + engine.propConns[instance] = conns + end + else + engine.positiveSet[instance] = nil + const attrConn = engine.attrConns[instance] + if attrConn then + engine.attrConns[instance] = nil + attrConn:Disconnect() + end + const propConns = engine.propConns[instance] + if propConns then + engine.propConns[instance] = nil + for _, conn in propConns do + conn:Disconnect() + end + end + end + + const isMatch = positive + and self:_positiveCandidate(instance, subMatches) + and self:_matchesRest(instance, subMatches) + const wasMatch = engine.matched[instance] ~= nil + if isMatch == wasMatch then + return + end + + const matchedList = engine.matchedList + if isMatch then + const n = #matchedList + 1 + matchedList[n] = instance + engine.matched[instance] = n + for obs in engine.observers do + const matchJanitor = Janitor.new() + obs.janitors[instance] = matchJanitor + dispatchMatch(obs, instance, matchJanitor) + end + else + -- Swap-remove: move the tail into the vacated slot. When the + -- instance IS the tail, the reassignments are harmless no-ops. + const index = engine.matched[instance] :: number + const lastIndex = #matchedList + const last = matchedList[lastIndex] + matchedList[index] = last + engine.matched[last] = index + matchedList[lastIndex] = nil + engine.matched[instance] = nil + for obs in engine.observers do + const matchJanitor = obs.janitors[instance] + if matchJanitor then + obs.janitors[instance] = nil + matchJanitor:Destroy() + end + end + end + engine.changed:Fire(instance, isMatch) + end + + -- Subscribe to every referenced input so a change re-evaluates the instance. + const connectedClasses: { [ComponentClassLike]: boolean } = {} + const connectedTags: { [string]: boolean } = {} + const function subscribeRef(req: Queryable) + if isFilter(req) then + -- Attr/prop names already fed the watched sets above; a `where` node's + -- recheck signal is connected with the chain predicates below. + return + end + if type(req) == "string" then + if connectedTags[req] then + return + end + connectedTags[req] = true + janitor:Add(CollectionService:GetInstanceAddedSignal(req):Connect(reevaluate), "Disconnect") + janitor:Add(CollectionService:GetInstanceRemovedSignal(req):Connect(reevaluate), "Disconnect") + elseif isQuery(req) then + const subQuery = req :: QueryInternal + if engine.subEngines[subQuery] then + return + end + const subEngine = subQuery:_activate() + engine.subEngines[subQuery] = subEngine + janitor:Add(function() + subQuery:_deactivate() + end) + janitor:Add( + subEngine.changed:Connect(function(instance, _isMatch) + reevaluate(instance) + end), + "Disconnect" + ) + else -- component class + const class = req :: ComponentClassLike + if connectedClasses[class] then + return + end + connectedClasses[class] = true + const started = class.Started :: ClassSignalView + const stopped = class.Stopped :: ClassSignalView + janitor:Add( + started:Connect(function(component) + reevaluate(component.Instance) + end), + "Disconnect" + ) + janitor:Add( + stopped:Connect(function(component) + reevaluate(component.Instance) + end), + "Disconnect" + ) + end + end + + for _, req in allReferences do + subscribeRef(req) + end + + -- `where` recheck signals (chain predicates AND Pred nodes) force a full + -- re-evaluation of tracked instances. + -- Deduped by signal identity: the same recheck signal reachable through + -- several clauses (e.g. one predicate spliced in from two source queries) + -- must trigger ONE sweep, not one per reference. + const connectedRechecks: { [any]: boolean } = {} + const function connectRecheck(signal: unknown) + if connectedRechecks[signal] then + return + end + connectedRechecks[signal] = true + const recheck = signal :: RecheckSignalView + janitor:Add( + recheck:Connect(function() + for instance in engine.positiveSet do + reevaluate(instance) + end + end), + "Disconnect" + ) + end + for _, pred in self._predicates do + if pred.signal ~= nil then + connectRecheck(pred.signal) + end + end + for _, req in allReferences do + if isFilter(req) then + const filter = req :: Filter + if filter.op == "where" and filter.signal ~= nil then + connectRecheck(filter.signal) + end + end + end + + -- Seed from the current members of every positive source. + for instance in self:_enumerate(true) do + reevaluate(instance) + end + + return engine +end + +function prototype._deactivate(self: QueryInternal) + const engine = self._engine + if not engine then + return + end + self._refcount = math.max(0, self._refcount - 1) + engine.refcount -= 1 + -- Holders stay attached until the engine dies: an attached query keeps + -- serving `get()` straight from the live match set for free. + if engine.refcount <= 0 then + activeEngines[engine.signature] = nil + for holder in engine.holders do + holder._engine = nil + holder._refcount = 0 + end + table.clear(engine.holders) + engine.janitor:Destroy() + end +end + +-- Enumerate the candidate universe (union of positive sources). When `reactive` +-- is true, sub-query membership comes from live engines (already activated); +-- otherwise it is computed statically via each sub-query's GetMatches. +function prototype._enumerate( + self: QueryInternal, + reactive: boolean, + subSets: { [QueryInternal]: { [Instance]: boolean } }? +): { [Instance]: boolean } + const set: { [Instance]: boolean } = {} + const function addFromRef(req: Queryable) + if type(req) == "string" then + for _, instance in CollectionService:GetTagged(req) do + set[instance] = true + end + elseif isSub(req) or isQuery(req) then + const subQuery = (if isSub(req) then (req :: Sub).query else req) :: QueryInternal + if reactive then + const engine = self._engine :: Engine + const subEngine = engine.subEngines[subQuery] + if subEngine then + for _, instance in subEngine.matchedList do + set[instance] = true + end + end + else + -- Reuse the caller's per-call memo when there is one, so a nested + -- sub-query is evaluated once per `get()` rather than per candidate. + const memo = if subSets then subSets[subQuery] else nil + if memo then + for instance in memo do + set[instance] = true + end + else + for _, instance in subQuery:get() do + set[instance] = true + end + end + end + elseif isFilter(req) then + -- Refinement-only: contributes no candidates. + return + elseif isCombinator(req) then + const combo = req :: Combinator + const op = combo.op + if op == "not" then + return -- exclusion: contributes no candidates + elseif op == "and" then + -- Any one enumerable child's members are a superset of the And's + -- satisfiers, so ONE child bounds it. Prefer the narrowest child + -- whose population is known O(1) (a class's started list length), + -- mirroring top-level seed selection; otherwise first enumerable. + local best: Queryable? = nil + local bestSize = math.huge + for _, child in combo.children do + if isEnumerable(child) then + local startedList: { Instance }? = nil + if type(child) == "table" and not isCombinator(child) and not isSub(child) then + const internal = (child :: any)[INTERNAL] :: any + if internal ~= nil then + startedList = internal.startedList + end + end + if startedList ~= nil then + const size = #startedList + if size < bestSize then + best, bestSize = child, size + end + elseif best == nil then + best = child + end + end + end + if best ~= nil then + addFromRef(best) + end + return + else -- or: the union of all children (only valid fully enumerable) + if isEnumerable(req) then + for _, child in combo.children do + addFromRef(child) + end + end + end + else -- component class + -- One of ours: its started list already holds exactly the instances + -- this source contributes, pre-filtered. Foreign class-likes fall back + -- to `GetAll()` + a phase check per component. + const class = req :: ComponentClassLike + const internal = (class :: any)[Keys.Internal] + if internal then + for _, instance in internal.startedList do + set[instance] = true + end + else + for _, component in class:GetAll() do + if Keys.inst(component).phase == "Started" then + set[component.Instance] = true + end + end + end + end + end + for _, req in self:_positiveSources() do + addFromRef(req) + end + return set +end + +-------------------------------------------------------------------------------- +-- Public terminals +-------------------------------------------------------------------------------- + +--[=[ + @within Query + @param callback (instance: Instance, janitor: Janitor) -> () + @return QueryConnection + + Runs `callback` for every instance that currently matches, and for every + instance that matches later, each with a fresh Janitor cleaned up when that + instance stops matching. Fetch matched components with `Class:FromInstance`. + Disconnecting the returned handle destroys all active match janitors and stops + watching. +]=] +-- Shared body of `observe` / `observeUnyielding`: register an observer, fire it +-- for the current matches, and return a disconnect handle. `unyielding` selects +-- the dispatch strategy (see `dispatchMatch`); `method` names the caller for the +-- assertion message. +const function attachObserver( + self: QueryInternal, + callback: (Instance, Janitor) -> (), + unyielding: boolean, + method: string +): QueryConnection + assert(type(callback) == "function", `[Component] Query:{method}() expects a callback function`) + self:_validate() + const engine = self:_activate() + const obs: Observer = { callback = callback, janitors = {}, unyielding = unyielding } + engine.observers[obs] = true + + -- Fire for instances already matched at subscribe time. Iterate a SNAPSHOT: + -- under Immediate signal behavior a dispatched callback can synchronously + -- retag/untag and mutate the live match set mid-loop. Unyielding observers + -- run the whole snapshot under one coroutine (one yield-check for the batch). + const snapshot = table.clone(engine.matchedList) + if unyielding then + dispatchSeedUnyielding(obs, snapshot) + else + for _, instance in snapshot do + const matchJanitor = Janitor.new() + obs.janitors[instance] = matchJanitor + task.spawn(callback, instance, matchJanitor) + end + end + + const connProxy = {} :: QueryConnection + connProxy.IsConnected = true + function connProxy.Disconnect() + if not connProxy.IsConnected then + return + end + connProxy.IsConnected = false + engine.observers[obs] = nil + for _, matchJanitor in obs.janitors do + matchJanitor:Destroy() + end + table.clear(obs.janitors) + self:_deactivate() + end + connProxy.Destroy = connProxy.Disconnect + return connProxy +end + +function prototype.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection + return attachObserver(self, callback, false, "observe") +end + +--[=[ + @within Query + @param callback (instance: Instance, janitor: Janitor) -> () + @return QueryConnection + + Like [Query:observe], but each callback runs INLINE on the thread driving the + match change instead of on its own spawned thread — no per-match thread + allocation, the fast dispatch path for hot bind/unbind work. + + :::danger The callback must run to completion synchronously. If it **yields** + or **errors** it is reported loudly (a red error with a traceback) and + abandoned mid-run; because it shares the dispatch thread, the violation can + also disrupt the other observers and matches reacting to the same change. Use + [Query:observe] for any callback that may yield. ::: +]=] +function prototype.observeUnyielding(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection + return attachObserver(self, callback, true, "observeUnyielding") +end + +--[=[ + @within Query + @return QueryConnection + + Keeps this query's match set maintained for cheap repeated reads, WITHOUT + running a per-match callback. While tracked, [Query:get] / [Query:iter] / + [Query:count] / [Query:first] / [Query:contains] all answer from the live + reactive engine (an O(matches) clone or O(1) lookup) instead of re-enumerating + the candidate set each call — the read path for an ECS-style system that polls + a join every frame: + + ```lua + local tracked = Component.query(Physics, Velocity):track() + game:GetService("RunService").Heartbeat:Connect(function(dt) + for instance in tracked:iter() do ... end + end) + -- when the system shuts down: + tracked:Disconnect() + ``` + + This is [Query:observe] minus the per-match Janitor and callback: it maintains + the same engine (structurally-equal tracked and observed queries share it), so + tracking is strictly cheaper than observing when you only need to read. The + returned handle MUST be disconnected to release the engine — unlike a one-shot + [Query:get], a tracked query holds live subscriptions until then. +]=] +function prototype.track(self: QueryInternal): QueryConnection + self:_validate() + self:_activate() + + const connProxy = {} :: QueryConnection + connProxy.IsConnected = true + function connProxy.Disconnect() + if not connProxy.IsConnected then + return + end + connProxy.IsConnected = false + self:_deactivate() + end + connProxy.Destroy = connProxy.Disconnect + return connProxy +end + +-- The sole positive requirement when the query is the ECS hot shape -- exactly +-- one required SOURCE requirement (class / tag / sub-query / class-like; node +-- kinds are refinements, not dumpable sources) and no anyOf / negative / +-- attribute / property / predicate clause -- so a read can answer straight from +-- that one source. `nil` otherwise. +function prototype._singleSource(self: QueryInternal): PlanReq? + const plan = self:_plan() + if + #plan.required == 1 + and #plan.anyOf == 0 + and not plan.hasNegative + and not plan.hasAttributes + and not plan.hasProperties + and not plan.hasPredicates + then + const req = plan.required[1] + const kind = req.kind + if kind == "class" or kind == "tag" or kind == "query" or kind == "classlike" then + return req + end + end + return nil +end + +-- Builds the per-call sub-query memoization cold reads use: returns +-- `(satisfiedFn, ensureSet, subSets)`. Each sub-query's match-set is computed +-- ONCE and then answered by lookup (re-running it per candidate is quadratic in +-- nested queries). When the plan references no sub-query, returns the no-op +-- `neverSub` and nils, so callers allocate nothing. +function prototype._staticSub( + self: QueryInternal, + plan: Plan +): (SatisfiedFn, ((QueryInternal) -> { [Instance]: boolean })?, { + [QueryInternal]: { [Instance]: boolean }, +}?) + if not plan.hasQueryRefs then + return neverSub, nil, nil + end + const sets: { [QueryInternal]: { [Instance]: boolean } } = {} + local sub: SatisfiedFn + local ensure: (QueryInternal) -> { [Instance]: boolean } + function ensure(subQuery: QueryInternal): { [Instance]: boolean } + const existing = sets[subQuery] + if existing then + return existing + end + -- Seed the entry before recursing so shared sub-queries are computed + -- exactly once (recursion depth is finite: queries are immutable, so + -- the reference graph is a DAG by construction). + const set: { [Instance]: boolean } = {} + sets[subQuery] = set + -- Deepest first, so this sub-query's own enumeration finds its + -- references already memoized. + for _, req in subQuery:_allReferences() do + if isQuery(req) then + ensure(req :: QueryInternal) + end + end + for candidate in subQuery:_enumerate(false, sets) do + if subQuery:_fullMatch(candidate, sub) then + set[candidate] = true + end + end + return set + end + function sub(subQuery: QueryInternal, instance: Instance): boolean + return ensure(subQuery)[instance] == true + end + for _, req in self:_allReferences() do + if isQuery(req) then + ensure(req :: QueryInternal) + end + end + return sub, ensure, sets +end + +-- General cold scan (no live engine): resolves the narrowest seed, orders probes +-- most-selective first, and invokes `onMatch(instance)` for every match. +-- `onMatch` may return truthy to STOP the scan early -- how `first()` touches +-- one candidate, not all. The live-engine path and the single-requirement ECS +-- shape are cheaper answers the public terminals handle themselves before +-- falling back here, so this deliberately does NOT special-case them. +function prototype._collect(self: QueryInternal, onMatch: (Instance) -> boolean?) + const plan = self:_plan() + const staticSub, ensureSet, subSets = self:_staticSub(plan) + const required = plan.required + if #required == 0 then + -- anyOf-only query: the candidate set genuinely is a union, so build it. + for instance in self:_enumerate(false, subSets) do + if self:_fullMatch(instance, staticSub) then + if onMatch(instance) then + return + end + end + end + return + end + + -- Required requirements intersect, so enumerate ONE of them (the narrowest + -- we can size cheaply) and check the rest by direct lookup per candidate. + -- Building the union of every positive source just to intersect it back + -- down was the dominant cost of joins (measured 12x on a 2-class join). + -- Preference: smallest class (its array length is O(1)), else first tag, + -- else first sub-query (its match-set is already memoized), else first + -- foreign class-like. + local seed: PlanReq? = nil + local seedSize = math.huge -- smallest KNOWN population so far + local firstTag: PlanReq? = nil + local firstQuery: PlanReq? = nil + local firstClasslike: PlanReq? = nil + for _, req in required do + const kind = req.kind + if kind == "class" then + const size = #(req.startedList :: { Instance }) + if size < seedSize then + seed, seedSize = req, size + end + elseif kind == "tag" then + firstTag = firstTag or req + elseif kind == "query" then + firstQuery = firstQuery or req + elseif kind == "classlike" then + firstClasslike = firstClasslike or req + end + end + + -- Live tag sizing: population is the selectivity signal declaration order + -- cannot give us. When the candidate set would otherwise be large (or there + -- is no class seed at all), size every required tag; the narrowest source + -- seeds regardless of where the user wrote it, and the fetched arrays are + -- reused for seeding and for probe ordering below. + local tagSizes: { [PlanReq]: number }? = nil + local tagArrays: { [PlanReq]: { Instance } }? = nil + if firstTag and (seed == nil or seedSize > TAG_SIZING_MIN_SEED) then + const sizes: { [PlanReq]: number } = {} + const arrays: { [PlanReq]: { Instance } } = {} + tagSizes, tagArrays = sizes, arrays + for _, req in required do + if req.kind == "tag" then + const instances = CollectionService:GetTagged(req.tag :: string) + arrays[req] = instances + const size = #instances + sizes[req] = size + if size < seedSize then + seed, seedSize = req, size + -- Early exit: the seed is already narrow, so remaining tags + -- will be probed at most `seedSize` times each -- fetching + -- their (possibly huge) arrays just to rank them would cost + -- more than the probes they could save. + if size <= TAG_SIZING_EARLY_EXIT then + break + end + end + end + end + end + -- No direct source among the required reqs (they are all filter/combinator + -- nodes; validation guarantees an enumerable one is nested somewhere): fall + -- back to the union scan, same as the anyOf-only shape. + const fallbackSeed = seed or firstTag or firstQuery or firstClasslike + if not fallbackSeed then + for instance in self:_enumerate(false, subSets) do + if self:_fullMatch(instance, staticSub) then + if onMatch(instance) then + return + end + end + end + return + end + const chosenSeed: PlanReq = fallbackSeed :: PlanReq + const seedKind = chosenSeed.kind + -- Probes = every required requirement except the seed, most-selective-first + -- when populations are known (smaller population rejects more candidates + -- sooner, so each later probe runs against fewer survivors). Unknown counts + -- keep the plan's cheap-kind-first order. + const probes: { PlanReq } = {} + for _, req in required do + if req ~= chosenSeed then + table.insert(probes, req) + end + end + if tagSizes and #probes > 1 then + const sizes = tagSizes :: { [PlanReq]: number } + const function populationOf(req: PlanReq): number + if req.kind == "class" then + return #(req.startedList :: { Instance }) + end + const sized = sizes[req] + if sized then + return sized + end + return math.huge + end + table.sort(probes, function(x: PlanReq, y: PlanReq): boolean + const px, py = populationOf(x), populationOf(y) + if px ~= py then + return px < py + end + return x.buildIndex < y.buildIndex + end) + end + + -- A sized tag whose probe will run many times is cheaper as a hash set than + -- as repeated `HasTag` C-calls: one insert (~45ns) buys back every probe + -- (~150ns -> ~25ns). Convert when the expected probe count (the seed size) + -- makes the build pay for itself; the fetched array is reused, so this only + -- ever spends allocations already made for sizing. + local probeSets: { [PlanReq]: { [Instance]: boolean } }? = nil + if tagArrays and tagSizes then + const arrays = tagArrays :: { [PlanReq]: { Instance } } + const sizes = tagSizes :: { [PlanReq]: number } + for _, req in probes do + const instances = arrays[req] + if instances and seedSize * 3 > (sizes[req] :: number) then + const set: { [Instance]: boolean } = {} + for _, instance in instances do + set[instance] = true + end + const outSets = probeSets or {} + probeSets = outSets + outSets[req] = set + end + end + end + + -- Checks everything except the seed requirement (the seed's own iteration + -- already proves it). Rest-checks are inlined here so a candidate costs no + -- extra method dispatch. Returns whatever `onMatch` returned (truthy = stop). + const anyOf = plan.anyOf + const function consider(instance: Instance): boolean? + for _, req in probes do + const set = if probeSets then (probeSets :: { [PlanReq]: { [Instance]: boolean } })[req] else nil + if set then + if not set[instance] then + return nil + end + elseif not reqSatisfied(req, instance, staticSub) then + return nil + end + end + for _, group in anyOf do + local anySatisfied = false + for _, req in group do + if reqSatisfied(req, instance, staticSub) then + anySatisfied = true + break + end + end + if not anySatisfied then + return nil + end + end + if plan.hasNegative then + for _, req in plan.negative do + if reqSatisfied(req, instance, staticSub) then + return nil + end + end + end + if plan.hasAttributes and not self:_attributesMatch(instance) then + return nil + end + if plan.hasProperties and not self:_propertiesMatch(instance) then + return nil + end + if plan.hasPredicates and not self:_predicatesPass(instance) then + return nil + end + return onMatch(instance) + end + + if seedKind == "class" then + for _, instance in chosenSeed.startedList :: { Instance } do + if consider(instance) then + return + end + end + elseif seedKind == "tag" then + const seedInstances = if tagArrays then (tagArrays :: { [PlanReq]: { Instance } })[chosenSeed] else nil + for _, instance in seedInstances or CollectionService:GetTagged(chosenSeed.tag :: string) do + if consider(instance) then + return + end + end + elseif seedKind == "query" then + const ensure = ensureSet :: (QueryInternal) -> { [Instance]: boolean } + for instance in ensure(chosenSeed.query :: QueryInternal) do + if consider(instance) then + return + end + end + else + for _, component in (chosenSeed.class :: ComponentClassLike):GetAll() do + if Keys.inst(component).phase == "Started" then + if consider(component.Instance) then + return + end + end + end + end +end + +--[=[ + @within Query + @return { Instance } + Returns the instances that match right now. A one-shot read: it sets up no + subscriptions. + + While the query is actively observed (any live [Query:observe] connection), + the read is served straight from the reactive engine's maintained match set — + an O(matches) copy, identical to what observers see — making per-frame + `GetMatches` loops cheap enough for ECS-style iteration. +]=] +function prototype.get(self: QueryInternal): { Instance } + self:_validate() + + -- Live-engine fast path: the engine already maintains exactly this set. + -- An engine built by any structurally equal query serves just as well -- + -- borrow it read-only via the intern registry. + const engine = self._engine or activeEngines[self:_signature()] + if engine then + return table.clone(engine.matchedList) + end + + -- The ECS hot shape — one requirement, nothing else — is a straight dump of + -- the seed source, before any per-candidate machinery is even allocated. + const single = self:_singleSource() + if single then + const kind = single.kind + if kind == "class" then + return table.clone(single.startedList :: { Instance }) + elseif kind == "tag" then + return CollectionService:GetTagged(single.tag :: string) + elseif kind == "query" then + const out: { Instance } = {} + const _, ensure = self:_staticSub(self:_plan()) + for instance in (ensure :: (QueryInternal) -> { [Instance]: boolean })(single.query :: QueryInternal) do + table.insert(out, instance) + end + return out + else + const out: { Instance } = {} + for _, component in (single.class :: ComponentClassLike):GetAll() do + if Keys.inst(component).phase == "Started" then + table.insert(out, component.Instance) + end + end + return out + end + end + + const out: { Instance } = {} + self:_collect(function(instance) + table.insert(out, instance) + return nil + end) + return out +end +prototype.GetMatches = prototype.get + +--[=[ + @within Query + @param instance Instance + @return boolean + Whether `instance` matches this query right now. A membership test, not a + scan: while the query is actively observed it is an O(1) lookup in the + reactive engine's match set; cold, it evaluates this one instance against + every clause (no candidate enumeration). +]=] +function prototype.contains(self: QueryInternal, instance: Instance): boolean + assert(typeof(instance) == "Instance", "[Component] Query:contains() expects an Instance") + self:_validate() + const engine = self._engine or activeEngines[self:_signature()] + if engine then + return engine.matched[instance] ~= nil + end + const staticSub = self:_staticSub(self:_plan()) + return self:_fullMatch(instance, staticSub) +end + +--[=[ + @within Query + @return number + How many instances match right now. While the query is actively observed + this is an O(1) read of the engine's match count; cold it scans without + building the match array [Query:get] would allocate. +]=] +function prototype.count(self: QueryInternal): number + self:_validate() + const engine = self._engine or activeEngines[self:_signature()] + if engine then + return #engine.matchedList + end + const single = self:_singleSource() + if single and single.kind == "class" then + return #(single.startedList :: { Instance }) + end + local n = 0 + self:_collect(function() + n += 1 + return nil + end) + return n +end + +--[=[ + @within Query + @return Instance? + One instance that matches right now, or `nil` if none do. While the query is + actively observed this is an O(1) read of the engine's first match; cold it + stops at the first matching candidate instead of collecting them all. +]=] +function prototype.first(self: QueryInternal): Instance? + self:_validate() + const engine = self._engine or activeEngines[self:_signature()] + if engine then + return engine.matchedList[1] + end + const single = self:_singleSource() + if single and single.kind == "class" then + return (single.startedList :: { Instance })[1] + end + local found: Instance? = nil + self:_collect(function(instance) + found = instance + return true + end) + return found +end + +--[=[ + @within Query + @return () -> Instance? + Iterates the instances that match right now, without copying the match set: + + ```lua + for instance in query:iter() do ... end + ``` + + While the query is actively observed, this walks the reactive engine's live + match list directly (newest match first) — the zero-allocation per-frame read + path; like engine-backed [Query:get] it reflects the reactive view. An + instance that stops matching mid-iteration is handled (it is simply not + visited); other match-set mutations made *during* the loop may re-visit an + already-seen instance. Without a live observer it iterates a fresh + [Query:get] snapshot. + + Trade-off (measured): the per-element iterator call costs more than + [Query:get]'s single `table.clone`, so `get()` + a numeric `for` is faster in + raw throughput; `iter()` is for hot per-frame loops where avoiding the cloned + array's GC garbage matters more than wall time. +]=] +function prototype.iter(self: QueryInternal): () -> Instance? + self:_validate() + const engine = self._engine or activeEngines[self:_signature()] + -- Backwards, so the engine's swap-remove (which moves an already-visited + -- tail element into the vacated slot) never skips an unvisited instance. + const list = if engine then engine.matchedList else self:get() + local index = #list + 1 + return function(): Instance? + index -= 1 + return list[index] + end +end + +return Runtime diff --git a/lib/component/src/Query/Types.luau b/lib/component/src/Query/Types.luau new file mode 100644 index 00000000..4875b9ab --- /dev/null +++ b/lib/component/src/Query/Types.luau @@ -0,0 +1,263 @@ +--!strict +-- Type surface for the Query engine (see the `@class Query` doc in `init.luau`). +-- A leaf: declares every public and internal Query type in one place so the +-- Build / Plan / Runtime submodules share a single definition of each (identity +-- can never diverge). Public types are re-exported from `init.luau`. +-- Authors: Logan Hunt [Raildex] + +const Packages = script.Parent.Parent.Parent +const Signal = require(Packages.Signal) +const Janitor = require(Packages.Janitor) + +export type Janitor = Janitor.Janitor + +-- Minimal structural view of a component class and its instances (see +-- `ComponentClass` / `TypedClass` in `init.luau`); declared here so real +-- classes are subtypes without a cyclic require of `init.luau`. Props are +-- `read` (covariant) and the class API is self-generic, matching the real +-- types, so both class variants satisfy this view. +export type ComponentLike = { read Instance: Instance } +export type ComponentClassLike = { + read Tag: string, + read Instance: Instance, + -- `unknown`, not a structural signal type: `Signal`'s generics are + -- invariant (via `Fire`), so no one signal type accepts every class's + -- signal. Cast to `ClassSignalView` at the connect site. + read Started: unknown, + read Stopped: unknown, + read FromInstance: (self: T, instance: Instance) -> T?, + read GetAll: (self: T) -> { T }, +} + +-- Runtime connection shape shared by better-signal and RBXScriptSignal. +export type ConnectionLike = { Disconnect: (self: ConnectionLike) -> () } + +-- Connect-only view a class's Started/Stopped signal is cast to. +export type ClassSignalView = { + Connect: (self: ClassSignalView, fn: (ComponentLike) -> ()) -> ConnectionLike, +} + +-- Connect-only view a `:where` recheck signal is duck-cast to; accepts any +-- signal-like value (better-signal, RBXScriptSignal, ...) with `:Connect`. +export type RecheckSignalView = { + Connect: (self: RecheckSignalView, fn: () -> ()) -> ConnectionLike, +} + +-- A single matcher for an attribute/property value: either a value the value +-- must EQUAL, or a predicate `(instance, value) -> boolean`. `unknown` because a +-- value can be anything; the predicate case is duck-detected via `type == "function"`. +export type Matcher = unknown + +-- Caller-owned signal that forces a re-evaluation of all bounded instances when +-- fired. Structural so it accepts both better-signal `Signal` and RBXScriptSignal. +export type RecheckSignal = { + Connect: (self: RecheckSignal, callback: () -> ()) -> { Disconnect: (self: any) -> () }, +} + +-- ── Composable nodes ───────────────────────────────────────────────────────── +-- A Filter is a leaf refinement: it tests ONE instance and contributes NO +-- candidate source, so it is valid only nested inside a bounded query (via +-- with / withAny / without / a combinator), never as a query's sole bound. +-- Built by Query.Attr / Query.Prop / Query.Pred; never constructed by hand. +export type Filter = { + _node: "filter", + op: "attr" | "prop" | "where", + name: string?, -- attr / prop + -- attr/prop: the compiled MatchSpec (existence flag + packed any-of matcher + -- list; explicit `nil` matchers survive via table.pack). Opaque to users. + spec: unknown?, + fn: ((instance: Instance) -> boolean)?, -- where + signal: RecheckSignal?, -- where: optional recheck +} + +-- A Combinator composes child Queryables under a boolean operator. `not`, and +-- an `or` with any unbounded child, are refinement-only; `and` is bounded when +-- any child is (see the boundedness rules in `_validate`). Built by +-- Query.And / Query.Or / Query.Not. +export type Combinator = { + _node: "combinator", + op: "not" | "or" | "and", + children: { Queryable }, +} + +-- Compose-BY-REFERENCE marker: `Query.Sub(q)` keeps `q` as a live nested +-- sub-query (own reactive engine, shared by every parent referencing an +-- equivalent shape) instead of lowering its clauses into the parent. A raw +-- `Query` passed anywhere composes BY VALUE (its clauses are spliced/lowered +-- at build time); `Sub` is the only spelling that nests. +export type Sub = { + _node: "sub", + query: Query, +} + +export type Queryable = ComponentClassLike | string | Query | Filter | Combinator | Sub + +--[=[ + @interface QueryConnection + @within Query + .IsConnected boolean + .Disconnect () -> () + .Destroy () -> () + Returned by [Query:observe]. +]=] +export type QueryConnection = { + IsConnected: boolean, + Disconnect: () -> (), + Destroy: () -> (), +} + +export type Query = { + with: (self: Query, ...Queryable) -> Query, + withAny: (self: Query, ...Queryable) -> Query, + without: (self: Query, ...Queryable) -> Query, + withAttribute: (self: Query, name: string, ...Matcher) -> Query, + withProperty: (self: Query, name: string, ...Matcher) -> Query, + where: (self: Query, predicate: (Instance) -> boolean, recheckSignal: RecheckSignal?) -> Query, + observe: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, + observeUnyielding: (self: Query, callback: (Instance, Janitor) -> ()) -> QueryConnection, + track: (self: Query) -> QueryConnection, + get: (self: Query) -> { Instance }, + iter: (self: Query) -> () -> Instance?, + contains: (self: Query, instance: Instance) -> boolean, + count: (self: Query) -> number, + first: (self: Query) -> Instance?, +} + +-- satisfiedFn(query, instance): is `instance` currently matching `query`? +-- Supplied by caller so the same predicate logic serves both the reactive +-- engine (sub-query engines) and the one-shot GetMatches (static membership). +export type SatisfiedFn = (QueryInternal, Instance) -> boolean + +-- Unified attribute/property clause used by BOTH the fast-path lists +-- (`_attributes` / `_properties`) and compiled attr/prop nodes. `exists` +-- short-circuits to an existence check (0-matcher form); otherwise match = +-- value satisfies ANY matcher (any-of). The list may legitimately hold `nil` +-- matchers ("value == nil"), so `count` (table.pack's `n`) sizes it, never `#`. +export type MatchSpec = { + name: string, + exists: boolean, + matchers: { Matcher }, + count: number, +} + +-- One `:where` requirement; `signal` is duck-cast to `RecheckSignalView` when +-- the engine activates. +export type PredicateRequirement = { + fn: (Instance) -> boolean, + signal: RecheckSignal?, +} + +export type Observer = { + callback: (Instance, Janitor) -> (), + janitors: { [Instance]: Janitor }, + -- `observeUnyielding` observers run their callback inline (no per-match + -- thread) and must neither yield nor error; a violation is reported loudly. + unyielding: boolean, +} + +export type ChangedSignal = Signal.Signal<(Instance, boolean) -> (), Instance, boolean> + +-- One requirement, compiled: its kind resolved once and, for component classes, +-- the class's live started sparse set captured directly (see +-- `Keys.ClassInternal`). Checking a class requirement per candidate is then a +-- SINGLE table lookup — membership in `startedMap` IS "started" — and +-- `startedList` gives `get()` an O(1)-sized, already-filtered seed source. +export type PlanReq = { + kind: "tag" | "class" | "classlike" | "query" | "attr" | "prop" | "where" | "not" | "or" | "and", + buildIndex: number, -- declaration position; tiebreak for the stable sort + tag: string?, + startedList: { Instance }?, -- class: live dense started-instance array + startedMap: { [Instance]: number }?, -- class: live instance -> list index + class: ComponentClassLike?, -- foreign class-like: FromInstance fallback + query: QueryInternal?, + spec: MatchSpec?, -- attr / prop node + fn: ((Instance) -> boolean)?, -- where node + signal: RecheckSignal?, -- where node + children: { PlanReq }?, -- not / or / and (compiled recursively) +} + +-- The compiled shape of a query: requirement lists as PlanReqs plus presence +-- flags so empty clauses cost nothing per candidate. Cached on the query and +-- rebuilt by `_invalidate` (i.e. on any mutation). +export type Plan = { + required: { PlanReq }, + anyOf: { { PlanReq } }, + negative: { PlanReq }, + hasNegative: boolean, + hasAttributes: boolean, + hasProperties: boolean, + hasPredicates: boolean, + -- True when any clause references a sub-query; `get()` only builds its + -- per-call memoization machinery (and a real SatisfiedFn) when it is. + hasQueryRefs: boolean, +} + +-- Reactive state backing an activated query (ref-counted, shared when a query +-- is used more than once). +export type Engine = { + -- Sparse-set pair: `matched[instance]` is its 1-based position in + -- `matchedList` (the map doubles as the membership set), and `matchedList` + -- is the dense, insertion-ordered array reads iterate/clone. Removal is a + -- swap-remove, so both stay O(1) per transition. + matched: { [Instance]: number }, + matchedList: { Instance }, + changed: ChangedSignal, + observers: { [Observer]: boolean }, + janitor: Janitor, + positiveSet: { [Instance]: boolean }, -- [Instance]: true while positively bounded (candidate universe) + attrConns: { [Instance]: ConnectionLike }, -- one AttributeChanged sub per candidate + propConns: { [Instance]: { ConnectionLike } }, -- one GetPropertyChangedSignal sub per watched property, per candidate + subEngines: { [QueryInternal]: Engine }, + -- Interning bookkeeping: the canonical signature this engine is registered + -- under, total activations across every equivalent query sharing it, and + -- the queries currently attached (so their `_engine` pointers can be + -- cleared when the engine dies). + signature: string, + refcount: number, + holders: { [QueryInternal]: boolean }, +} + +export type QueryInternal = Query & { + _positive: { Queryable }, + _anyOf: { { Queryable } }, -- array of groups + _negative: { Queryable }, + _attributes: { MatchSpec }, + _properties: { MatchSpec }, + _predicates: { PredicateRequirement }, + _engine: Engine?, + _refcount: number, + -- Lazily-built caches. Queries are immutable after construction (builders + -- copy-on-write), so none of these can ever go stale. + _validated: boolean, + _sources: { Queryable }?, + _planned: Plan?, + _signatureCache: string?, + + _plan: (self: QueryInternal) -> Plan, + _signature: (self: QueryInternal) -> string, + _positiveSources: (self: QueryInternal) -> { Queryable }, + _validate: (self: QueryInternal) -> (), + _allReferences: (self: QueryInternal) -> { Queryable }, + _positiveCandidate: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, + _universeCandidate: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, + _attributesMatch: (self: QueryInternal, instance: Instance) -> boolean, + _propertiesMatch: (self: QueryInternal, instance: Instance) -> boolean, + _predicatesPass: (self: QueryInternal, instance: Instance) -> boolean, + _matchesRest: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, + _fullMatch: (self: QueryInternal, instance: Instance, satisfiedFn: SatisfiedFn) -> boolean, + _activate: (self: QueryInternal) -> Engine, + _deactivate: (self: QueryInternal) -> (), + _enumerate: ( + self: QueryInternal, + reactive: boolean, + subSets: { [QueryInternal]: { [Instance]: boolean } }? + ) -> { [Instance]: boolean }, + _singleSource: (self: QueryInternal) -> PlanReq?, + _staticSub: ( + self: QueryInternal, + plan: Plan + ) -> (SatisfiedFn, ((QueryInternal) -> { [Instance]: boolean })?, { [QueryInternal]: { [Instance]: boolean } }?), + _collect: (self: QueryInternal, onMatch: (Instance) -> boolean?) -> (), +} + +return {} diff --git a/lib/component/src/Query/init.luau b/lib/component/src/Query/init.luau new file mode 100644 index 00000000..b0bed454 --- /dev/null +++ b/lib/component/src/Query/init.luau @@ -0,0 +1,102 @@ +--!strict +-- World-level component query engine. +-- Authors: Logan Hunt [Raildex] +--[=[ + @class Query + + A reusable, reactive query over tagged instances. Built with + `Component.query(...)` and refined with chain methods, then either observed + (`:observe`) or read once (`:get`/`:GetMatches`, `:iter`, `:count`, `:first`, + `:contains`). + + A *requirement* is a **component class** (satisfied while that component is + *started* on the instance), a **tag string** (satisfied while the instance has + the raw CollectionService tag), **another Query** (satisfied while the + instance matches it), a **filter node** (`Query.Attr` / `Query.Prop` / + `Query.Pred` — satisfied while the value test passes), or a **combinator + node** (`Query.And` / `Query.Or` / `Query.Not` — boolean composition of any + requirements, nesting freely). An instance *matches* while: + + - every positional / `:with` requirement is satisfied, + - every `:withAny(...)` group has at least one satisfied, + - no `:without` requirement is satisfied, + - every `:withAttribute` matches, + - every `:withProperty` matches, and + - every `:where` predicate returns true. + + A query must have at least one ENUMERABLE positive requirement (component / + tag / sub-query — or an `And`/`Or` of them) so its candidate set is bounded; + filter nodes and `Not` only refine, and a query with no enumerable source + errors when observed or read. + + ## Composition: by value, normalized at build time + + Every requirement is canonicalized as it enters a query, so one logical + shape has ONE internal form — and therefore one signature, one plan, and one + interned engine — no matter how it was spelled: + + - `Query(q1):with(B)` is **literally** `q1:with(B)` (a raw `Query` composes + by value: its clauses are spliced in positive position, or lowered to an + equivalent node in `withAny`/`without`, where the query must stay atomic); + - `with(Query.And(a, b))` == `with(a, b)`; `with(Query.Or(a, b))` == + `withAny(a, b)`; `with(Query.Not(x))` == `without(x)`; + - `with(Query.Attr/Prop/Pred(...))` == `withAttribute` / `withProperty` / + `where`; duplicated requirements do not split signatures. + + `Query.Sub(q)` is the one deliberate exception: it composes **by + reference**, keeping `q` as a live nested sub-query with its own (shared) + reactive engine — see [Query.Sub] for when that is worth it. + + Boolean recipes compose from `And`/`Or`/`Not` (a functionally complete + basis; variadic `Not(...)` is "none of" — i.e. NOR). E.g. exclusive-or, + "B or C but not both": + + ```lua + Component.query(A):withAny(B, C):without(Query.And(B, C)) + ``` + + :::caution Canonicalization can change the ORDER (and short-circuit count) + in which user predicate functions run relative to the exact spelling used. + Match results are unaffected; do not rely on side effects inside `Pred` / + `where` / function matchers. ::: + + See the Component `CONTEXT.md` for the glossary and the `README` for examples. +]=] + +const Types = require(script.Types) +const Build = require(script.Build) +require(script.Plan) -- attaches validation / plan / matching methods to the shared prototype +require(script.Runtime) -- attaches interning / engine / terminal methods to the shared prototype + +export type Matcher = Types.Matcher +export type RecheckSignal = Types.RecheckSignal +export type Filter = Types.Filter +export type Combinator = Types.Combinator +export type Sub = Types.Sub +export type Queryable = Types.Queryable +export type QueryConnection = Types.QueryConnection +export type Query = Types.Query + +-- Module table: static constructors (`new`, `Attr`, `Prop`, `Pred`, `And`, +-- `Or`, `Not`, `Sub`) plus the instance `prototype`. Made callable (`Query(...)` +-- == `Query.new(...)`) at the bottom of the file. +const Query = {} +Query.prototype = Build.prototype +Query.new = Build.new +Query.Attr = Build.Attr +Query.Prop = Build.Prop +Query.Pred = Build.Pred +Query.And = Build.And +Query.Or = Build.Or +Query.Not = Build.Not +Query.Sub = Build.Sub + +-- Callable module: `Query(...)` == `Query.new(...)`. Query INSTANCES are +-- unaffected — their metatable is `prototype`; this metatable is the module's. +setmetatable(Query, { + __call = function(_, ...: Queryable): Query + return Query.new(...) + end, +}) + +return Query diff --git a/moonwave.toml b/moonwave.toml index fc74ff5f..7b8fc6d6 100644 --- a/moonwave.toml +++ b/moonwave.toml @@ -69,7 +69,16 @@ classes = [ [[classOrder]] section = "Instance Component Systems" -classes = ["Component", "RemoteComponent", "BaseComponent"] +classes = ["Component", "Query", "RemoteComponent", "BaseComponent"] + +[[classOrder.items]] +section = "[Guides]" +classes = [ + "CO Getting Started", + "CO Extensions", + "CO Lifecycle & Cleanup", + "CO Queries", +] [[classOrder]] section = "DropletManager" From e6df5d7d13cf613a34e7c0b692ff77944038acc3 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 22:36:46 -0400 Subject: [PATCH 17/19] Improved coverage and removed dead code --- lib/component/src/Query/Plan.luau | 43 +- lib/component/src/Query/Runtime.luau | 40 +- lib/component/src/Query/Types.luau | 5 +- .../Tests/Component.PublicMethods.spec.luau | 169 +++++++ .../Tests/Component.Query.coldscan.spec.luau | 443 ++++++++++++++++++ 5 files changed, 640 insertions(+), 60 deletions(-) create mode 100644 lib/component/src/Tests/Component.PublicMethods.spec.luau create mode 100644 lib/component/src/Tests/Component.Query.coldscan.spec.luau diff --git a/lib/component/src/Query/Plan.luau b/lib/component/src/Query/Plan.luau index 438d64ec..faedd1ca 100644 --- a/lib/component/src/Query/Plan.luau +++ b/lib/component/src/Query/Plan.luau @@ -21,7 +21,6 @@ type MatchSpec = Types.MatchSpec type PlanReq = Types.PlanReq type Plan = Types.Plan type SatisfiedFn = Types.SatisfiedFn -type ComponentClassLike = Types.ComponentClassLike const prototype = Build.prototype :: any const isQuery = Build.isQuery @@ -178,9 +177,9 @@ end -------------------------------------------------------------------------------- -- Probe cost by kind, measured per candidate: a class check is one direct table --- lookup (~30ns), a sub-query check is a memo/engine hash lookup, a class-like --- goes through `FromInstance` (~80ns), and a tag check is a `HasTag` C-call --- (~150ns). Node kinds follow: an attr read is one `GetAttribute` C-call, a prop +-- lookup (~30ns), a sub-query check is a memo/engine hash lookup, and a tag check +-- is a `HasTag` C-call (~150ns). Node kinds follow: an attr read is one +-- `GetAttribute` C-call, a prop -- read a pcall'd index, `where` a user pcall, and combinators recurse into an -- unknown number of children — probed last. Every compiled list is sorted -- cheapest-first so per-candidate evaluation short-circuits on the cheap probes; @@ -189,7 +188,6 @@ end const KIND_COST: { [string]: number } = { class = 1, query = 2, - classlike = 3, tag = 4, attr = 5, prop = 6, @@ -246,20 +244,23 @@ const function compileReq(req: Queryable, buildIndex: number): PlanReq children = children, } end + -- A component class: capture its live started sparse set. Both tables are + -- mutated in place (never replaced) by the lifecycle, so the references stay + -- valid for the class's whole life; `Destroy` clears them, which correctly + -- reads as "no matches". Requires the class to be one of ours (carries the + -- Internal state); a foreign look-alike could never match anyway, since + -- `Keys.inst` reads that same private key off its instances. const internal = (req :: any)[INTERNAL] - if internal then - -- Our own class: capture its live started sparse set. Both tables are - -- mutated in place (never replaced) by the lifecycle, so the references - -- stay valid for the class's whole life; `Destroy` clears them, which - -- correctly reads as "no matches". - return { - kind = "class" :: "class", - buildIndex = buildIndex, - startedList = internal.startedList, - startedMap = internal.startedInstances, - } - end - return { kind = "classlike" :: "classlike", buildIndex = buildIndex, class = req :: ComponentClassLike } + assert( + internal, + "[Component] Query requirement is not a component class, tag string, Query, or filter/combinator node" + ) + return { + kind = "class" :: "class", + buildIndex = buildIndex, + startedList = internal.startedList, + startedMap = internal.startedInstances, + } end -- Evaluates one MatchSpec. `present` is whether the attribute/property exists at @@ -348,17 +349,13 @@ const function reqSatisfied(req: PlanReq, instance: Instance, satisfiedFn: Satis end end return false - elseif kind == "and" then + else -- and for _, child in req.children :: { PlanReq } do if not reqSatisfied(child, instance, satisfiedFn) then return false end end return true - else - const class = req.class :: ComponentClassLike - const component = class:FromInstance(instance) - return component ~= nil and Keys.inst(component).phase == "Started" end end Plan.reqSatisfied = reqSatisfied diff --git a/lib/component/src/Query/Runtime.luau b/lib/component/src/Query/Runtime.luau index d95642cc..5b8ccde3 100644 --- a/lib/component/src/Query/Runtime.luau +++ b/lib/component/src/Query/Runtime.luau @@ -694,20 +694,13 @@ function prototype._enumerate( end else -- component class -- One of ours: its started list already holds exactly the instances - -- this source contributes, pre-filtered. Foreign class-likes fall back - -- to `GetAll()` + a phase check per component. - const class = req :: ComponentClassLike - const internal = (class :: any)[Keys.Internal] + -- this source contributes, pre-filtered. (A non-class requirement never + -- reaches here — the plan compiler rejects it up front.) + const internal = (req :: any)[Keys.Internal] if internal then for _, instance in internal.startedList do set[instance] = true end - else - for _, component in class:GetAll() do - if Keys.inst(component).phase == "Started" then - set[component.Instance] = true - end - end end end end @@ -864,7 +857,7 @@ function prototype._singleSource(self: QueryInternal): PlanReq? then const req = plan.required[1] const kind = req.kind - if kind == "class" or kind == "tag" or kind == "query" or kind == "classlike" then + if kind == "class" or kind == "tag" or kind == "query" then return req end end @@ -956,7 +949,6 @@ function prototype._collect(self: QueryInternal, onMatch: (Instance) -> boolean? local seedSize = math.huge -- smallest KNOWN population so far local firstTag: PlanReq? = nil local firstQuery: PlanReq? = nil - local firstClasslike: PlanReq? = nil for _, req in required do const kind = req.kind if kind == "class" then @@ -968,8 +960,6 @@ function prototype._collect(self: QueryInternal, onMatch: (Instance) -> boolean? firstTag = firstTag or req elseif kind == "query" then firstQuery = firstQuery or req - elseif kind == "classlike" then - firstClasslike = firstClasslike or req end end @@ -1006,7 +996,7 @@ function prototype._collect(self: QueryInternal, onMatch: (Instance) -> boolean? -- No direct source among the required reqs (they are all filter/combinator -- nodes; validation guarantees an enumerable one is nested somewhere): fall -- back to the union scan, same as the anyOf-only shape. - const fallbackSeed = seed or firstTag or firstQuery or firstClasslike + const fallbackSeed = seed or firstTag or firstQuery if not fallbackSeed then for instance in self:_enumerate(false, subSets) do if self:_fullMatch(instance, staticSub) then @@ -1132,21 +1122,13 @@ function prototype._collect(self: QueryInternal, onMatch: (Instance) -> boolean? return end end - elseif seedKind == "query" then + else -- query const ensure = ensureSet :: (QueryInternal) -> { [Instance]: boolean } for instance in ensure(chosenSeed.query :: QueryInternal) do if consider(instance) then return end end - else - for _, component in (chosenSeed.class :: ComponentClassLike):GetAll() do - if Keys.inst(component).phase == "Started" then - if consider(component.Instance) then - return - end - end - end end end @@ -1181,21 +1163,13 @@ function prototype.get(self: QueryInternal): { Instance } return table.clone(single.startedList :: { Instance }) elseif kind == "tag" then return CollectionService:GetTagged(single.tag :: string) - elseif kind == "query" then + else -- query const out: { Instance } = {} const _, ensure = self:_staticSub(self:_plan()) for instance in (ensure :: (QueryInternal) -> { [Instance]: boolean })(single.query :: QueryInternal) do table.insert(out, instance) end return out - else - const out: { Instance } = {} - for _, component in (single.class :: ComponentClassLike):GetAll() do - if Keys.inst(component).phase == "Started" then - table.insert(out, component.Instance) - end - end - return out end end diff --git a/lib/component/src/Query/Types.luau b/lib/component/src/Query/Types.luau index 4875b9ab..4db08ccf 100644 --- a/lib/component/src/Query/Types.luau +++ b/lib/component/src/Query/Types.luau @@ -25,8 +25,6 @@ export type ComponentClassLike = { -- signal. Cast to `ClassSignalView` at the connect site. read Started: unknown, read Stopped: unknown, - read FromInstance: (self: T, instance: Instance) -> T?, - read GetAll: (self: T) -> { T }, } -- Runtime connection shape shared by better-signal and RBXScriptSignal. @@ -163,12 +161,11 @@ export type ChangedSignal = Signal.Signal<(Instance, boolean) -> (), Instance, b -- SINGLE table lookup — membership in `startedMap` IS "started" — and -- `startedList` gives `get()` an O(1)-sized, already-filtered seed source. export type PlanReq = { - kind: "tag" | "class" | "classlike" | "query" | "attr" | "prop" | "where" | "not" | "or" | "and", + kind: "tag" | "class" | "query" | "attr" | "prop" | "where" | "not" | "or" | "and", buildIndex: number, -- declaration position; tiebreak for the stable sort tag: string?, startedList: { Instance }?, -- class: live dense started-instance array startedMap: { [Instance]: number }?, -- class: live instance -> list index - class: ComponentClassLike?, -- foreign class-like: FromInstance fallback query: QueryInternal?, spec: MatchSpec?, -- attr / prop node fn: ((Instance) -> boolean)?, -- where node diff --git a/lib/component/src/Tests/Component.PublicMethods.spec.luau b/lib/component/src/Tests/Component.PublicMethods.spec.luau new file mode 100644 index 00000000..4857ea2e --- /dev/null +++ b/lib/component/src/Tests/Component.PublicMethods.spec.luau @@ -0,0 +1,169 @@ +--!nonstrict +--[[ + Coverage for the public class/instance methods that the lifecycle and query + specs never touch directly: GetAll, GetAncestors/UpdateAncestors, + WaitForInstance, GetComponent, the AddPromise/RemoveTask/GetTask task API, and + the module-level getUnsetupComponents getter. +]] + +return function(t: any) + local H = require(script.Parent.Helpers) + local Component = require(script.Parent.Parent :: any) :: any + local Promise = require(script.Parent.Parent.Parent.Promise :: any) :: any + + local describe = t.describe + local test = t.test + local expect = t.expect + + describe("class reads", function() + test("GetAll returns a fresh copy of the active components", function() + local A, aTag = H.makeClass() + local p1 = H.taggedPart(aTag) + local p2 = H.taggedPart(aTag) + expect(H.waitStarted(A, p1, 3)).is(true) + expect(H.waitStarted(A, p2, 3)).is(true) + + local all = A:GetAll() + expect(#all).is(2) + -- A copy: clearing the returned array doesn't disturb the class. + table.clear(all) + expect(#A:GetAll()).is(2) + + A:Destroy() + p1:Destroy() + p2:Destroy() + end) + + test("GetAncestors returns a copy; UpdateAncestors swaps it and fires the signal", function() + local A = H.makeClass() + local original = A:GetAncestors() + expect(#original).is(1) + expect(original[1]).is(workspace) + -- Mutating the returned copy leaves the class untouched. + table.insert(original, game) + expect(#A:GetAncestors()).is(1) + + local firedNew, firedOld = nil, nil + local conn = A.AncestorsChanged:Connect(function(newList, oldList) + firedNew, firedOld = newList, oldList + end) + A:UpdateAncestors { workspace, game } + expect(#A:GetAncestors()).is(2) + expect(firedNew ~= nil and #firedNew == 2).is(true) + expect(firedOld ~= nil and #firedOld == 1).is(true) + + conn:Disconnect() + A:Destroy() + end) + end) + + describe("cross-component lookup", function() + test("GetComponent finds a sibling on the same instance, nil otherwise", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local Absent = H.makeClass() + local part = Instance.new("Part") + part.Anchored = true + game:GetService("CollectionService"):AddTag(part, aTag) + game:GetService("CollectionService"):AddTag(part, bTag) + part.Parent = workspace + + expect(H.waitStarted(A, part, 3)).is(true) + expect(H.waitStarted(B, part, 3)).is(true) + + local aInst = A:FromInstance(part) + expect(aInst:GetComponent(B)).is(B:FromInstance(part)) + expect(aInst:GetComponent(Absent)).never_exists() + + A:Destroy() + B:Destroy() + Absent:Destroy() + part:Destroy() + end) + end) + + describe("WaitForInstance", function() + test("resolves immediately for an already-started instance", function() + local A, aTag = H.makeClass() + local p = H.taggedPart(aTag) + expect(H.waitStarted(A, p, 3)).is(true) + + local ok, comp = A:WaitForInstance(p):await() + expect(ok).is(true) + expect(comp).is(A:FromInstance(p)) + + A:Destroy() + p:Destroy() + end) + + test("rejects on timeout when no component ever starts", function() + local A = H.makeClass() + local never = Instance.new("Part") -- never tagged / never constructs + local ok = A:WaitForInstance(never, 0.1):await() + expect(ok).is(false) + + A:Destroy() + never:Destroy() + end) + end) + + describe("core-Janitor task API", function() + test("AddTask + GetTask + RemoveTask (clean and no-clean)", function() + local A, aTag = H.makeClass() + local p = H.taggedPart(aTag) + expect(H.waitStarted(A, p, 3)).is(true) + local inst = A:FromInstance(p) + + local cleaned = false + local ran = inst:AddTask(function() + cleaned = true + end, true, "cleanKey") + -- GetTask returns the stored task by index. + expect(inst:GetTask("cleanKey")).is(ran) + + -- RemoveTask cleans by default. + inst:RemoveTask("cleanKey") + expect(cleaned).is(true) + expect(inst:GetTask("cleanKey")).never_exists() + + -- RemoveTask with dontClean skips cleanup. + local cleaned2 = false + inst:AddTask(function() + cleaned2 = true + end, true, "keepKey") + inst:RemoveTask("keepKey", true) + expect(cleaned2).is(false) + + A:Destroy() + p:Destroy() + end) + + test("AddPromise registers a promise that RemoveTask cancels", function() + local A, aTag = H.makeClass() + local p = H.taggedPart(aTag) + expect(H.waitStarted(A, p, 3)).is(true) + local inst = A:FromInstance(p) + + local cancelled = false + local pending = Promise.new(function(_resolve, _reject, onCancel) + onCancel(function() + cancelled = true + end) + end) + inst:AddPromise(pending, "promiseKey") + inst:RemoveTask("promiseKey") + expect(H.waitUntil(function() + return cancelled + end, 2)).is(true) + + A:Destroy() + p:Destroy() + end) + end) + + describe("module getters", function() + test("getUnsetupComponents returns a table", function() + expect(type(Component.getUnsetupComponents())).is("table") + end) + end) +end diff --git a/lib/component/src/Tests/Component.Query.coldscan.spec.luau b/lib/component/src/Tests/Component.Query.coldscan.spec.luau new file mode 100644 index 00000000..51c6272a --- /dev/null +++ b/lib/component/src/Tests/Component.Query.coldscan.spec.luau @@ -0,0 +1,443 @@ +--!nonstrict +--[[ + Cold-read coverage for the query engine: the one-shot terminals + (`get`/`GetMatches`/`count`/`first`/`iter`) when NO reactive engine is active, + plus `Query.Pred` filter nodes and the selectivity-seeding scan. These paths + are the general cold scan in `Runtime._collect` and the non-class single-source + reads — the reactive specs never exercise them because an observed query answers + straight off its engine. + + Each test uses freshly-tagged classes/tags so no structurally-equal query is + interned elsewhere; that keeps every read on the cold path. +]] + +local CollectionService = game:GetService("CollectionService") + +return function(t: any) + local H = require(script.Parent.Helpers) + local Component = require(script.Parent.Parent :: any) :: any + local Signal = require(script.Parent.Parent.Parent.Signal :: any) :: any + local Query = Component.Query + + local describe = t.describe + local test = t.test + local expect = t.expect + + local function part(tags): Instance + local p = Instance.new("Part") + p.Anchored = true + for _, tg in tags do + CollectionService:AddTag(p, tg) + end + p.Parent = workspace + return p + end + + -- tiniest `.is()` compares tables by identity, so assert set membership + -- element-by-element instead of comparing two freshly-built tables. + local function expectMatches(got, expected) + local s = {} + for _, inst in got do + s[inst] = true + end + expect(#got).is(#expected) + for _, e in expected do + expect(s[e]).is(true) + end + end + + describe("cold reads on non-class single sources", function() + test("cold get/count/first/iter on a single raw-tag source", function() + local tag = H.uniqueTag() + local q = Query(tag) + -- Empty first: general scan touches no candidate. + expect(q:first()).never_exists() + expect(q:count()).is(0) + + local p1 = part { tag } + local p2 = part { tag } + + -- get() dumps the tagged set directly (single-source tag branch). + expectMatches(q:get(), { p1, p2 }) + -- count()/first() take the general scan, NOT the class fast path. + expect(q:count()).is(2) + expect(q:first() == p1 or q:first() == p2).is(true) + + -- iter() over the cold snapshot. + local seen, n = {}, 0 + for inst in q:iter() do + seen[inst] = true + n += 1 + end + expect(n).is(2) + expect(seen[p1] and seen[p2]).is(true) + + p1:Destroy() + p2:Destroy() + end) + + test("cold get/count/first on a single Sub-query source", function() + local inner = H.uniqueTag() + local sub = Query(inner) + local q = Query(Query.Sub(sub)) + + local p = part { inner } + local other = part { H.uniqueTag() } + + -- Single-source query branch: builds the per-call sub memo (ensure/sub). + expectMatches(q:get(), { p }) + expect(q:count()).is(1) + expect(q:first()).is(p) + expect(q:contains(p)).is(true) + expect(q:contains(other)).is(false) + + p:Destroy() + other:Destroy() + end) + end) + + describe("cold general scan on joins with refinements", function() + test("cold get on a class join with without/attribute/property/where", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local C, cTag = H.makeClass() + local gate = true + + local q = Query(A, B):without(C):withAttribute("K", 1):withProperty("Anchored", true):where(function() + return gate + end) + + local match = part { aTag, bTag } -- full match + match:SetAttribute("K", 1) + local excluded = part { aTag, bTag, cTag } -- has C -> excluded + excluded:SetAttribute("K", 1) + local wrongAttr = part { aTag, bTag } -- K mismatch + wrongAttr:SetAttribute("K", 2) + + expect(H.waitUntil(function() + return A:Has(match) and B:Has(match) and C:Has(excluded) and A:Has(wrongAttr) + end, 3)).is(true) + + -- Cold general scan: every rest-probe kind runs in `consider`. + expectMatches(q:get(), { match }) + expect(q:count()).is(1) + expect(q:first()).is(match) + + -- The where-gate flips the same cold result off. + gate = false + expect(#q:get()).is(0) + + A:Destroy() + B:Destroy() + C:Destroy() + match:Destroy() + excluded:Destroy() + wrongAttr:Destroy() + end) + + test("cold get on a join carrying an anyOf group", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local orTag = H.uniqueTag() + + -- Required A, plus (B OR orTag). + local q = Query(A):withAny(B, orTag) + + local viaB = part { aTag, bTag } + local viaTag = part { aTag, orTag } + local neither = part { aTag } + + expect(H.waitUntil(function() + return A:Has(viaB) and B:Has(viaB) and A:Has(viaTag) and A:Has(neither) + end, 3)).is(true) + + expectMatches(q:get(), { viaB, viaTag }) + expect(q:count()).is(2) + + A:Destroy() + B:Destroy() + viaB:Destroy() + viaTag:Destroy() + neither:Destroy() + end) + end) + + describe("cold selectivity seeding (tag sizing)", function() + test("a multi-tag join sizes tags, orders probes, and matches", function() + local t1, t2, t3 = H.uniqueTag(), H.uniqueTag(), H.uniqueTag() + local q = Query(t1, t2, t3) + + local all = part { t1, t2, t3 } + local some = part { t1, t2 } + local one = part { t1 } + + -- No class seed -> live tag sizing picks the narrowest tag, then probes + -- the rest most-selective-first (multiple probes -> the sort runs). + expectMatches(q:get(), { all }) + expect(q:count()).is(1) + expect(q:first()).is(all) + + all:Destroy() + some:Destroy() + one:Destroy() + end) + + test("a large tag population is converted to a hash-set probe", function() + -- Populations above TAG_SIZING_EARLY_EXIT (32) skip the early exit, so + -- sizing fetches every tag and the seed*3 > population rule converts a + -- probe tag into a membership set instead of repeated HasTag calls. + local t1, t2 = H.uniqueTag(), H.uniqueTag() + local q = Query(t1, t2) + local parts = {} + -- 40 carry both tags (all match); 40 carry only t1 (fail the t2 probe). + for i = 1, 40 do + table.insert(parts, part { t1, t2 }) + end + for i = 1, 40 do + table.insert(parts, part { t1 }) + end + + expect(q:count()).is(40) + + for _, p in parts do + p:Destroy() + end + end) + end) + + describe("cold enumeration of a combinator source", function() + test("an anyOf-only query whose member is an And enumerates via its narrowest child", function() + local A, aTag = H.makeClass() + local andTag = H.uniqueTag() + local B, bTag = H.makeClass() + + -- No required source; the candidate set is the union of the anyOf group, + -- one member of which is an And node enumerated through its class child. + local q = Query():withAny(Query.And(A, andTag), B) + + local viaAnd = part { aTag, andTag } + local viaB = part { bTag } + local andMissingTag = part { aTag } -- A but not andTag -> no match + + expect(H.waitUntil(function() + return A:Has(viaAnd) and B:Has(viaB) and A:Has(andMissingTag) + end, 3)).is(true) + + expectMatches(q:get(), { viaAnd, viaB }) + + A:Destroy() + B:Destroy() + viaAnd:Destroy() + viaB:Destroy() + andMissingTag:Destroy() + end) + end) + + describe("Query.Pred filter node", function() + test("a Pred node in an anyOf group matches and reacts to its recheck signal", function() + local A, aTag = H.makeClass() + local D, dTag = H.makeClass() + local gate = false + local recheck = Signal.new() + -- Required D, plus (A OR pred). A part with D and no A must satisfy the + -- group through the predicate branch alone. + local pred = Query.Pred(function() + return gate + end, recheck) + local q = Query(D):withAny(A, pred) + + local viaPred = part { dTag } + expect(H.waitStarted(D, viaPred, 3)).is(true) + + -- Cold: computes a signature over the Pred node and scans; no match yet. + expect(q:contains(viaPred)).is(false) + expect(#q:get()).is(0) + + -- Reactive: the recheck signal drives the Pred branch true. + local live = {} + local obs = q:observe(function(i, jani) + live[i] = true + jani:Add(function() + live[i] = nil + end) + end) + gate = true + recheck:Fire() + expect(H.waitUntil(function() + return live[viaPred] == true + end, 3)).is(true) + + obs:Disconnect() + A:Destroy() + D:Destroy() + viaPred:Destroy() + end) + end) + + describe("error tolerance in user predicates and matchers", function() + test("erroring where/matcher/Pred are caught and treated as no-match, cold", function() + local A, aTag = H.makeClass() + local absentTag = H.uniqueTag() + + -- Erroring :where predicate — caught, warned, counts as false. + local qWhere = Query(A):where(function() + error("bad predicate") + end) + -- Erroring attribute function-matcher — caught, warned, matcher fails. + local qAttr = Query(A):withAttribute("K", function() + error("bad matcher") + end) + -- Erroring Pred node evaluated via reqSatisfied inside an anyOf group + -- whose other alternative (absentTag) is not present. + local qCombo = Query(A):withAny( + Query.Pred(function() + error("bad pred node") + end), + absentTag + ) + + local p = part { aTag } + p:SetAttribute("K", 1) + expect(H.waitStarted(A, p, 3)).is(true) + + -- None crash; each erroring clause resolves to "no match". + expect(#qWhere:get()).is(0) + expect(#qAttr:get()).is(0) + expect(#qCombo:get()).is(0) + + A:Destroy() + p:Destroy() + end) + + test("an erroring unyielding seed callback is reported, not crashed", function() + local A, aTag = H.makeClass() + local q = Query(A) + local p = part { aTag } + expect(H.waitStarted(A, p, 3)).is(true) + + -- Errors DURING the seed batch — pcall-isolated, reported loudly, and the + -- Disconnect still succeeds. + local obs = q:observeUnyielding(function() + error("boom in seed callback") + end) + obs:Disconnect() + + A:Destroy() + p:Destroy() + end) + end) + + describe("reactive seeding edges", function() + test("observeUnyielding seeds over pre-existing matches", function() + local A, aTag = H.makeClass() + local q = Query(A) + local p = part { aTag } + expect(H.waitStarted(A, p, 3)).is(true) + + -- The match exists BEFORE subscribing, so the seed loop (one shared + -- coroutine) fires the callback for it. + local seen = {} + local obs = q:observeUnyielding(function(instance) + seen[instance] = true + end) + expect(seen[p]).is(true) + + obs:Disconnect() + A:Destroy() + p:Destroy() + end) + + test("a yielding seed callback is reported loudly, not silently swallowed", function() + local ScriptContext = game:GetService("ScriptContext") + local A, aTag = H.makeClass() + local q = Query(A) + local p = part { aTag } + expect(H.waitStarted(A, p, 3)).is(true) + + local caught = nil + local conn = ScriptContext.Error:Connect(function(message) + if type(message) == "string" and string.find(message, "YIELDED", 1, true) then + caught = message + end + end) + -- Yields DURING the seed batch -> the shared coroutine survives the resume, + -- which is the violation the reporter fires on. + local obs = q:observeUnyielding(function() + task.wait(0.05) + end) + H.waitUntil(function() + return caught ~= nil + end, 2) + conn:Disconnect() + expect(caught ~= nil).is(true) + + obs:Disconnect() + A:Destroy() + p:Destroy() + end) + + test("observing the same query instance twice reuses its attached engine", function() + local A, aTag = H.makeClass() + local q = Query(A) + local p = part { aTag } + + local a, b = {}, {} + local o1 = q:observe(function(i) + a[i] = true + end) + -- Second observe on the SAME instance: self._engine is already attached. + local o2 = q:observe(function(i) + b[i] = true + end) + + expect(H.waitUntil(function() + return a[p] == true and b[p] == true + end, 3)).is(true) + + -- One observer leaving must not tear the shared engine down. + o1:Disconnect() + local p2 = part { aTag } + expect(H.waitUntil(function() + return b[p2] == true + end, 3)).is(true) + + o2:Disconnect() + A:Destroy() + p:Destroy() + p2:Destroy() + end) + + test("a Sub-query parent seeds from the sub engine's existing matches", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local sub = Query(B) + local q = Query(A):with(Query.Sub(sub)) + + -- The match exists BEFORE the parent activates, so the parent's seed + -- enumeration reads it out of the already-live sub engine's match list. + local p = part { aTag, bTag } + -- Prime the sub engine so it already holds `p` at parent-activation time. + local subHold = sub:track() + expect(H.waitUntil(function() + return sub:contains(p) + end, 3)).is(true) + + local live = {} + local obs = q:observe(function(i, jani) + live[i] = true + jani:Add(function() + live[i] = nil + end) + end) + expect(H.waitUntil(function() + return live[p] == true + end, 3)).is(true) + + obs:Disconnect() + subHold:Disconnect() + A:Destroy() + B:Destroy() + p:Destroy() + end) + end) +end From 24c6da42ecbb4c77322dd67bbce1bfb2e304450f Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 24 Jul 2026 23:10:04 -0400 Subject: [PATCH 18/19] Tests for edge cases --- .../Component.Lifecycle.updates.spec.luau | 70 +++++ .../Tests/Component.Query.coldscan.spec.luau | 103 ++++++ .../Component.Query.combinators.spec.luau | 297 ++++++++++++++++++ 3 files changed, 470 insertions(+) create mode 100644 lib/component/src/Tests/Component.Lifecycle.updates.spec.luau create mode 100644 lib/component/src/Tests/Component.Query.combinators.spec.luau diff --git a/lib/component/src/Tests/Component.Lifecycle.updates.spec.luau b/lib/component/src/Tests/Component.Lifecycle.updates.spec.luau new file mode 100644 index 00000000..42802e09 --- /dev/null +++ b/lib/component/src/Tests/Component.Lifecycle.updates.spec.luau @@ -0,0 +1,70 @@ +--!nonstrict +--[[ + Lifecycle branches the other specs don't reach: the optional per-frame update + loops (HeartbeatUpdate / SteppedUpdate / RenderSteppedUpdate) that connect at + start, and construction hooks that YIELD and then resolve via a Promise or + error (the adopted-Promise `finish` continuation). +]] + +return function(t: any) + local H = require(script.Parent.Helpers) + local Promise = require(script.Parent.Parent.Parent.Promise :: any) :: any + + local describe = t.describe + local test = t.test + local expect = t.expect + + describe("update loops", function() + test("defining update methods connects them on start", function() + local heartbeats = 0 + local A, aTag = H.makeClass { + HeartbeatUpdate = function(_self, _dt) + heartbeats += 1 + end, + SteppedUpdate = function(_self, _dt) end, + RenderSteppedUpdate = function(_self, _dt) end, + } + local p = H.taggedPart(aTag) + expect(H.waitStarted(A, p, 3)).is(true) + -- Best-effort: the Heartbeat connection fires in Studio's edit loop. The + -- branch coverage comes from the connect code running at start regardless. + H.waitUntil(function() + return heartbeats > 0 + end, 2) + + A:Destroy() + p:Destroy() + end) + end) + + describe("asynchronous construction", function() + test("a Construct that yields then returns a Promise chains to completion", function() + local A, aTag = H.makeClass { + Construct = function(_self) + task.wait(0.05) + return Promise.resolve() + end, + } + local p = H.taggedPart(aTag) + expect(H.waitStarted(A, p, 3)).is(true) + + A:Destroy() + p:Destroy() + end) + + test("a Construct that yields then errors never starts", function() + local A, aTag = H.makeClass { + Construct = function(_self) + task.wait(0.05) + error("late boom") + end, + } + local p = H.taggedPart(aTag) + task.wait(0.2) + expect(A:Has(p)).is(false) + + A:Destroy() + p:Destroy() + end) + end) +end diff --git a/lib/component/src/Tests/Component.Query.coldscan.spec.luau b/lib/component/src/Tests/Component.Query.coldscan.spec.luau index 51c6272a..fef1e7f9 100644 --- a/lib/component/src/Tests/Component.Query.coldscan.spec.luau +++ b/lib/component/src/Tests/Component.Query.coldscan.spec.luau @@ -440,4 +440,107 @@ return function(t: any) p:Destroy() end) end) + + describe("compose-by-value invariants", function() + -- A raw Query is a valid Queryable ANYWHERE, including nested inside a + -- combinator in an atomic (withAny / without) position. There, normalization + -- does NOT splice it — `normalizeAnyMember` leaves combinator children + -- untouched — so it survives in a requirement list and is compiled and + -- evaluated as a by-value sub-query, indistinguishable from wrapping it in + -- `Query.Sub`. These nested shapes are the ONLY ones that reach the + -- raw-Query arms of compileReq / _signature / _enumerate; the direct + -- spellings always splice or lower first. Both invariants are asserted + -- across a battery of shapes in one test each to keep the noise down. + + local function matchSet(q) + local set = {} + for _, inst in q:get() do + set[inst] = true + end + return set + end + + test("a raw Query nested in a combinator == the same shape via Query.Sub", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local C, cTag = H.makeClass() + local inner = Query(B):withAttribute("X", 1) + + -- Each pair is (raw-nested, Sub-nested) and must be indistinguishable in + -- both structural signature and match set. + local cases = { + { + Query():withAny(Query.And(inner, aTag), C), + Query():withAny(Query.And(Query.Sub(inner), aTag), C), + }, + { + Query(A):without(Query.Or(inner, C)), + Query(A):without(Query.Or(Query.Sub(inner), C)), + }, + } + + -- Shared world: pAnd satisfies (inner AND aTag); pC has C; pExcluded has + -- A and inner (excluded by the without cases); pA has A alone. + local pAnd = part { aTag, bTag } + pAnd:SetAttribute("X", 1) + local pC = part { cTag } + local pExcluded = part { aTag, bTag } + pExcluded:SetAttribute("X", 1) + local pA = part { aTag } + expect(H.waitUntil(function() + return A:Has(pA) and A:Has(pExcluded) and B:Has(pAnd) and B:Has(pExcluded) and C:Has(pC) + end, 3)).is(true) + + for _, spellings in cases do + local raw, sub = spellings[1], spellings[2] + -- Same structural signature -> they intern to one engine. + expect(raw:_signature()).is(sub:_signature()) + -- Same match set, cold (both non-vacuous). + local rawGet = raw:get() + expect(#rawGet > 0).is(true) + expect(#rawGet).is(#sub:get()) + local subSet = matchSet(sub) + for _, inst in rawGet do + expect(subSet[inst]).is(true) + end + -- count() agrees with get() on the same shape. + expect(raw:count()).is(#rawGet) + end + + A:Destroy() + B:Destroy() + C:Destroy() + pAnd:Destroy() + pC:Destroy() + pExcluded:Destroy() + pA:Destroy() + end) + + test("the compiled plan keeps only enumerable sources in `required`", function() + -- required holds only class / tag / sub-query source kinds; combinators + -- live in anyOf / negative and filters in the attribute/property/predicate + -- fast paths, never in required. Checked across representative shapes. + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local sourceKinds = { class = true, tag = true, query = true } + + local shapes = { + Query(A, B), + Query(A, aTag):with(Query.Sub(Query(B))), + Query(A):withAny(B, Query.And(A, B)), + Query(A):without(Query.Or(B, aTag)):withAttribute("K", 1):where(function() + return true + end), + } + for _, q in shapes do + local plan = q:_plan() + for _, req in plan.required do + expect(sourceKinds[req.kind] == true).is(true) + end + end + + A:Destroy() + B:Destroy() + end) + end) end diff --git a/lib/component/src/Tests/Component.Query.combinators.spec.luau b/lib/component/src/Tests/Component.Query.combinators.spec.luau new file mode 100644 index 00000000..14f5b2bb --- /dev/null +++ b/lib/component/src/Tests/Component.Query.combinators.spec.luau @@ -0,0 +1,297 @@ +--!nonstrict +--[[ + Builder-normalization, input-validation, and combinator-kind evaluation edges: + the branches in Build (assertQueryable, queryToNode lowering, withAny collapse/ + flatten), Plan (isEnumerable through combinators, reqInUniverse/reqSatisfied + over or/and/not kinds), that the behavioral specs never reach because they only + use direct spellings. +]] + +return function(t: any) + local CollectionService = game:GetService("CollectionService") + local H = require(script.Parent.Helpers) + local Component = require(script.Parent.Parent :: any) :: any + local Query = Component.Query + + local describe = t.describe + local test = t.test + local expect = t.expect + + local function part(tags): Instance + local p = Instance.new("Part") + p.Anchored = true + for _, tg in tags do + CollectionService:AddTag(p, tg) + end + p.Parent = workspace + return p + end + + local function has(list, inst): boolean + for _, i in list do + if i == inst then + return true + end + end + return false + end + + describe("input validation", function() + test("every builder rejects a non-queryable argument", function() + local A = H.makeClass() + -- assertQueryable / isComponentClass reject numbers, booleans, and plain + -- tables (no string `.Tag`), in every position. + expect(function() + Query(5) + end).fails() + expect(function() + Query(A):with(true) + end).fails() + expect(function() + Query(A):withAny {} + end).fails() + expect(function() + Query(A):without(42) + end).fails() + -- Valid kinds pass through (the guard is not over-eager). + Query("aTag"):_signature() + Query(A):with(Query.Attr("X")):_signature() + + A:Destroy() + end) + end) + + describe("raw-query lowering in atomic positions (queryToNode)", function() + test("without lowers a raw query by its clause count", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local C, cTag = H.makeClass() + + -- Single-clause query -> the lone node. + local oneClause = Query(A):without(Query(B)) + -- Multi-clause query WITH a negative -> And( positive, Not(negative) ). + local withNeg = Query(A):without(Query(B):without(C)) + -- An empty query has nothing to lower. + expect(function() + Query(A):without(Component.query()) + end).fails() + + local pB = part { aTag, bTag } + local pBC = part { aTag, bTag, cTag } + local pA = part { aTag } + expect(H.waitUntil(function() + return A:Has(pB) and B:Has(pB) and A:Has(pBC) and C:Has(pBC) and A:Has(pA) + end, 3)).is(true) + + -- without(Query(B)) excludes anything matching B. + expect(oneClause:contains(pB)).is(false) + expect(oneClause:contains(pA)).is(true) + -- without(Query(B):without(C)) excludes (B AND not C): pB out, pBC kept. + expect(withNeg:contains(pB)).is(false) + expect(withNeg:contains(pBC)).is(true) + expect(withNeg:contains(pA)).is(true) + + A:Destroy() + B:Destroy() + C:Destroy() + pB:Destroy() + pBC:Destroy() + pA:Destroy() + end) + end) + + describe("combinator normalization in withAny", function() + test("a single-child Or collapses to a required requirement; a multi-child Or flattens", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local C, cTag = H.makeClass() + local dTag = H.uniqueTag() + + -- withAny(Or(B)) == with(B): a one-member group is required. + local collapsed = Query(A):withAny(Query.Or(B)) + -- withAny(Or(B, C), dTag) flattens the Or's members into the group. + local flattened = Query(A):withAny(Query.Or(B, C), dTag) + + local pAB = part { aTag, bTag } + local pAC = part { aTag, cTag } + local pAD = part { aTag, dTag } + local pA = part { aTag } + expect(H.waitUntil(function() + return A:Has(pAB) and B:Has(pAB) and A:Has(pAC) and C:Has(pAC) and A:Has(pAD) and A:Has(pA) + end, 3)).is(true) + + expect(collapsed:contains(pAB)).is(true) + expect(collapsed:contains(pAC)).is(false) + + expect(flattened:contains(pAB)).is(true) + expect(flattened:contains(pAC)).is(true) + expect(flattened:contains(pAD)).is(true) + expect(flattened:contains(pA)).is(false) + + A:Destroy() + B:Destroy() + C:Destroy() + pAB:Destroy() + pAC:Destroy() + pAD:Destroy() + pA:Destroy() + end) + end) + + describe("filter nodes in positive position", function() + test("Prop and Pred nodes passed to :with compose like withProperty/where", function() + local A, aTag = H.makeClass() + local q = Query(A):with(Query.Prop("Anchored", true)):with(Query.Pred(function(i) + return i.Name == "Yes" + end)) + + local match = part { aTag } + match.Name = "Yes" + local wrongName = part { aTag } + wrongName.Name = "No" + expect(H.waitUntil(function() + return A:Has(match) and A:Has(wrongName) + end, 3)).is(true) + + local got = q:get() + expect(#got).is(1) + expect(has(got, match)).is(true) + + A:Destroy() + match:Destroy() + wrongName:Destroy() + end) + end) + + describe("enumerability through combinators (validation)", function() + test("Not makes an anyOf unbounded; And/Or enumerability is computed recursively", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local xTag = H.uniqueTag() + + -- An anyOf alternative that is `Not` is not a candidate source -> unbounded. + expect(function() + Component.query():withAny(A, Query.Not(B)):get() + end).fails() + + -- And is enumerable when ANY child is (its first child here is a filter, + -- the second a class); a nested Or is enumerable only when EVERY child is, + -- so an Or with a filter child is NOT a source (but the And still is). + local boundedAnd = Component.query():withAny(Query.And(Query.Attr("X", 1), B), A) + local boundedOrInAnd = Component.query():withAny(Query.And(Query.Or(A, Query.Attr("Y", 1)), xTag), A) + + local pB = part { bTag } + pB:SetAttribute("X", 1) + local pA = part { aTag } + expect(H.waitUntil(function() + return B:Has(pB) and A:Has(pA) + end, 3)).is(true) + + -- Both validate (no error) and read cold. + expect(boundedAnd:contains(pB)).is(true) + expect(boundedAnd:contains(pA)).is(true) + expect(#boundedOrInAnd:get() >= 1).is(true) + + A:Destroy() + B:Destroy() + pB:Destroy() + pA:Destroy() + end) + end) + + describe("combinator requirements under reactive evaluation", function() + test("nested or/and members track in the candidate universe; a Not negative filters matches", function() + local A, aTag = H.makeClass() + local B, bTag = H.makeClass() + local C, cTag = H.makeClass() + local xTag = H.uniqueTag() + local yTag = H.uniqueTag() + + -- anyOf holds And( Or(B, xTag), yTag ) — the nested Or forces reqInUniverse + -- over the or/and kinds, and an instance missing yTag exercises the + -- "a required And child is not in the universe" path. A part matching none + -- of the alternatives exercises the "no alternative satisfied" path. + local qAny = Query(A):withAny(Query.And(Query.Or(B, xTag), yTag), C) + local liveAny = {} + local o1 = qAny:observe(function(i, j) + liveAny[i] = true + j:Add(function() + liveAny[i] = nil + end) + end) + + -- negative Not(B): keep only A-instances that DO have B -> reqSatisfied "not". + local qNot = Query(A):without(Query.Not(B)) + local liveNot = {} + local o2 = qNot:observe(function(i, j) + liveNot[i] = true + j:Add(function() + liveNot[i] = nil + end) + end) + + local pBY = part { aTag, bTag, yTag } -- (B via Or) AND yTag + local pXY = part { aTag, xTag, yTag } -- (xTag via Or) AND yTag + local pC = part { aTag, cTag } -- C alternative + local pB = part { aTag, bTag } -- Or ok but no yTag; no C -> no match + expect(H.waitUntil(function() + return A:Has(pBY) and B:Has(pBY) and A:Has(pXY) and A:Has(pC) and C:Has(pC) and A:Has(pB) and B:Has(pB) + end, 3)).is(true) + task.wait(0.1) + + expect(liveAny[pBY]).is(true) + expect(liveAny[pXY]).is(true) + expect(liveAny[pC]).is(true) + expect(liveAny[pB]).never_exists() -- Or ok but yTag missing, no C + + expect(liveNot[pBY]).is(true) -- has B -> not excluded + expect(liveNot[pC]).never_exists() -- no B -> Not(B) true -> excluded + + o1:Disconnect() + o2:Disconnect() + A:Destroy() + B:Destroy() + C:Destroy() + pBY:Destroy() + pXY:Destroy() + pC:Destroy() + pB:Destroy() + end) + end) + + describe("builder & terminal edge cases", function() + test("withAny() with no arguments is a no-op that returns an equivalent query", function() + local A, aTag = H.makeClass() + local q = Query(A):withAny() + local p = part { aTag } + expect(H.waitStarted(A, p, 3)).is(true) + expect(q:contains(p)).is(true) + A:Destroy() + p:Destroy() + end) + + test("a where predicate without a recheck signal still activates and disconnects idempotently", function() + local A, aTag = H.makeClass() + local gate = true + -- No recheck signal: the activation's recheck-connect loop skips it. + local q = Query(A):where(function() + return gate + end) + local live = {} + local obs = q:observe(function(i) + live[i] = true + end) + local p = part { aTag } + expect(H.waitUntil(function() + return live[p] == true + end, 3)).is(true) + + -- Disconnecting twice is a no-op the second time. + obs:Disconnect() + obs:Disconnect() + + A:Destroy() + p:Destroy() + end) + end) +end From 992d6816f5c7ddf1a0483f3ed2073dfe2db2c61d Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Thu, 30 Jul 2026 19:38:56 -0400 Subject: [PATCH 19/19] Fix moonwave doc placement and tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move misplaced `@within` tags onto the correct functions, convert a single-line deprecated comment to proper moonwave block, and fix parameter name mismatches in doc blocks (`task` → `task_`, `reason` → `_reason`). --- lib/component/src/Query/Runtime.luau | 22 ++++++++++----------- lib/component/src/init.luau | 29 ++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/lib/component/src/Query/Runtime.luau b/lib/component/src/Query/Runtime.luau index 5b8ccde3..319a93a0 100644 --- a/lib/component/src/Query/Runtime.luau +++ b/lib/component/src/Query/Runtime.luau @@ -714,17 +714,6 @@ end -- Public terminals -------------------------------------------------------------------------------- ---[=[ - @within Query - @param callback (instance: Instance, janitor: Janitor) -> () - @return QueryConnection - - Runs `callback` for every instance that currently matches, and for every - instance that matches later, each with a fresh Janitor cleaned up when that - instance stops matching. Fetch matched components with `Class:FromInstance`. - Disconnecting the returned handle destroys all active match janitors and stops - watching. -]=] -- Shared body of `observe` / `observeUnyielding`: register an observer, fire it -- for the current matches, and return a disconnect handle. `unyielding` selects -- the dispatch strategy (see `dispatchMatch`); `method` names the caller for the @@ -774,6 +763,17 @@ const function attachObserver( return connProxy end +--[=[ + @within Query + @param callback (instance: Instance, janitor: Janitor) -> () + @return QueryConnection + + Runs `callback` for every instance that currently matches, and for every + instance that matches later, each with a fresh Janitor cleaned up when that + instance stops matching. Fetch matched components with `Class:FromInstance`. + Disconnecting the returned handle destroys all active match janitors and stops + watching. +]=] function prototype.observe(self: QueryInternal, callback: (Instance, Janitor) -> ()): QueryConnection return attachObserver(self, callback, false, "observe") end diff --git a/lib/component/src/init.luau b/lib/component/src/init.luau index 45cd41b0..1242e1e6 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -408,6 +408,7 @@ end -------------------------------------------------------------------------------- --[=[ + @within Component @tag Component Class @return {Component} Returns a copy of all active component instances of this class. @@ -417,6 +418,7 @@ function Component.prototype.GetAll(self: Class_Internal): { Instance_Internal } end --[=[ + @within Component @tag Component Class @param instance Instance @return Component? @@ -428,6 +430,7 @@ function Component.prototype.FromInstance(self: Class_Internal, instance: Instan end --[=[ + @within Component @tag Component Class @param instance Instance @return boolean @@ -439,6 +442,7 @@ function Component.prototype.Has(self: Class_Internal, instance: Instance): bool end --[=[ + @within Component @tag Component Class @param instanceOrComponent Instance | Component @return LifecyclePhase @@ -453,6 +457,7 @@ function Component.prototype.GetLifecycleStatus( end --[=[ + @within Component @tag Component Class @param instance Instance @param timeout number? @@ -475,6 +480,7 @@ function Component.prototype.WaitForInstance( end --[=[ + @within Component @tag Component Class @param instance Instance @return Promise @@ -521,10 +527,15 @@ function Component.prototype.GetCreateFromInstance( end) end ---- @deprecated v1.0.0 -- Renamed to [Component:GetCreateFromInstance]. +--[=[ + @method GetOrCreateFromInstance + @within Component + @deprecated v1.0.0 -- Renamed to [Component:GetCreateFromInstance]. +]=] Component.prototype.GetOrCreateFromInstance = Component.prototype.GetCreateFromInstance --[=[ + @within Component @tag Component Class Updates the valid ancestors of this class and re-evaluates watched instances. ]=] @@ -536,6 +547,7 @@ function Component.prototype.UpdateAncestors(self: Class_Internal, newAncestors: end --[=[ + @within Component @tag Component Class Returns a copy of the current valid ancestors. ]=] @@ -544,6 +556,7 @@ function Component.prototype.GetAncestors(self: Class_Internal): { Instance } end --[=[ + @within Component @tag Component Class Called before the component starts, to initialize it. May yield or return a Promise. @@ -551,6 +564,7 @@ end function Component.prototype.Construct(_self: Instance_Internal) end --[=[ + @within Component @tag Component Class Called when the component starts. Sibling components on the same instance are safe to access here. May yield or return a Promise. @@ -558,8 +572,9 @@ function Component.prototype.Construct(_self: Instance_Internal) end function Component.prototype.Start(_self: Instance_Internal) end --[=[ + @within Component @tag Component Class - @param reason StopReason + @param _reason StopReason Called when the component stops. The bound instance may already be gone — check `reason`. Anything added via `self:AddTask` is cleaned up automatically after this returns. @@ -571,6 +586,7 @@ function Component.prototype.Stop(_self: Instance_Internal, _reason: StopReason) -------------------------------------------------------------------------------- --[=[ + @within Component @tag Component Instance @param componentClass ComponentClass @return Component? @@ -581,6 +597,7 @@ function Component.prototype.GetComponent(self: Instance_Internal, componentClas end --[=[ + @within Component @tag Component Instance @return boolean Whether the component has fully started. @@ -590,8 +607,9 @@ function Component.prototype.IsStarted(self: Instance_Internal): boolean end --[=[ + @within Component @tag Component Instance - @param task T + @param task_ T @param cleanupMethod (string | true)? @param index any? @return T @@ -608,6 +626,7 @@ function Component.prototype.AddTask( end --[=[ + @within Component @tag Component Instance @param promise Promise @param index any? @@ -620,6 +639,7 @@ function Component.prototype.AddPromise(self: Instance_Internal, promise: end --[=[ + @within Component @tag Component Instance @param index any @param dontClean boolean? @@ -635,6 +655,7 @@ function Component.prototype.RemoveTask(self: Instance_Internal, index: unknown, end --[=[ + @within Component @tag Component Instance @param index any @return any @@ -669,8 +690,8 @@ end ]=] --[=[ - @tag Component Class @within Component + @tag Component Class Destroys the component class: stops all its components (with reason `"ClassDestroyed"`), disconnects from CollectionService, and clears all state. ]=]