@flamework-experimental/core 2.0.0-alpha.3 → 2.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,423 @@
1
+ # 4. Lifecycle events
2
+
3
+ Lifecycle events are methods, such as `onStart` and `onTick`, that Flamework calls at set points:
4
+ on your providers, and also on components and on objects you attach by hand (both covered below).
5
+ They come from `LifecyclePlugin`, which every module includes from the start:
6
+
7
+ ```ts
8
+ Flamework.createModule()
9
+ .registerProviders("src/server/services")
10
+ .ignite();
11
+ ```
12
+
13
+ It is an ordinary plugin with no special access. `disableDefaultLifecycle()` on the builder leaves
14
+ it out, for a module that wants no per-frame work at all. Including one built with
15
+ `createLifecyclePlugin({ … })` replaces it, rather than adding a second.
16
+
17
+ ## The events
18
+
19
+ Implement the interface, and the plugin finds your class.
20
+
21
+ ```ts
22
+ import { OnStart, OnTick, Provider } from "@flamework-experimental/core";
23
+
24
+ @Provider()
25
+ export class Spawner implements OnStart, OnTick {
26
+ public onStart() {
27
+ print("ignited");
28
+ }
29
+
30
+ public onTick(dt: number) {
31
+ // every frame, after physics
32
+ }
33
+ }
34
+ ```
35
+
36
+ | Interface | Method | Fires on |
37
+ |---|---|---|
38
+ | `OnInit` | `onInit()` | Once, during ignition, in dependency order and then `loadOrder`, before any `onStart`. May return a Promise. |
39
+ | `OnStart` | `onStart()` | Once, at the end of ignition, in `loadOrder`. |
40
+ | `OnTick` | `onTick(dt)` | `RunService.Heartbeat` |
41
+ | `OnPhysics` | `onPhysics(dt, time)` | `RunService.PreSimulation`; `time` is the elapsed game time. |
42
+ | `OnRender` | `onRender(dt)` | `RunService.PreRender` -- client only |
43
+ | `OnExtinguished` | `onExtinguished()` | `module.extinguish()` |
44
+
45
+ The three per-frame events repeat in a fixed cycle: `onPhysics`, then `onTick`, then `onRender`, then
46
+ `onPhysics` again. This follows Roblox's task scheduler: `PreSimulation` fires before the physics
47
+ simulation, `Heartbeat` after it, and, on the client, `PreRender` before the frame is rendered.
48
+
49
+ In a running game, `Heartbeat` fires at the same point of the frame as `PostSimulation`. Flamework
50
+ uses `Heartbeat` because it also fires where nothing is simulated, such as an edit-mode plugin or an
51
+ Open Cloud task, so `onTick` keeps working there. `PreSimulation` has no such alias, so `onPhysics`
52
+ does not fire in those environments.
53
+
54
+ There is nothing to register. Flamework checks each constructed object against the interfaces that
55
+ plugins have claimed. The check goes by name, not by shape: a class matches the interfaces listed in
56
+ its `implements` clause, which the transformer records as metadata on the class. A class that only
57
+ has the method, without `implements OnTick`, is not matched. The metadata is why the class must
58
+ carry a Flamework decorator for this to work at all. A parent class's `implements` clause counts
59
+ too, but only if the parent carries a Flamework decorator as well. See
60
+ [Plugins](08-plugins.md#observing-interfaces).
61
+
62
+ ## `onInit` in detail
63
+
64
+ `onInit` is the setup step. It runs in order, and it can be awaited. It runs once per provider,
65
+ after every provider has been constructed, **in dependency order**, and everything after it waits
66
+ for it:
67
+
68
+ ```ts
69
+ @Provider()
70
+ class Database implements OnInit {
71
+ public async onInit() {
72
+ await this.connect(); // the next provider's onInit waits for this
73
+ }
74
+ }
75
+ ```
76
+
77
+ A rejected Promise fails ignition with `onInit failed for '<id>': <reason>`. Because `onInit` blocks,
78
+ use it only for setup that other providers really depend on. Anything else belongs in `onStart`.
79
+
80
+ ### Across an import
81
+
82
+ Most of this section only matters when one module imports another (see
83
+ [Modules](02-modules.md#importing-a-module)).
84
+
85
+ A provider can take a lazy provider of a module it imports while that lazy provider is still running
86
+ its `onInit`. This happens in two ways:
87
+
88
+ - Nothing had resolved the lazy provider until this provider's constructor did. The import then
89
+ initialises it on a *turn* of its own: a separate batch in which the lifecycle plugin runs the
90
+ `onInit`s, then the `onStart`s, of lazy providers resolved too late to join ignition's own
91
+ `onInit` step.
92
+ - It was resolved earlier and is still loading.
93
+
94
+ The order is kept for what a constructor takes **directly**. The provider's `onInit` waits for the
95
+ lazy provider's `onInit` to finish, and the ignition waits with it, as it does for an `onInit` that
96
+ yields. A provider without an `onInit` waits the same way, before its `onStart` and per-frame events.
97
+ Providers that take nothing still initialising do not wait.
98
+
99
+ ```ts
100
+ // in the game module
101
+ @Provider({ lazy: true })
102
+ class GameStore implements OnInit {
103
+ public async onInit() {
104
+ await this.load();
105
+ }
106
+ }
107
+
108
+ // in a module ignited per player, importing the game module
109
+ @Provider()
110
+ class PlayerData implements OnInit {
111
+ constructor(private store: GameStore) {}
112
+
113
+ public onInit() {
114
+ // GameStore's onInit has finished
115
+ }
116
+ }
117
+ ```
118
+
119
+ The wait is not transitive: Flamework does not follow a provider in between that has no pending
120
+ `onInit` of its own. Say `PlayerInventory` takes `InventoryService`, which has no `onInit` and takes
121
+ the loading `DataStore`. Then `PlayerInventory` does not wait for `DataStore`. There are two fixes:
122
+
123
+ - Give `InventoryService` an `onInit`. An empty one will do: `InventoryService` then waits for
124
+ `DataStore`, and `PlayerInventory` waits for `InventoryService`.
125
+ - Have `PlayerInventory` take `DataStore` directly.
126
+
127
+ ```ts
128
+ @Provider({ lazy: true })
129
+ class InventoryService implements OnInit {
130
+ constructor(private data: DataStore) {}
131
+
132
+ public onInit() {} // makes whatever takes this service wait for DataStore too
133
+ }
134
+ ```
135
+
136
+ If the import begins to extinguish during the wait, the ignition fails without running that `onInit`
137
+ (`'<id>' takes a provider of a module that was extinguished while this module was igniting`).
138
+
139
+ The wait happens wherever the ignition runs, including in Promise work (a profile load's `andThen`,
140
+ an `async` handler).
141
+
142
+ **The one exception: an import's own `onInit` that ignites this module.** This applies when the
143
+ `onInit` ignites the module before it yields, or from a thread it started and has not got back
144
+ from. The ignition then does not wait for:
145
+
146
+ - what that `onInit` is itself initialising, since the `onInit` cannot finish before the ignition
147
+ does;
148
+ - a lazy provider of the import first resolved there, which joins the `onInit`'s turn.
149
+
150
+ After the `onInit` yields (an `async` `onInit` after an `await`, or a Promise callback), Flamework
151
+ cannot tell such an ignition apart from one started by unrelated Promise work, so it waits. A module
152
+ that takes a provider whose `onInit` ignites it, or a provider that joins that `onInit`'s turn, then
153
+ waits for itself. If such a wait lasts more than a few seconds, Flamework warns once, naming the
154
+ provider that waits and the one it waits for. The warning can also come for an ordinary wait on a
155
+ load that takes that long. Ignite such a module from `onStart` or a `PlayerAdded` handler instead.
156
+
157
+ ## `onStart` in detail
158
+
159
+ `onStart` runs once per provider, at the end of ignition, **on its own thread**. Two consequences:
160
+
161
+ - **It may yield.** `task.wait`, `WaitForChild` and network calls are fine. They do not stop other
162
+ providers from starting.
163
+ - **Order between providers is their `loadOrder`**, and otherwise the order they were constructed in.
164
+ Each one starts on its own thread, so one runs up to its first yield before the next one starts,
165
+ and nothing waits for one that yields. If a provider needs another one *initialised*, inject it:
166
+ the injected provider's `onInit` always finishes first.
167
+
168
+ Constructors run during ignition, in dependency order, and must **not** yield. A yielding
169
+ constructor stalls ignition.
170
+
171
+ ```ts
172
+ @Provider()
173
+ class Matchmaker implements OnStart {
174
+ // runs first, synchronously, in dependency order
175
+ constructor(private economy: Economy) {}
176
+
177
+ // runs last, on its own thread, in loadOrder
178
+ public onStart() {}
179
+ }
180
+ ```
181
+
182
+ ## Load order
183
+
184
+ `@Provider({ loadOrder })` orders `onInit` and `onStart` among the providers one ignition constructs.
185
+ Lower values go first. The default is `1`, as in v1.
186
+
187
+ - **Construction and `onInit`.** The module constructs its providers in ascending `loadOrder`, each
188
+ one after what its constructor takes, and `onInit` runs in that order. Dependency order wins: a
189
+ provider's dependencies are initialised before it, even when their `loadOrder` is higher. So a low
190
+ `loadOrder` pulls what the provider needs forward with it. Providers with the same `loadOrder` keep
191
+ their registration order.
192
+ - **`onStart`** runs in ascending `loadOrder` alone, whatever the dependencies. Among providers with
193
+ the same `loadOrder`, the order is the same as for `onInit`. Each one runs on its own thread up to
194
+ its first yield before the next one starts. So the synchronous setup of a lower `loadOrder` is done
195
+ before a higher one begins, which is what v1 did.
196
+ - **Per-frame events** (`onTick`, `onPhysics`, `onRender`) are not ordered. The listener set is
197
+ unordered, and sorting it would cost time every frame.
198
+ - **Lazy providers** are not part of the order. A lazy provider starts when it is first resolved,
199
+ and its `loadOrder` is ignored.
200
+ - **One module at a time.** An imported module ignites, and starts, before the module that imports
201
+ it, whatever their `loadOrder`s.
202
+
203
+ ```ts
204
+ @Provider({ loadOrder: 10 })
205
+ class Heavy implements OnInit, OnStart {
206
+ public onInit() {}
207
+ public onStart() {}
208
+ }
209
+
210
+ @Provider({ loadOrder: 0 })
211
+ class Needy implements OnInit, OnStart {
212
+ constructor(private heavy: Heavy) {}
213
+
214
+ public onInit() {} // after Heavy's: it needs Heavy initialised
215
+ public onStart() {} // first
216
+ }
217
+
218
+ @Provider()
219
+ class Plain implements OnInit, OnStart {
220
+ public onInit() {}
221
+ public onStart() {}
222
+ }
223
+
224
+ // onInit: Heavy, Needy, Plain
225
+ // onStart: Needy, Plain, Heavy (loadOrder alone)
226
+ ```
227
+
228
+ ## Ad-hoc listeners
229
+
230
+ For something that is not a provider, such as a UI component or a temporary system, `module.listen`
231
+ attaches a listener. It returns a function that detaches the listener again.
232
+
233
+ ```ts
234
+ // Full form: an object implementing the interface
235
+ const stop = module.listen<OnTick>({
236
+ onTick(dt) {
237
+ print(dt);
238
+ },
239
+ });
240
+
241
+ // Shorthand: a bare function, for single-method interfaces
242
+ const stop = module.listen<OnTick>((dt) => print(dt));
243
+
244
+ stop();
245
+ ```
246
+
247
+ `listen` attaches *after* ignition, so `onStart` is **not** replayed for a listener attached with it.
248
+ Per-frame events start at once.
249
+
250
+ The same applies to anything built with `createClassInstance`. It is attached to the lifecycle
251
+ events it implements, and detached by `removeClassInstance` or when the module extinguishes. Once
252
+ detached, it gets no further events, `onExtinguished` included. So an instance that an earlier
253
+ `onExtinguished` handler removes is not told.
254
+
255
+ `onInit` and `onStart` belong to providers. The plugin never runs them for an instance, before or
256
+ after ignition. Whoever created the instance is in charge of initialising and starting it.
257
+
258
+ ## Lazy providers
259
+
260
+ A [lazy provider](03-providers.md#lazy-providers) is different from an instance: it is a provider.
261
+ So when it is first resolved after ignition, the plugin runs its `onInit` and then its `onStart`, at
262
+ the next resume point.
263
+
264
+ The plugin runs lazy providers in *turns*. A turn works the way ignition does for eager providers:
265
+ it runs every `onInit` in the order the providers were resolved, each one finished before the next
266
+ begins, and then every `onStart`. The cases below say which turn a lazy provider joins, what it
267
+ waits for, and what happens when something goes wrong:
268
+
269
+ - **Resolved together.** A lazy provider and the lazy providers its constructor takes share a turn,
270
+ a dependency first.
271
+ - **Resolved by one of the turn's `onInit`s.** It joins that turn, whether the `onInit` is sync or
272
+ `async`, and before or after the `onInit` yields. That covers a lazy provider resolved:
273
+ - on the `onInit`'s own thread;
274
+ - on a thread the `onInit` started and has not yet got back from;
275
+ - while a Promise the `onInit` returned is pending, on a thread running Promise work (an `async`
276
+ body, a Promise executor, an `andThen` callback). This counts any Promise's work, since
277
+ Flamework cannot tell which Promise a thread works for.
278
+ - **Resolved anywhere else meanwhile.** It gets its own turn. A thread that an `onInit` spawned
279
+ counts as "anywhere else" once it has yielded. Its `onInit` (or, without one, its `onStart` and
280
+ per-frame events) waits only for the `onInit`s still running of the providers its constructor
281
+ takes directly. So if another turn is still initialising one of those dependencies, this provider
282
+ is initialised once that dependency's `onInit` has finished, and never sees it half-initialised.
283
+ The wait is not transitive; see [Across an import](#across-an-import).
284
+ - **Waiting in a circle.** A dependency that is itself waiting for what depends on it hangs both, as
285
+ with eager providers.
286
+ - **An `onInit` that raises.** The error is reported, and that provider never ticks and is never
287
+ started. The providers after it, or waiting for it, carry on.
288
+ - **Resolved while the module is still igniting**, by a plugin's `onPostIgnite` hook that runs after
289
+ the lifecycle plugin's. It waits for ignition to finish, and gets neither `onInit` nor `onStart` if
290
+ the ignition fails. Its per-frame events wait for that too: it does not tick before its `onInit`
291
+ has finished.
292
+ - **Once the module has begun to extinguish**, neither `onInit` nor `onStart` runs for a lazy
293
+ provider that has not had them yet, whenever it was resolved. One first resolved by an
294
+ `onExtinguished` handler is told `onExtinguished`, and that is all it hears.
295
+
296
+ ## Components
297
+
298
+ Components are constructed through the module that includes `ComponentPlugin`. So they get their
299
+ per-frame events from **that module's** lifecycle plugin: the default one, unless the module
300
+ disabled it, in which case components do not tick. `onInit` and `onStart` are the exceptions:
301
+ `Components` calls both itself, so they work either way.
302
+
303
+ - `onInit` runs synchronously, right after construction, before the component can be seen anywhere:
304
+ before `getComponent` hands it back, before another component receives it through a link, and
305
+ before an added listener hears of it. A Promise it returns is not awaited. If it raises, the
306
+ component stays in place but is invalid, hidden from everything until the tracker rebuilds it.
307
+ - `onStart` runs on its own thread once the component is attached, and not before ignition has
308
+ finished. So a component built from a provider's `onInit` starts once every provider has started.
309
+
310
+ **Tagged instances get their components after the providers start.** The component plugin starts
311
+ watching tags once the module has ignited, in its `onIgnited` hook, which runs after the lifecycle
312
+ plugin has started the providers: every provider's `onStart` has been called, whatever its
313
+ `loadOrder`, and has run up to its first yield. So in a provider's `onStart`:
314
+
315
+ - `getAllComponents<T>()` and `getComponents<T>(instance)` find none of the instances tagged before
316
+ ignition (`getComponent` still builds one on demand);
317
+ - `onComponentAdded<T>(cb)` hears about each of them as it is built. It never replays components
318
+ that already exist, so connect it there, and read `getAllComponents<T>()` first only when you
319
+ subscribe later: after a yield, from a lazy provider, or from an event handler.
320
+
321
+ See [Components](05-components.md#lifecycle).
322
+
323
+ ## Profiling
324
+
325
+ In Studio, every per-frame callback runs under `debug.profilebegin` and `debug.setmemorycategory`,
326
+ labelled with the provider's id. So providers show up by name in the MicroProfiler and the memory
327
+ view. To force profiling on or off, build the plugin with options instead of using the default:
328
+
329
+ ```ts
330
+ import { createLifecyclePlugin } from "@flamework-experimental/core";
331
+
332
+ Flamework.createModule()
333
+ .includePlugin(createLifecyclePlugin({ profiling: false }))
334
+ .ignite();
335
+ ```
336
+
337
+ Including a configured plugin replaces the default, so the module still runs exactly one. The
338
+ project-wide default is `core.profiling` in `flamework.config.json`. This option overrides it for one
339
+ module.
340
+
341
+ The id each object is profiled under is looked up once, and remembered until the object leaves its
342
+ last lifecycle event. So components that come and go leave nothing behind.
343
+
344
+ ## Asking what is attached
345
+
346
+ The plugin provides its `LifecycleProvider`, so you can ask a module what it is currently running:
347
+
348
+ ```ts
349
+ import { LifecycleProvider } from "@flamework-experimental/core";
350
+
351
+ const lifecycle = module.resolveDependency<LifecycleProvider>();
352
+ print(lifecycle.onTick.size(), "objects are ticking");
353
+ ```
354
+
355
+ `onStart`, `onTick`, `onPhysics`, `onRender` and `onExtinguished` are the live sets, one per module.
356
+ They are there to be read. The plugin fills and empties them from the interfaces a class implements.
357
+ To attach something by hand, use `listen`.
358
+
359
+ ```ts
360
+ @Injectable()
361
+ class Countdown implements OnTick {
362
+ public onTick(dt: number) {}
363
+ }
364
+
365
+ const countdown = module.createClassInstance(Countdown); // starts ticking
366
+ module.removeClassInstance(countdown); // stops
367
+ ```
368
+
369
+ ## Patterns
370
+
371
+ **Constructor for wiring, `onStart` for work.** Take your dependencies in the constructor and do
372
+ nothing else there. Put anything that yields, waits on replication or touches the world in
373
+ `onStart`.
374
+
375
+ **Keep `OnRender` on the client.** `PreRender` does not fire on the server, so a provider
376
+ implementing `OnRender` does nothing there. Still, it is clearer to register it only in the client
377
+ module.
378
+
379
+ **Clean up in `OnExtinguished`.** If a provider opens connections that outlive it (signals, threads,
380
+ Instances), close them there. Then a module that extinguishes leaves nothing behind.
381
+
382
+ ```ts
383
+ @Provider()
384
+ class Broadcaster implements OnExtinguished {
385
+ private connection = someSignal.Connect(() => {});
386
+
387
+ public onExtinguished() {
388
+ this.connection.Disconnect();
389
+ }
390
+ }
391
+ ```
392
+
393
+ **One listener, many objects.** If you have hundreds of short-lived objects that need a tick, use one
394
+ provider that loops over them rather than hundreds of `listen` calls.
395
+
396
+ ## Caveats
397
+
398
+ - **`disableDefaultLifecycle()` is silent.** Nothing warns you that `onStart` never ran.
399
+ - **One lifecycle plugin per module.** Including a configured one on the builder replaces the
400
+ default. A plugin that includes a second one is refused at ignition, so include it on the module
401
+ instead.
402
+ - **Per-frame events are unordered.** `loadOrder` orders `onInit` and `onStart` only; the per-frame
403
+ listener set is unordered.
404
+ - **`listen` does not replay `onStart`.** It attaches from that moment on.
405
+ - **Extinguishing disconnects everything.** The plugin disconnects its `RunService` connections and
406
+ releases the providers, so a module that has been extinguished stops ticking. (This was once a
407
+ bug; a spec covers it now.) Nothing ticks or starts from the moment `extinguish()` is called. That
408
+ includes the time while the modules importing it go down first, whose `onExtinguished` handlers
409
+ may yield.
410
+ - **A failing `onExtinguished` does not abort extinguish.** Flamework warns about it, and the
411
+ remaining handlers still run, so the module cannot get stuck half-extinguished. The same goes for
412
+ a plugin's extinguished hook.
413
+ - **A failing ignition is extinguished.** An error raised during ignition (in a constructor, an
414
+ `onInit` or a plugin's hook) first runs the extinguished hooks for what had been set up, so nothing
415
+ keeps ticking. Then the error comes out of `ignite()`.
416
+ - **`onInit` blocks.** A yielding `onInit` delays every provider after it. A rejected Promise fails
417
+ ignition.
418
+ - **A yielding constructor stalls ignition**, because construction is synchronous. Yield in
419
+ `onStart`.
420
+
421
+ ---
422
+
423
+ Previous: [Providers](03-providers.md) · Next: [Components](05-components.md)