Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4589,7 +4589,7 @@ strict missing-fragment failure.

`tx[]` stores text runs as `[slot, parts, raw?]`, where `parts` reuse the compact attribute-part encoding (`string` for static text, `[path]` for dynamic text). Escaped text omits `raw` and client-created DOM inserts one runtime `Text` node per run. Triple-brace bindings set `raw` to `1` and own the sibling-safe DOM range between paired `<!--wN-->` and `<!--/wN-->` markers.

**Element addressing.** Every locator - the `slot` in `tx` / `c` / `r`, the target in `ag`, and the event target in `eg` - names an element by its **pre-order index** within its own compiled section: `0` is the section root and elements are numbered `1..N` in the order a depth-first walk of `h` meets them. The root template and each `<if>` / `<for>` block number independently, matching the `b[]` split. A `slot` is `[parentIndex, beforeIndex, order?]`, where `beforeIndex` remains a child offset within that parent. Both runtime paths rebuild the same numbering in one walk - client-created DOM by walking the cloned `h`, SSR by walking the server output while skipping structural block ranges - so a binding resolves by array index rather than by descending a chain of child offsets.
**Element addressing.** Every locator - the `slot` in `tx` / `c` / `r`, the target in `ag`, and the event target in `eg` - names an element by its **pre-order index** within its own compiled section: `0` is the section root and elements are numbered `1..N` in the order a depth-first walk of `h` meets them. The root template and each `<if>` / `<for>` block number independently, matching the `b[]` split. A `slot` is `[parentIndex, beforeIndex, order?]`, where `beforeIndex` remains a child offset within that parent and `order` is the zero-based source order of dynamic text, conditional, and repeat bindings that share that static offset. Both runtime paths rebuild the same numbering in one walk - client-created DOM by walking the cloned `h`, SSR by walking the server output while skipping structural block ranges - so a binding resolves by array index rather than by descending a chain of child offsets.

Attribute bindings are recorded in `a[]`, while `ag[]` points at the owning element and the contiguous `[start, count)` range inside `a[]`. The compiled client HTML never embeds `data-w-*` markers; those remain SSR-only handler markers.

Expand Down
24 changes: 16 additions & 8 deletions packages/webui-framework/RENDERING.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ The matching executable payload is stored under `window.__webui.templateFns['tod
| Field | Purpose |
|---|---|
| `h` | Static HTML, marker-free, used for client-created cloning. **Never has SSR markers.** |
| `tx` | Text-binding runs, slot path + parts. |
| `tx` | Text-binding runs, ordered slot + parts. |
| `a` / `ag` | Attribute bindings and the elements they target. |
| `c` | Conditional blocks with `[conditionRef, blockIndex, slot]`. |
| `r` | Repeat blocks with `[collection, itemVar, blockIndex, slot]`. |
Expand Down Expand Up @@ -218,13 +218,21 @@ Two details shape the walk:
children to the parent's `h`, so whatever the server rendered inside belongs
to them.

### `$findSSRText`

Text is the one thing that cannot be indexed. The renderer strips
inter-element whitespace that `meta.h` keeps, so text nodes do not line up
positionally even though elements do. The compiler emits text-slot positions as
`[parentIndex, beforeIndex]`, and `$findSSRText` walks SSR text-node ordinals up
to that index, skipping marker ranges.
### Text slot boundaries

Text is the one thing that cannot use the element index directly. The compiler
emits each dynamic slot as `[parentIndex, beforeIndex, order?]`. `order`
disambiguates text, conditional, and repeat bindings removed at the same static
child offset.

During hydration, the runtime finds the slot's right-hand boundary: the next
co-located structural marker by `order`, or the next static child. A
server-rendered text node is the boundary's immediate previous sibling. When an
empty server value produced no text node, the runtime inserts one at that exact
boundary. The text-to-marker relation is encoded once into an `Int32Array`
cached by template metadata, so every instance resolves a structural boundary
in O(1) without retaining a `Map`. This avoids rescanning sibling ranges and
prevents adjacent dynamic slots from claiming the same text node.

---

Expand Down
7 changes: 4 additions & 3 deletions packages/webui-framework/src/element/markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,9 +328,10 @@ export function buildSSRIndex(
* type are handled via depth tracking. Returns the child at the given
* `ordinal`, or null.
*
* Used by `$findSSRText` to keep SSR text ordinals aligned with the template.
* Elements are addressed by pre-order index instead (see `buildSSRIndex`);
* text cannot be, because the renderer strips whitespace that `meta.h` keeps.
* Used by static slot fallback to keep SSR node ordinals aligned with the
* template. Elements are addressed by pre-order index instead (see
* `buildSSRIndex`); text cannot be, because the renderer strips whitespace
* that `meta.h` keeps.
*
* **Requires closing markers to still be in the DOM** — caller must
* not remove `<!--/wc-->` or `<!--/wr-->` before all resolution is done.
Expand Down
196 changes: 105 additions & 91 deletions packages/webui-framework/src/template-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,34 @@ const RAW_MARKER_BOUNDARY_BASE = 0x40000000;
*/
const tplElementCache = new WeakMap<Node, Array<Node | undefined>>();

interface PendingSlot {
parent: Node;
before: Node | null;
order: number;
node: Node;
end?: Comment;
}

function comparePendingSlots(left: PendingSlot, right: PendingSlot): number {
return left.order - right.order;
}

function insertPendingSlots(
slots: PendingSlot[],
count: number,
needsOrdering: boolean,
): void {
if (needsOrdering) {
slots.length = count;
slots.sort(comparePendingSlots);
}
for (let i = 0; i < count; i++) {
const slot = slots[i];
slot.parent.insertBefore(slot.node, slot.before);
if (slot.end) slot.parent.insertBefore(slot.end, slot.before);
}
}

function getTemplateElements(tplRoot: Node): Array<Node | undefined> {
let cached = tplElementCache.get(tplRoot);
if (!cached) {
Expand Down Expand Up @@ -1748,63 +1776,103 @@ export class TemplateElement extends HTMLElement {
// pre-order reproduces the indices the compiler assigned.
const elements = collectTemplateElements(root);

// Pre-resolve text binding slots
const textRefs = new Array<{ parent: Node; ref: Node | null; parts: CompiledAttrPart[]; raw?: boolean }>(meta.tx?.length ?? 0);
let textRefCount = 0;
const pendingSlots = new Array<PendingSlot>(
(meta.tx?.length ?? 0) + (meta.c?.length ?? 0) + (meta.r?.length ?? 0),
);
let pendingSlotCount = 0;
let needsSlotOrdering = false;

// Text bindings: create direct nodes and queue their compiled placement.
let rawIndex = 0;
if (meta.tx) {
for (let i = 0; i < meta.tx.length; i++) {
const entry = meta.tx[i];
const [slot, parts] = entry;
const raw = entry[2] === 1;
const [parentIndex, beforeIndex] = slot;
const [parentIndex, beforeIndex, order = 0] = slot;
const parent = elements[parentIndex];
if (!parent || (parent.nodeType !== 1 && parent.nodeType !== 11)) continue;
textRefs[textRefCount] = { parent, ref: parent.childNodes[beforeIndex] || null, parts, raw };
textRefCount += 1;
let node: Node;
let end: Comment | undefined;
if (raw) {
const start = document.createComment(rawMarker(rawIndex));
end = document.createComment(rawMarker(rawIndex, true));
rawIndex++;
instance.texts.push({
node: start,
parts,
scope,
raw: true,
rawEnd: end,
rawOwner: instance,
});
node = start;
} else {
const textNode = document.createTextNode('');
instance.texts.push({ node: textNode, parts, scope });
node = textNode;
}
pendingSlots[pendingSlotCount++] = {
parent,
before: parent.childNodes[beforeIndex] || null,
order,
node,
end,
};
if (order > 0) needsSlotOrdering = true;
}
}

// Pre-resolve conditional slots
type CondRef = { parent: Node; ref: Node | null; condition: CompiledCondition; blockIndex: number };
const condRefs = new Array<CondRef>(meta.c?.length ?? 0);
let condRefCount = 0;
// Conditional bindings: create stable anchors and queue their placement.
if (meta.c) {
for (let i = 0; i < meta.c.length; i++) {
const [condition, blockIndex, slotMeta] = meta.c[i];
const [parentIndex, beforeIndex] = slotMeta;
const [parentIndex, beforeIndex, order = 0] = slotMeta;
const parent = elements[parentIndex];
if (!parent || (parent.nodeType !== 1 && parent.nodeType !== 11)) continue;
condRefs[condRefCount] = { parent, ref: parent.childNodes[beforeIndex] || null, condition: condition as CompiledCondition, blockIndex };
condRefCount += 1;
const anchor = document.createComment('');
instance.conds.push({
condition: condition as CompiledCondition,
blockIndex,
anchor,
scope,
owner: instance,
instance: null,
});
pendingSlots[pendingSlotCount++] = {
parent,
before: parent.childNodes[beforeIndex] || null,
order,
node: anchor,
};
if (order > 0) needsSlotOrdering = true;
}
}

// Pre-resolve repeat slots
type RepRef = {
parent: Node;
ref: Node | null;
collection: string;
itemVar: string;
blockIndex: number;
keyPath?: string;
};
const repRefs = new Array<RepRef>(meta.r?.length ?? 0);
let repRefCount = 0;
// Repeat bindings: create stable anchors and queue their placement.
if (meta.r) {
for (let i = 0; i < meta.r.length; i++) {
const [collection, itemVar, blockIndex, slotMeta, keyPath] = meta.r[i];
const [parentIndex, beforeIndex] = slotMeta;
const [parentIndex, beforeIndex, order = 0] = slotMeta;
const parent = elements[parentIndex];
if (!parent || (parent.nodeType !== 1 && parent.nodeType !== 11)) continue;
repRefs[repRefCount] = {
const anchor = document.createComment('');
const binding: RepeatBinding = {
markerId: i, collection, itemVar, blockIndex,
container: parent as ParentNode & Node, start: anchor, end: null,
scope, owner: instance, instances: [],
};
if (keyPath !== undefined) {
binding.keyState = createRepeatKeyState(keyPath);
}
instance.repeats.push(binding);
pendingSlots[pendingSlotCount++] = {
parent,
ref: parent.childNodes[beforeIndex] || null,
collection,
itemVar,
blockIndex,
keyPath,
before: parent.childNodes[beforeIndex] || null,
order,
node: anchor,
};
repRefCount += 1;
if (order > 0) needsSlotOrdering = true;
}
}

Expand All @@ -1816,66 +1884,12 @@ export class TemplateElement extends HTMLElement {
// insertions still shift childNode indices for sibling elements.
this.$finalize(instance, root, meta, (_r, i) => elements[i] ?? null, scope);

// Now insert anchors using pre-resolved references

// Text bindings
let rawIndex = 0;
for (let i = 0; i < textRefCount; i++) {
const t = textRefs[i];
const anchor = document.createComment(t.raw ? rawMarker(rawIndex, true) : '');
t.parent.insertBefore(anchor, t.ref);
if (t.raw) {
const start = document.createComment(rawMarker(rawIndex));
rawIndex++;
t.parent.insertBefore(start, anchor);
instance.texts.push({
node: start,
parts: t.parts,
scope,
raw: true,
rawEnd: anchor,
rawOwner: instance,
});
} else {
const textNode = document.createTextNode('');
t.parent.insertBefore(textNode, anchor);
instance.texts.push({ node: textNode, parts: t.parts, scope });
}
}

// Conditional bindings
for (let i = 0; i < condRefCount; i++) {
const c = condRefs[i];
const anchor = document.createComment('');
c.parent.insertBefore(anchor, c.ref);
instance.conds.push({
condition: c.condition,
blockIndex: c.blockIndex,
anchor,
scope,
owner: instance,
instance: null,
});
}

// Repeat bindings
for (let i = 0; i < repRefCount; i++) {
const r = repRefs[i];
const anchor = document.createComment('');
r.parent.insertBefore(anchor, r.ref);
const binding: RepeatBinding = {
markerId: i, collection: r.collection, itemVar: r.itemVar, blockIndex: r.blockIndex,
container: r.parent as ParentNode & Node, start: anchor, end: null,
scope, owner: instance, instances: [],
};
if (r.keyPath !== undefined) {
binding.keyState = createRepeatKeyState(r.keyPath);
}
instance.repeats.push(binding);
}
// Co-located slots share one static insertion reference. Commit them only
// after every reference has been captured from the untouched DOM.
insertPendingSlots(pendingSlots, pendingSlotCount, needsSlotOrdering);

// Evaluate conditionals and repeats inline so blocks are created
// immediately — no deferred $update() flush needed.
// Create conditional blocks immediately; the first full binding pass
// reconciles repeats after this wiring step returns.
for (let i = 0; i < instance.conds.length; i++) this.$toggleCond(instance.conds[i]);

return instance;
Expand Down
8 changes: 8 additions & 0 deletions packages/webui-framework/src/template-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export type CompiledAttrPart = string | [path: string];
* array index instead of by walking a path of child offsets.
*/
export type TemplateNodeIndex = number;

/**
* A dynamic insertion point in marker-free template HTML.
*
* `order` disambiguates text, conditional, and repeat bindings removed at the
* same static child offset. It starts at zero for each `(parentIndex,
* beforeIndex)` pair and is omitted for the first binding.
*/
export type TemplateSlot = [
parentIndex: TemplateNodeIndex,
beforeIndex: number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
* sibling nodes) because that is the shape that stresses SSR node lookup.
* `test-hydration-deep` nests bindings inside static elements, and
* `test-hydration-nested` chains `<if>` blocks so structural nesting depth -
* the one dimension that is not linear - stays measurable. Each instance brackets its own hydration and accumulates into
* the one dimension that is not linear - stays measurable.
* `test-hydration-slots` interleaves text and conditionals at one static slot
* so cross-kind boundary lookup stays linear. Each instance brackets its own hydration and accumulates into
* `window.__hydrationBench`, so the spec can report a stable per-instance
* mean over many instances instead of a single noisy sample.
*/
Expand Down Expand Up @@ -54,7 +56,10 @@ export class TestHydrationWide extends HydrationTimed {}
export class TestHydrationDeep extends HydrationTimed {}
/** Structural nesting: a chain of `<if>` blocks, each hydrating the next. */
export class TestHydrationNested extends HydrationTimed {}
/** Shared static slot: interleaved text and conditional boundaries. */
export class TestHydrationSlots extends HydrationTimed {}

TestHydrationWide.define('test-hydration-wide');
TestHydrationDeep.define('test-hydration-deep');
TestHydrationNested.define('test-hydration-nested');
TestHydrationSlots.define('test-hydration-slots');
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ test.describe('hydration bench fixture', () => {
expect(totals['test-hydration-wide'].count).toBe(150);
expect(totals['test-hydration-deep'].count).toBe(150);
expect(totals['test-hydration-nested'].count).toBe(40);
expect(totals['test-hydration-slots'].count).toBe(40);
expect(totals['test-hydration-wide'].totalMs).toBeGreaterThan(0);

// Hydration must actually have wired the bindings.
await expect(page.locator('test-hydration-wide').first().locator('.w0')).toHaveText('v0');
await expect(page.locator('test-hydration-deep').first().locator('.d0')).toHaveText('v0');
await expect(page.locator('test-hydration-slots').first().locator('.slots')).toHaveText(
'v0av1v2cv3dv4v5fv0av1v2cv3dv4v5fv0av1v2cv3dv4v5fv0av1v2cv3dv4v5fv0av1v2cv3dv4v5f',
);
});

test('keeps hydrated bindings reactive', async ({ page }) => {
Expand Down
Loading
Loading