From 7737912eaac2ade648680d986c23f1411b073169 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 21 May 2026 00:14:49 +0530 Subject: [PATCH 01/22] feat(core): rename ReactiveController hooks to lit names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onMount → hostConnected onUnmount → hostDisconnected beforeRender → hostUpdate afterRender → hostUpdated No backwards compatibility shim. The previous names were a webjs-specific divergence that broke interop with the lit ecosystem: any lit ReactiveController dropped into a webjs WebComponent silently no-op'd because the hook names didn't match. AI agents trained on lit also emit the lit-shaped names by default. Renamed across: WebComponent base class, Task controller, Context provider + consumer, all tests, AGENTS.md, agent-docs/components.md, docs/app/docs/controllers/page.ts, docs/app/docs/lifecycle/page.ts, TS .d.ts declarations. All 940 tests pass. --- AGENTS.md | 4 +- agent-docs/components.md | 4 +- docs/app/docs/controllers/page.ts | 18 +++++---- docs/app/docs/lifecycle/page.ts | 6 +-- packages/core/src/component.d.ts | 8 ++-- packages/core/src/component.js | 40 ++++++++++---------- packages/core/src/context.js | 10 ++--- packages/core/src/task.js | 12 +++--- test/component-lifecycle.test.js | 24 ++++++------ test/context-protocol.test.js | 56 ++++++++++++++-------------- test/task.test.js | 18 ++++----- test/types/component-types.test-d.ts | 8 ++-- 12 files changed, 105 insertions(+), 103 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ef0a03ad..02ae7129 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -300,9 +300,9 @@ class StudentCard extends WebComponent { | Hook | When | Use for | |---|---|---| -| controllers' `beforeRender()` | Before render | Pre-render logic | +| controllers' `hostUpdate()` | Before render | Pre-render logic | | `render()` | Render phase | Return `TemplateResult` | -| controllers' `afterRender()` | After render | Post-render logic | +| controllers' `hostUpdated()` | After render | Post-render logic | | `firstUpdated()` | After first render only | One-time DOM setup | No `shouldUpdate`/`willUpdate`/`updated`/`changedProperties`. Compute inputs at top of `render()`. Use `queueMicrotask()` after `setState()` for post-render side effects. diff --git a/agent-docs/components.md b/agent-docs/components.md index 1ca11428..4e47e45a 100644 --- a/agent-docs/components.md +++ b/agent-docs/components.md @@ -37,11 +37,11 @@ class FetchController { this.data = null; host.addController(this); // ← register } - async onMount() { + async hostConnected() { this.data = await (await fetch(this.url)).json(); this.host.requestUpdate(); } - onUnmount() { /* cleanup */ } + hostDisconnected() { /* cleanup */ } } class MyEl extends WebComponent { diff --git a/docs/app/docs/controllers/page.ts b/docs/app/docs/controllers/page.ts index b932eb52..afbc2849 100644 --- a/docs/app/docs/controllers/page.ts +++ b/docs/app/docs/controllers/page.ts @@ -7,6 +7,8 @@ export default function Controllers() {

Reactive Controllers

Reactive controllers are a composition pattern for sharing lifecycle-bound logic across components without using inheritance. Instead of building mixin chains or base class hierarchies, you create standalone controller objects that hook into any component's lifecycle.

+

Why the lit-shaped hook names? webjs adopts lit's hostConnected / hostDisconnected / hostUpdate / hostUpdated protocol verbatim because AI coding agents have substantial training data on lit. Matching lit's API names means agents emit idiomatic webjs code without framework-specific translation, and any lit ReactiveController found in the wild is drop-in compatible here.

+

What Controllers Solve

Consider a scenario where three different components all need to fetch data on connect, poll on an interval, and clean up on disconnect. Without controllers, your options are:

@@ -22,10 +24,10 @@ export default function Controllers() {

A controller is any object that implements some or all of these methods:

All methods are optional. Implement only the ones your controller needs.

@@ -42,7 +44,7 @@ export default function Controllers() { host.addController(this); // register with the host } - onMount() { + hostConnected() { this._observer = new IntersectionObserver( ([entry]) => { this.isVisible = entry.isIntersecting; @@ -53,7 +55,7 @@ export default function Controllers() { this._observer.observe(this.host); } - onUnmount() { + hostDisconnected() { this._observer?.disconnect(); this._observer = null; } @@ -97,7 +99,7 @@ LazyImage.register('lazy-image'); host.addController(this); } - async onMount() { + async hostConnected() { this.loading = true; this.host.requestUpdate(); @@ -115,7 +117,7 @@ LazyImage.register('lazy-image'); } } - onUnmount() { + hostDisconnected() { // Could abort an in-flight request here if using AbortController } } diff --git a/docs/app/docs/lifecycle/page.ts b/docs/app/docs/lifecycle/page.ts index f68e0d80..75098b04 100644 --- a/docs/app/docs/lifecycle/page.ts +++ b/docs/app/docs/lifecycle/page.ts @@ -10,9 +10,9 @@ export default function Lifecycle() {

The Update Cycle

When setState() or a property change triggers a re-render:

    -
  1. Controllers' beforeRender()
  2. +
  3. Controllers' hostUpdate()
  4. render() + DOM commit (with error boundary)
  5. -
  6. Controllers' afterRender()
  7. +
  8. Controllers' hostUpdated()
  9. firstUpdated() runs once, on the first render only
@@ -89,7 +89,7 @@ this.setState({ name: 'updated' }); disconnectedCallback()❌✅ firstUpdated()❌✅ attributeChangedCallback()❌✅ - controllers' beforeRender / afterRender❌✅ + controllers' hostUpdate / hostUpdated❌✅ diff --git a/packages/core/src/component.d.ts b/packages/core/src/component.d.ts index b68b24b3..a32e757d 100644 --- a/packages/core/src/component.d.ts +++ b/packages/core/src/component.d.ts @@ -43,10 +43,10 @@ export interface PropertyDeclaration { /** Reactive controller protocol (Lit-compatible). */ export interface ReactiveController { - onMount?(): void; - onUnmount?(): void; - beforeRender?(): void; - afterRender?(): void; + hostConnected?(): void; + hostDisconnected?(): void; + hostUpdate?(): void; + hostUpdated?(): void; } /** diff --git a/packages/core/src/component.js b/packages/core/src/component.js index 4070a37a..933822a2 100644 --- a/packages/core/src/component.js +++ b/packages/core/src/component.js @@ -28,18 +28,18 @@ const isBrowser = typeof window !== 'undefined' && typeof HTMLElement !== 'undef * **Why it exists:** mirrors Lit's `ReactiveController` protocol so * ecosystem controllers are interoperable. * - * @property {() => void} [onMount] + * @property {() => void} [hostConnected] * Called when the host element is inserted into the DOM * (`connectedCallback`). Use for subscriptions, observers, timers. - * @property {() => void} [onUnmount] + * @property {() => void} [hostDisconnected] * Called when the host element is removed from the DOM * (`disconnectedCallback`). Use for cleanup: unsubscribe, disconnect * observers, clear timers. - * @property {() => void} [beforeRender] + * @property {() => void} [hostUpdate] * Called just before the host renders (inside `_performRender`, after * `willUpdate` but before `render()`). Use for reading layout or * preparing data that the render depends on. - * @property {() => void} [afterRender] + * @property {() => void} [hostUpdated] * Called after the host has rendered and the DOM is up to date. Use for * post-render side effects that depend on the new DOM (measuring, * focusing, scrolling). @@ -105,9 +105,9 @@ function defaultHasChanged(a, b) { * at the bottom of the file. Tag must contain a hyphen (HTML spec). * * Lifecycle (called in order during each update cycle): - * 1. controllers' `beforeRender()` + * 1. controllers' `hostUpdate()` * 2. `render()` + DOM commit (with error boundary) - * 3. controllers' `afterRender()` + * 3. controllers' `hostUpdated()` * 4. `firstUpdated()`: once, after the very first render * * "Less is more": only hooks with no native workaround are included. @@ -469,7 +469,7 @@ export class WebComponent extends Base { // Notify all controllers that the host is connected. for (const c of this.__controllers) { - if (c.onMount) c.onMount(); + if (c.hostConnected) c.hostConnected(); } // For both shadow and light DOM: proceed with _performRender(). @@ -532,7 +532,7 @@ export class WebComponent extends Base { // reconnection picks up where it left off. if (this._renderRoot === this) detachSlotObservers(this); for (const c of this.__controllers) { - if (c.onUnmount) c.onUnmount(); + if (c.hostDisconnected) c.hostDisconnected(); } } @@ -595,17 +595,17 @@ export class WebComponent extends Base { /** * Core update cycle: - * 1. Controllers' beforeRender() + * 1. Controllers' hostUpdate() * 2. render() + DOM commit (with error boundary) - * 3. Controllers' afterRender() + * 3. Controllers' hostUpdated() * 4. firstUpdated() runs once, on the first render only */ _performRender() { if (!this._renderRoot) return; - // --- 1. beforeRender --- + // --- 1. hostUpdate --- for (const c of this.__controllers) { - if (c.beforeRender) c.beforeRender(); + if (c.hostUpdate) c.hostUpdate(); } // --- 2. render + DOM commit (with error boundary) --- @@ -622,9 +622,9 @@ export class WebComponent extends Base { } } - // --- 3. afterRender --- + // --- 3. hostUpdated --- for (const c of this.__controllers) { - if (c.afterRender) c.afterRender(); + if (c.hostUpdated) c.hostUpdated(); } // --- 4. firstUpdated (once) --- @@ -676,20 +676,20 @@ export class WebComponent extends Base { * this.host = host; * host.addController(this); * } - * onMount() { window.addEventListener('mousemove', this._onMove); } - * onUnmount() { window.removeEventListener('mousemove', this._onMove); } + * hostConnected() { window.addEventListener('mousemove', this._onMove); } + * hostDisconnected() { window.removeEventListener('mousemove', this._onMove); } * } * ``` * * If the host is already connected when the controller is added, the - * controller's `onMount()` is called immediately. + * controller's `hostConnected()` is called immediately. * * @param {ReactiveController} controller */ addController(controller) { this.__controllers.add(controller); - if (this._connected && controller.onMount) { - controller.onMount(); + if (this._connected && controller.hostConnected) { + controller.hostConnected(); } } @@ -700,7 +700,7 @@ export class WebComponent extends Base { * than the component's: e.g. a controller that tracks a specific * resource and should be swapped out when the resource changes. * - * The controller's `onUnmount()` is NOT called by `removeController`; + * The controller's `hostDisconnected()` is NOT called by `removeController`; * if cleanup is needed, call it yourself before removing. * * @param {ReactiveController} controller diff --git a/packages/core/src/context.js b/packages/core/src/context.js index 5f767656..7624fe34 100644 --- a/packages/core/src/context.js +++ b/packages/core/src/context.js @@ -216,12 +216,12 @@ export class ContextProvider { } /** Called by the host's controller lifecycle when the element connects. */ - onMount() { + hostConnected() { this._host.addEventListener('context-request', this._onRequest); } /** Called by the host's controller lifecycle when the element disconnects. */ - onUnmount() { + hostDisconnected() { this._host.removeEventListener('context-request', this._onRequest); this._subscriptions.clear(); } @@ -235,7 +235,7 @@ export class ContextProvider { * A ReactiveController that consumes a context value from an ancestor * provider. * - * On `onMount` it dispatches a `ContextRequestEvent`. If a provider + * On `hostConnected` it dispatches a `ContextRequestEvent`. If a provider * for the matching context key exists higher in the DOM tree, the consumer * receives the current value immediately (and, if `subscribe: true`, all * future updates as well). @@ -288,12 +288,12 @@ export class ContextConsumer { } /** Called by the host's controller lifecycle when the element connects. */ - onMount() { + hostConnected() { this._dispatchRequest(); } /** Called by the host's controller lifecycle when the element disconnects. */ - onUnmount() { + hostDisconnected() { if (this._unsubscribe) { this._unsubscribe(); this._unsubscribe = undefined; diff --git a/packages/core/src/task.js b/packages/core/src/task.js index 953742f2..5c0d5a4a 100644 --- a/packages/core/src/task.js +++ b/packages/core/src/task.js @@ -130,7 +130,7 @@ export const TaskStatus = /** @type {const} */ ({ * or any other signal-aware API. * * - **`args`** is a function that returns an array. It is re-evaluated - * on every `beforeRender()`. When `autoRun` is true (the default) and + * on every `hostUpdate()`. When `autoRun` is true (the default) and * the args have changed (shallow identity comparison per element), * the task automatically re-runs. This is how search-as-you-type and * reactive data loading work. @@ -324,7 +324,7 @@ export class Task { * Called before the host renders. When `autoRun` is enabled, checks * whether `args()` have changed and re-runs the task if so. */ - beforeRender() { + hostUpdate() { if (!this._autoRun) return; const nextArgs = this._argsFn(); @@ -340,19 +340,19 @@ export class Task { * Called after the host has rendered. Currently a no-op; required by * the ReactiveController interface. */ - afterRender() {} + hostUpdated() {} /** * Called when the host connects to the DOM. Currently a no-op - - * initial run (if autoRun) happens on the first `beforeRender()`. + * initial run (if autoRun) happens on the first `hostUpdate()`. */ - onMount() {} + hostConnected() {} /** * Called when the host disconnects from the DOM. Aborts any in-flight * task to prevent updates to an unmounted component. */ - onUnmount() { + hostDisconnected() { this.abort(); } diff --git a/test/component-lifecycle.test.js b/test/component-lifecycle.test.js index e8ff7e4f..47d233ff 100644 --- a/test/component-lifecycle.test.js +++ b/test/component-lifecycle.test.js @@ -222,13 +222,13 @@ test('disconnectedCallback clears _connected', () => { /* -------------------- controllers: dispatch -------------------- */ -test('controller hooks fire in order: onMount → beforeRender → afterRender → onUnmount', async () => { +test('controller hooks fire in order: hostConnected → hostUpdate → hostUpdated → hostDisconnected', async () => { const calls = []; const ctrl = { - onMount() { calls.push('onMount'); }, - beforeRender() { calls.push('beforeRender'); }, - afterRender() { calls.push('afterRender'); }, - onUnmount() { calls.push('onUnmount'); }, + hostConnected() { calls.push('hostConnected'); }, + hostUpdate() { calls.push('hostUpdate'); }, + hostUpdated() { calls.push('hostUpdated'); }, + hostDisconnected() { calls.push('hostDisconnected'); }, }; class C extends WebComponent { @@ -241,12 +241,12 @@ test('controller hooks fire in order: onMount → beforeRender → afterRender await Promise.resolve(); // let microtask render flush await Promise.resolve(); el.remove(); - assert.ok(calls.indexOf('onMount') < calls.indexOf('beforeRender')); - assert.ok(calls.indexOf('beforeRender') < calls.indexOf('afterRender')); - assert.ok(calls.indexOf('onUnmount') > calls.indexOf('afterRender')); + assert.ok(calls.indexOf('hostConnected') < calls.indexOf('hostUpdate')); + assert.ok(calls.indexOf('hostUpdate') < calls.indexOf('hostUpdated')); + assert.ok(calls.indexOf('hostDisconnected') > calls.indexOf('hostUpdated')); }); -test('addController on an already-connected host fires onMount immediately', () => { +test('addController on an already-connected host fires hostConnected immediately', () => { let called = false; class C extends WebComponent { render() { return html``; } @@ -254,13 +254,13 @@ test('addController on an already-connected host fires onMount immediately', () C.register('ctrl-late'); const el = document.createElement('ctrl-late'); document.body.appendChild(el); - el.addController({ onMount() { called = true; } }); + el.addController({ hostConnected() { called = true; } }); assert.equal(called, true); }); test('removeController detaches a controller', async () => { let updates = 0; - const ctrl = { beforeRender() { updates++; } }; + const ctrl = { hostUpdate() { updates++; } }; class C extends WebComponent { constructor() { super(); this.addController(ctrl); } render() { return html``; } @@ -272,7 +272,7 @@ test('removeController detaches a controller', async () => { el.removeController(ctrl); el.setState({ foo: 1 }); await Promise.resolve(); - assert.equal(updates, 1, 'beforeRender only fired once: once removed, no more'); + assert.equal(updates, 1, 'hostUpdate only fired once: once removed, no more'); }); /* -------------------- setState batching -------------------- */ diff --git a/test/context-protocol.test.js b/test/context-protocol.test.js index 04563f2b..a2488c84 100644 --- a/test/context-protocol.test.js +++ b/test/context-protocol.test.js @@ -77,8 +77,8 @@ test('ContextProvider: responds to context-request with value', () => { const ctx = createContext('color'); const provider = new ContextProvider(host, { context: ctx, initialValue: 'red' }); - // Simulate onMount: starts listening. - provider.onMount(); + // Simulate hostConnected: starts listening. + provider.hostConnected(); let received; const event = new ContextRequestEvent(ctx, (value) => { received = value; }); @@ -87,7 +87,7 @@ test('ContextProvider: responds to context-request with value', () => { assert.equal(received, 'red'); assert.equal(provider.value, 'red'); - provider.onUnmount(); + provider.hostDisconnected(); }); test('ContextProvider: ignores requests for a different context', () => { @@ -95,21 +95,21 @@ test('ContextProvider: ignores requests for a different context', () => { const ctxA = createContext('a'); const ctxB = createContext('b'); const provider = new ContextProvider(host, { context: ctxA, initialValue: 42 }); - provider.onMount(); + provider.hostConnected(); let received; const event = new ContextRequestEvent(ctxB, (value) => { received = value; }); host.dispatchEvent(event); assert.equal(received, undefined); - provider.onUnmount(); + provider.hostDisconnected(); }); test('ContextProvider: setValue notifies subscribers', () => { const { host } = createMockHost(); const ctx = createContext('count'); const provider = new ContextProvider(host, { context: ctx, initialValue: 0 }); - provider.onMount(); + provider.hostConnected(); const values = []; const event = new ContextRequestEvent(ctx, (value) => { values.push(value); }, true); @@ -123,14 +123,14 @@ test('ContextProvider: setValue notifies subscribers', () => { provider.setValue(2); assert.deepEqual(values, [0, 1, 2]); - provider.onUnmount(); + provider.hostDisconnected(); }); test('ContextProvider: setValue with same value is a no-op', () => { const { host } = createMockHost(); const ctx = createContext('val'); const provider = new ContextProvider(host, { context: ctx, initialValue: 'x' }); - provider.onMount(); + provider.hostConnected(); const values = []; const event = new ContextRequestEvent(ctx, (v) => { values.push(v); }, true); @@ -139,14 +139,14 @@ test('ContextProvider: setValue with same value is a no-op', () => { provider.setValue('x'); // same value: should not notify assert.deepEqual(values, ['x']); - provider.onUnmount(); + provider.hostDisconnected(); }); test('ContextProvider: subscriber can unsubscribe', () => { const { host } = createMockHost(); const ctx = createContext('unsub'); const provider = new ContextProvider(host, { context: ctx, initialValue: 'a' }); - provider.onMount(); + provider.hostConnected(); const values = []; let unsub; @@ -163,29 +163,29 @@ test('ContextProvider: subscriber can unsubscribe', () => { // After unsubscribe, no new values should arrive. assert.deepEqual(values, ['a']); - provider.onUnmount(); + provider.hostDisconnected(); }); // --------------------------------------------------------------------------- // ContextConsumer // --------------------------------------------------------------------------- -test('ContextConsumer: dispatches context-request on onMount', () => { +test('ContextConsumer: dispatches context-request on hostConnected', () => { const { host } = createMockHost(); const ctx = createContext('theme'); // Set up a provider first. const provider = new ContextProvider(host, { context: ctx, initialValue: 'dark' }); - provider.onMount(); + provider.hostConnected(); // Create consumer on the same host (in a real app it would be a descendant). const consumer = new ContextConsumer(host, { context: ctx, subscribe: true }); - consumer.onMount(); + consumer.hostConnected(); assert.equal(consumer.value, 'dark'); - provider.onUnmount(); - consumer.onUnmount(); + provider.hostDisconnected(); + consumer.hostDisconnected(); }); test('ContextConsumer: receives updated value when provider calls setValue', () => { @@ -193,10 +193,10 @@ test('ContextConsumer: receives updated value when provider calls setValue', () const ctx = createContext('lang'); const provider = new ContextProvider(host, { context: ctx, initialValue: 'en' }); - provider.onMount(); + provider.hostConnected(); const consumer = new ContextConsumer(host, { context: ctx, subscribe: true }); - consumer.onMount(); + consumer.hostConnected(); assert.equal(consumer.value, 'en'); const beforeCount = getUpdateCount(); @@ -205,23 +205,23 @@ test('ContextConsumer: receives updated value when provider calls setValue', () assert.equal(consumer.value, 'fr'); assert.ok(getUpdateCount() > beforeCount, 'requestUpdate should have been called'); - provider.onUnmount(); - consumer.onUnmount(); + provider.hostDisconnected(); + consumer.hostDisconnected(); }); -test('ContextConsumer: onUnmount unsubscribes', () => { +test('ContextConsumer: hostDisconnected unsubscribes', () => { const { host, getUpdateCount } = createMockHost(); const ctx = createContext('size'); const provider = new ContextProvider(host, { context: ctx, initialValue: 10 }); - provider.onMount(); + provider.hostConnected(); const consumer = new ContextConsumer(host, { context: ctx, subscribe: true }); - consumer.onMount(); + consumer.hostConnected(); assert.equal(consumer.value, 10); - consumer.onUnmount(); + consumer.hostDisconnected(); const countAfterDisconnect = getUpdateCount(); provider.setValue(20); @@ -229,7 +229,7 @@ test('ContextConsumer: onUnmount unsubscribes', () => { assert.equal(consumer.value, 10); assert.equal(getUpdateCount(), countAfterDisconnect); - provider.onUnmount(); + provider.hostDisconnected(); }); test('ContextConsumer: subscribe false does a one-shot read', () => { @@ -237,10 +237,10 @@ test('ContextConsumer: subscribe false does a one-shot read', () => { const ctx = createContext('once'); const provider = new ContextProvider(host, { context: ctx, initialValue: 'snap' }); - provider.onMount(); + provider.hostConnected(); const consumer = new ContextConsumer(host, { context: ctx, subscribe: false }); - consumer.onMount(); + consumer.hostConnected(); assert.equal(consumer.value, 'snap'); @@ -248,5 +248,5 @@ test('ContextConsumer: subscribe false does a one-shot read', () => { // Non-subscribing consumer should not receive updates. assert.equal(consumer.value, 'snap'); - provider.onUnmount(); + provider.hostDisconnected(); }); diff --git a/test/task.test.js b/test/task.test.js index 0259b659..eff2dc0d 100644 --- a/test/task.test.js +++ b/test/task.test.js @@ -218,7 +218,7 @@ test('Task.abort: aborts the signal of the in-flight task', async () => { await runPromise; }); -test('Task: onUnmount aborts in-flight task', async () => { +test('Task: hostDisconnected aborts in-flight task', async () => { const host = createMockHost(); let capturedSignal; let resolve; @@ -234,7 +234,7 @@ test('Task: onUnmount aborts in-flight task', async () => { const runPromise = task.run(); - task.onUnmount(); + task.hostDisconnected(); assert.equal(capturedSignal.aborted, true); resolve(); @@ -245,7 +245,7 @@ test('Task: onUnmount aborts in-flight task', async () => { // Auto-run // --------------------------------------------------------------------------- -test('Task: auto-run triggers when args change on beforeRender', async () => { +test('Task: auto-run triggers when args change on hostUpdate', async () => { const host = createMockHost(); let currentQuery = 'foo'; let runCount = 0; @@ -256,20 +256,20 @@ test('Task: auto-run triggers when args change on beforeRender', async () => { autoRun: true, }); - // First beforeRender: prevArgs is null so it always runs. - task.beforeRender(); + // First hostUpdate: prevArgs is null so it always runs. + task.hostUpdate(); // run() is async, give it a tick to settle. await new Promise((r) => setTimeout(r, 10)); assert.equal(runCount, 1); // Same args: should not re-run. - task.beforeRender(); + task.hostUpdate(); await new Promise((r) => setTimeout(r, 10)); assert.equal(runCount, 1); // Changed args: should re-run. currentQuery = 'bar'; - task.beforeRender(); + task.hostUpdate(); await new Promise((r) => setTimeout(r, 10)); assert.equal(runCount, 2); @@ -277,7 +277,7 @@ test('Task: auto-run triggers when args change on beforeRender', async () => { assert.equal(task.value, 'result:bar'); }); -test('Task: autoRun false does not run on beforeRender', () => { +test('Task: autoRun false does not run on hostUpdate', () => { const host = createMockHost(); let runCount = 0; @@ -287,7 +287,7 @@ test('Task: autoRun false does not run on beforeRender', () => { autoRun: false, }); - task.beforeRender(); + task.hostUpdate(); assert.equal(runCount, 0); }); diff --git a/test/types/component-types.test-d.ts b/test/types/component-types.test-d.ts index bf960a38..cef82582 100644 --- a/test/types/component-types.test-d.ts +++ b/test/types/component-types.test-d.ts @@ -79,10 +79,10 @@ void decl; // silence "unused" for the fixture /* ------------- ReactiveController shape ------------- */ const ctrl: ReactiveController = { - onMount() {}, - onUnmount() {}, - beforeRender() {}, - afterRender() {}, + hostConnected() {}, + hostDisconnected() {}, + hostUpdate() {}, + hostUpdated() {}, }; void ctrl; From b413311c783cc1f7cfd2d61c6bd966e6d3a6713b Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 21 May 2026 00:16:18 +0530 Subject: [PATCH 02/22] docs: explain why webjs aligns its API with lit AI coding agents have substantial training data on lit. Aligning the component runtime API (reactive properties, lifecycle hooks, ReactiveController hooks, directives, html/css templates) means agents emit idiomatic webjs code without framework-specific translation. The implementation under packages/core/src/ stays webjs's own; only the public surface matches lit. --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 02ae7129..e14f055c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,8 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, refer An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. +**Why lit-style web components specifically?** AI coding agents have substantial training data on lit. Aligning webjs's component runtime API (reactive properties via `static properties`, lifecycle hooks like `shouldUpdate` / `willUpdate` / `updated` / `firstUpdated` / `updateComplete`, ReactiveController hooks `hostConnected` / `hostDisconnected` / `hostUpdate` / `hostUpdated`, the full `lit-html` directive set, `html` / `css` tagged templates) lets agents emit idiomatic webjs code without framework-specific translation. Webjs ships its own implementation under `packages/core/src/` (clean JSDoc-typed JS, no-build), but the public API surface matches lit so the ecosystem's collective lit knowledge transfers directly. Decorators are the one exception (banned by invariant 10, non-erasable TS); the `declare` + `static properties` pattern replaces them. + - **Sensible defaults, overridable.** Memory store in dev, Redis when configured. HTTP caching via standard `Cache-Control`. - **Built-in essentials.** Auth, sessions, caching, cache store, rate limiting, all with pluggable adapters. - **No build step.** Source files are served as native ES modules. From da33567c09e70092f80fca7e44efa11fbb7779b8 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 21 May 2026 00:33:15 +0530 Subject: [PATCH 03/22] feat(core): add lit-aligned lifecycle hooks Adds shouldUpdate, willUpdate, update, updated, updateComplete, getUpdateComplete, and the changedProperties Map. firstUpdated now receives changedProperties. Pipeline (lit-aligned): 1. shouldUpdate(cp) gate; false skips 2. willUpdate(cp), folds in-cycle property changes 3. controllers' hostUpdate 4. update(cp), default calls render+commit 5. controllers' hostUpdated 6. firstUpdated(cp), once 7. updated(cp), every render 8. updateComplete promise resolves changedProperties: Map. Keys are property names or 'state' for setState patches. The Map is mutated in place during the update phase (steps 2-5) so willUpdate mutations fold into this cycle; swapped for a fresh Map between steps 5 and 6 so firstUpdated/updated mutations queue the NEXT cycle (lit's behavior). this.state + this.setState() continue to work unchanged externally; internally they now route through requestUpdate and track 'state' in changedProperties so hooks can detect setState invocations. requestUpdate(name?, oldValue?) gains optional args. Property setters call requestUpdate(propName, oldVal). Inside the update phase, requestUpdate folds into the current Map without scheduling a new cycle (gated by _isUpdating flag). All 950 tests pass (10 new lifecycle hook tests added). --- AGENTS.md | 26 ++- packages/core/src/component.d.ts | 21 +- packages/core/src/component.js | 319 +++++++++++++++++++++++++++---- test/component-lifecycle.test.js | 243 +++++++++++++++++++++++ 4 files changed, 562 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e14f055c..006af8a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -298,16 +298,22 @@ class StudentCard extends WebComponent { | `hasChanged` | strict `!==` | Custom change detection | | `converter` | type-based | Custom attribute ↔ property serialization | -### Lifecycle (less-is-more) - -| Hook | When | Use for | -|---|---|---| -| controllers' `hostUpdate()` | Before render | Pre-render logic | -| `render()` | Render phase | Return `TemplateResult` | -| controllers' `hostUpdated()` | After render | Post-render logic | -| `firstUpdated()` | After first render only | One-time DOM setup | - -No `shouldUpdate`/`willUpdate`/`updated`/`changedProperties`. Compute inputs at top of `render()`. Use `queueMicrotask()` after `setState()` for post-render side effects. +### Lifecycle (lit-aligned) + +Every update cycle runs these hooks in order. All receive a `changedProperties` Map: keys are property names (or `'state'` for setState patches), values are the previous value before the change. + +| # | Hook | When | Use for | +|---|---|---|---| +| 1 | `shouldUpdate(changedProperties)` | Update queued | Return `false` to skip this update. Default `true`. | +| 2 | `willUpdate(changedProperties)` | Pre-render | Compute derived values. Property assignments fold into THIS cycle without re-triggering. | +| 3 | controllers' `hostUpdate()` | Pre-render | Controller pre-render logic | +| 4 | `update(changedProperties)` | Render phase | Default calls `render()` + commits. Override to wrap or short-circuit (rare). | +| 5 | controllers' `hostUpdated()` | Post-render | Controller post-render logic | +| 6 | `firstUpdated(changedProperties)` | After first render only | One-time DOM setup | +| 7 | `updated(changedProperties)` | After every render | Post-render DOM work conditional on what changed | +| 8 | `updateComplete` Promise | Resolves last | `await el.updateComplete` after triggering an update | + +The `update()` body has an error boundary that calls `renderError(error)` if `render()` throws. All hooks are **client-only**; SSR doesn't call them (SSR walker calls `instance.render()` directly). **ReactiveControllers** are composable lifecycle logic via `host.addController(this)`. Built-in `Task`, `ContextProvider`, `ContextConsumer` are all controllers. See `agent-docs/components.md`. diff --git a/packages/core/src/component.d.ts b/packages/core/src/component.d.ts index a32e757d..6801d9c2 100644 --- a/packages/core/src/component.d.ts +++ b/packages/core/src/component.d.ts @@ -84,16 +84,31 @@ export abstract class WebComponent extends HTMLElement { state: Record; /** Schedule a re-render with a state patch. Batches via microtask. */ setState(patch: Record): void; - /** Schedule a re-render without mutating state. */ - requestUpdate(): void; + /** + * Schedule a re-render. Optionally record a property change so lifecycle + * hooks can branch on what changed via `changedProperties`. + */ + requestUpdate(name?: string, oldValue?: unknown): void; + /** A Promise that resolves after the next render commit. */ + readonly updateComplete: Promise; + /** Override point for `updateComplete`. Default returns the internal promise. */ + getUpdateComplete(): Promise; /** Attach a reactive controller. */ addController(controller: ReactiveController): void; /** Detach a reactive controller. */ removeController(controller: ReactiveController): void; /** Returns the template for this render. May be async. */ render(): TemplateResult | Promise | void; + /** Decide whether to update. Default returns `true`. */ + shouldUpdate(changedProperties: Map): boolean; + /** Pre-render hook. Safe to set properties; folds into current cycle. */ + willUpdate(changedProperties: Map): void; + /** Render-and-commit step. Default calls `render()` and commits. */ + update(changedProperties: Map): void; + /** Post-render hook. Runs after every commit. */ + updated(changedProperties: Map): void; /** One-shot hook after the first render lands in the DOM. */ - firstUpdated?(): void; + firstUpdated?(changedProperties: Map): void; /** Optional render-error boundary inside the component. */ renderError?(error: Error): TemplateResult | void; diff --git a/packages/core/src/component.js b/packages/core/src/component.js index 933822a2..f7e0a9df 100644 --- a/packages/core/src/component.js +++ b/packages/core/src/component.js @@ -104,15 +104,18 @@ function defaultHasChanged(a, b) { * The tag name is not a static field: pass it to `.register('tag-name')` * at the bottom of the file. Tag must contain a hyphen (HTML spec). * - * Lifecycle (called in order during each update cycle): - * 1. controllers' `hostUpdate()` - * 2. `render()` + DOM commit (with error boundary) - * 3. controllers' `hostUpdated()` - * 4. `firstUpdated()`: once, after the very first render + * Lifecycle (lit-aligned, called in order during each update cycle): + * 1. `shouldUpdate(changedProperties)`. Skip update if false. + * 2. `willUpdate(changedProperties)`. Safe to set properties; folds into this cycle. + * 3. controllers' `hostUpdate()` + * 4. `update(changedProperties)`. Default impl calls `render()` + commits. + * 5. controllers' `hostUpdated()` + * 6. `firstUpdated(changedProperties)`: once, on the first render only + * 7. `updated(changedProperties)`: every render commit + * 8. `updateComplete` promise resolves * - * "Less is more": only hooks with no native workaround are included. - * Use `render()` for derived state. Use `firstUpdated()` for one-time - * DOM setup. Use `this.shadowRoot.querySelector()` for element refs. + * `changedProperties` is a `Map` where each entry maps a + * property name (or `'state'` for setState patches) to its previous value. * * Usage: * ```js @@ -224,6 +227,43 @@ export class WebComponent extends Base { */ this.__firstRendered = false; + /** + * Map of changed properties accumulated since the last render. Keys are + * property names (or `'state'` for setState patches); values are the + * previous value before the change. Passed to `shouldUpdate`, `willUpdate`, + * `update`, `firstUpdated`, and `updated`. Cleared at the start of each + * render cycle so accumulations during hooks queue for the next cycle. + * @type {Map} + */ + this._changedProperties = new Map(); + + /** + * Resolver for the currently-pending updateComplete promise. `null` when + * no update is pending. + * @type {((value: boolean) => void) | null} + * @private + */ + this._updateResolve = null; + + /** + * Promise that resolves after the next render commit. Resolves to `true` + * when there are no further pending updates, `false` otherwise. + * @type {Promise} + * @private + */ + this._updatePromise = Promise.resolve(true); + + /** + * Set while the component is inside the update phase (between + * `shouldUpdate` and `updated`). Property assignments during this window + * fold into the CURRENT `changedProperties` Map without scheduling a + * new microtask render. Assignments during `updated()` (after the flag + * clears) queue a fresh cycle. + * @type {boolean} + * @private + */ + this._isUpdating = false; + // Install reactive property accessors for `static properties` declarations. this._initializeProperties(); } @@ -267,7 +307,11 @@ export class WebComponent extends Base { this._reflectAttribute(propName, newVal, d); } - if (this._connected) this.requestUpdate(); + // requestUpdate records the (name, oldValue) entry AND schedules + // a render. When called during the update phase (willUpdate / + // hostUpdate / update / hostUpdated), the scheduler short-circuits + // and the entry folds into the current cycle's changedProperties. + this.requestUpdate(propName, oldVal); }, }); @@ -573,10 +617,50 @@ export class WebComponent extends Base { /** * Shallow-merge new state and schedule a re-render. * + * Adds a `'state'` entry to `changedProperties` with the previous state + * bag as the old value, so lifecycle hooks (`shouldUpdate`, `willUpdate`, + * `updated`) can detect that setState was invoked this cycle. + * * @param {Record} patch */ setState(patch) { + const oldState = this.state; this.state = { ...this.state, ...patch }; + if (!this._changedProperties.has('state')) { + this._changedProperties.set('state', oldState); + } + this._scheduleUpdate(); + } + + /** + * Schedule a re-render. Optionally record a property change in + * `changedProperties` so hooks can branch on what changed. + * + * @param {string} [name] Property name that changed + * @param {unknown} [oldValue] Previous value of the property + */ + requestUpdate(name, oldValue) { + if (name !== undefined && !this._changedProperties.has(name)) { + this._changedProperties.set(name, oldValue); + } + this._scheduleUpdate(); + } + + /** + * Internal scheduler shared by `setState` and `requestUpdate`. Coalesces + * multiple changes in the same tick into a single microtask render. + * Manages the `updateComplete` promise lifecycle. Short-circuits when + * the component is mid-update (assignments during `willUpdate` / `update` + * fold into the current cycle's `changedProperties` Map). + * @private + */ + _scheduleUpdate() { + if (this._updateResolve === null) { + this._updatePromise = new Promise((resolve) => { + this._updateResolve = resolve; + }); + } + if (this._isUpdating) return; if (this._scheduled || !this._connected) return; this._scheduled = true; queueMicrotask(() => { @@ -586,32 +670,51 @@ export class WebComponent extends Base { } /** - * Manually schedule a re-render. Used by controllers to trigger - * host updates from external events. - */ - requestUpdate() { - this.setState({}); - } - - /** - * Core update cycle: - * 1. Controllers' hostUpdate() - * 2. render() + DOM commit (with error boundary) - * 3. Controllers' hostUpdated() - * 4. firstUpdated() runs once, on the first render only + * Core update cycle, lit-aligned: + * 1. Snapshot + clear `changedProperties` + * 2. `shouldUpdate(changedProperties)`. If false, resolve updateComplete and return. + * 3. `willUpdate(changedProperties)`. Safe to set properties without re-triggering. + * 4. Controllers' `hostUpdate()` + * 5. `update(changedProperties)`. Default impl calls `render()` + commits. + * Wrapped in error boundary that falls back to `renderError(error)`. + * 6. Controllers' `hostUpdated()` + * 7. `firstUpdated(changedProperties)` runs once, on the first render only + * 8. `updated(changedProperties)` runs after every commit + * 9. Resolve `updateComplete` promise + * @private */ _performRender() { if (!this._renderRoot) return; - // --- 1. hostUpdate --- + // Hold a stable reference to the current Map so all hooks see the same + // snapshot. During the update phase (steps 3-6) the Map is mutated in + // place when property setters fire, folding those changes into THIS + // cycle. Between step 6 and step 7 we install a fresh Map so any + // assignments during firstUpdated/updated queue the NEXT cycle. + const changedProperties = this._changedProperties; + + // --- 1. Mark we're inside an update cycle --- + this._isUpdating = true; + + // --- 2. shouldUpdate gate --- + if (!this.shouldUpdate(changedProperties)) { + this._isUpdating = false; + this._changedProperties = new Map(); + this._resolveUpdate(); + return; + } + + // --- 3. willUpdate (may mutate properties; folds into this cycle) --- + this.willUpdate(changedProperties); + + // --- 4. controllers' hostUpdate --- for (const c of this.__controllers) { if (c.hostUpdate) c.hostUpdate(); } - // --- 2. render + DOM commit (with error boundary) --- + // --- 5. update + DOM commit (with error boundary) --- try { - const tpl = this.render(); - clientRender(tpl, this._renderRoot); + this.update(changedProperties); } catch (error) { console.error(`[webjs] render error in <${tagOf(/** @type any */ (this.constructor)) || this.tagName?.toLowerCase()}>:`, error); try { @@ -622,15 +725,39 @@ export class WebComponent extends Base { } } - // --- 3. hostUpdated --- + // --- 6. controllers' hostUpdated --- for (const c of this.__controllers) { if (c.hostUpdated) c.hostUpdated(); } - // --- 4. firstUpdated (once) --- + // --- End of update phase. Future setter calls queue a NEW cycle. --- + this._isUpdating = false; + this._changedProperties = new Map(); + + // --- 7. firstUpdated (once) --- if (!this.__firstRendered) { this.__firstRendered = true; - this.firstUpdated(); + this.firstUpdated(changedProperties); + } + + // --- 8. updated (every render) --- + this.updated(changedProperties); + + // --- 9. Resolve updateComplete --- + this._resolveUpdate(); + } + + /** + * Resolve the pending updateComplete promise. Value reflects whether any + * further updates were queued during the current cycle: `true` means the + * component has settled, `false` means another render will run. + * @private + */ + _resolveUpdate() { + if (this._updateResolve) { + const settled = this._changedProperties.size === 0; + this._updateResolve(settled); + this._updateResolve = null; } } @@ -638,22 +765,146 @@ export class WebComponent extends Base { // Lifecycle hooks: override in subclasses // --------------------------------------------------------------------------- + /** + * Decide whether the component should render in response to a queued + * update. Default implementation returns `true` (always render). + * + * **When to use:** override to skip an update when the relevant inputs + * haven't changed, e.g. an expensive component that only depends on a + * subset of its reactive properties. + * + * ```js + * shouldUpdate(changedProperties) { + * return changedProperties.has('itemCount') || changedProperties.has('mode'); + * } + * ``` + * + * @param {Map} _changedProperties + * @returns {boolean} + */ + shouldUpdate(_changedProperties) { + return true; + } + + /** + * Run immediately before `update()`. Safe to set reactive properties + * here without triggering another update cycle: assignments made inside + * `willUpdate` are folded into the current `changedProperties` snapshot + * and rendered in the same pass. + * + * **When to use:** computing derived values from changed inputs before + * `render()` reads them. Lit users typically migrate `shouldUpdate` + * heuristics that mutate state into `willUpdate`. + * + * ```js + * willUpdate(changedProperties) { + * if (changedProperties.has('items')) { + * this.totalCount = this.items.length; + * } + * } + * ``` + * + * @param {Map} _changedProperties + */ + willUpdate(_changedProperties) {} + + /** + * The render-and-commit step. Default impl calls `render()` and commits + * the returned `TemplateResult` to the render root. + * + * **When to use:** rarely. Override only when you need to wrap or + * short-circuit the commit. Most users override `render()` instead. + * If you do override, you MUST commit something to `this._renderRoot` + * (or call `super.update(changedProperties)`). + * + * @param {Map} _changedProperties + */ + update(_changedProperties) { + const tpl = this.render(); + clientRender(tpl, this._renderRoot); + } + + /** + * Called after every render commit (both the first and all subsequent + * renders). Receives the `changedProperties` Map so you can branch on + * what changed this cycle. + * + * **When to use:** post-render DOM work that depends on the new DOM, + * triggered by specific property changes. This is the lit-aligned + * replacement for ad-hoc `requestAnimationFrame` shims that components + * historically used to defer DOM work after `render()`. + * + * ```js + * updated(changedProperties) { + * if (changedProperties.has('open') && this.open) { + * this.querySelector('input')?.focus(); + * } + * } + * ``` + * + * @param {Map} _changedProperties + */ + updated(_changedProperties) {} + /** * Called exactly once, after the component's very first render completes - * and the DOM is live. + * and the DOM is live. Receives the same `changedProperties` Map that + * `updated()` does for the first render; entries reflect the initial + * values of reactive properties (old values are `undefined`). * - * **When to use:** one-time post-render setup that requires DOM access - - * auto-focusing an input, measuring layout, initializing a third-party + * **When to use:** one-time post-render setup that requires DOM access. + * Auto-focusing an input, measuring layout, initializing a third-party * library on a DOM node. `connectedCallback` fires before the first * render, so querying shadow children there yields nothing. * * ```js - * firstUpdated() { + * firstUpdated(changedProperties) { * this.shadowRoot.querySelector('input')?.focus(); * } * ``` + * + * @param {Map} _changedProperties */ - firstUpdated() {} + firstUpdated(_changedProperties) {} + + /** + * A Promise that resolves after the next render commit. Resolves to + * `true` when the component has settled (no further updates queued), + * `false` if another render is already scheduled. + * + * **When to use:** awaiting a re-render in tests, or in user code that + * needs to read the post-render DOM after triggering an update. + * + * ```js + * el.count = 5; + * await el.updateComplete; + * // DOM now reflects count = 5 + * ``` + * + * @returns {Promise} + */ + get updateComplete() { + return this.getUpdateComplete(); + } + + /** + * Override point for `updateComplete`. Default returns the internal + * update promise. Override to await additional async work that should + * be considered part of the update cycle (e.g. lazy-loaded subcomponents). + * + * ```js + * async getUpdateComplete() { + * const result = await super.getUpdateComplete(); + * await this._chart?.updateComplete; + * return result; + * } + * ``` + * + * @returns {Promise} + */ + getUpdateComplete() { + return this._updatePromise; + } // --------------------------------------------------------------------------- // Reactive controllers diff --git a/test/component-lifecycle.test.js b/test/component-lifecycle.test.js index 47d233ff..e98c074e 100644 --- a/test/component-lifecycle.test.js +++ b/test/component-lifecycle.test.js @@ -369,3 +369,246 @@ test('default hasChanged treats NaN !== NaN correctly (via strict inequality sem el.n = NaN; // same NaN, but strict inequality says changed assert.equal(updates, 2); }); + +/* -------------------- Phase 2: lit lifecycle hooks -------------------- */ + +test('changedProperties: property setter records (name, oldValue) entries', async () => { + class C extends WebComponent { + static properties = { count: { type: Number } }; + constructor() { super(); this.count = 0; this._captured = null; } + updated(cp) { this._captured = new Map(cp); } + render() { return html``; } + } + C.register('cp-prop'); + const el = document.createElement('cp-prop'); + document.body.appendChild(el); + await el.updateComplete; + el._captured = null; + + el.count = 5; + await el.updateComplete; + assert.equal(el._captured.has('count'), true); + assert.equal(el._captured.get('count'), 0); +}); + +test('changedProperties: setState records a "state" entry with the prior bag', async () => { + class C extends WebComponent { + constructor() { super(); this.state = { n: 1 }; this._captured = null; } + updated(cp) { this._captured = new Map(cp); } + render() { return html``; } + } + C.register('cp-state'); + const el = document.createElement('cp-state'); + document.body.appendChild(el); + await el.updateComplete; + el._captured = null; + + el.setState({ n: 2 }); + await el.updateComplete; + assert.equal(el._captured.has('state'), true); + assert.deepEqual(el._captured.get('state'), { n: 1 }); + assert.deepEqual(el.state, { n: 2 }); +}); + +test('shouldUpdate returning false skips update and updated() hook', async () => { + let renders = 0, updatedCalls = 0; + class C extends WebComponent { + static properties = { val: { type: Number } }; + constructor() { super(); this.val = 0; } + shouldUpdate(_cp) { return this.val < 5; } + updated(_cp) { updatedCalls++; } + render() { renders++; return html``; } + } + C.register('su-gate'); + const el = document.createElement('su-gate'); + document.body.appendChild(el); + await el.updateComplete; + const baselineRenders = renders; + const baselineUpdated = updatedCalls; + + el.val = 10; // shouldUpdate returns false + await el.updateComplete; + assert.equal(renders, baselineRenders); // no render + assert.equal(updatedCalls, baselineUpdated); // no updated() either +}); + +test('willUpdate runs pre-render and can set properties without re-triggering', async () => { + let willRuns = 0, updateRuns = 0; + class C extends WebComponent { + static properties = { + a: { type: Number }, + b: { type: Number, state: true }, + }; + constructor() { super(); this.a = 0; this.b = -1; } + willUpdate(cp) { + willRuns++; + if (cp.has('a')) this.b = this.a * 2; // mutate during willUpdate + } + updated() { updateRuns++; } + render() { return html``; } + } + C.register('wu-fold'); + const el = document.createElement('wu-fold'); + document.body.appendChild(el); + await el.updateComplete; + const wuBaseline = willRuns; + const updBaseline = updateRuns; + + el.a = 7; + await el.updateComplete; + assert.equal(el.b, 14); + assert.equal(willRuns, wuBaseline + 1); + // Single render even though willUpdate set `b`. + assert.equal(updateRuns, updBaseline + 1); +}); + +test('updated runs after every render commit; firstUpdated runs once', async () => { + let firsts = 0, updates = 0; + class C extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + firstUpdated(_cp) { firsts++; } + updated(_cp) { updates++; } + render() { return html``; } + } + C.register('fu-vs-u'); + const el = document.createElement('fu-vs-u'); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(firsts, 1); + assert.equal(updates, 1); + + el.v = 1; + await el.updateComplete; + assert.equal(firsts, 1); // still 1 + assert.equal(updates, 2); + + el.v = 2; + await el.updateComplete; + assert.equal(updates, 3); +}); + +test('firstUpdated receives changedProperties Map with initial values', async () => { + let captured = null; + class C extends WebComponent { + static properties = { n: { type: Number } }; + constructor() { super(); this.n = 42; } + firstUpdated(cp) { captured = new Map(cp); } + render() { return html``; } + } + C.register('fu-cp'); + const el = document.createElement('fu-cp'); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(captured.has('n'), true); + assert.equal(captured.get('n'), undefined); // initial oldValue +}); + +test('update() override can short-circuit the commit', async () => { + let renderCalls = 0; + class C extends WebComponent { + static properties = { n: { type: Number } }; + constructor() { super(); this.n = 0; this._allowRender = true; } + update(cp) { + if (this._allowRender) super.update?.(cp); + } + render() { renderCalls++; return html``; } + } + // super.update calls render+commit. Since we're not actually calling super, + // we need to manually invoke render to count it. Simpler: just check the + // override is called. + let updateCalls = 0; + class D extends WebComponent { + static properties = { n: { type: Number } }; + constructor() { super(); this.n = 0; } + update(cp) { updateCalls++; /* no render */ } + render() { renderCalls++; return html``; } + } + D.register('upd-override'); + const el = document.createElement('upd-override'); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(updateCalls, 1); + assert.equal(renderCalls, 0); // override didn't call super, so render never ran +}); + +test('updateComplete resolves after the next render', async () => { + class C extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; this._renderedV = null; } + updated() { this._renderedV = this.v; } + render() { return html``; } + } + C.register('uc-resolve'); + const el = document.createElement('uc-resolve'); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(el._renderedV, 0); + + el.v = 99; + const settled = await el.updateComplete; + assert.equal(el._renderedV, 99); + assert.equal(typeof settled, 'boolean'); +}); + +test('getUpdateComplete can be overridden to chain additional async work', async () => { + let extraAwaited = false; + class C extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + async getUpdateComplete() { + const r = await super.getUpdateComplete(); + await new Promise(res => setTimeout(res, 1)); + extraAwaited = true; + return r; + } + render() { return html``; } + } + C.register('uc-override'); + const el = document.createElement('uc-override'); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(extraAwaited, true); +}); + +test('hook order: shouldUpdate → willUpdate → hostUpdate → update → hostUpdated → firstUpdated → updated', async () => { + const order = []; + class C extends WebComponent { + static properties = { n: { type: Number } }; + constructor() { super(); this.n = 0; } + shouldUpdate() { order.push('shouldUpdate'); return true; } + willUpdate() { order.push('willUpdate'); } + update(cp) { order.push('update'); super.update?.(cp); } + firstUpdated() { order.push('firstUpdated'); } + updated() { order.push('updated'); } + render() { order.push('render'); return html``; } + } + const controller = { + hostUpdate() { order.push('hostUpdate'); }, + hostUpdated() { order.push('hostUpdated'); }, + }; + C.register('hook-order'); + const el = document.createElement('hook-order'); + el.addController(controller); + document.body.appendChild(el); + await el.updateComplete; + + // Default update() calls render() internally, but we override here + // and DO call super.update?.(cp) which invokes the default impl. + // The default impl is defined on the prototype; super.update?.(cp) on + // a direct WebComponent subclass calls the WebComponent.prototype.update + // method which does the render+commit. + assert.deepEqual( + order, + [ + 'shouldUpdate', + 'willUpdate', + 'hostUpdate', + 'update', + 'render', + 'hostUpdated', + 'firstUpdated', + 'updated', + ], + ); +}); From 9efdd76e6bd5c5312355b22dc1cddde452a2525c Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 21 May 2026 00:36:04 +0530 Subject: [PATCH 04/22] docs: full lit-aligned lifecycle hooks documentation Updates lifecycle/page.ts and agent-docs/components.md to document the new hooks added in the previous commit: shouldUpdate, willUpdate, update, updated, updateComplete, getUpdateComplete, firstUpdated with changedProperties. The lifecycle page is now a comprehensive per-hook reference; the agent-docs lifecycle section is the compact summary that AGENTS.md points to. Both flag the hooks as client-only (SSR pipeline calls render() directly without invoking lifecycle hooks). --- agent-docs/components.md | 23 +++++++++ docs/app/docs/lifecycle/page.ts | 91 ++++++++++++++++++++------------- 2 files changed, 79 insertions(+), 35 deletions(-) diff --git a/agent-docs/components.md b/agent-docs/components.md index 4e47e45a..ce379a5c 100644 --- a/agent-docs/components.md +++ b/agent-docs/components.md @@ -27,6 +27,29 @@ The `.d.ts` overlay shipped with the framework makes every other class member fully typed, so only the reactive properties need the `declare` line, and only in TypeScript files. +## Lifecycle hooks (lit-aligned) + +`WebComponent` ships lit's full reactive lifecycle. Every update cycle runs these hooks in order; each receives a `changedProperties` Map (`Map`, where keys are property names or `'state'` for setState patches). + +| # | Hook | When | +|---|---|---| +| 1 | `shouldUpdate(changedProperties)` | Return `false` to skip the update. Default `true`. | +| 2 | `willUpdate(changedProperties)` | Pre-render. Property assignments here fold into THIS cycle. | +| 3 | controllers' `hostUpdate()` | Pre-render controller hook | +| 4 | `update(changedProperties)` | Default calls `render()` + commits. Override to wrap or short-circuit (rare). | +| 5 | controllers' `hostUpdated()` | Post-render controller hook | +| 6 | `firstUpdated(changedProperties)` | Once, on the first render only | +| 7 | `updated(changedProperties)` | Every render commit. Right place for ad-hoc post-render DOM work. | +| 8 | `updateComplete` Promise resolves | `await el.updateComplete` to read post-render DOM in tests | + +Assignments during `willUpdate` fold into the current cycle (no new render scheduled); assignments during `updated` or `firstUpdated` queue a fresh cycle. The framework gates this via an internal flag, so authors don't manage it. + +All hooks are **client-only**. The SSR pipeline calls `instance.render()` directly and does not invoke `shouldUpdate` / `willUpdate` / `update` / `updated` / `firstUpdated` / `connectedCallback` / `disconnectedCallback`. Set SSR-meaningful defaults in the constructor; use lifecycle hooks for browser-only work. + +`setState(patch)` still works and routes through the same machinery: the `changedProperties` Map gets a `'state'` entry whose old value is the previous state bag. + +See [`/docs/lifecycle`](https://docs.webjs.com/docs/lifecycle) for per-hook usage examples. + ## ReactiveControllers: composable lifecycle ```js diff --git a/docs/app/docs/lifecycle/page.ts b/docs/app/docs/lifecycle/page.ts index 75098b04..dbeaec5a 100644 --- a/docs/app/docs/lifecycle/page.ts +++ b/docs/app/docs/lifecycle/page.ts @@ -5,65 +5,86 @@ export const metadata = { title: 'Lifecycle Hooks | webjs' }; export default function Lifecycle() { return html`

Lifecycle Hooks

-

webjs follows a "less is more" philosophy for lifecycle hooks. Only hooks with no native workaround are included. AI agents don't need abstractions for things that a few lines of code can handle.

+

webjs ships the full lit-aligned component lifecycle. AI coding agents have substantial training data on lit, so adopting lit's hook names and semantics lets agents write idiomatic webjs code without framework-specific translation.

The Update Cycle

-

When setState() or a property change triggers a re-render:

+

Every render goes through this pipeline. Each hook receives a changedProperties Map where keys are property names (or 'state' for setState patches) and values are the previous value before the change.

    +
  1. shouldUpdate(changedProperties) returns false to skip this update entirely.
  2. +
  3. willUpdate(changedProperties) is the pre-render hook. Safe to set reactive properties; assignments fold into this cycle.
  4. Controllers' hostUpdate()
  5. -
  6. render() + DOM commit (with error boundary)
  7. +
  8. update(changedProperties) is the render-and-commit step. The default implementation calls render() and commits to the render root.
  9. Controllers' hostUpdated()
  10. -
  11. firstUpdated() runs once, on the first render only
  12. +
  13. firstUpdated(changedProperties) runs once, on the first render only.
  14. +
  15. updated(changedProperties) runs after every render commit.
  16. +
  17. updateComplete Promise resolves.

render()

-

The core of every component. Returns a TemplateResult via the html tag. Called on every state change.

+

The template the component should produce for the current state. Returns a TemplateResult via the html tag.

render() {
-  // Derived state goes here, before the template:
-  const filtered = this.state.items.filter(i => i.active);
-  const count = filtered.length;
-
   return html\`
-    <p>\${count} active items</p>
-    <ul>\${filtered.map(i => html\`<li>\${i.name}</li>\`)}</ul>
+    <p>\${this.filtered.length} active items</p>
+    <ul>\${this.filtered.map(i => html\`<li>\${i.name}</li>\`)}</ul>
   \`;
 }
-

No willUpdate needed. Compute derived state at the top of render().

-

setState(patch)

-

Shallow-merges the patch into this.state and schedules a microtask-batched re-render. Multiple setState calls within the same microtask are batched into one render.

-
this.setState({ count: this.state.count + 1 });
-this.setState({ name: 'updated' });
-// Only one render happens
+

shouldUpdate(changedProperties)

+

Decide whether to render at all. Default returns true. Use to skip expensive renders when only irrelevant properties changed.

+
shouldUpdate(cp) {
+  return cp.has('items') || cp.has('mode');
+}
-

firstUpdated()

-

Called once after the first render. The shadow DOM is populated, so you can query elements. Use for one-time setup: focus, measurements, third-party library init.

+

willUpdate(changedProperties)

+

Compute derived values from inputs before render() reads them. Property assignments inside willUpdate fold into the current cycle without triggering another update.

+
willUpdate(cp) {
+  if (cp.has('items')) {
+    this.totalCount = this.items.length;
+  }
+}
+ +

update(changedProperties)

+

The render-and-commit step. The default implementation calls render() and commits to the render root. Override only when you need to wrap or short-circuit the commit. Most users override render() instead.

+ +

updated(changedProperties)

+

Post-render DOM work. Runs after every commit (both the first render and all subsequent ones). Inspect changedProperties to branch on what changed this cycle. This is the right place for ad-hoc DOM work that previously needed requestAnimationFrame shims.

+
updated(cp) {
+  if (cp.has('open') && this.open) {
+    this.querySelector('input')?.focus();
+  }
+}
+ +

firstUpdated(changedProperties)

+

Runs once, after the first render. Use for one-time DOM-dependent setup: focus, measurements, third-party library init on a DOM node. The changedProperties Map on the first render contains every reactive property that has a value, with undefined as the old value.

firstUpdated() {
-  this.shadowRoot.querySelector('input')?.focus();
+  this.shadowRoot?.querySelector('input')?.focus();
   this._chart = new Chart(this.shadowRoot.querySelector('canvas'));
 }
-

connectedCallback fires before the first render, so shadow children don't exist there yet. That's why firstUpdated exists.

+

connectedCallback fires before the first render, so shadow children don't exist there yet. firstUpdated is the post-render equivalent.

+ +

updateComplete (and getUpdateComplete)

+

A Promise that resolves after the next render commit. await el.updateComplete in tests or in code that needs to read the post-render DOM after triggering an update. Override getUpdateComplete() to chain additional async work.

+
el.count = 5;
+await el.updateComplete;
+// DOM now reflects count = 5
+ +

setState(patch)

+

Shallow-merges the patch into this.state and schedules a microtask-batched re-render. Multiple setState calls within the same microtask coalesce into one render. The changedProperties Map includes a 'state' entry whose old value is the previous state bag, so lifecycle hooks can detect setState invocations.

+
this.setState({ count: this.state.count + 1 });
+this.setState({ name: 'updated' });
+// One render. changedProperties.has('state') is true; .get('state') is the prior bag.
+ +

requestUpdate(name, oldValue)

+

Manually schedule a re-render. Optionally record a property change so hooks see it in changedProperties. Used by controllers and code that mutates outside the reactive property system.

+
this.requestUpdate('items', oldItems);

renderError(error)

-

Called when render() throws. Return a fallback template to show instead of crashing the page.

+

Runs when update()/render() throws. Return a fallback template to show instead of crashing the page.

renderError(error) {
   return html\`<p style="color:red">Error: \${error.message}</p>\`;
 }

Without this, one broken component would crash the entire page. The default implementation renders nothing and logs to console.

-

What's NOT included (and why)

- - - - - - - - - -
HookNative workaround
shouldUpdateReturn early from render() with an if-statement
willUpdateCompute at the top of render()
updatedUse queueMicrotask() after setState()
changedPropertiesTrack manually with this._prev = {...this.state}
query(sel)this.shadowRoot.querySelector(sel)
-

These abstractions add API surface without solving problems that native code can't. Fewer hooks = fewer concepts for AI agents to choose between.

-

Native Web Component Callbacks

These are provided by HTMLElement itself and work as normal in webjs components:

    From 5c331975a0713a11e8b497620c045c1340817c47 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 21 May 2026 00:40:56 +0530 Subject: [PATCH 05/22] feat(core): Tier-1 lit-html directives (keyed, guard, templateContent, ref) Adds four directives that lit-html ships and webjs lacked. Pure-function exports backed by marker objects ({_$webjs: 'tag', ...}), handled by both the SSR walker (render-server.js) and the client renderer (render-client.js). - keyed(key, template): forces a remount when the key changes between renders. Server ignores the key (one-shot render); client compares with Object.is against the prior key at the same part and tears down before re-rendering. - guard(deps, fn): memoizes a sub-template by shallow-compared deps. Server always invokes fn (no cache for one-shot); client skips re-evaluation when deps array is shallow-equal to the prior call. - templateContent(tpl): emits a