Render Lifecycle Signals
The @threadplane/render library exposes per-context lifecycle signals through the RENDER_LIFECYCLE injection token. The signals are fed by the same RenderEvent stream <render-spec> emits, through three notify* methods on RenderLifecycleService, so there is no double-counting.
Prerequisite: provideRender()
RENDER_LIFECYCLE is provided by provideRender(), and by nothing else. Call it at the application injector (or a feature route's) before injecting the token:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRender } from '@threadplane/render';
export const appConfig: ApplicationConfig = {
providers: [provideRender({ registry })],
};Without provideRender() the token has no provider at all, so inject(RENDER_LIFECYCLE) throws a NullInjectorError. <render-spec> itself injects the backing service optionally, so a spec still renders without the provider -- the signals simply never exist. The scope of the signals follows the provideRender() call: one set per injector that calls it.
Interface
import { InjectionToken, Signal } from '@angular/core';
export interface RenderLifecycle {
/** First mount event in this render context. Sticky — does not reset. */
readonly firstMountAt: Signal<{ kind: 'spec' | 'element'; elementType?: string; at: number } | null>;
/** Total mount count since render context started. */
readonly mountCount: Signal<number>;
/** Epoch ms of the most recent mount event. */
readonly lastMountAt: Signal<number | null>;
/** Epoch ms of the most recent state-change event. */
readonly lastStateChangeAt: Signal<number | null>;
/** Most recent handler invocation. */
readonly lastHandlerInvokedAt: Signal<{ action: string; at: number } | null>;
}
export const RENDER_LIFECYCLE = new InjectionToken<RenderLifecycle>('RENDER_LIFECYCLE');How the signals are fed
RenderLifecycleService does not subscribe to anything. RenderSpecComponent taps every RenderEvent it emits and pushes it into the service, which updates the signals:
| Signal | Pushed by |
|---|---|
firstMountAt | the first RenderLifecycleEvent with event === 'mounted' |
mountCount | every RenderLifecycleEvent with event === 'mounted' |
lastMountAt | every RenderLifecycleEvent with event === 'mounted' |
lastStateChangeAt | every RenderStateChangeEvent |
lastHandlerInvokedAt | every RenderHandlerEvent |
Only mounts are recorded. destroyed lifecycle events are pushed through the same tap but the service ignores them, so nothing decrements and no signal reports teardown. See the Events guide for the full event union.
<render-spec> emits one spec-scope mounted event in ngOnInit, and <render-element> emits one element-scope mounted event per element it mounts. A three-element spec therefore leaves mountCount at 4. The counter only ever climbs: destroyed events are ignored, so a spec that mounts and tears down elements as it streams keeps adding to the total rather than reporting how many elements are on screen right now.
Reading the signals
import { Component, inject, effect } from '@angular/core';
import { RENDER_LIFECYCLE } from '@threadplane/render';
@Component({ /* ... */ })
export class MyComponent {
private lifecycle = inject(RENDER_LIFECYCLE);
constructor() {
effect(() => {
const first = this.lifecycle.firstMountAt();
if (first) {
console.log('First render at', first.at, 'kind:', first.kind);
}
});
}
}All four of the non-sticky signals keep moving in an ordinary application: every store write, every dispatched handler, and every element the renderer mounts updates one of them.
Reset semantics
firstMountAt is sticky for the life of the render context -- once set, it does not reset. The remaining four signals update on every relevant event, and none of them reset when a spec is replaced or destroyed.