Page actions

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:

SignalPushed by
firstMountAtthe first RenderLifecycleEvent with event === 'mounted'
mountCountevery RenderLifecycleEvent with event === 'mounted'
lastMountAtevery RenderLifecycleEvent with event === 'mounted'
lastStateChangeAtevery RenderStateChangeEvent
lastHandlerInvokedAtevery 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.

Note: mountCount counts mounts, not live elements

<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.

Next Steps

Looking for something specific?