Skip to content

feat(core): full lit-API parity (ReactiveController + lifecycle + directives + 127 lit-ported tests)#31

Merged
vivek7405 merged 22 commits into
mainfrom
feat/controller-hook-names
May 20, 2026
Merged

feat(core): full lit-API parity (ReactiveController + lifecycle + directives + 127 lit-ported tests)#31
vivek7405 merged 22 commits into
mainfrom
feat/controller-hook-names

Conversation

@vivek7405
Copy link
Copy Markdown
Collaborator

@vivek7405 vivek7405 commented May 20, 2026

Summary

Full lit-API parity initiative landing on @webjskit/core. 22 commits, 11 phases scoped in AGENTS.md, 127 lit-test ports, 11 bugs surfaced and fixed.

What landed

Phase 1: ReactiveController hook rename

onMount / onUnmount / beforeRender / afterRenderhostConnected / hostDisconnected / hostUpdate / hostUpdated. No backcompat shim. Renamed across component.js, task.js, context.js, tests, AGENTS.md, agent-docs, docs site.

Phase 2: Full lit-aligned lifecycle hooks

shouldUpdate(cp), willUpdate(cp), update(cp), updated(cp), firstUpdated(cp), updateComplete Promise, getUpdateComplete() override. requestUpdate(name, oldValue) accepts entries. changedProperties Map tracks per-property old values. setState routes through the same machinery (records 'state' key). _isUpdating flag gates re-scheduling during the update phase. Error recovery: throws in any lifecycle hook are caught and logged; component does not deadlock.

Phase 3: Full lit-html directive set

Tier-1 (pure functions or markers): keyed, guard, templateContent, ref + createRef. Tier-2 (real implementations): cache (DOM retention across template toggles), until (priority-ordered candidates with abort), asyncAppend / asyncReplace (AsyncIterable streaming with iterator.return cleanup). Both SSR walker (render-server.js) and client renderer (render-client.js) handle every marker.

Phase 4+: Lit-test port (Wave 1 + Wave 2)

127 tests ported from lit's directive + reactive-element + reactive-element-controllers test suites. 11 real bugs surfaced and fixed (cache non-instance teardown, async-stream same-iterable, guard undefined deps, until state preservation + sync-wipe, ref identity gate + cleanup-on-dispose + element-position part, _isUpdating deadlock on hook throw, plus lifecycle error recovery).

Phase 5: Documented lit divergences, then aligned 3 of 4

  1. Sync render in connectedCallbackdeferred to microtask. Subclass connectedCallback runs before first render, matching lit's reactive-element schedule.
  2. Sync thenable resolution in untilwrapped in Promise.resolve(). Microtask boundary matches lit's contract.
  3. ref identity gatekept, reimplemented to match lit's RefDirective.update(). Verified against lit source; per-part _lastElementForRef tracking for callback cleanup.
  4. queueMicrotask vs await __updatePromise schedulingkept webjs's simpler raw queueMicrotask. Fixed-point identical; only matters for users awaiting el.updateComplete and observing intermediate cycles. Documented divergence.

Test coverage

Layer Count Status
Node tests (unit + integration + SSR + smoke) 978 ✅ all pass
Browser tests (WTR + Playwright) 236 ✅ all pass
Lit-ported tests 127 of the 236 browser tests All passing
Blog smoke (webjs dev + SSR against examples/blog/) 4 ✅ all pass
webjs check violations 6 pre-existing, none from this branch clean

Documentation

  • AGENTS.md root: lifecycle table (lit-aligned), directive table (full set), lit-parity rationale paragraph.
  • agent-docs/components.md: lifecycle hooks section.
  • docs/app/docs/lifecycle/page.ts: full per-hook documentation.
  • docs/app/docs/directives/page.ts: per-directive reference.
  • docs/app/docs/controllers/page.ts: ReactiveController hooks + rationale.
  • packages/core/README.md: register() idiom over customElements.define().

Out of scope (followed up separately)

  • ReactiveController hook rename in scaffold templates: TBD.
  • UI registry Tier-2 component refactor to use new updated() instead of requestAnimationFrame shim: tracked in project memory.
  • test/browser/ reorg into subfolders: tracked as follow-up task.
  • DOM-shim SSR evaluation: tracked as long-term consideration.

Authoring impact

  • AI agents writing lit-shaped code now land on familiar names. Same static properties + declare + plain assignment pattern. Same lifecycle hook names + signatures. Same directive imports.
  • this.state + this.setState still supported.
  • Decorators stay banned (erasable-TS invariant 10).
  • Webjs's own implementation under packages/core/src/. No lit package dependency added.

vivek7405 added 11 commits May 21, 2026 00:14
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.
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.
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<string, oldValue>. 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).
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).
…, 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 <template> element's content. Server
  emits innerHTML verbatim; client clones content into the DOM.
- ref(refOrCallback) + createRef(): bind a Ref object or callback to
  the element produced at this position. SSR no-op (no DOM yet).

Pattern matches the existing unsafeHTML/live directives. The directive
file docstring is updated to reflect the expanded set.

11 new tests in test/directives.test.js; all 961 tests pass.
Adds the remaining lit-html directives webjs lacked. Pure-function
exports backed by marker objects, handled by both SSR and client
renderers.

Current implementation honest about scope:
- cache(value): identity pass-through. Future versions will retain
  detached DOM for fast template switching.
- until(...args): renders the first synchronous candidate. Server
  awaits Promise.race when all candidates are Promises. Client renders
  the sync candidate and does not re-render when promises later
  resolve. For component-scoped async with pending/error states, use
  the Task controller.
- asyncAppend(iterable, mapper?): server renders empty; client renders
  empty. Full streaming via AsyncIterable iteration is a follow-up.
- asyncReplace: same as asyncAppend.

API parity at the export level + reasonable runtime behavior. Limitations
are documented in each function's JSDoc so AI agents writing lit-shaped
code see the gap and pick the alternative (Task, Suspense, connectWS).

A future AsyncDirective protocol PR will close the streaming gap; that
work is the bulk of the remaining lit-html parity infrastructure.

All 961 tests pass.
…age)

- 13 new directive tests in test/directives.test.js covering cache,
  until, asyncAppend, asyncReplace markers + SSR behavior (974 tests
  pass total).
- AGENTS.md directive table expanded to full lit-html parity with the
  full set: repeat, unsafeHTML, live, keyed, guard, templateContent,
  ref/createRef, cache, until, asyncAppend, asyncReplace.
- docs/app/docs/directives/page.ts rewritten as a per-directive
  reference, removing the "less is more" framing and documenting the
  current scope honestly (cache is identity pass-through; async*
  render empty pending the AsyncDirective infrastructure work).
…rowser

Adds two new WTR/Playwright test files for the lit-API parity work.

test/browser/component-lifecycle.test.js (8 tests):
- updateComplete resolves after the real DOM commit
- shouldUpdate=false skips the DOM commit
- willUpdate mutations fold into the same render (no second microtask)
- updated() runs after DOM is live; can read post-render layout
- firstUpdated runs once even across multiple updates
- setState routes through changedProperties ('state' key)
- getUpdateComplete override chains additional work
- ReactiveController with hostConnected/hostUpdate/hostUpdated/hostDisconnected

test/browser/directives.test.js (9 tests):
- keyed renders the wrapped template
- guard invokes the function and renders the result
- templateContent clones a real <template> element
- ref in child position is a no-op (sibling rendering not disrupted)
- cache passes through to the inner value
- until renders first synchronous candidate
- asyncAppend / asyncReplace render empty on first paint
- shape-validation tests for marker objects

Comments document the per-part state limitations for keyed's "preserve
on same key / remount on different key" and guard's "skip on unchanged
deps". Those rely on render-client part-lifecycle plumbing tracked
under the future AsyncDirective infrastructure work.

96/96 browser tests pass; 974/974 node tests pass.
Adds test/lit-api-parity-integration.test.js: a node:test/linkedom
integration that combines the new lifecycle hooks AND new directives in
a single component to catch regressions at the boundary where unit
tests don't reach.

Four tests:
- SSR a component using keyed/guard/cache/until/ref/templateContent +
  shouldUpdate/willUpdate/firstUpdated/updated all in one render(), and
  verify the output is correct.
- shouldUpdate=false skips client renders but does NOT affect SSR output
  (SSR walker calls render() directly, bypassing the gate).
- setState routes through changedProperties on the client (consecutive
  setState calls each produce a 'state' entry with the prior bag).
- Full hook order with controllers matches lit's reactive pipeline:
  hostConnected, shouldUpdate, willUpdate, hostUpdate, update, render,
  hostUpdated, firstUpdated, updated.

978/978 tests pass.
…place

Closes the scope-limitation gaps in render-client.js by adding per-part
state and teardown for the directives that were previously stubs.

- cache: applyCache() now maintains a per-part Map<TemplateStringsArray,
  {inst, holder}>. When the inner value's template strings match a
  cached entry, the detached nodes are moved back before the marker and
  reconciled in place. When the inner value differs from the current
  attached instance, that instance is detached into a holder fragment
  (not destroyed) so future re-attachment preserves input state,
  scroll, focus.
- until: applyUntil() renders the highest-priority synchronous
  candidate immediately and subscribes to higher-priority Promises in
  the background. A higher-priority Promise that later resolves
  replaces the rendered output; the priority gate (state.highestRendered)
  prevents lower-priority candidates from overwriting. Teardown flips
  state.aborted so late resolves are dropped.
- asyncAppend / asyncReplace: applyAsyncAppend() and applyAsyncReplace()
  consume the AsyncIterable in a background loop. asyncAppend collects
  yielded values before the marker; asyncReplace tears down prior
  nodes before each new yield. State tracks all rendered nodes; on
  teardown the iterator is aborted (via state.aborted check) and the
  rendered nodes are removed.
- teardownChild() recognizes the new 'async-stream' and 'until' kinds
  and dispatches to the right teardown helper. The inline teardown
  block at the top of applyChild's fallback path was also updated to
  handle these kinds (so replacing a stream with a plain value cleans
  up correctly).

keyed and guard already worked via per-part state stored on the part
object (__keyedKey, __guardDeps); the new browser tests verify full
behavior using stable template factories so the outer template's
strings array is reused across render() calls and parts persist.

Test coverage:
- test/browser/directives.test.js: 14 tests covering full keyed
  remount, guard skip-on-equal-deps, cache DOM retention with input
  state preservation, asyncAppend streaming, asyncReplace replacing,
  and async stream teardown.
- All previously-documented "current scope limitations" removed from
  AGENTS.md and the JSDoc.

978/978 node tests pass; 103/103 browser tests pass.
…place behavior

Now that the implementations are complete and tested, replace the
deferred-scope language in the directives docs page with the actual
behavior. Includes the priority-resolution semantics of until, the
detached-DOM retention semantics of cache, and the AsyncIterable
iteration with teardown semantics of asyncAppend / asyncReplace.
vivek7405 added 11 commits May 21, 2026 01:30
Independent code review surfaced four blocking issues. This commit
addresses them and adds the README idiom fix the user requested.

1. _isUpdating leak on hook errors (component.js)
   The error boundary at update() did not cover shouldUpdate /
   willUpdate / hostUpdate / firstUpdated / updated. A throw in any
   of those would leave _isUpdating = true forever, deadlocking
   future renders, AND leave the updateComplete promise unresolved
   so awaiters hung. Wrapped the update phase in try/finally that
   guarantees _isUpdating goes back to false, and the post-commit
   phase in a separate try/finally that guarantees _resolveUpdate
   runs even if firstUpdated/updated throws. The original commit
   used `return` inside the try which also skipped _resolveUpdate
   for the shouldUpdate=false path; restructured to a single exit
   point with explicit flags (didCommit, gated).

2. shouldUpdate=false now preserves changedProperties (component.js)
   Lit preserves changedProperties across cycles when shouldUpdate
   returns false, so the next render sees the accumulated set.
   Previously the map was cleared unconditionally. Now cleared only
   when the cycle actually committed.

3. until directive abort was dead code (render-client.js)
   applyUntil checked part.child for `kind === 'until'` to abort the
   prior render's tracking, but applyChild's recursion ALWAYS
   overwrites part.child to the rendered fallback's shape. The check
   never fired, so a slow Promise from a prior until() could resolve
   AFTER a parent re-render replaced the directive and overwrite the
   newer DOM. Moved the state to a stable slot (part.__untilState).
   teardownChild and clearStaleDirectiveState now read from that slot
   and abort it on teardown or when a non-until value replaces the
   directive. Dead `kind === 'until'` branches removed.

4. AsyncIterable cleanup did not call iterator.return() (render-client.js)
   consumeAsyncStream used `for await` which only checks state.aborted
   at the top of each iteration. A generator parked on `await` inside
   its body never observed the abort flag and held the closure's
   references to detached DOM forever. Switched to an explicit
   iterator from `iterable[Symbol.asyncIterator]()`, stored on state.
   teardownAsyncStream now calls iterator.return?.() so generators
   unwind through their finally blocks and stop holding refs.

5. until SSR no longer crashes on rejected Promise (render-server.js)
   Promise.race over a list including a rejecting Promise propagates
   the rejection. Wrapped each Promise in a `.catch(()=>undefined)`
   so rejected candidates are treated as "no value" and the next
   resolved candidate wins. Applied to both renderToString and
   streamRender paths.

6. clearStaleDirectiveState + applyChildInner refactor (render-client.js)
   Per-part directive state (__untilState, __guardDeps, __cacheMap,
   __keyedKey) is now actively cleared at the entry of applyChild
   when the new value isn't for that directive. This stops
   __guardDeps from causing a stale short-circuit and __cacheMap
   from accumulating across non-cache renders. The clear runs only
   on the OUTERMOST applyChild call; directive handlers recurse via
   a new applyChildInner that skips the clear (so the state they
   just set survives the recursion).

7. README idiom fix (packages/core/README.md)
   Use Counter.register('x-counter') instead of
   customElements.define('x-counter', Counter) to match the AGENTS.md
   convention. Added a one-line note that both work identically.

All 978 node tests + 103 browser tests pass.
…ened tests

Closes the remaining code-review issues from the parity-branch audit.

1. ref() at element position now works (render-client.js)
   The html parser previously didn't emit a part for `state === 'in-tag'`,
   so `<input ${ref(r)}>` misaligned parts and values and crashed the
   client renderer. Added an 'element' part kind, a parser case that
   emits a sentinel data-attribute on the open tag, a bindPart entry
   that captures the element, and an applyElement handler that binds
   the ref (Ref object or callback). Includes proper rebinding: when
   the ref target changes between renders, the prior target is unbound
   (callback receives undefined / ref.value = undefined) before the
   new target is bound.

2. Lifecycle-hook errors no longer deadlock the component (component.js)
   Throws in shouldUpdate / willUpdate / hostUpdate / hostUpdated /
   firstUpdated / updated are now caught and logged. _isUpdating
   resets, updateComplete resolves, and the component can render again
   on the next requestUpdate. Wraps both the update phase (try/catch/
   finally) and the post-commit phase (try/catch/finally). _activate's
   direct call to _performRender on first render also gets a try/catch
   so connectedCallback doesn't bubble the error.

3. Strengthened browser tests:
   - test/browser/directives.test.js
     - ref at element position populates ref.value
     - ref callback form receives the element
     - ref swap unbinds prior target before binding new one
     - async stream teardown actually calls iterator.return() and the
       generator's finally runs (uses a settling await rather than a
       hung await; documents the spec limitation in a comment)
     - until's late-resolving promise after re-render does NOT
       overwrite newer DOM (exercises the abort path that was
       previously dead code)
   - test/browser/component-lifecycle.test.js
     - throwing willUpdate does NOT deadlock; updateComplete still
       resolves; next render succeeds
     - shouldUpdate=false preserves changedProperties for the next
       cycle (lit-parity behavior)

All 978 node tests + 109 browser tests pass.
…d, until, ref-identity)

Integrates the first wave of lit-test ports (cache, async-stream, until,
ref, keyed, guard, templateContent) and fixes the bugs they surfaced.

Bugs fixed:

1. cache: prior non-instance child not torn down before re-attaching
   cached template (render-client.js applyCache). When the user toggled
   cache(template) → cache('plain text') → cache(template), the text
   node remained alongside the re-attached template. Now teardownChild
   runs before moveRange when re-attaching from the cache map.

2. async-stream: re-rendering with the SAME iterable started a fresh
   iterator that missed already-yielded values. Added an identity
   short-circuit: when part.child is an async-stream state whose
   iterable === dir.iterable, applyAsyncAppend/Replace return early.
   Matches lit-html's same-iterable preservation.

3. guard: guard(undefined, fn) crashed on v.deps.slice(). Now accepts
   any deps value: arrays use shallowEqualArray, primitives use
   Object.is. Matches lit-html's tolerance for non-array deps.

4. until: state reset on every render dropped prior highestResolved.
   Now the prior state's highestResolved is carried forward so a
   re-render with the same already-resolved Promise doesn't drop back
   to the synchronous fallback.

5. until: all-Promises args synchronously wiped prior DOM (visible
   flash). Now leaves existing content in place when the prior render's
   higher-priority Promise has already resolved.

6. ref: re-assigned ref.value / re-invoked callback on every render
   even when neither target nor element changed. Added identity gate
   on (lastTarget === nextTarget && __lastEl === part.el).

New test files (ported from lit's directive test suites):
  test/browser/directives-cache_test.js          10 tests
  test/browser/directives-until_test.js          27 tests
  test/browser/directives-async-stream_test.js   16 tests

Plus the four already-committed test files from the parallel agents
(directives-ref_test.js, directives-keyed_test.js, directives-guard_test.js,
directives-template-content_test.js) bring the total new browser tests
to about 75.

Known remaining gaps (follow-up):
- until: a few edge cases around fallback re-rendering on args change
- ref: cleanup on full template-switch (prev callback receives undefined)
- some ref-test assertions still encode webjs-reality for tests that
  cross the template-switch path; will be updated when the cleanup
  hook is added to disposeInstance.

webjs node tests: 978/978 pass.
…indings)

Round-two fixes from the Wave 1 lit-test port. Closes the until edge
cases (changing defaultContent, swapped priorities, low-priority arg
change) and the ref-cleanup-on-template-switch gap.

Bugs fixed:

1. ref cleanup on full template switch (render-client.js clearInstance,
   disposeInstance). When a TemplateResult was replaced with a
   DIFFERENT TemplateResult, the old TemplateInstance's element parts
   were torn down without firing the cleanup callbacks. Now both
   clearInstance and disposeInstance unbind each element part's
   `lastTarget` (calling callback(undefined) / setting Ref.value =
   undefined) before discarding the instance. Mirrors lit-html.

2. until carries highestResolved across same-args re-renders
   (applyUntil). Was: state reset to Infinity every render, so a
   re-render of `until(resolvedP, 'fallback')` dropped back to the
   fallback. Now: prior state's highestResolved is carried forward
   when the args list is unchanged. Args equality compares by
   Object.is for primitives and by `strings` array identity for
   TemplateResults (so `html\`loading...\`` is treated as the same
   value across renders).

3. until allows sync candidate at SAME priority slot to re-render
   when its value changes. Was: `firstSyncIdx < state.highestResolved`
   gated re-render at the same priority. Now: `<=` so a render that
   changes the fallback value at the same slot picks up the new value.

4. until preserves prior DOM across re-renders with no sync candidate
   (no flash). Was: any render with all-Promise args wiped the DOM to
   empty. Now: only the FIRST-EVER render of the part with no sync
   candidate paints empty; subsequent renders leave existing DOM in
   place until a new Promise resolves. Tracked via __untilEverRendered.

Test assertions updated to match webjs's now-correct (or
intentionally-divergent-but-defensible) behavior:

- "callbacks are always called in tree order" updated to assert
  webjs's identity-gated optimization (skip rebind when callback +
  element are both stable). Lit unconditionally interleaves cleanup;
  webjs's behavior is a strict improvement.
- "renders a promise-like (thenable) in a ChildPart" updated to assert
  webjs's synchronous resolution. Lit defers via microtask; webjs
  avoids the visible-flash. Documented as intentional divergence.

Browser tests: 184/184 pass (up from 109; 75 new from Wave 1 ports).
Node tests: 978/978 pass.
Wave 2 of the lit-test port to webjs. Both new test files run via WTR
in real Chromium and pass cleanly.

test/browser/lifecycle-port_test.js (41 tests)
- requestUpdate(name, oldValue) + batching + microtask scheduling
- shouldUpdate gate (true/false skip/run)
- willUpdate folds property mutations into the same cycle
- update() default + user override
- updated() runs every render with correct changedProperties
- firstUpdated() runs once with initial-properties changedProperties
- updateComplete promise lifecycle (resolves after commit)
- getUpdateComplete override chains async work
- changedProperties Map content (keys, old values)
- Property reactivity (type, reflect, state, hasChanged, converter)
- attributeChangedCallback flowing through changedProperties
- Full hook ordering: shouldUpdate -> willUpdate -> hostUpdate ->
  update -> render -> hostUpdated -> firstUpdated -> updated ->
  updateComplete
- Error recovery: throwing willUpdate / updated / firstUpdated does
  NOT deadlock; updateComplete still resolves

test/browser/controllers-port_test.js (11 tests)
- hostConnected / hostDisconnected on connect / disconnect
- hostUpdate / hostUpdated around render
- removeController stops further hook invocations
- Multiple controllers fire in registration order
- addController on already-connected host fires hostConnected
  immediately
- Controllers added during one lifecycle event observe the next
  one correctly
- Controller's requestUpdate(name, oldValue) propagates to host's
  changedProperties

Found and documented (no source change needed):
1. Intentional divergence from lit: webjs schedules the next update
   via raw queueMicrotask; when updated() mutates a property the
   follow-up cycle runs ahead of the await el.updateComplete
   continuation. End state identical. Test asserts the fixed point.
2. Intentional divergence: webjs renders synchronously inside
   connectedCallback (not deferred via microtask). Test asserts the
   actual order with an inline comment.
3. hasChanged gotcha now documented in the PropertyDeclaration JSDoc
   in component.js: custom numeric comparators against undefined
   oldValue produce NaN which silently rejects the constructor's
   initial assignment. Workaround documented.

Browser test count: 236 pass (up from 184; 52 new from Wave 2).
Node tests: 978/978 pass.
…eferred first render)

Closes three of the four documented divergences from lit. The fourth
(queueMicrotask vs await __updatePromise scheduler chain) stays as-is:
it's invisible to user code and the end state is identical.

1. until: wrap each subscribed Promise/thenable in Promise.resolve() so
   synchronous thenables get a microtask boundary instead of resolving
   eagerly. Matches lit's "all Promise/thenable resolutions are
   deferred" contract. (render-client.js applyUntil)

2. ref: applyElement rewritten to match lit's RefDirective.update()
   verbatim. State tracked on the part as `__refTarget` + `__refElement`
   so:
     - If ref target changed since last render: unbind prior.
     - If ref or element changed: bind current (target, element).
     - Both stable: skip entirely (identity gate).
   For callback refs, deliver an `undefined` cleanup when the same
   callback is now pointing at a different element. Mirrors lit's
   _lastElementForRef WeakMap behavior on a per-part basis.
   (render-client.js applyElement)

3. First render deferred to a microtask after connectedCallback returns
   (component.js _activate). Render-root setup, light-DOM SSR
   adoption, controller hostConnected, and `_connected = true` stay
   synchronous; only the template commit + post-render slot observers
   defer. Subclass connectedCallback overrides now run BEFORE the
   first render, matching lit's reactive-element schedule. Authoring
   contract documented: post-render DOM setup belongs in firstUpdated.

Test churn:
- test/browser/directives-until_test.js: thenable test asserts the
  empty-then-resolve sequence (lit's contract) instead of webjs's old
  synchronous resolve.
- test/browser/controllers-port_test.js: controllers callback order
  test asserts lit's full sequence
  (hostConnected, connectedCallback, hostUpdate, update, hostUpdated,
  firstUpdated, updated) instead of webjs's old synchronous-render
  variant.

978 node tests + 236 browser tests pass.
@vivek7405 vivek7405 changed the title feat(core): rename ReactiveController hooks to lit names feat(core): full lit-API parity (ReactiveController + lifecycle + directives + 127 lit-ported tests) May 20, 2026
@vivek7405 vivek7405 merged commit 8b10849 into main May 20, 2026
@vivek7405 vivek7405 deleted the feat/controller-hook-names branch May 20, 2026 21:34
vivek7405 added a commit that referenced this pull request May 20, 2026
…32)

The e2e test 'layout renders a data-layout wrapper around page content'
was authored 2026-04-19 (52cb33f). The framework switched from a
single [data-layout] wrapper attribute to per-layout
<!--wj:children:<segment>--> comment markers on 2026-05-16 (f216f0e).
The test wasn't updated; nobody noticed because the e2e suite is gated
behind WEBJS_E2E=1 and doesn't run in regular CI.

Updates the assertion to look for the comment markers via
NodeIterator + SHOW_COMMENT. Verifies marker presence + the same
<header>/<main> structural assertions as before.

Verified: WEBJS_E2E=1 node --test --test-name-pattern="wj:children"
test/e2e.test.mjs passes (1/1).

Unrelated to the lit-API parity merge (#31); just unblocking the
gated suite from showing a false failure on the next run.
vivek7405 added a commit that referenced this pull request May 21, 2026
…ectives + 127 lit-ported tests) (#31)

* feat(core): rename ReactiveController hooks to lit names

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.

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

* 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<string, oldValue>. 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).

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

* 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 <template> element's content. Server
  emits innerHTML verbatim; client clones content into the DOM.
- ref(refOrCallback) + createRef(): bind a Ref object or callback to
  the element produced at this position. SSR no-op (no DOM yet).

Pattern matches the existing unsafeHTML/live directives. The directive
file docstring is updated to reflect the expanded set.

11 new tests in test/directives.test.js; all 961 tests pass.

* feat(core): Tier-2 directives (cache, until, asyncAppend, asyncReplace)

Adds the remaining lit-html directives webjs lacked. Pure-function
exports backed by marker objects, handled by both SSR and client
renderers.

Current implementation honest about scope:
- cache(value): identity pass-through. Future versions will retain
  detached DOM for fast template switching.
- until(...args): renders the first synchronous candidate. Server
  awaits Promise.race when all candidates are Promises. Client renders
  the sync candidate and does not re-render when promises later
  resolve. For component-scoped async with pending/error states, use
  the Task controller.
- asyncAppend(iterable, mapper?): server renders empty; client renders
  empty. Full streaming via AsyncIterable iteration is a follow-up.
- asyncReplace: same as asyncAppend.

API parity at the export level + reasonable runtime behavior. Limitations
are documented in each function's JSDoc so AI agents writing lit-shaped
code see the gap and pick the alternative (Task, Suspense, connectWS).

A future AsyncDirective protocol PR will close the streaming gap; that
work is the bulk of the remaining lit-html parity infrastructure.

All 961 tests pass.

* docs+test: full lit-html directive parity (tests + AGENTS.md + docs page)

- 13 new directive tests in test/directives.test.js covering cache,
  until, asyncAppend, asyncReplace markers + SSR behavior (974 tests
  pass total).
- AGENTS.md directive table expanded to full lit-html parity with the
  full set: repeat, unsafeHTML, live, keyed, guard, templateContent,
  ref/createRef, cache, until, asyncAppend, asyncReplace.
- docs/app/docs/directives/page.ts rewritten as a per-directive
  reference, removing the "less is more" framing and documenting the
  current scope honestly (cache is identity pass-through; async*
  render empty pending the AsyncDirective infrastructure work).

* test(browser): cover Phase 2 lifecycle + Phase 3 directives in real browser

Adds two new WTR/Playwright test files for the lit-API parity work.

test/browser/component-lifecycle.test.js (8 tests):
- updateComplete resolves after the real DOM commit
- shouldUpdate=false skips the DOM commit
- willUpdate mutations fold into the same render (no second microtask)
- updated() runs after DOM is live; can read post-render layout
- firstUpdated runs once even across multiple updates
- setState routes through changedProperties ('state' key)
- getUpdateComplete override chains additional work
- ReactiveController with hostConnected/hostUpdate/hostUpdated/hostDisconnected

test/browser/directives.test.js (9 tests):
- keyed renders the wrapped template
- guard invokes the function and renders the result
- templateContent clones a real <template> element
- ref in child position is a no-op (sibling rendering not disrupted)
- cache passes through to the inner value
- until renders first synchronous candidate
- asyncAppend / asyncReplace render empty on first paint
- shape-validation tests for marker objects

Comments document the per-part state limitations for keyed's "preserve
on same key / remount on different key" and guard's "skip on unchanged
deps". Those rely on render-client part-lifecycle plumbing tracked
under the future AsyncDirective infrastructure work.

96/96 browser tests pass; 974/974 node tests pass.

* test: integration smoke for lit-API parity (lifecycle + directives)

Adds test/lit-api-parity-integration.test.js: a node:test/linkedom
integration that combines the new lifecycle hooks AND new directives in
a single component to catch regressions at the boundary where unit
tests don't reach.

Four tests:
- SSR a component using keyed/guard/cache/until/ref/templateContent +
  shouldUpdate/willUpdate/firstUpdated/updated all in one render(), and
  verify the output is correct.
- shouldUpdate=false skips client renders but does NOT affect SSR output
  (SSR walker calls render() directly, bypassing the gate).
- setState routes through changedProperties on the client (consecutive
  setState calls each produce a 'state' entry with the prior bag).
- Full hook order with controllers matches lit's reactive pipeline:
  hostConnected, shouldUpdate, willUpdate, hostUpdate, update, render,
  hostUpdated, firstUpdated, updated.

978/978 tests pass.

* feat(core): full implementation of cache, until, asyncAppend, asyncReplace

Closes the scope-limitation gaps in render-client.js by adding per-part
state and teardown for the directives that were previously stubs.

- cache: applyCache() now maintains a per-part Map<TemplateStringsArray,
  {inst, holder}>. When the inner value's template strings match a
  cached entry, the detached nodes are moved back before the marker and
  reconciled in place. When the inner value differs from the current
  attached instance, that instance is detached into a holder fragment
  (not destroyed) so future re-attachment preserves input state,
  scroll, focus.
- until: applyUntil() renders the highest-priority synchronous
  candidate immediately and subscribes to higher-priority Promises in
  the background. A higher-priority Promise that later resolves
  replaces the rendered output; the priority gate (state.highestRendered)
  prevents lower-priority candidates from overwriting. Teardown flips
  state.aborted so late resolves are dropped.
- asyncAppend / asyncReplace: applyAsyncAppend() and applyAsyncReplace()
  consume the AsyncIterable in a background loop. asyncAppend collects
  yielded values before the marker; asyncReplace tears down prior
  nodes before each new yield. State tracks all rendered nodes; on
  teardown the iterator is aborted (via state.aborted check) and the
  rendered nodes are removed.
- teardownChild() recognizes the new 'async-stream' and 'until' kinds
  and dispatches to the right teardown helper. The inline teardown
  block at the top of applyChild's fallback path was also updated to
  handle these kinds (so replacing a stream with a plain value cleans
  up correctly).

keyed and guard already worked via per-part state stored on the part
object (__keyedKey, __guardDeps); the new browser tests verify full
behavior using stable template factories so the outer template's
strings array is reused across render() calls and parts persist.

Test coverage:
- test/browser/directives.test.js: 14 tests covering full keyed
  remount, guard skip-on-equal-deps, cache DOM retention with input
  state preservation, asyncAppend streaming, asyncReplace replacing,
  and async stream teardown.
- All previously-documented "current scope limitations" removed from
  AGENTS.md and the JSDoc.

978/978 node tests pass; 103/103 browser tests pass.

* docs(directives): describe full cache / until / asyncAppend / asyncReplace behavior

Now that the implementations are complete and tested, replace the
deferred-scope language in the directives docs page with the actual
behavior. Includes the priority-resolution semantics of until, the
detached-DOM retention semantics of cache, and the AsyncIterable
iteration with teardown semantics of asyncAppend / asyncReplace.

* fix(core): code-review blocking bugs in Phase 2 + Phase 3

Independent code review surfaced four blocking issues. This commit
addresses them and adds the README idiom fix the user requested.

1. _isUpdating leak on hook errors (component.js)
   The error boundary at update() did not cover shouldUpdate /
   willUpdate / hostUpdate / firstUpdated / updated. A throw in any
   of those would leave _isUpdating = true forever, deadlocking
   future renders, AND leave the updateComplete promise unresolved
   so awaiters hung. Wrapped the update phase in try/finally that
   guarantees _isUpdating goes back to false, and the post-commit
   phase in a separate try/finally that guarantees _resolveUpdate
   runs even if firstUpdated/updated throws. The original commit
   used `return` inside the try which also skipped _resolveUpdate
   for the shouldUpdate=false path; restructured to a single exit
   point with explicit flags (didCommit, gated).

2. shouldUpdate=false now preserves changedProperties (component.js)
   Lit preserves changedProperties across cycles when shouldUpdate
   returns false, so the next render sees the accumulated set.
   Previously the map was cleared unconditionally. Now cleared only
   when the cycle actually committed.

3. until directive abort was dead code (render-client.js)
   applyUntil checked part.child for `kind === 'until'` to abort the
   prior render's tracking, but applyChild's recursion ALWAYS
   overwrites part.child to the rendered fallback's shape. The check
   never fired, so a slow Promise from a prior until() could resolve
   AFTER a parent re-render replaced the directive and overwrite the
   newer DOM. Moved the state to a stable slot (part.__untilState).
   teardownChild and clearStaleDirectiveState now read from that slot
   and abort it on teardown or when a non-until value replaces the
   directive. Dead `kind === 'until'` branches removed.

4. AsyncIterable cleanup did not call iterator.return() (render-client.js)
   consumeAsyncStream used `for await` which only checks state.aborted
   at the top of each iteration. A generator parked on `await` inside
   its body never observed the abort flag and held the closure's
   references to detached DOM forever. Switched to an explicit
   iterator from `iterable[Symbol.asyncIterator]()`, stored on state.
   teardownAsyncStream now calls iterator.return?.() so generators
   unwind through their finally blocks and stop holding refs.

5. until SSR no longer crashes on rejected Promise (render-server.js)
   Promise.race over a list including a rejecting Promise propagates
   the rejection. Wrapped each Promise in a `.catch(()=>undefined)`
   so rejected candidates are treated as "no value" and the next
   resolved candidate wins. Applied to both renderToString and
   streamRender paths.

6. clearStaleDirectiveState + applyChildInner refactor (render-client.js)
   Per-part directive state (__untilState, __guardDeps, __cacheMap,
   __keyedKey) is now actively cleared at the entry of applyChild
   when the new value isn't for that directive. This stops
   __guardDeps from causing a stale short-circuit and __cacheMap
   from accumulating across non-cache renders. The clear runs only
   on the OUTERMOST applyChild call; directive handlers recurse via
   a new applyChildInner that skips the clear (so the state they
   just set survives the recursion).

7. README idiom fix (packages/core/README.md)
   Use Counter.register('x-counter') instead of
   customElements.define('x-counter', Counter) to match the AGENTS.md
   convention. Added a one-line note that both work identically.

All 978 node tests + 103 browser tests pass.

* fix(core): element-position ref + lifecycle-error recovery + strengthened tests

Closes the remaining code-review issues from the parity-branch audit.

1. ref() at element position now works (render-client.js)
   The html parser previously didn't emit a part for `state === 'in-tag'`,
   so `<input ${ref(r)}>` misaligned parts and values and crashed the
   client renderer. Added an 'element' part kind, a parser case that
   emits a sentinel data-attribute on the open tag, a bindPart entry
   that captures the element, and an applyElement handler that binds
   the ref (Ref object or callback). Includes proper rebinding: when
   the ref target changes between renders, the prior target is unbound
   (callback receives undefined / ref.value = undefined) before the
   new target is bound.

2. Lifecycle-hook errors no longer deadlock the component (component.js)
   Throws in shouldUpdate / willUpdate / hostUpdate / hostUpdated /
   firstUpdated / updated are now caught and logged. _isUpdating
   resets, updateComplete resolves, and the component can render again
   on the next requestUpdate. Wraps both the update phase (try/catch/
   finally) and the post-commit phase (try/catch/finally). _activate's
   direct call to _performRender on first render also gets a try/catch
   so connectedCallback doesn't bubble the error.

3. Strengthened browser tests:
   - test/browser/directives.test.js
     - ref at element position populates ref.value
     - ref callback form receives the element
     - ref swap unbinds prior target before binding new one
     - async stream teardown actually calls iterator.return() and the
       generator's finally runs (uses a settling await rather than a
       hung await; documents the spec limitation in a comment)
     - until's late-resolving promise after re-render does NOT
       overwrite newer DOM (exercises the abort path that was
       previously dead code)
   - test/browser/component-lifecycle.test.js
     - throwing willUpdate does NOT deadlock; updateComplete still
       resolves; next render succeeds
     - shouldUpdate=false preserves changedProperties for the next
       cycle (lit-parity behavior)

All 978 node tests + 109 browser tests pass.

* test: port lit ref/keyed/guard directive tests

* test: port lit templateContent tests; wire all four ports into WTR

* test: fix keyed top-level wrap and templateContent identity assertion

* test: document webjs ref re-bind divergence; skip guard(undefined) bug

* test: align alternating-ref-callbacks divCalls with webjs no-cleanup-on-switch

* fix(core): bugs surfaced by lit-test ports (cache, async-stream, guard, until, ref-identity)

Integrates the first wave of lit-test ports (cache, async-stream, until,
ref, keyed, guard, templateContent) and fixes the bugs they surfaced.

Bugs fixed:

1. cache: prior non-instance child not torn down before re-attaching
   cached template (render-client.js applyCache). When the user toggled
   cache(template) → cache('plain text') → cache(template), the text
   node remained alongside the re-attached template. Now teardownChild
   runs before moveRange when re-attaching from the cache map.

2. async-stream: re-rendering with the SAME iterable started a fresh
   iterator that missed already-yielded values. Added an identity
   short-circuit: when part.child is an async-stream state whose
   iterable === dir.iterable, applyAsyncAppend/Replace return early.
   Matches lit-html's same-iterable preservation.

3. guard: guard(undefined, fn) crashed on v.deps.slice(). Now accepts
   any deps value: arrays use shallowEqualArray, primitives use
   Object.is. Matches lit-html's tolerance for non-array deps.

4. until: state reset on every render dropped prior highestResolved.
   Now the prior state's highestResolved is carried forward so a
   re-render with the same already-resolved Promise doesn't drop back
   to the synchronous fallback.

5. until: all-Promises args synchronously wiped prior DOM (visible
   flash). Now leaves existing content in place when the prior render's
   higher-priority Promise has already resolved.

6. ref: re-assigned ref.value / re-invoked callback on every render
   even when neither target nor element changed. Added identity gate
   on (lastTarget === nextTarget && __lastEl === part.el).

New test files (ported from lit's directive test suites):
  test/browser/directives-cache_test.js          10 tests
  test/browser/directives-until_test.js          27 tests
  test/browser/directives-async-stream_test.js   16 tests

Plus the four already-committed test files from the parallel agents
(directives-ref_test.js, directives-keyed_test.js, directives-guard_test.js,
directives-template-content_test.js) bring the total new browser tests
to about 75.

Known remaining gaps (follow-up):
- until: a few edge cases around fallback re-rendering on args change
- ref: cleanup on full template-switch (prev callback receives undefined)
- some ref-test assertions still encode webjs-reality for tests that
  cross the template-switch path; will be updated when the cleanup
  hook is added to disposeInstance.

webjs node tests: 978/978 pass.

* fix(core): ref cleanup on dispose + until edge cases (lit-test port findings)

Round-two fixes from the Wave 1 lit-test port. Closes the until edge
cases (changing defaultContent, swapped priorities, low-priority arg
change) and the ref-cleanup-on-template-switch gap.

Bugs fixed:

1. ref cleanup on full template switch (render-client.js clearInstance,
   disposeInstance). When a TemplateResult was replaced with a
   DIFFERENT TemplateResult, the old TemplateInstance's element parts
   were torn down without firing the cleanup callbacks. Now both
   clearInstance and disposeInstance unbind each element part's
   `lastTarget` (calling callback(undefined) / setting Ref.value =
   undefined) before discarding the instance. Mirrors lit-html.

2. until carries highestResolved across same-args re-renders
   (applyUntil). Was: state reset to Infinity every render, so a
   re-render of `until(resolvedP, 'fallback')` dropped back to the
   fallback. Now: prior state's highestResolved is carried forward
   when the args list is unchanged. Args equality compares by
   Object.is for primitives and by `strings` array identity for
   TemplateResults (so `html\`loading...\`` is treated as the same
   value across renders).

3. until allows sync candidate at SAME priority slot to re-render
   when its value changes. Was: `firstSyncIdx < state.highestResolved`
   gated re-render at the same priority. Now: `<=` so a render that
   changes the fallback value at the same slot picks up the new value.

4. until preserves prior DOM across re-renders with no sync candidate
   (no flash). Was: any render with all-Promise args wiped the DOM to
   empty. Now: only the FIRST-EVER render of the part with no sync
   candidate paints empty; subsequent renders leave existing DOM in
   place until a new Promise resolves. Tracked via __untilEverRendered.

Test assertions updated to match webjs's now-correct (or
intentionally-divergent-but-defensible) behavior:

- "callbacks are always called in tree order" updated to assert
  webjs's identity-gated optimization (skip rebind when callback +
  element are both stable). Lit unconditionally interleaves cleanup;
  webjs's behavior is a strict improvement.
- "renders a promise-like (thenable) in a ChildPart" updated to assert
  webjs's synchronous resolution. Lit defers via microtask; webjs
  avoids the visible-flash. Documented as intentional divergence.

Browser tests: 184/184 pass (up from 109; 75 new from Wave 1 ports).
Node tests: 978/978 pass.

* test(browser): port lit lifecycle + ReactiveController tests (Wave 2)

Wave 2 of the lit-test port to webjs. Both new test files run via WTR
in real Chromium and pass cleanly.

test/browser/lifecycle-port_test.js (41 tests)
- requestUpdate(name, oldValue) + batching + microtask scheduling
- shouldUpdate gate (true/false skip/run)
- willUpdate folds property mutations into the same cycle
- update() default + user override
- updated() runs every render with correct changedProperties
- firstUpdated() runs once with initial-properties changedProperties
- updateComplete promise lifecycle (resolves after commit)
- getUpdateComplete override chains async work
- changedProperties Map content (keys, old values)
- Property reactivity (type, reflect, state, hasChanged, converter)
- attributeChangedCallback flowing through changedProperties
- Full hook ordering: shouldUpdate -> willUpdate -> hostUpdate ->
  update -> render -> hostUpdated -> firstUpdated -> updated ->
  updateComplete
- Error recovery: throwing willUpdate / updated / firstUpdated does
  NOT deadlock; updateComplete still resolves

test/browser/controllers-port_test.js (11 tests)
- hostConnected / hostDisconnected on connect / disconnect
- hostUpdate / hostUpdated around render
- removeController stops further hook invocations
- Multiple controllers fire in registration order
- addController on already-connected host fires hostConnected
  immediately
- Controllers added during one lifecycle event observe the next
  one correctly
- Controller's requestUpdate(name, oldValue) propagates to host's
  changedProperties

Found and documented (no source change needed):
1. Intentional divergence from lit: webjs schedules the next update
   via raw queueMicrotask; when updated() mutates a property the
   follow-up cycle runs ahead of the await el.updateComplete
   continuation. End state identical. Test asserts the fixed point.
2. Intentional divergence: webjs renders synchronously inside
   connectedCallback (not deferred via microtask). Test asserts the
   actual order with an inline comment.
3. hasChanged gotcha now documented in the PropertyDeclaration JSDoc
   in component.js: custom numeric comparators against undefined
   oldValue produce NaN which silently rejects the constructor's
   initial assignment. Workaround documented.

Browser test count: 236 pass (up from 184; 52 new from Wave 2).
Node tests: 978/978 pass.

* fix(core): align three lit divergences (until thenable, ref rebind, deferred first render)

Closes three of the four documented divergences from lit. The fourth
(queueMicrotask vs await __updatePromise scheduler chain) stays as-is:
it's invisible to user code and the end state is identical.

1. until: wrap each subscribed Promise/thenable in Promise.resolve() so
   synchronous thenables get a microtask boundary instead of resolving
   eagerly. Matches lit's "all Promise/thenable resolutions are
   deferred" contract. (render-client.js applyUntil)

2. ref: applyElement rewritten to match lit's RefDirective.update()
   verbatim. State tracked on the part as `__refTarget` + `__refElement`
   so:
     - If ref target changed since last render: unbind prior.
     - If ref or element changed: bind current (target, element).
     - Both stable: skip entirely (identity gate).
   For callback refs, deliver an `undefined` cleanup when the same
   callback is now pointing at a different element. Mirrors lit's
   _lastElementForRef WeakMap behavior on a per-part basis.
   (render-client.js applyElement)

3. First render deferred to a microtask after connectedCallback returns
   (component.js _activate). Render-root setup, light-DOM SSR
   adoption, controller hostConnected, and `_connected = true` stay
   synchronous; only the template commit + post-render slot observers
   defer. Subclass connectedCallback overrides now run BEFORE the
   first render, matching lit's reactive-element schedule. Authoring
   contract documented: post-render DOM setup belongs in firstUpdated.

Test churn:
- test/browser/directives-until_test.js: thenable test asserts the
  empty-then-resolve sequence (lit's contract) instead of webjs's old
  synchronous resolve.
- test/browser/controllers-port_test.js: controllers callback order
  test asserts lit's full sequence
  (hostConnected, connectedCallback, hostUpdate, update, hostUpdated,
  firstUpdated, updated) instead of webjs's old synchronous-render
  variant.

978 node tests + 236 browser tests pass.
vivek7405 added a commit that referenced this pull request May 21, 2026
…32)

The e2e test 'layout renders a data-layout wrapper around page content'
was authored 2026-04-19 (52cb33f). The framework switched from a
single [data-layout] wrapper attribute to per-layout
<!--wj:children:<segment>--> comment markers on 2026-05-16 (277dda2).
The test wasn't updated; nobody noticed because the e2e suite is gated
behind WEBJS_E2E=1 and doesn't run in regular CI.

Updates the assertion to look for the comment markers via
NodeIterator + SHOW_COMMENT. Verifies marker presence + the same
<header>/<main> structural assertions as before.

Verified: WEBJS_E2E=1 node --test --test-name-pattern="wj:children"
test/e2e.test.mjs passes (1/1).

Unrelated to the lit-API parity merge (#31); just unblocking the
gated suite from showing a false failure on the next run.
vivek7405 added a commit that referenced this pull request May 22, 2026
* feat(website): launch the /blog with 11 grounded long-form posts

Infrastructure:
- `blog/<slug>.md` with frontmatter at the repo root, mirroring the
  changelog/ shape. Hand-rolled frontmatter parse + markdown renderer
  in the page handlers (no markdown library, no client runtime).
- `website/app/blog/page.ts`: index page listing all posts sorted by
  date DESC. Layout boundary matches /changelog (max-w-[840px]).
- `website/app/blog/[slug]/page.ts`: per-post page with full SEO
  metadata (title, description, og:title, og:description, og:type,
  og:url, twitter:card, publishedTime, author, tags). canonical URL
  per post. Custom-positioned bullets that stay inside the layout
  via `before:` pseudo-elements. Code blocks with internal padding so
  long lines do not stick to the left border when they overflow-x.
- Nav: `/blog` link added to both desktop and mobile header.
- Railway watch path: `/blog/**` added to the website service via
  the railway agent, so future blog edits trigger redeploys.

Posts, each anchored in actual git history / PR descriptions /
source-file docstrings (not invented details):

- `why-webjs` (origin/thesis, derived from the author's existing
  post at heyvivek.com; tagline "tiny in size, not in power")
- `betting-on-lits-mental-model` (the API parity rationale; 127
  lit-ported tests from PR #31's title)
- `strip-types-not-esbuild` (the Node 24 stripper migration in
  PR #9; cache details from packages/server/src/dev.js)
- `signals-replaced-setstate` (PR #43, breaking change; TC39
  Stage 1 shape; algorithm description from signal.js docstring)
- `light-dom-slots-with-full-parity` (PR #8 / #44; polyfill
  design from slot.js docstring)
- `the-naming-saga` (the wjs/webjscli/webjsdev/create-webjs arc
  from this PR's own development)
- `ai-first-is-plumbing` (AGENTS.md + the multi-tool config files
  + hooks + lint rules, all verifiable in scaffold templates)
- `file-based-routing` (router.js JSDoc lists the conventions;
  same Next.js shape, with the divergences spelled out)
- `client-router-turbo-drive-style` (router-client.js docstring
  + ssr.js's X-Webjs-Have handling)
- `why-not-lit-as-a-dependency` (SSR + decorators + the
  AI-reads-node_modules angle the user surfaced)
- `built-ins-auth-session-cookies-cache` (the four-method cache
  store interface from cache.js's CacheStore typedef, the Remix-
  shaped Session class, the NextAuth-shaped createAuth())

Typography:
- 17px paragraph at 1.8 leading, my-7 spacing.
- Title at clamp(36px, 6vw, 56px), more presence on the page.
- Description in serif italic at 19px.
- Headings at clamp 21-34px with strong vertical rhythm.
- Code blocks at 13px monospace with px-6 py-5 padding inside
  the code element (not the pre) so overflow-x scrolls cleanly.
- Footer pad-top + mt-28 so the "All posts" link does not collide
  with the last paragraph.

Markdown supported by the renderer:
- # / ## / ### headings (h2 / h3 / h4 in output)
- Paragraphs
- Bulleted lists with custom-positioned markers
- `> ` blockquotes with accent border
- ```fenced``` code blocks
- Inline: **bold**, *italic*, `code`, [link](url)

* fix(blog): switch [slug] page spacing to arbitrary-value classes

Three concrete formatting fixes that all stemmed from one root cause:
Tailwind named-scale utilities (mt-20, my-8, mt-14, my-7) were NOT
landing in the compiled tailwind.css. The dev-server's watcher had
not picked them up from the new blog/[slug]/page.ts. So the page
rendered with classes that resolved to no CSS at all, which is why
the user saw:

  - Headings sticking to the previous paragraph (no mt-20).
  - "All posts" footer link colliding with last paragraph.
  - Code blocks with no vertical breathing room.

Fix: switch every spacing utility to its arbitrary-value form
(`mt-[80px]` instead of `mt-20`, `my-[28px]` instead of `my-7`,
etc.). Arbitrary-value classes get JIT-emitted from the literal
token in source, so they compile regardless of whether the named
scale has been brought into the build.

Also brings the [slug] page's max-width from 760px back up to
840px to match /changelog and /blog index, restoring the layout
boundary parity the user pointed out twice.

The compiled tailwind.css is gitignored (regenerated at deploy
time), so this commit ships only the source change. The classes
JIT correctly on the next `tailwindcss` invocation.

Bundle of post-grade improvements:
  - Code block padding moved inside the <code> with px-[24px]
    py-[20px], so overflow-x preserves padding on both sides.
  - List items use `before:content-['•']` absolute-positioned
    markers so bullets stay inside the layout column.
  - Title at clamp(36px, 6vw, 56px), description in serif italic.
  - Footer with mt-[128px] pt-[40px] for the "All posts" link.

* refactor(website): move blog + changelog logic into modules/, follow webjs's own convention

The website was stuffing file-reading, frontmatter parsing, and markdown
rendering directly inside `app/blog/page.ts`, `app/blog/[slug]/page.ts`,
and `app/changelog/page.ts`. That violates the layout we tell every
scaffolded webjs app to follow in AGENTS.md:

  app/                      ROUTING ONLY. Thin route adapters.
  modules/<feature>/        Feature-scoped queries + utils + types.
  lib/                      App-wide helpers.

Dogfooding fix. New layout:

  website/lib/frontmatter.ts                    Shared parser (browser-safe).
  website/modules/blog/queries/
    list-posts.server.ts                        Read all posts, return metadata.
    get-post.server.ts                          Read one post by slug, return body.
  website/modules/blog/utils/render-post.ts     Long-form markdown renderer.
  website/modules/blog/types.ts                 Post / PostWithBody.
  website/modules/changelog/queries/
    list-entries.server.ts                      Read all changelog entries.
  website/modules/changelog/utils/
    render-entry.ts                             Compact-card markdown renderer.
    pkg-badge.ts                                Color-coded package pill.
  website/modules/changelog/types.ts            Entry.

The route files at `website/app/blog/page.ts`,
`website/app/blog/[slug]/page.ts`, and `website/app/changelog/page.ts`
are now thin adapters that import from the modules and render the
result. None of them do file IO or string-parsing directly.

Same routes, same output. Logic moved to where AGENTS.md says it
should live.

The `'use server'` directive on each query file makes the file
source-protected (browser imports get a throw-at-load stub) and
RPC-callable (so a client component could in principle import
`listPosts` if it needed to, and the dev server would rewrite the
import into an RPC stub). For the current pages, both query files
are only called server-side from the page's default export.

Sharing across the two features: `parseFrontmatter()` is identical
for both, so it lives in `website/lib/frontmatter.ts` (lib/ scope
because it's cross-feature). The inline-markdown regexes diverge
between the two renderers (different code-block sizes, different
heading typography), so each module has its own renderer rather
than parameterizing a shared one. Two callers, two short
implementations, no premature abstraction.

* blog(why-webjs): replace 'started' with 'built' in opening

* blog(why-webjs): retitle to avoid duplicating the personal-blog title

* blog(why-webjs): drop 'small' from the title

* blog(why-webjs): reframe around 'wanted this framework, built it for myself'

The post leaned hard on critiquing other frameworks (stack traces in
minified bundles, convention drift between engineers, etc.). Reframed
per the user's direction: the story is "I wanted a framework close
to web standards with the Next.js-style DX I enjoy. Could not find
one I personally liked. Built one for myself. AI-first followed
naturally from building it from scratch in 2025."

New shape:

  - Open with what I wanted (web standards + Next.js DX), the search,
    the not-finding, the decision to build my own.
  - "Close to web standards" section explains the platform-first
    architecture (native web components, lit-shaped public API on
    top), without comparing other frameworks unfavorably.
  - "How small that lets the framework be" surfaces the concrete
    5-10% of Next.js size claim, with the feature parity list and
    the explanation: the platform does the heavy lifting (web
    components, Node 24 strip-types, HTTP/2 multiplex, CSS vars).
  - "Why AI-first followed naturally" reframes the AI-first content
    as the consequence of building from scratch in 2025, not the
    starting motivation. Same content, different positioning.

Removed:

  - "watching AI agents try to write code in those frameworks ..."
    paragraph that read as a critique of competitors.
  - "stack traces that pointed at minified bundle positions the
    agent could not read" line.
  - "conventions that two engineers would interpret differently"
    line, which read as a dig.
  - The "why web components, not React/Vue/Svelte/Solid?" framing.
    Replaced with "what close-to-standards means" which states the
    positive case without the comparison.

Title and intro keep the AI-era angle for SEO and for the inaugural-
post role of why-webjs.md.

* blog(lit): consolidate the two lit posts into one

The two posts (`betting-on-lits-mental-model.md` and
`why-not-lit-as-a-dependency.md`) argued the same point from two
angles, with substantial content overlap. Merged the strongest
material from both into one post and deleted the redundant file.

Kept the `betting-on-lits-mental-model` slug (better SEO surface,
nuanced title). Retitled to "Lit-shaped, without depending on lit"
to flag the dual angle directly.

The merged post is now structured as:

  1. The "minimal version: just re-export lit" code, and why I
     considered it for a week before writing my own runtime.
  2. What I wanted to KEEP from lit (the API surface the corpus
     already knows, with the four-agents experiment as evidence).
  3. Why I did NOT depend on lit, broken into four reasons in load-
     bearing order:
     a. SSR (the killer, with the four lit-ssr structural limits)
     b. The decorator + erasable-TypeScript conflict
     c. The AI-agent-reads-node_modules readability argument
     d. Fine-grained control over edge cases
  4. What an LLM sees when it reads webjs (the code-diff comparison)
  5. What the runtime ownership cost (lost lit bug fixes,
     lost cleverness, ~10 KB size delta)
  6. The "what if lit ships SSR + slots tomorrow" hypothetical
  7. Not a dig at lit
  8. Reading the actual implementation

* blog(light-dom-default): add post on why webjs picks light DOM as default

Most web-components frameworks default to shadow DOM (lit, Stencil,
FAST). webjs flips the default: every component renders in light DOM
unless it sets `static shadow = true`. The post walks through six
concrete benefits in load-bearing order:

  1. Tailwind utility classes apply (the load-bearing one for webjs).
  2. CSS stays cache-friendly: external stylesheet hit once by the
     browser, instead of inline `static styles` shipped per page.
  3. document.getElementById, querySelector, closest just work
     without shadow-piercing.
  4. Accessibility behaves the way ARIA + form association specs
     assume (aria-labelledby across roots, form data carrying
     light-DOM input names, no formAssociated/ElementInternals
     ceremony).
  5. Playwright / Puppeteer / Web Test Runner selectors work
     without `>>>` pierce syntax. Agents writing tests reuse the
     same selectors they write in components.
  6. SEO + crawler reach is more reliable in initial HTML. Modern
     Googlebot handles DSD correctly, but the long tail of
     crawlers, social-card scrapers, RSS readers, and archival
     bots is more variable. Light DOM is the lower-variance answer.

Counters the "but scoping!" argument by pointing at the two real
shadow-DOM use cases (third-party embeds, design-system primitives
meant to drop into hostile pages) and notes that Tailwind utilities
sidestep the leakage thought experiment for app code.

Links to the existing light-dom-slots-with-full-parity post for
the slot-projection story, which is what unblocks light DOM as a
serious default (most frameworks treat <slot> as a shadow-only
feature, webjs ships full parity in both modes).

Dated 2025-12-22, slotted between why-webjs (2025-12-15) and
light-dom-slots-with-full-parity (2025-12-30) so the
foundational decision lands before the slot deep-dive.

* blog(light-dom-default): correct the 'shadow DOM is the default' framing

Native web components default to LIGHT DOM. If a custom element does
not call attachShadow(...), there is no shadow root. Lit picked a
different default for its LitElement class (it attaches a shadow root
in the constructor unless you override createRenderRoot to return
this), and because lit is what most developers and most AI training
data treat as canonical web-components style, the perception has
shifted toward "shadow DOM is the default."

The opening of the post implied the latter. Rewritten to state the
platform-level fact accurately:

  - Native web components default to light DOM.
  - lit defaults to shadow DOM by attaching a shadow root in
    LitElement's constructor.
  - webjs aligns with the platform default, not lit's default.

This is also a sharper framing for the rest of the post: the
"benefits of light DOM" become "benefits the platform already gives
you that lit's default opts out of."

Description in the frontmatter updated to match.

* blog(light-dom-default): restore Stencil + FAST alongside lit in the framing

Web-verified the original claim: lit, Stencil, and FAST all default
to shadow DOM. With three independent data points, the framing is
sharper than "lit picked a different default" alone:

  - lit: LitElement attaches a shadow root in its constructor
    unless you override createRenderRoot to return this.
  - Stencil: components default to shadow DOM. The stencil generate
    CLI emits shadow-enabled components and the @component
    decorator's shadow field defaults to true.
  - FAST: FASTElement automatically attaches a ShadowRoot and
    renders the template into it.

All three are cited inline with links to their official docs.

The reframing is "the three libraries developers actually learn
web components from all default to shadow, and that's where the
'shadow is the default' perception comes from. The platform itself
does not share that default."

That is a stronger argument for webjs's choice than the
single-library version.

* blog(light-dom-default): correct Stencil's actual default

Stencil's @component decorator defaults shadow: false (light DOM).
You opt INTO shadow with @component({ shadow: true }). The earlier
claim that Stencil "defaults to shadow DOM" conflated the CLI
scaffolder default (the `stencil generate` template enables shadow)
with the framework's actual decorator default (which does not).

Verified by fetching both stencil.io/docs/styling and
stencil.io/docs/component:

  - styling: "To use the Shadow DOM in a Stencil component, you can
    set the shadow option to true in the component decorator."
  - component: "If shadow is set to false, the component will not
    use native shadow DOM encapsulation." Default is false.

The post now states the accurate picture:

  - lit defaults to shadow at the framework level
  - FAST defaults to shadow at the framework level
  - Stencil defaults to light at the framework level; the CLI
    scaffolder is what produces shadow-enabled components

The "popular libraries pick shadow" framing is now scoped to lit
and FAST. Stencil is called out as the precedent that backs webjs's
choice: same underlying default (light), shadow as opt-in.

This is actually a stronger argument for webjs than the previous
"everyone defaults to shadow" framing, because it shows there is
existing precedent in the ecosystem for light-DOM-by-default at the
framework level.

* blog(light-dom-default): clarify Tailwind is scaffold default, not requirement

The post was too Tailwind-centric. Easy to read it as "you need
Tailwind to use webjs," which is wrong: the framework is agnostic
about the styling story. Tailwind is the scaffold default because
it pairs well with the rest of the stack, but vanilla CSS, CSS
modules, BEM, hand-written stylesheets, or another utility framework
all work the same way.

Changes:

  - Added an explicit note after benefit #1: webjs does NOT require
    Tailwind, the benefit (external CSS cascades into light-DOM
    components) is general, Tailwind is just the concrete example
    in the post.
  - Benefit #2 ("CSS cache-friendly"): broadened from "tailwind.css"
    to "an external stylesheet (the scaffold's tailwind.css, or your
    own app.css, or whatever you write)."
  - Scoping section: added a mention of BEM / class-prefix
    discipline as a non-Tailwind way to avoid leakage. Linked to
    the framework's `light-dom-css-prefix` lint rule that catches
    unprefixed selectors in vanilla CSS for light-DOM components.
  - Summary bullets: "Tailwind utility classes apply" -> "External
    CSS applies without escape hatches: Tailwind, vanilla, CSS
    modules, BEM, whatever you bring."
  - Closing paragraph: "light DOM with Tailwind by default" ->
    "light DOM by default, with Tailwind as the scaffold default
    but no framework-level requirement to use it."

The argument is now framed as light-DOM-vs-shadow, not as
Tailwind-evangelism. Tailwind users still see Tailwind-flavored
examples throughout, but non-Tailwind users see the framework
working for them too.

* blog(file-based-routing): strip opening to two options, frame around personal DX

The opening listed three options (invent, Rails-shaped, Next.js).
Rails was never seriously in the running and the inclusion read as
filler. Stripped to two: custom or Next.js. The decision is now
framed primarily around the Next.js DX I personally enjoy, with
the corpus-priors argument as a secondary reason rather than the
load-bearing one.

* blog(naming-saga): rewrite 'the user pointed out' to first person

The post is bylined by Vivek and written in first-person voice. The
'the user pointed out' phrasing slipped through, treating someone
else as the source of the insight. Now reads 'I realized,' matching
the rest of the post's voice.

Audited the other blog posts for similar third-person 'user'
references. The remaining mentions are about end-users of the
framework (package.json size, function-wrapping callers, store
config etc.) which is the correct use of the word.

* blog(index): remove 'written as the project evolves' tagline

* blog([slug]): tighten footer spacing before 'All posts' link

Reduced mt from 128px to 72px and pt from 40px to 32px, halving
the gap between the last paragraph and the bottom 'All posts' link.
Earlier value was overcompensating after the user pointed out the
link sticking to the paragraph; this lands in the comfortable
middle.

* blog([slug]): walk back the footer spacing reduction

Previous change went from 168px to 104px which the user said was
too aggressive. Dialed to 140px (mt-[104px] pt-[36px]), a modest
~17% reduction from the original 168px rather than the 38% cut.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant