Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0705fab
Update file type from lua to luau and split out WhileHasComponents me…
Raild3x Sep 24, 2025
384b361
Merge branch 'main' into fix/WhileHasComponent
Raild3x Dec 1, 2025
ec47f10
Add detailed documentation and robust lifecycle handling
Raild3x Dec 1, 2025
6c887ae
Merge branch 'main' into fix/WhileHasComponent
Raild3x Dec 1, 2025
f1d98db
Merge branch 'main' into fix/WhileHasComponent
Raild3x Dec 2, 2025
d6d7275
Improve component construction error handling and validation
Raild3x Dec 2, 2025
ba091b5
Merge branch 'main' into fix/WhileHasComponent
Raild3x Dec 3, 2025
d1b4e84
Refactor component construction to use cancellable Promise
Raild3x Dec 3, 2025
a8cc1ca
Merge branch 'main' into fix/WhileHasComponent
Raild3x Jul 22, 2026
e8f7b09
Component Rework pt1
Raild3x Jul 24, 2026
383da7e
Refactor component lifecycle and query engine
Raild3x Jul 24, 2026
7b2d0ca
Optimize Query engine and immutable builders
Raild3x Jul 24, 2026
55e7407
Refactor lifecycle protocol into Lifecycle module
Raild3x Jul 24, 2026
d1597d9
Inline component registry into lifecycle
Raild3x Jul 24, 2026
65b1d00
Optimize query matching with started sparse set
Raild3x Jul 24, 2026
6080b1d
Add Query contains/count/first terminals
Raild3x Jul 24, 2026
d74812c
Add withProperty and observeUnyielding to Query
Raild3x Jul 24, 2026
150cdd3
Add Query:track API and tests
Raild3x Jul 24, 2026
725e3f4
Change component to use a prototype for its __index
Raild3x Jul 24, 2026
e6e461b
Flatten Query Logic
Raild3x Jul 24, 2026
769edff
Reformatted Query
Raild3x Jul 25, 2026
e6df5d7
Improved coverage and removed dead code
Raild3x Jul 25, 2026
24c6da4
Tests for edge cases
Raild3x Jul 25, 2026
992d681
Fix moonwave doc placement and tags
Raild3x Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"types/tiniest_lib.d.luau"
],
"luau-lsp.fflags.override": {
"DebugLuauTimeTracing": "false"
"DebugLuauTimeTracing": "false",
"LuauSolverV2": "true"
},

"selene.selenePath": "",
Expand Down
4 changes: 4 additions & 0 deletions CONTEXT-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
116 changes: 116 additions & 0 deletions lib/component/src/Docs/CO_Extensions.luau
Original file line number Diff line number Diff line change
@@ -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.
]=]
120 changes: 120 additions & 0 deletions lib/component/src/Docs/CO_Getting_Started.luau
Original file line number Diff line number Diff line change
@@ -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`.
]=]
117 changes: 117 additions & 0 deletions lib/component/src/Docs/CO_Lifecycle_And_Cleanup.luau
Original file line number Diff line number Diff line change
@@ -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.
]=]
Loading
Loading