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
| Export | Signature | What it does |
|---|---|---|
islandComponent | islandComponent<C>({ app, client|worker, selector?, mode? }): Type<Component> | The facade — generates a standalone component (<counter-island>) typed against the worker component class via import type. [props] accepts IslandInputs<C>; 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 / IslandEventHandler | IslandInputs<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<C>) so [app] accepts the stamped component class and props/events infer. worker/workerOptions/mode inputs cover the no-shared-client case. |
defineAngularPolyWorker | defineAngularPolyWorker() | ({ 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. |
defineAngularMonoWorker | defineAngularMonoWorker(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.
/**
* 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.
/**
* 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-wiredupdatePropscalls. - Fixed-dimension libs (recharts) get width/height as props — there's no ResizeObserver channel into the worker.
emitneeds instance scope — it routes through the active task, so it's free inside event handlers. Async commit hooks (VueonUpdated, Svelte$effect, AngularafterEveryRender) run after the task releases it: capturegetActiveInstance()during setup and re-enter withrunInInstance(scope, () => emit(…))— or, in Angular, just declare anoutput()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 rootoutput()/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/workerinput changes remount the island; props andmodeupdate 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.