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/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/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/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..b75871d1 --- /dev/null +++ b/lib/component/src/Keys.luau @@ -0,0 +1,129 @@ +--!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 = { + -- 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 }, + -- 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 + monotonic id that supersedes a stale request; keeping them in one module is + what makes that protocol reviewable. ]] + lockConstruct: { [Instance]: number }, + pending: { [Instance]: any }, + 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 + -- 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, +} + +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..8c46ac08 --- /dev/null +++ b/lib/component/src/Lifecycle.luau @@ -0,0 +1,962 @@ +--!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 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") + +local Packages = script.Parent.Parent +local Promise = require(Packages.Promise) +local Janitor = require(Packages.Janitor) + +local Keys = require(script.Parent.Keys) +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() + +--[[ + 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) + +-- 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(...)` 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`. + + 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 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 chained: PromiseLike? + local cancelled = false + onCancel(function() + cancelled = true + if coroutine.status(thread) == "suspended" then + pcall(task.cancel, thread) + end + if chained then + chained:cancel() + end + end) + finish = function(ok: boolean, res: any) + if cancelled then + return + end + if not ok then + reject(res) + elseif Promise.is(res) then + const inner = res :: PromiseLike + chained = inner + inner:andThen(function(...) + resolve(...) + end, function(...) + reject(...) + end) + else + resolve(res) + end + end + end) :: unknown + ) :: PromiseLike + return promise, true, nil +end + +--[[ + 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?, + -- Warn on a hook error instead of failing 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. + + 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?, any?) + const reverse = options.reverse == true + const warnErrors = options.warnErrors == true + + -- extension -> the extensions whose hooks must settle before its own starts. + -- Only built when some extension actually declares dependencies. + local waitsOn: { [any]: { any } }? = nil + const function addEdge(after: any, before: any) + const map = waitsOn or {} + waitsOn = map + const list = map[after] + if list then + table.insert(list, before) + else + map[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 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 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 + + 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 + end + end + + if node then + const pendingMapOut = pendingOf or {} + pendingOf = pendingMapOut + pendingMapOut[extension] = node + const nodeList = nodes or {} + nodes = nodeList + table.insert(nodeList, node) + end + end + + if not nodes then + return nil, nil + end + return (if #nodes == 1 then nodes[1] else promiseAll(nodes)), nil +end + +-- 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 = {} +const STOP_PHASE: PhaseOptions = { reverse = 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: 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) + 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 + unregister(instance, class) + end +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 + + -- 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 + + -- 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) + warn(string.format("[Component] Error during teardown of '%s': %s", tostring(class.Tag), tostring(err))) + end + + -- 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 + 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 + 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 + + -- 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 + + 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 + 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) + -- 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, + -- 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 + + -- 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 + + -- 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 + -- `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? + --[[ 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 not reason then + return nil + end + if ic.stopReason == nil then + ic.stopReason = reason + end + return ic.stopReason + 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 + + 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 function invokeMethod(fn: unknown): any? + if type(fn) ~= "function" then + return checkValidity() + end + const pending, ok, err = invokeSmart(fn :: (...any) -> any, component) + if not ok then + return if err == nil then CANCELLED else err + end + 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) + 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() + 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 + -- 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 + + const ok, abort = pcall(drive) + + -- 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 + + -- 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 + 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.startedInstances) + table.clear(ci.startedList) + table.clear(ci.lockConstruct) + table.clear(ci.pending) +end + +return { + Request = request, + Release = release, + GetPhase = getPhase, + DestroyClass = destroyClass, + GetAllForInstance = getAllForInstance, +} 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..faedd1ca --- /dev/null +++ b/lib/component/src/Query/Plan.luau @@ -0,0 +1,567 @@ +--!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 + +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, 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, + 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 + -- 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] + 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 +-- 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 + else -- and + for _, child in req.children :: { PlanReq } do + if not reqSatisfied(child, instance, satisfiedFn) then + return false + end + end + return true + 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..319a93a0 --- /dev/null +++ b/lib/component/src/Query/Runtime.luau @@ -0,0 +1,1290 @@ +--!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. (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 + end + end + end + for _, req in self:_positiveSources() do + addFromRef(req) + end + return set +end + +-------------------------------------------------------------------------------- +-- Public terminals +-------------------------------------------------------------------------------- + +-- 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 + +--[=[ + @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 + +--[=[ + @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" 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 + 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 + 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 + 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 + else -- query + const ensure = ensureSet :: (QueryInternal) -> { [Instance]: boolean } + for instance in ensure(chosenSeed.query :: QueryInternal) do + if consider(instance) then + return + 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) + 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 + 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..4db08ccf --- /dev/null +++ b/lib/component/src/Query/Types.luau @@ -0,0 +1,260 @@ +--!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, +} + +-- 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" | "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 + 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/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..1ac17de4 --- /dev/null +++ b/lib/component/src/Tests/Component.Lifecycle.spec.luau @@ -0,0 +1,415 @@ +--!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). + + 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 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() + 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("instances outside valid ancestors do not construct", function() + local class, tag = makeClass(nil, { 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) + + 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:GetOrCreateFromInstance(part) + class:GetOrCreateFromInstance(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) + + 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/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.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.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..fef1e7f9 --- /dev/null +++ b/lib/component/src/Tests/Component.Query.coldscan.spec.luau @@ -0,0 +1,546 @@ +--!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) + + 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 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..242dff07 --- /dev/null +++ b/lib/component/src/Tests/Component.Query.spec.luau @@ -0,0 +1,1135 @@ +--!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("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() + 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("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("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("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 + 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("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() + expect(function() + Component.query():without(Bad):GetMatches() + end).fails() + end) + + 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) + + 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) + + 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 diff --git a/lib/component/src/Tests/Component.types.luau b/lib/component/src/Tests/Component.types.luau new file mode 100644 index 00000000..c73d273b --- /dev/null +++ b/lib/component/src/Tests/Component.types.luau @@ -0,0 +1,172 @@ +--!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:GetOrCreateFromInstance(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 +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..60d59311 --- /dev/null +++ b/lib/component/src/TypeFunctions.luau @@ -0,0 +1,78 @@ +--!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 + +return {} diff --git a/lib/component/src/Types.luau b/lib/component/src/Types.luau new file mode 100644 index 00000000..9ab878f6 --- /dev/null +++ b/lib/component/src/Types.luau @@ -0,0 +1,294 @@ +--!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 Signal = require(Packages.Signal) +const Promise = require(Packages.Promise) +const Janitor = require(Packages.Janitor) + +const Keys = require("./Keys") +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 + +-- 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, +} + +--[[ + 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) -> ())?, + + GetLifecycleStatus: (self: TypedClass, instanceOrComponent: any) -> LifecyclePhase, + FromInstance: (self: TypedClass, instance: I & Instance) -> TypedInstance?, + WaitForInstance: ( + self: TypedClass, + instance: I & Instance, + timeout: number? + ) -> Promise>, + GetOrCreateFromInstance: (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 })?, +} & TypedClass & F + +-- 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> + +-------------------------------------------------------------------------------- +-- Internal implementation views +-------------------------------------------------------------------------------- + +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, + _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?, + -- 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, +} + +return {} 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..1242e1e6 100644 --- a/lib/component/src/init.luau +++ b/lib/component/src/init.luau @@ -1,182 +1,75 @@ +--!strict -- Component -- Stephen Leitnick, Logan Hunt --- November 26, 2021 - -type AncestorList = { Instance } - ---[=[ - @type ExtensionFn (component) -> () - @within Component -]=] -type ExtensionFn = (any) -> () +-- November 26, 2021 (rewritten for v1.0.0) --[=[ - @type ExtensionShouldFn (component) -> boolean - @within Component + @class Component + + ## Overview + + 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`) 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 a component instance is bound to. + + ## Lifecycle + + 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 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 + 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]: function}? - - 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 player = game:GetService("Players").LocalPlayer - - local OnlyLocalPlayer = {} - function OnlyLocalPlayer.ShouldConstruct(component) - local ownerId = component.Instance:GetAttribute("OwnerId") - return ownerId == player.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. ]=] --[=[ @@ -184,283 +77,223 @@ 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) - -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: -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 - -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 - -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 - --- Handles which extensions should be applied and in what order. -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 - - if not fn then - shouldExtend = true - elseif not isClass and type(fn) == "function" then - shouldExtend = fn(component) - end - end - - 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 - - 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 - --- Added by Raildex -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 - -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 +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 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 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` +-- into their typed-path self alias. +export type extensionMethods = TypeFunctions.extensionMethods + +type Janitor = Types.Janitor +type Promise = Types.Promise +type Class_Internal = Types.ComponentClass_Internal +type Instance_Internal = Types.ComponentInstance_Internal + +const DEFAULT_ANCESTORS: { Instance } = { workspace, game:GetService("Players") } +const DEFAULT_TIMEOUT = 60 +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)?) -> Promise +const fromEvent = (Promise.fromEvent :: unknown) :: FromEventFn + +const Component = {} +Component.prototype = {} +Component.__index = Component.prototype --[=[ @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, 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 +Component.Query = Query - 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 = Lifecycle.GetAllForInstance +-------------------------------------------------------------------------------- +-- 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 - }) + function MyComponent:Construct() self.Data = "Hello" end + function MyComponent:Start() print(self.Data) end + function MyComponent:Stop(reason) print("stopped:", reason) end + ``` - local AnotherComponent = require(somewhere.AnotherComponent) + 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). - -- Optional if UpdateRenderStepped should use BindToRenderStep: - MyComponent.RenderPriority = Enum.RenderPriority.Camera.Value + `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:Construct() - self.MyData = "Hello" - end - - 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 = {}, + startedInstances = {}, + startedList = {}, + lockConstruct = {}, + watching = {}, + 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 + 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 @@ -470,600 +303,381 @@ function Component.new(config: ComponentConfig) end return customComponent end - ---[=[ - @tag Component - @return {ComponentClass} - - Gets a table array of all unsetup component classes. This allows you to call `:_setup()` on them later. - - ```lua - local unsetupComponents = Component.getUnsetupComponents() - for _, componentClass in unsetupComponents do - Component._setup(componentClass) +Component.new = (componentNew :: any) :: Types.NewFn + +-------------------------------------------------------------------------------- +-- Tag / ancestry watching +-------------------------------------------------------------------------------- + +--[[ + Returns true if `instance` is a descendant of any valid ancestor. +]] +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 + end end - ``` -]=] -function Component.getUnsetupComponents(): {ComponentClass} - return table.clone(UNSETUP_COMPONENTS) :: any + return false end - -function Component:_instantiate(instance: Instance) - local component = setmetatable({}, self) - component.Instance = instance - - 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) +--[[ + Begins watching `instance` for ancestry changes, constructing/deconstructing as + it enters or leaves the valid ancestor list. Idempotent. +]] +function Component.prototype._startWatching(self: Class_Internal, instance: Instance) + const ci = Keys.class(self) + if ci.watching[instance] then + return + end + + const function evaluate() + if self:_isInAncestorList(instance) then + Lifecycle.Request(self, instance) + else + const reason: StopReason = if instance:IsDescendantOf(game) then "LeftAncestry" else "InstanceDestroyed" + Lifecycle.Release(self, instance, reason) end end - if not ShouldConstruct(component) then - return nil - end - InvokeExtensionFn(component, "Constructing") - if type(component.Construct) == "function" then - component:Construct() + ci.watching[instance] = { + instance.AncestryChanged:Connect(evaluate), + self.AncestorsChanged:Connect(evaluate), + } + + if self:_isInAncestorList(instance) then + Lifecycle.Request(self, instance) end - InvokeExtensionFn(component, "Constructed") - return component end ---[=[ - @tag Component Class - @within Component - @method _setup +function Component.prototype._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 + end +end - 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. -]=] -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 Component.prototype._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 - - local watchingInstances = {} - - self[KEY_CLASS_ACTIVE_EXTENSIONS] = GetActiveExtensions(self, self[KEY_EXTENSIONS], {}, true) - BindExtensionMethods(self, self[KEY_CLASS_ACTIVE_EXTENSIONS]) -- Added by Raildex - local function StartComponent(component) - component[KEY_STARTING] = coroutine.running() + const ci = Keys.class(self) + const classActiveExtensions = Extensions.Resolve(self, ci.extensions, true) + ci.classActiveExtensions = classActiveExtensions + Extensions.BindMethods(self, classActiveExtensions) - InvokeExtensionFn(component, "Starting") - - component:Start() - if component[KEY_STARTING] == nil then - -- Component's Start method stopped the component - return - end - - InvokeExtensionFn(component, "Started") - - 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 + -- Without a `ShouldExtend` anywhere, every instance resolves to exactly this + -- 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 + break end - - component[KEY_STARTED] = true - component[KEY_STARTING] = nil - - self.Started:Fire(component) - end - - 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) - end) - end) - end - component[KEY_STARTING] = nil - 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 - local function SafeConstruct(instance, id) - if self[KEY_LOCK_CONSTRUCT][instance] ~= id then - return nil - end - local component = self:_instantiate(instance) - if self[KEY_LOCK_CONSTRUCT][instance] ~= id then - 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) + Lifecycle.Release(self, instance, "Untagged") + end)) - 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) - end) - end - - local function TryDeconstructComponent(instance) - local component = self[KEY_INST_TO_COMPONENTS][instance] - 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 - 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 - - 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 - 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 + self:_startWatching(instance) end) - watchingInstances[instance] = ancestryChangedHandle - if IsInAncestorList() then - TryConstructComponent(instance) - end - end - - local function InstanceTagged(instance: Instance) - StartWatchingInstance(instance) - end - - local function InstanceUntagged(instance: Instance) - local watchHandle = watchingInstances[instance] - if watchHandle then - watchingInstances[instance] = nil - self[KEY_TROVE]:Remove(watchHandle) - end - TryDeconstructComponent(instance) - end - - self[KEY_TROVE]:Connect(CollectionService:GetInstanceAddedSignal(self.Tag), InstanceTagged) - self[KEY_TROVE]:Connect(CollectionService:GetInstanceRemovedSignal(self.Tag), InstanceUntagged) - - local tagged = CollectionService:GetTagged(self.Tag) - for _, instance in ipairs(tagged) do - task.defer(InstanceTagged, instance) end end +-------------------------------------------------------------------------------- +-- Public class API +-------------------------------------------------------------------------------- + --[=[ + @within Component @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 Component.prototype.GetAll(self: Class_Internal): { Instance_Internal } + return table.clone(Keys.class(self).components) end --[=[ + @within Component @tag Component Class + @param instance Instance @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) - ``` + Returns the component of this class bound to `instance`, or nil. The component + may still be constructing; use [Component:GetLifecycleStatus] to check. ]=] -function Component:FromInstance(instance: Instance) - return self[KEY_INST_TO_COMPONENTS][instance] +function Component.prototype.FromInstance(self: Class_Internal, instance: Instance): Instance_Internal? + return Keys.class(self).instToComponents[instance] end --[=[ + @within Component @tag Component Class - @return Promise - - 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. + @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 Component.prototype.Has(self: Class_Internal, instance: Instance): boolean + return Keys.class(self).startedInstances[instance] ~= nil +end - ```lua - local MyComponent = require(somewhere.MyComponent) +--[=[ + @within Component + @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.prototype.GetLifecycleStatus( + self: Class_Internal, + instanceOrComponent: Instance | Types.AnyComponent +): LifecyclePhase + return Lifecycle.GetPhase(self, instanceOrComponent) +end - MyComponent:WaitForInstance(workspace.SomeInstance):andThen(function(myComponentInstance) - -- Do something with the component class - end) - ``` +--[=[ + @within Component + @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 Component:WaitForInstance(instance: Instance, timeout: number?) - local componentInstance = self:FromInstance(instance) - if componentInstance and componentInstance[KEY_STARTED] then +function Component.prototype.WaitForInstance( + self: Class_Internal, + instance: Instance, + timeout: number? +): Promise + const componentInstance = self:FromInstance(instance) + if componentInstance and Keys.inst(componentInstance).started then return Promise.resolve(componentInstance) end - return Promise.fromEvent(self.Started, function(c) - local match = c.Instance == instance - if match then - componentInstance = c - end - return match - end) - :andThen(function() - return componentInstance - end) - :timeout(if type(timeout) == "number" then timeout else DEFAULT_TIMEOUT) + return fromEvent(self.Started, function(c: Instance_Internal) + return c.Instance == instance + end):timeout(if type(timeout) == "number" then timeout else DEFAULT_TIMEOUT) end --[=[ + @within Component @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 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 - 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. - - ```lua - local MyComponent = Component.new({ - Tag = "MyComponent", - Ancestors = {workspace}, - }) + 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)) + 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)) - task.defer(function() - local newAncestors = {workspace:WaitForChild("SomeFolder")} - MyComponent:UpdateAncestors(newAncestors) + 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 + +--[=[ + @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. ]=] -function Component:UpdateAncestors(newAncestors: {Instance}) - local lastAncestors = self[KEY_ANCESTORS] - self[KEY_ANCESTORS] = newAncestors +function Component.prototype.UpdateAncestors(self: Class_Internal, newAncestors: { Instance }) + const ci = Keys.class(self) + const lastAncestors = ci.ancestors + ci.ancestors = newAncestors self.AncestorsChanged:Fire(newAncestors, lastAncestors) end --[=[ + @within Component @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 Component.prototype.GetAncestors(self: Class_Internal): { Instance } + return table.clone(Keys.class(self).ancestors) end --[=[ + @within Component @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 Component.prototype.Construct(_self: Instance_Internal) end --[=[ + @within Component @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 Component.prototype.Start(_self: Instance_Internal) end --[=[ + @within Component @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 Component.prototype.Stop(_self: Instance_Internal, _reason: StopReason) end + +-------------------------------------------------------------------------------- +-- Public instance API +-------------------------------------------------------------------------------- --[=[ + @within Component @tag Component Instance @param componentClass ComponentClass @return Component? - - 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 - ``` + Retrieves another component bound to the same Roblox instance. ]=] -function Component:GetComponent(componentClass) - return componentClass[KEY_INST_TO_COMPONENTS][self.Instance] +function Component.prototype.GetComponent(self: Instance_Internal, componentClass: Class_Internal): Instance_Internal? + return Keys.class(componentClass).instToComponents[self.Instance] end - --[=[ + @within Component @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 compenent 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 - ``` + @return boolean + Whether the component has fully started. ]=] -function Component:WhileHasComponent(componentClassOrClasses: ComponentClass | {ComponentClass}, fn: (components: Component | {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(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} - - -- 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 janitors for each set of components - local activeJanitors = {} +function Component.prototype.IsStarted(self: Instance_Internal): boolean + return Keys.inst(self).started == true +end - 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 +--[=[ + @within Component + @tag Component Instance + @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.prototype.AddTask( + self: Instance_Internal, + task_: T, + cleanupMethod: (string | boolean)?, + index: unknown? +): T + return Keys.inst(self).janitor:Add(task_, cleanupMethod, index) +end - 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 any component stops, destroy janitor - for i, class in ipairs(componentClasses) do - currentJani:AddPromise(Promise.fromEvent(class.Stopped, function(c) - return c.Instance == self.Instance - end):andThen(function() - currentJani:Destroy() - activeJanitors[self.Instance] = nil - end)) - end - end +--[=[ + @within Component + @tag Component Instance + @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.prototype.AddPromise(self: Instance_Internal, promise: Promise, index: unknown?): Promise + return Keys.inst(self).janitor:AddPromise(promise, index) +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)) +--[=[ + @within Component + @tag Component Instance + @param index any + @param dontClean boolean? + Removes a task from the core Janitor, cleaning it unless `dontClean` is true. +]=] +function Component.prototype.RemoveTask(self: Instance_Internal, index: unknown, dontClean: boolean?) + const janitor = Keys.inst(self).janitor + if dontClean then + janitor:RemoveNoClean(index) + else + janitor:Remove(index) end - - -- Initial check in case all are already present - SetupIfAllPresent() - - return connProxy end --- DEPRECATED: Use WhileHasComponent instead. Kept for backwards compat -function Component:ForEachSibling(...) - warn("ForEachSibling is deprecated. Use WhileHasComponent instead.") - return self:WhileHasComponent(...) +--[=[ + @within Component + @tag Component Instance + @param index any + @return any + Gets a task previously added with an index. +]=] +function Component.prototype.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 @@ -1071,45 +685,30 @@ 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. ]=] -function Component:Destroy() - local idx = table.find(UNSETUP_COMPONENTS, self) +--[=[ + @within Component + @tag Component Class + Destroys the component class: stops all its components (with reason + `"ClassDestroyed"`), disconnects from CollectionService, and clears all state. +]=] +function Component.prototype.Destroy(self: Class_Internal) + const idx = table.find(UNSETUP_COMPONENTS, self) if idx then table.remove(UNSETUP_COMPONENTS, idx) end - self[KEY_TROVE]:Destroy() + + -- Stop watching every instance first, so no new construction begins mid-destroy. + const ci = Keys.class(self) + for instance in ci.watching do + self:_stopWatching(instance) + end + table.clear(ci.watching) + + Lifecycle.DestroyClass(self) end return Component 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/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" 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"