Angular — islands

@atolljs/angular-island — a worker-hosted tree mounted as an ordinary element in a Angular shell. The worker's render loop produces serialized DOM ops; the main thread just replays them.

Live demo — Angular in the worker

Four islands, Angular rendered inside the workers: a counter, a second counter instance, a notes composer, and a 1,000,000-record incident benchmark — the two counters share ONE baked-in client — both mounts live in a single worker (counter@N keys, one OS thread); notes and incidents bake the worker shorthand, so each facade owns its worker. The shell is just a thin islandComponent host from @atolljs/angular-island that replays their ops.

The incident benchmark is the real-world case for offloading a heavy component. One million incidents exist as lazily generated logical rows — the component is a fixed-height virtualized scroller that renders only the ~22 visible rows plus overscan, no matter where you scroll. Each scroll event crosses the island protocol as a structured payload (the driver stamps scrollTop), Angularre-computes the window worker-side, and the commit rides back as an op batch — the stats line reports the worker-side re-render time, and a rendered emit updates the shell's status line. The main thread never touches more than a handful of DOM nodes; a million rows of state, generation, and diffing all stay off it.

If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.

The Angular API

ExportSignatureWhat it does
islandComponentislandComponent<C>({ app, client|worker, selector?, mode? }): Type<Component>The facade — generates a standalone component (&lt;counter-island&gt;) typed against the worker component class via import type. [props] accepts IslandInputs&lt;C&gt;; onEvent narrows per the output()/model() fields.
AngularIsland@AngularIsland | @AngularIsland('name') | @AngularIsland({ name?, providers? })Worker-side class decorator — stamps the registry name (default: kebab-cased class name minus Component) and registers the component, so defineAngularPolyWorker() collects the whole set with no apps map.
IslandInputs / IslandEvents / IslandEventHandlerIslandInputs<C> · IslandEvents<C> · IslandEventHandler<C>Type-only inference from the component class — input()/model() write-types become the props contract; output()/model() payload types become the event map.
AtollIslandComponent / AtollIslandDirective<atoll-island [client|worker] [app] [props] [mode] [onEvent]/> · <div atollIsland …/>The low-level surface — generic (AtollIslandComponent&lt;C&gt;) so [app] accepts the stamped component class and props/events infer. worker/workerOptions/mode inputs cover the no-shared-client case.
defineAngularPolyWorkerdefineAngularPolyWorker() | ({ apps: [C,…] }) | ({ apps: { name: C } })Registry worker — no-arg collects every @AngularIsland in the module graph; the array form names entries by stamp/kebab-cased class name; the record form is unchanged. Renderer2/RendererFactory2 over the proxy document; components mount via createComponent.
defineAngularMonoWorkerdefineAngularMonoWorker(Component)Instance worker — the 1:1 topology, mounted namelessly.

Mounting — the shell is a thin Angular host

Every island below mounts against a pre-connected connectIslandWorker({ worker, doorbell }) client. Worker emits land in onEvent → shell state → the status line; props flow the other way — [props] is typed as IslandInputs<C> — the worker component's own input()/model() fields — so a wrong key or type is a template compile error.

examples/react-dom-worker/src/angular-shell.tsfrontend
/**
 * The Angular islands demo — ANGULAR RUNNING INSIDE WORKERS.
 *
 * Each island below mounts an `@AngularIsland` component from
 * `worker/angular.worker.ts`'s decorator registry: standalone @Component
 * classes (signal inputs + (click) bindings + output() events) rendered
 * against the proxy DOM. The worker bundle carries Angular; this page is
 * a thin host that replays its ops.
 *
 * What changes versus shell.tsx:
 *   - The shell itself is also Angular — each island is a generated
 *     `islandComponent` facade (`<counter-island [props]/>`), typed
 *     against the WORKER component's class via `import type`: props come
 *     from its input() fields, events from its output() fields, and no
 *     worker code reaches this bundle.
 *   - `client`/`worker` are baked into the facade — the two counters
 *     deliberately share ONE client so they live in the same OS worker;
 *     notes/incidents use the `worker` shorthand (one worker each).
 *   - JIT + zoneless: `import '@angular/compiler'` compiles decorator
 *     templates at runtime, `provideZonelessChangeDetection` schedules
 *     change detection off signal writes — no zone.js.
 *
 * The seven-island React demo lives in index.html / react-shell.html —
 * this page is the small framework-island edition from the consumer docs.
 */
// The JIT compiler — required for @Component templates at runtime.
import '@angular/compiler';
import { Component, computed, provideZonelessChangeDetection, signal } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import {
  islandComponent,
  type IslandEventHandler,
  type IslandInputs,
} from '@atolljs/angular-island';
import { connectIslandWorker } from '@atolljs/islands';
import type { IslandHandle, Mode } from '@atolljs/islands';
// import type — the component classes carry the props/events contract; the
// worker module (and the Angular bundle it pulls) never enters this chunk.
import type {
  CounterComponent,
  IncidentsComponent,
  NotesComponent,
} from './worker/angular.worker';

/** The registry worker — one script serving all Angular apps. */
const angularWorker = (): Worker =>
  new Worker(new URL('./worker/angular.worker.ts', import.meta.url), { type: 'module' });

// SharedArrayBuffer only exists in cross-origin-isolated contexts — on
// hosts without COOP/COEP (GitHub Pages where coi-sw.js didn't take, or a
// browser without `credentialless`) the doorbell can't bind, so every
// island runs its 50ms poll transport instead of push.
const isolated =
  typeof SharedArrayBuffer !== 'undefined' &&
  (typeof window.crossOriginIsolated === 'undefined' || window.crossOriginIsolated);
const initialMode: Mode = isolated ? 'push' : 'poll';

/* ── Island facades ───────────────────────────────────────────────────────
 * `islandComponent<C>` generates a standalone component whose inputs are
 * the island surface: [props] types as IslandInputs<C> (the worker
 * component's input()/model() fields), onEvent as IslandEventHandler<C>
 * (its output()/model() fields). `app`/`client`/`worker` bake in as
 * defaults — the template call sites below carry only per-instance data.
 */
const counterClient = connectIslandWorker({ worker: angularWorker, doorbell: isolated });

const CounterIsland = islandComponent<CounterComponent>({
  app: 'counter',
  // Both <counter-island> mounts share this client — two 'counter@N'
  // instances in ONE OS worker.
  client: counterClient,
  selector: 'counter-island',
});
const NotesIsland = islandComponent<NotesComponent>({
  app: 'notes',
  worker: angularWorker, // shorthand — the facade owns its worker
  selector: 'notes-island',
});
const IncidentsIsland = islandComponent<IncidentsComponent>({
  app: 'incidents',
  // The 1M-row benchmark gets a dedicated worker — the whole point is the
  // heavy component never contends with anything else.
  worker: angularWorker,
  selector: 'incidents-island',
});

/* ── Shell ──────────────────────────────────────────────────────────────── */

@Component({
  selector: 'atoll-shell',
  standalone: true,
  imports: [CounterIsland, NotesIsland, IncidentsIsland],
  template: `
    <h1>Angular islands — Angular in the worker</h1>
    <p style="font: 12px monospace; color: #9aa4b2; margin-top: -8px">
      decorated worker components + generated facade components — props and
      events are typed off the worker classes' own input()/output() fields.
      <a href="./index.html" style="color: #7fb6ff">framework-free shell →</a>
    </p>

    <div id="transport-bar">
      <span>transport:</span>
      <label
        id="transport-toggle"
        [title]="isolated ? 'push via SharedArrayBuffer doorbell — off falls back to 50ms polling' : 'needs cross-origin isolation (no SharedArrayBuffer)'"
      >
        <input
          id="push-toggle"
          type="checkbox"
          [checked]="mode() === 'push'"
          [disabled]="!isolated"
          (change)="mode.set(mode() === 'push' ? 'poll' : 'push')"
        />
        push (SAB doorbell)
      </label>
      <span id="transport-stats">{{ stats() }}</span>
    </div>
    <div id="status-line">{{ status() }}</div>

    <section class="island">
      <div class="island-head"><span>counter-island (Angular bootstrap)</span><span class="badge">{{ pids()['counter'] ?? 'worker …' }}</span></div>
      <counter-island
        class="island-root"
        [props]="alphaProps"
        [mode]="mode()"
        [onReady]="onReady('counter')"
        [onActivity]="bump"
        [onEvent]="onCounterEvent"
      />
    </section>

    <section class="island">
      <div class="island-head"><span>counter-island — second instance (SAME worker as the first, one client)</span><span class="badge">{{ pids()['counter2'] ?? 'worker …' }}</span></div>
      <counter-island
        class="island-root"
        [props]="betaProps"
        [mode]="mode()"
        [onReady]="onReady('counter2')"
        [onActivity]="bump"
        [onEvent]="onCounterEvent"
      />
    </section>

    <section class="island">
      <div class="island-head"><span>notes-island (own worker, same script)</span><span class="badge">{{ pids()['notes'] ?? 'worker …' }}</span></div>
      <notes-island
        class="island-root"
        [props]="notesProps"
        [mode]="mode()"
        [onReady]="onReady('notes')"
        [onActivity]="bump"
        [onEvent]="onNotesEvent"
      />
    </section>

    <section class="island">
      <div class="island-head"><span>incidents-island — 1,000,000 rows, virtualized (own worker)</span><span class="badge">{{ pids()['incidents'] ?? 'worker …' }}</span></div>
      <incidents-island
        class="island-root"
        [mode]="mode()"
        [onReady]="onReady('incidents')"
        [onActivity]="bump"
        [onEvent]="onIncidentsEvent"
      />
    </section>
  `,
})
class ShellComponent {
  readonly isolated = isolated;

  /* Props typed off the worker components' input() fields. */
  readonly alphaProps: IslandInputs<CounterComponent> = { label: 'alpha' };
  readonly betaProps: IslandInputs<CounterComponent> = { label: 'beta' };
  readonly notesProps: IslandInputs<NotesComponent> = { title: 'angular island' };

  /* Mediation state — worker emits → signals → template bindings. */
  readonly status = signal('mounting islands…');
  readonly mode = signal<Mode>(initialMode);
  readonly pids = signal<Record<string, string>>({});
  /** Bump counter — CD trigger for the aggregate stats read. */
  readonly statsTick = signal(0);

  /** Every mounted island's handle — the aggregate stats read them. */
  private readonly handles = new Map<string, IslandHandle>();

  readonly stats = computed(() => {
    this.statsTick(); // tracked — recompute after every op batch
    const flushes = [...this.handles.values()].reduce((a, i) => a + i.flushCalls, 0);
    const ops = [...this.handles.values()].reduce((a, i) => a + i.opsApplied, 0);
    return `sync: ${this.mode()} · flush calls: ${flushes} · ops applied: ${ops}`;
  });

  readonly bump = (): void => {
    this.statsTick.update((t) => t + 1);
  };

  readonly onReady = (key: string) => (handle: IslandHandle): void => {
    this.handles.set(key, handle);
    this.pids.update((p) => ({ ...p, [key]: `worker ${handle.pid}` }));
    this.bump();
  };

  /* Event handlers typed off the worker components' output()/model()
   * fields — 'incremented' narrows payload to { count, label }. */
  readonly onCounterEvent: IslandEventHandler<CounterComponent> = (name, payload) => {
    if (name === 'incremented')
      this.status.set(`${payload.label} counter → ${payload.count} (Angular state stayed in the worker)`);
  };

  readonly onNotesEvent: IslandEventHandler<NotesComponent> = (name, payload) => {
    if (name === 'noteAdded')
      this.status.set(`notes island emitted noteAdded → "${payload.text}" (${payload.total} total)`);
  };

  readonly onIncidentsEvent: IslandEventHandler<IncidentsComponent> = (name, payload) => {
    if (name === 'rendered')
      this.status.set(`incidents island rendered rows ${payload.start.toLocaleString()}–${payload.end.toLocaleString()} in ${payload.ms.toFixed(1)}ms (of 1,000,000)`);
  };
}

const rootEl = document.getElementById('root');
if (rootEl) {
  const host = document.createElement('atoll-shell');
  rootEl.appendChild(host);
  void bootstrapApplication(ShellComponent, {
    providers: [provideZonelessChangeDetection()],
  });
}

The worker side — Angular running in the worker

The worker bundle carries Angular itself — the framework's own renderer drives the proxy DOM and state stays worker-side.defineAngularPolyWorker serves the whole apps map from one script; islands mount by registry name. See the Islands page for modes, slots, and lifecycle.

examples/react-dom-worker/src/worker/angular.worker.tsfrontend
/**
 * Angular island worker — a registry worker serving JIT decorator
 * components. Each island is a standalone `@Component` marked with
 * `@AngularIsland`: the decorator stamps the registry name (derived from
 * the class name — `CounterComponent` → 'counter') and registers the class
 * so `defineAngularPolyWorker()` collects the whole set with no `apps` map.
 *
 * The component's own signal API IS the island contract:
 *  - `input()`/`model()` fields are the props the shell's `[props]` sends;
 *  - `output()`/`model()` fields bridge onto the island's emit channel —
 *    `incremented.emit(n)` reaches the shell as onEvent('incremented', n),
 *    no manual `emit()` (or instance re-entry bookkeeping) required.
 *
 * `import '@angular/compiler'` is required — vite doesn't AOT-compile this
 * example, so the JIT compiler must ship in the worker bundle. Zoneless
 * change detection is the default in the island bootstrap.
 */
import '@angular/compiler';
import { Component, computed, input, output, signal, afterEveryRender } from '@angular/core';
import { AngularIsland, defineAngularPolyWorker } from '@atolljs/angular-island/worker';

/**
 * 'counter' — the docs' canonical Angular island: a `label` signal input,
 * a `signal` count, a `(click)` binding, and an 'incremented' OUTPUT for
 * the shell's status line — the declared output is the island's event.
 *
 * NOTE: pass the registry key explicitly — `@AngularIsland` bare derives
 * it from the class name, which minification mangles in production builds.
 */
@AngularIsland('counter')
@Component({
  selector: 'demo-counter',
  template: `
    <div class="angular-counter">
      <span class="vanilla-heading">{{ label() }}: {{ count() }}</span>
      <button class="mw-btn" (click)="increment()">increment</button>
    </div>
  `,
})
export class CounterComponent {
  readonly label = input('count');
  readonly count = signal(0);
  readonly incremented = output<{ count: number; label: string }>();
  increment(): void {
    const n = this.count() + 1;
    this.count.set(n);
    this.incremented.emit({ count: n, label: this.label() });
  }
}

/**
 * 'notes' — the notes composer from the Vue demo, Angular-style: a list
 * `signal` rendered through `@for`, an `(input)` handler reading the wire
 * payload's stamped `value`, and a 'noteAdded' emit on each add.
 */
@AngularIsland('notes')
@Component({
  selector: 'demo-notes',
  template: `
    <div class="angular-notes">
      <h3 class="vanilla-heading">{{ title() }}</h3>
      <div class="atoll-map-places">
        <input
          placeholder="write a note…"
          [value]="draft()"
          (input)="onDraft($event)"
          (keydown.enter)="add()"
        />
        <button class="atoll-map-place-btn" (click)="add()">add</button>
      </div>
      <ul class="vanilla-log">
        @for (n of notes(); track $index) {
          <li class="vanilla-log-line">{{ n }}</li>
        }
      </ul>
      <div class="vanilla-readout">{{ notes().length }} note(s) — state lives in the worker</div>
    </div>
  `,
})
export class NotesComponent {
  readonly title = input('angular island');
  readonly draft = signal('');
  readonly notes = signal<string[]>([]);
  readonly noteAdded = output<{ text: string; total: number }>();
  onDraft(event: Event): void {
    // The wire payload's `value` is stamped onto the wrapped target.
    this.draft.set((event.target as HTMLInputElement).value);
  }
  add(): void {
    const text = this.draft().trim();
    if (text === '') return;
    this.notes.update((xs) => [...xs, text]);
    this.draft.set('');
    this.noteAdded.emit({ text, total: this.notes().length });
  }
}

/**
 * 'incidents' — the heavy-component benchmark: 1,000,000 incident records
 * in the worker, rendered through a virtualized scroller. The main thread
 * only ever sees ~20 rows of ops no matter how deep the user scrolls.
 * Rows are lazily generated; each scroll event re-reads the window and a
 * 'rendered' emit reports the re-render time back to the shell.
 */
const REGIONS = ['us-east', 'us-west', 'eu-central', 'ap-south', 'sa-east'];
const SEVS = ['P1', 'P2', 'P3', 'P4'];
const ROW_H = 24;
const OV = 4;
const VISIBLE = Math.ceil(320 / ROW_H) + OV * 2;
const incident = (i: number) => ({
  id: i,
  site: `site-${(i * 7919) % 1409}`,
  region: REGIONS[i % REGIONS.length],
  sev: (i * 31) % 100,
  dur: `${((i * 104729) % 977) % 60}m`,
});

interface Incident {
  id: number;
  site: string;
  region: string;
  sev: number;
  dur: string;
}

@AngularIsland('incidents')
@Component({
  selector: 'demo-incidents',
  template: `
    <div class="incidents">
      <div class="inc-stats">
        {{ fmt(count()) }} incidents · rows {{ fmt(first()) }}–{{ fmt(end()) }} ·
        worker re-render {{ lastMs().toFixed(1) }}ms
      </div>
      <div class="inc-viewport" (scroll)="onScroll($event)">
        <div class="inc-spacer" [style.height.px]="count() * ROW_H">
          @for (r of rows(); track r.id) {
            <div class="inc-row" [style.top.px]="r.id * ROW_H">
              <span class="inc-id">#{{ r.id }}</span>
              <span class="inc-site">{{ r.site }}</span>
              <span class="inc-region">{{ r.region }}</span>
              <span [class]="sevClass(r.sev)">{{ sevLabel(r.sev) }} · {{ r.sev }}</span>
              <span class="inc-dur">{{ r.dur }}</span>
            </div>
          }
        </div>
      </div>
    </div>
  `,
})
export class IncidentsComponent {
  readonly ROW_H = ROW_H;
  readonly count = input(1_000_000);
  readonly start = signal(0);
  readonly lastMs = signal(0);
  readonly rendered = output<{ start: number; end: number; ms: number }>();
  private t0 = performance.now();

  readonly first = computed(() =>
    Math.min(this.start(), Math.max(0, this.count() - VISIBLE)),
  );
  readonly rows = computed<Incident[]>(() =>
    Array.from({ length: Math.min(VISIBLE, this.count() - this.first()) }, (_, k) =>
      incident(this.first() + k),
    ),
  );
  readonly end = computed(() => this.first() + this.rows().length - 1);

  // Zoneless CD renders async — afterEveryRender fires after the commit,
  // when the dispatch's instance scope is already released. The adapter's
  // output bridge re-enters the instance for each emit, so the component
  // needs no getActiveInstance/runInInstance bookkeeping of its own. The
  // window-key guard stops lastMs's own write from looping into a report.
  private prevKey = '';
  constructor() {
    afterEveryRender(() => {
      const key = `${this.first()}:${this.end()}`;
      if (key === this.prevKey) return;
      this.prevKey = key;
      const ms = performance.now() - this.t0;
      this.lastMs.set(ms);
      this.rendered.emit({ start: this.first(), end: this.end(), ms });
    });
  }

  fmt(n: number): string {
    return n.toLocaleString();
  }
  sevClass(s: number): string {
    return `inc-sev sev-p${s > 75 ? 1 : s > 40 ? 2 : s > 15 ? 3 : 4}`;
  }
  sevLabel(s: number): string {
    return SEVS[s > 75 ? 0 : s > 40 ? 1 : s > 15 ? 2 : 3];
  }
  onScroll(event: Event): void {
    this.t0 = performance.now();
    // The driver stamps the scroller's scrollTop onto the wire payload —
    // the proxy element's geometry getters are stubs.
    const st = (event as Event & { scrollTop?: number }).scrollTop ?? 0;
    this.start.set(Math.max(0, Math.floor(st / ROW_H) - OV));
  }
}

// No apps map — `defineAngularPolyWorker()` collects every @AngularIsland
// component in the module graph ('counter', 'notes', 'incidents').
export const angularWorker = defineAngularPolyWorker();

Notes

  • Mediation is unidirectional — an island's emit lands in onEvent, the shell writes state, and it flows back in as props. No hand-wired updateProps calls.
  • Fixed-dimension libs (recharts) get width/height as props — there's no ResizeObserver channel into the worker.
  • emit needs instance scope — it routes through the active task, so it's free inside event handlers. Async commit hooks (Vue onUpdated, Svelte $effect, Angular afterEveryRender) run after the task releases it: capture getActiveInstance() during setup and re-enter with runInInstance(scope, () => emit(…)) — or, in Angular, just declare an output() field and the adapter's output bridging re-enters for you (that's how the incidents benchmark reports its re-render time).
  • The worker component's public API IS the island contract — input()/model() fields become [props] keys, and root output()/model() fields bridge onto the emit channel under their public names (x = model() → 'xChange'), with instance re-entry handled by the adapter.
  • import '@angular/compiler' once wherever JIT (decorator) components run — shell AND worker entries. AOT/ɵcmp components skip it; the generated facade component carries a hand-authored ɵcmp so it works under both.
  • Zoneless: provideZonelessChangeDetection() + signals — no zone.js. Signal writes from island callbacks schedule change detection directly.
  • app/client/worker input changes remount the island; props and mode update in place (mode rides the handle's setMode).
  • input()/model()/output() signal fields work on JIT components — but aliased inputs aren't discoverable; bind by field name or use AOT.