Skip to content

Commit 272d417

Browse files
authored
fix(core): filter framework records in light-DOM slot observer (#44)
* fix(core): filter framework records in light-DOM slot observer The slot host's MutationObserver could not distinguish renderer-driven childList mutations (createInstance / child-part template swaps) from user-authored appendChild calls. When a re-render's replaceChildren fired, the observer captured the framework's wrapper elements as new authored children; projectChildren then tried to append a wrapper into the slot it contains, raising HierarchyRequestError. Drop records whose added or removed nodes include a w$s/w$e bookend comment, since the renderer always inserts and removes its content as a marker-bounded unit while user authoring never produces those markers. * docs: replace remaining setState mentions with signals README pitch and the two agent-docs that AI tools and humans read still referenced the removed setState API. webjs.dev and docs.webjs.dev already render no stale mentions; these three files are the last ones in the repo.
1 parent 6e50ae6 commit 272d417

7 files changed

Lines changed: 257 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ TypeScript with zero build step, real SSR with Declarative Shadow DOM.
1414
- **AI-first.** Predictable file conventions, one function per file, an explicit `.server.ts` boundary, and an `AGENTS.md` contract. The whole design lets LLMs modify code without loading the entire codebase into context.
1515
- **No build step you run.** `.ts` files served directly. The dev server transforms TypeScript via esbuild for both server-side imports (SSR) and browser-bound modules (hydration). One transformer handles both at roughly 1ms per file, cached by mtime. Full TS feature support: enums, decorators, parameter properties, anything esbuild handles. Edit, refresh, done.
1616
- **Web components, light DOM by default.** Pages and components render as light DOM so global CSS and Tailwind utilities apply directly: no `::part`, no `:host`, no CSS-var plumbing. Shadow DOM is opt-in (`static shadow = true`) when you need scoped styles or real `<slot>` projection. Both modes SSR fully, no hydration runtime.
17-
- **Progressive enhancement, built in.** Pages *and* components are SSR'd to real HTML. Every web component's `render()` runs on the server, so its initial markup is in the response before any script loads. Content reads, links navigate, forms submit (server actions are plain HTML POSTs), and display-only custom elements look right, all without JavaScript. JS is opt-in *per interactive behavior*, not per component: a counter renders as "0" without JS, and only the +/- click handling needs scripts. The HTML is the floor, and the client router and `@click` / `setState` interactivity are layered on top.
17+
- **Progressive enhancement, built in.** Pages *and* components are SSR'd to real HTML. Every web component's `render()` runs on the server, so its initial markup is in the response before any script loads. Content reads, links navigate, forms submit (server actions are plain HTML POSTs), and display-only custom elements look right, all without JavaScript. JS is opt-in *per interactive behavior*, not per component: a counter renders as "0" without JS, and only the +/- click handling needs scripts. The HTML is the floor, and the client router and `@click` / signal interactivity are layered on top.
1818
- **Tailwind CSS by default.** The scaffold ships with the Tailwind browser runtime + `@theme` design tokens. Prefer hand-written CSS? Opt out entirely, and the framework works just as well with vanilla CSS when you follow the wrapper-scoping convention (`.page-<route>`, `.layout-<name>`, component-tag scoped). Full recipe in the [Styling docs](./docs/app/docs/styling/page.ts).
1919
- **Full-stack type safety.** Import a `.server.ts` function from a component, and TypeScript sees the real signature. webjs's built-in ESM serializer on the wire preserves `Date`, `Map`, `Set`, `BigInt`, `TypedArray`, `Blob`, `File`, `FormData`, and reference cycles.
2020
- **Server-file source is unreachable from the browser.** Framework invariant: any file ending `.server.{js,ts}` is source-protected. With `'use server'` it serves an RPC stub (server action); without, a throw-at-load stub (server-only utility). Either way the real source never reaches the browser. Enforced in the HTTP layer with regression tests.

agent-docs/components.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ line, and only in TypeScript files.
2929

3030
## Lifecycle hooks (lit-aligned)
3131

32-
`WebComponent` ships lit's full reactive lifecycle. Every update cycle runs these hooks in order; each receives a `changedProperties` Map (`Map<string, oldValue>`, where keys are property names or `'state'` for setState patches).
32+
`WebComponent` ships lit's full reactive lifecycle. Every update cycle runs these hooks in order; each receives a `changedProperties` Map (`Map<string, oldValue>`, where keys are reactive-property names).
3333

3434
| # | Hook | When |
3535
|---|---|---|
@@ -46,7 +46,7 @@ Assignments during `willUpdate` fold into the current cycle (no new render sched
4646

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

49-
`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.
49+
For component-local state, create an instance signal in the constructor and call `signal.set(...)` to mutate. The built-in `SignalWatcher` re-runs `render()` on the next microtask; the same lifecycle hooks fire as for reactive-property changes.
5050

5151
See [`/docs/lifecycle`](https://docs.webjs.com/docs/lifecycle) for per-hook usage examples.
5252

@@ -326,6 +326,6 @@ slot's content in place.
326326
327327
| Method | Purpose |
328328
|---|---|
329-
| `this.setState({...})` | Batched state update via microtask |
329+
| `signal.set(v)` (instance signal) | Component-local reactive state; auto-tracked by SignalWatcher |
330330
| `this.requestUpdate()` | Manually schedule a re-render (controllers) |
331331
| `this.shadowRoot.querySelector(sel)` | Query shadow DOM (native API) |

agent-docs/lit-muscle-memory-gotchas.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ therefore needs JS shipped and run) at the component boundary.
2929
In webjs, the granularity is different. Every component is server
3030
rendered. JavaScript is requested **by the specific interactive holes
3131
you write in the template**. A `@click=${...}` binding requests JS for
32-
click handling. A `setState({ ... })` call requests JS for reactive
33-
updates. A property binding `.data=${richObject}` requests JS for
34-
property hydration. A controller like `Task` requests JS for that
35-
async behavior. A plain `<a href>` does not request JS. A
36-
`<form action="...">` does not request JS. A purely display-time
37-
component (no event listeners, no `setState`, no property bindings to
38-
hydrate) does not request JS.
32+
click handling. A `signal.set(...)` call (instance or module-scope)
33+
requests JS for reactive updates. A property binding
34+
`.data=${richObject}` requests JS for property hydration. A controller
35+
like `Task` requests JS for that async behavior. A plain `<a href>`
36+
does not request JS. A `<form action="...">` does not request JS. A
37+
purely display-time component (no event listeners, no signal
38+
mutations, no property bindings to hydrate) does not request JS.
3939

4040
A single component can mix both. A product card that shows
4141
server-rendered title, price, image, and a "View" link
@@ -84,8 +84,8 @@ The gotchas below are all violations of that rule.
8484

8585
### 1. Fetching data in `connectedCallback` or `firstUpdated`
8686

87-
The lit pattern is to subscribe or fetch on connect, then `setState`
88-
when the data arrives. In webjs the first paint is empty because
87+
The lit pattern is to subscribe or fetch on connect, then update
88+
state when the data arrives. In webjs the first paint is empty because
8989
neither hook runs server-side. Content pops in after hydration, often
9090
with a layout shift.
9191

packages/core/src/slot.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,53 @@
3232
* to be re-projected on the next render.
3333
*/
3434

35+
import { MARKER } from './html.js';
36+
3537
// ---------------------------------------------------------------------------
3638
// Module-scope constants
3739
// ---------------------------------------------------------------------------
3840

41+
// Comment-node values used by render-client.js to bookend every
42+
// rendered template instance. Used to distinguish framework-driven
43+
// childList mutations from user-driven appendChild / removeChild in
44+
// the host's MutationObserver below.
45+
const FRAMEWORK_MARKER_START = `${MARKER}s`;
46+
const FRAMEWORK_MARKER_END = `${MARKER}e`;
47+
48+
/**
49+
* True when the node is one of the framework's render-instance bookend
50+
* comment markers. The render-client.js paths that insert into or
51+
* remove from a slot host always include such a marker in the same
52+
* MutationRecord (either by `replaceChildren(start, ...content, end)`
53+
* at the top level or by `insertBefore(frag, marker)` where `frag`
54+
* itself is built from a `nodesToFrag([start, ...content, end])`).
55+
* Mutation records that touch one of these markers therefore belong
56+
* to the framework's render commit, not user authoring.
57+
*
58+
* @param {Node} node
59+
* @returns {boolean}
60+
*/
61+
function isFrameworkMarker(node) {
62+
if (!node || node.nodeType !== 8 /* COMMENT_NODE */) return false;
63+
const v = node.nodeValue;
64+
return v === FRAMEWORK_MARKER_START || v === FRAMEWORK_MARKER_END;
65+
}
66+
67+
/**
68+
* True when a MutationRecord's added or removed nodes include a
69+
* framework bookend marker. Used by the host's childList observer
70+
* to drop renderer-driven records that would otherwise be
71+
* misinterpreted as authored-child changes.
72+
*
73+
* @param {MutationRecord} record
74+
* @returns {boolean}
75+
*/
76+
function isFrameworkRecord(record) {
77+
for (const n of record.addedNodes) if (isFrameworkMarker(n)) return true;
78+
for (const n of record.removedNodes) if (isFrameworkMarker(n)) return true;
79+
return false;
80+
}
81+
3982
function detectBrowser() {
4083
return typeof HTMLElement !== 'undefined' && typeof HTMLSlotElement !== 'undefined';
4184
}
@@ -390,6 +433,20 @@ export function attachSlotObservers(host) {
390433
state.childObserver = new MutationObserver((records) => {
391434
let dirty = false;
392435
for (const r of records) {
436+
// Skip records produced by the framework's own renderer. Each
437+
// top-level TemplateInstance commit (`createInstance`) and each
438+
// child-part template swap (`applyChildPart`) inserts and removes
439+
// its content as a unit bounded by `w$s`/`w$e` comment markers
440+
// (see render-client.js). When such a record reaches the host's
441+
// childList observer, treating its addedNodes as authored
442+
// children pollutes assignedByName with the renderer's own
443+
// wrapper elements; projectChildren then tries to append those
444+
// wrappers into a <slot> they contain, raising a
445+
// HierarchyRequestError ("new child element contains the
446+
// parent"). Filtering on marker presence isolates renderer
447+
// commits from real authoring (which never inserts framework
448+
// markers).
449+
if (isFrameworkRecord(r)) continue;
393450
if (r.type === 'childList') {
394451
for (const node of r.addedNodes) {
395452
// A new child appeared directly under host (not via slot.append

test/browser/signal-slot-integration.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,56 @@ suite('signal + light-DOM slot integration', () => {
8484
shell.remove();
8585
});
8686

87+
test('signal-driven conditional toggles whether a child is slotted at all', async () => {
88+
// Parent re-renders so its inner shell custom element appears
89+
// with or without an authored child between its tags. Before the
90+
// slot-projection cycle fix, the parent's replaceChildren was
91+
// captured by the shell's MutationObserver as authored content,
92+
// and projection then tried to nest the shell inside its own
93+
// <slot>. The fix filters framework-driven records, so this case
94+
// now reads cleanly.
95+
const showChild = signal(false);
96+
97+
class IntShellEl extends WebComponent {
98+
render() { return html`<section class="int-shell"><slot></slot></section>`; }
99+
}
100+
customElements.define('intg-shell', IntShellEl);
101+
102+
class IntChildEl extends WebComponent {
103+
render() { return html`<i class="int-child">child rendered</i>`; }
104+
}
105+
customElements.define('intg-child', IntChildEl);
106+
107+
class IntParentEl extends WebComponent {
108+
render() {
109+
return showChild.get()
110+
? html`<intg-shell><intg-child></intg-child></intg-shell>`
111+
: html`<intg-shell></intg-shell>`;
112+
}
113+
}
114+
customElements.define('intg-parent', IntParentEl);
115+
116+
const root = document.createElement('intg-parent');
117+
document.body.appendChild(root);
118+
await root.updateComplete;
119+
assert.equal(root.querySelectorAll('intg-child').length, 0, 'child not authored initially');
120+
121+
showChild.set(true);
122+
await Promise.resolve(); await Promise.resolve();
123+
await root.updateComplete;
124+
await Promise.resolve(); await Promise.resolve();
125+
assert.equal(root.querySelectorAll('intg-child').length, 1, 'child appears');
126+
assert.equal(root.querySelectorAll('i.int-child').length, 1, 'child renders through projection');
127+
128+
showChild.set(false);
129+
await Promise.resolve(); await Promise.resolve();
130+
await root.updateComplete;
131+
await Promise.resolve(); await Promise.resolve();
132+
assert.equal(root.querySelectorAll('intg-child').length, 0, 'child gone when signal flips back');
133+
134+
root.remove();
135+
});
136+
87137
test('signal-driven content swap inside a slot host re-renders, projection preserves identity', async () => {
88138
// A slot host whose own render reads a signal but keeps the slot
89139
// shape stable. Signal change re-renders the host; authored
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Reproducer for the slot-projection cycle bug:
3+
*
4+
* HierarchyRequestError: Failed to execute 'appendChild' on 'Node':
5+
* The new child element contains the parent.
6+
*
7+
* Two scenarios are known to trigger it (both surfaced when I tried to
8+
* land integration tests for signals + slot interaction):
9+
*
10+
* 1. A parent custom element's render conditionally outputs a slot
11+
* host with a nested child. Flipping the condition re-renders
12+
* the parent, which swaps in a NEW slot-host element containing
13+
* a child as authored content. Projection on the new slot host
14+
* then throws.
15+
*
16+
* 2. A slot host's own render swaps between two different shapes
17+
* around the slot (e.g. compact `<section><slot></slot></section>`
18+
* vs expanded `<section><header>...</header><slot></slot></section>`).
19+
* Authored children stay alive on the host; projection should
20+
* re-target the new slot. It throws instead.
21+
*
22+
* Both cases are valid webjs use, both currently fail. This file is
23+
* the failing reference; flip the wrapper test back to `test(...)`
24+
* once the bug is fixed.
25+
*/
26+
import { html } from '../../packages/core/src/html.js';
27+
import { WebComponent } from '../../packages/core/src/component.js';
28+
import { signal } from '../../packages/core/src/signal.js';
29+
30+
const assert = {
31+
ok: (v, msg) => { if (!v) throw new Error(msg || `Expected truthy, got ${v}`); },
32+
equal: (a, b, msg) => { if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); },
33+
};
34+
35+
suite('slot projection cycle (regression)', () => {
36+
let nextTag = 0;
37+
const newTag = (base) => `${base}-cycle-${++nextTag}`;
38+
39+
test('parent re-render swaps in a new slot host with nested authored child', async () => {
40+
const showChild = signal(false);
41+
const Shell = newTag('cycle-shell');
42+
const Child = newTag('cycle-child');
43+
const Parent = newTag('cycle-parent');
44+
45+
class ShellEl extends WebComponent {
46+
render() { return html`<section><slot></slot></section>`; }
47+
}
48+
customElements.define(Shell, ShellEl);
49+
50+
class ChildEl extends WebComponent {
51+
render() { return html`<i>child</i>`; }
52+
}
53+
customElements.define(Child, ChildEl);
54+
55+
// Static tag literals at the JS-template level. The Parent class
56+
// is generated once, after the inner classes are defined, so we
57+
// can splice the tag names into a Function constructor without
58+
// dynamic interpolation inside the `html` template.
59+
const ParentFactory = new Function(
60+
'html', 'WebComponent', 'showChild',
61+
`class ParentEl extends WebComponent {
62+
render() {
63+
return showChild.get()
64+
? html\`<${Shell}><${Child}></${Child}></${Shell}>\`
65+
: html\`<${Shell}></${Shell}>\`;
66+
}
67+
}
68+
return ParentEl;`
69+
);
70+
const ParentEl = ParentFactory(html, WebComponent, showChild);
71+
customElements.define(Parent, ParentEl);
72+
73+
const root = document.createElement(Parent);
74+
document.body.appendChild(root);
75+
await root.updateComplete;
76+
assert.equal(root.querySelectorAll('i').length, 0);
77+
78+
showChild.set(true);
79+
await Promise.resolve(); await Promise.resolve();
80+
await root.updateComplete;
81+
await Promise.resolve(); await Promise.resolve();
82+
assert.equal(root.querySelectorAll('i').length, 1, 'child renders through projection');
83+
84+
showChild.set(false);
85+
await Promise.resolve(); await Promise.resolve();
86+
await root.updateComplete;
87+
await Promise.resolve(); await Promise.resolve();
88+
assert.equal(root.querySelectorAll('i').length, 0, 'child gone when signal flips back');
89+
90+
root.remove();
91+
});
92+
93+
test('slot host re-renders with a different wrapper shape around its slot', async () => {
94+
const expanded = signal(false);
95+
const T = newTag('cycle-wrap');
96+
class ShellEl extends WebComponent {
97+
render() {
98+
return expanded.get()
99+
? html`<section class="expanded"><header>BIG</header><slot></slot></section>`
100+
: html`<section class="compact"><slot></slot></section>`;
101+
}
102+
}
103+
customElements.define(T, ShellEl);
104+
105+
const shell = document.createElement(T);
106+
const child = document.createElement('p');
107+
child.id = 'projected-child';
108+
child.textContent = 'authored';
109+
shell.appendChild(child);
110+
document.body.appendChild(shell);
111+
await shell.updateComplete;
112+
const childRef = shell.querySelector('#projected-child');
113+
assert.ok(childRef, 'child projected on first render');
114+
assert.ok(shell.querySelector('section.compact'));
115+
116+
expanded.set(true);
117+
await Promise.resolve(); await Promise.resolve();
118+
await shell.updateComplete;
119+
await Promise.resolve(); await Promise.resolve();
120+
assert.ok(shell.querySelector('section.expanded'));
121+
assert.ok(shell.querySelector('header'));
122+
const childRef2 = shell.querySelector('#projected-child');
123+
assert.ok(childRef2, 'child still present after shape change');
124+
assert.ok(childRef2 === childRef, 'DOM identity preserved across the host re-render');
125+
126+
expanded.set(false);
127+
await Promise.resolve(); await Promise.resolve();
128+
await shell.updateComplete;
129+
await Promise.resolve(); await Promise.resolve();
130+
assert.ok(shell.querySelector('section.compact'));
131+
assert.equal(shell.querySelector('header'), null);
132+
assert.ok(shell.querySelector('#projected-child') === childRef, 'identity survives round-trip');
133+
134+
shell.remove();
135+
});
136+
});

web-test-runner.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export default {
3434
'test/browser/signal-component.test.js',
3535
'test/browser/signal-hydration.test.js',
3636
'test/browser/signal-slot-integration.test.js',
37+
'test/browser/slot-projection-cycle.test.js',
3738
],
3839
nodeResolve: true,
3940
// Transform .ts → JS on the fly so browsers can `import()` the @webjskit/ui

0 commit comments

Comments
 (0)