From 9ef77080d12e9c87c4e88ddd55ea3dec48e5b7e6 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 21 Aug 2026 23:19:00 -0700 Subject: [PATCH 1/5] Fix structural slot ordering Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DESIGN.md | 2 +- packages/webui-framework/RENDERING.md | 24 ++- .../webui-framework/src/element/markers.ts | 7 +- .../webui-framework/src/template-element.ts | 172 +++++++++--------- .../webui-framework/src/template-types.ts | 8 + .../tests/fixtures/hydration-bench/element.ts | 7 +- .../hydration-bench/hydration-bench.spec.ts | 4 + .../fixtures/hydration-bench/src/index.html | 40 ++++ .../test-hydration-slots.html | 1 + .../structural-sibling-order/element.ts | 64 +++++++ .../structural-sibling-order/src/index.html | 10 + .../test-structural-sibling-order.html | 34 ++++ .../structural-sibling-order/state.json | 23 +++ .../structural-sibling-order.spec.ts | 96 ++++++++++ 14 files changed, 394 insertions(+), 98 deletions(-) create mode 100644 packages/webui-framework/tests/fixtures/hydration-bench/src/test-hydration-slots/test-hydration-slots.html create mode 100644 packages/webui-framework/tests/fixtures/structural-sibling-order/element.ts create mode 100644 packages/webui-framework/tests/fixtures/structural-sibling-order/src/index.html create mode 100644 packages/webui-framework/tests/fixtures/structural-sibling-order/src/test-structural-sibling-order/test-structural-sibling-order.html create mode 100644 packages/webui-framework/tests/fixtures/structural-sibling-order/state.json create mode 100644 packages/webui-framework/tests/fixtures/structural-sibling-order/structural-sibling-order.spec.ts diff --git a/DESIGN.md b/DESIGN.md index 1909425b0..aa5fc4798 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 `` and `` 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 `` / `` 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 `` / `` 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. diff --git a/packages/webui-framework/RENDERING.md b/packages/webui-framework/RENDERING.md index a6284c939..3b503749f 100644 --- a/packages/webui-framework/RENDERING.md +++ b/packages/webui-framework/RENDERING.md @@ -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]`. | @@ -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. --- diff --git a/packages/webui-framework/src/element/markers.ts b/packages/webui-framework/src/element/markers.ts index fb10d677f..ad10f06f0 100644 --- a/packages/webui-framework/src/element/markers.ts +++ b/packages/webui-framework/src/element/markers.ts @@ -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 `` or `` before all resolution is done. diff --git a/packages/webui-framework/src/template-element.ts b/packages/webui-framework/src/template-element.ts index f378b87d6..f427d218a 100644 --- a/packages/webui-framework/src/template-element.ts +++ b/packages/webui-framework/src/template-element.ts @@ -1748,63 +1748,111 @@ export class TemplateElement extends HTMLElement { // pre-order reproduces the indices the compiler assigned. const elements = collectTemplateElements(root); + type SlotRef = { + parent: Node; + ref: Node | null; + order: number; + first: Node; + second?: Node; + }; + const slotRefs = new Array( + (meta.tx?.length ?? 0) + (meta.c?.length ?? 0) + (meta.r?.length ?? 0), + ); + let slotRefCount = 0; + let hasSharedSlots = false; + // 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; + 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 first: Node; + let second: Node | undefined; + if (raw) { + const start = document.createComment(rawMarker(rawIndex)); + const end = document.createComment(rawMarker(rawIndex, true)); + rawIndex++; + instance.texts.push({ + node: start, + parts, + scope, + raw: true, + rawEnd: end, + rawOwner: instance, + }); + first = start; + second = end; + } else { + const textNode = document.createTextNode(''); + instance.texts.push({ node: textNode, parts, scope }); + first = textNode; + } + slotRefs[slotRefCount++] = { + parent, + ref: parent.childNodes[beforeIndex] || null, + order, + first, + second, + }; + if (order > 0) hasSharedSlots = true; } } // Pre-resolve conditional slots - type CondRef = { parent: Node; ref: Node | null; condition: CompiledCondition; blockIndex: number }; - const condRefs = new Array(meta.c?.length ?? 0); - let condRefCount = 0; 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, + }); + slotRefs[slotRefCount++] = { + parent, + ref: parent.childNodes[beforeIndex] || null, + order, + first: anchor, + }; + if (order > 0) hasSharedSlots = true; } } // Pre-resolve repeat slots - type RepRef = { - parent: Node; - ref: Node | null; - collection: string; - itemVar: string; - blockIndex: number; - keyPath?: string; - }; - const repRefs = new Array(meta.r?.length ?? 0); - let repRefCount = 0; 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); + slotRefs[slotRefCount++] = { parent, ref: parent.childNodes[beforeIndex] || null, - collection, - itemVar, - blockIndex, - keyPath, + order, + first: anchor, }; - repRefCount += 1; + if (order > 0) hasSharedSlots = true; } } @@ -1816,66 +1864,20 @@ 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, - }); + // Co-located dynamic slots share one static insertion reference. The + // compiler's order field preserves source order across binding kinds. + if (hasSharedSlots) { + slotRefs.length = slotRefCount; + slotRefs.sort((left, right) => left.order - right.order); } - - // 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); + for (let i = 0; i < slotRefCount; i++) { + const slot = slotRefs[i]; + slot.parent.insertBefore(slot.first, slot.ref); + if (slot.second) slot.parent.insertBefore(slot.second, slot.ref); } - // 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; diff --git a/packages/webui-framework/src/template-types.ts b/packages/webui-framework/src/template-types.ts index 9394b12c2..7aeec3c80 100644 --- a/packages/webui-framework/src/template-types.ts +++ b/packages/webui-framework/src/template-types.ts @@ -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, diff --git a/packages/webui-framework/tests/fixtures/hydration-bench/element.ts b/packages/webui-framework/tests/fixtures/hydration-bench/element.ts index 8882e9515..fb5445da1 100644 --- a/packages/webui-framework/tests/fixtures/hydration-bench/element.ts +++ b/packages/webui-framework/tests/fixtures/hydration-bench/element.ts @@ -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 `` 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. */ @@ -54,7 +56,10 @@ export class TestHydrationWide extends HydrationTimed {} export class TestHydrationDeep extends HydrationTimed {} /** Structural nesting: a chain of `` 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'); diff --git a/packages/webui-framework/tests/fixtures/hydration-bench/hydration-bench.spec.ts b/packages/webui-framework/tests/fixtures/hydration-bench/hydration-bench.spec.ts index 1f399b68f..105d1582e 100644 --- a/packages/webui-framework/tests/fixtures/hydration-bench/hydration-bench.spec.ts +++ b/packages/webui-framework/tests/fixtures/hydration-bench/hydration-bench.spec.ts @@ -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 }) => { diff --git a/packages/webui-framework/tests/fixtures/hydration-bench/src/index.html b/packages/webui-framework/tests/fixtures/hydration-bench/src/index.html index 2589299a8..fcc0a7369 100644 --- a/packages/webui-framework/tests/fixtures/hydration-bench/src/index.html +++ b/packages/webui-framework/tests/fixtures/hydration-bench/src/index.html @@ -345,5 +345,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webui-framework/tests/fixtures/hydration-bench/src/test-hydration-slots/test-hydration-slots.html b/packages/webui-framework/tests/fixtures/hydration-bench/src/test-hydration-slots/test-hydration-slots.html new file mode 100644 index 000000000..7d183380a --- /dev/null +++ b/packages/webui-framework/tests/fixtures/hydration-bench/src/test-hydration-slots/test-hydration-slots.html @@ -0,0 +1 @@ +
{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f
diff --git a/packages/webui-framework/tests/fixtures/structural-sibling-order/element.ts b/packages/webui-framework/tests/fixtures/structural-sibling-order/element.ts new file mode 100644 index 000000000..49ddd3dd3 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/element.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WebUIElement, observable } from '../../../src/index.js'; + +interface Message { + messageId: string; + kind: string; + hidden: boolean; +} + +interface Turn { + turnId: string; + messages: Message[]; + terminal: { type: string }; +} + +interface MinimalTurn { + messages: string[]; + failed: boolean; +} + +function terminalTurn(turnId: string): Turn { + return { + turnId, + messages: [ + { messageId: `${turnId}-user`, kind: 'user', hidden: false }, + { messageId: `${turnId}-assistant`, kind: 'assistant', hidden: false }, + ], + terminal: { type: 'failed' }, + }; +} + +export class TestStructuralSiblingOrder extends WebUIElement { + @observable turns: Turn[] = []; + @observable minimalTurns: MinimalTurn[] = []; + @observable beforeRepeat = ''; + @observable beforeBlocks = ''; + @observable betweenBlocks = ''; + @observable showTextConditional = true; + @observable textItems: string[] = []; + + appendExactTerminal(): void { + this.turns = [...this.turns, terminalTurn('live-exact')]; + } + + appendMinimalTerminal(): void { + this.minimalTurns = [ + ...this.minimalTurns, + { messages: ['user', 'assistant'], failed: true }, + ]; + } + + setBeforeRepeat(): void { + this.beforeRepeat = 'before'; + } + + setBlockTexts(): void { + this.beforeBlocks = 'before'; + this.betweenBlocks = 'between'; + } +} + +TestStructuralSiblingOrder.define('test-structural-sibling-order'); diff --git a/packages/webui-framework/tests/fixtures/structural-sibling-order/src/index.html b/packages/webui-framework/tests/fixtures/structural-sibling-order/src/index.html new file mode 100644 index 000000000..dd1e7c062 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/src/index.html @@ -0,0 +1,10 @@ + + + + + Structural Sibling Order Fixture + + + + + diff --git a/packages/webui-framework/tests/fixtures/structural-sibling-order/src/test-structural-sibling-order/test-structural-sibling-order.html b/packages/webui-framework/tests/fixtures/structural-sibling-order/src/test-structural-sibling-order/test-structural-sibling-order.html new file mode 100644 index 000000000..53972a5b3 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/src/test-structural-sibling-order/test-structural-sibling-order.html @@ -0,0 +1,34 @@ +
+ +
+ + + {{message.kind}} + + + + +

error

+
+
+
+
+
+ +
+ +
+
+ {{message}} +

error

+
+
+

error

+ {{message}} +
+
+
+
+ +
{{beforeRepeat}}{{item}}tail
+
{{beforeBlocks}}if{{betweenBlocks}}{{item}}tail
diff --git a/packages/webui-framework/tests/fixtures/structural-sibling-order/state.json b/packages/webui-framework/tests/fixtures/structural-sibling-order/state.json new file mode 100644 index 000000000..e0b38d813 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/state.json @@ -0,0 +1,23 @@ +{ + "turns": [ + { + "turnId": "ssr-exact", + "messages": [ + { "messageId": "ssr-user", "kind": "user", "hidden": false }, + { "messageId": "ssr-assistant", "kind": "assistant", "hidden": false } + ], + "terminal": { "type": "failed" } + } + ], + "minimalTurns": [ + { + "messages": ["user", "assistant"], + "failed": true + } + ], + "beforeRepeat": "", + "beforeBlocks": "", + "betweenBlocks": "", + "showTextConditional": true, + "textItems": ["item"] +} diff --git a/packages/webui-framework/tests/fixtures/structural-sibling-order/structural-sibling-order.spec.ts b/packages/webui-framework/tests/fixtures/structural-sibling-order/structural-sibling-order.spec.ts new file mode 100644 index 000000000..a71c753b1 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/structural-sibling-order.spec.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test, type Locator } from '@playwright/test'; + +async function directChildKinds(section: Locator): Promise { + return section.locator(':scope > [data-kind]').evaluateAll( + (elements) => elements.map((element) => element.getAttribute('data-kind') ?? ''), + ); +} + +test.describe('client-created structural sibling order', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/structural-sibling-order/fixture.html'); + await page.waitForFunction(() => { + const element = document.querySelector('test-structural-sibling-order'); + return element && (element as unknown as { $ready?: boolean }).$ready === true; + }); + }); + + test('matches SSR order for the keyed nested consumer shape', async ({ page }) => { + const turns = page.locator('test-structural-sibling-order .exact-turn'); + + await expect(turns).toHaveCount(1); + expect(await directChildKinds(turns.first())).toEqual(['user', 'assistant', 'error']); + + await page.evaluate(() => { + (document.querySelector('test-structural-sibling-order') as HTMLElement & { + appendExactTerminal(): void; + }).appendExactTerminal(); + }); + + await expect(turns).toHaveCount(2); + expect(await directChildKinds(turns.nth(1))).toEqual(['user', 'assistant', 'error']); + }); + + test('needs neither keys nor nested conditionals nor multiple assignments', async ({ page }) => { + const turns = page.locator('test-structural-sibling-order .minimal-turn'); + + await expect(turns).toHaveCount(1); + expect(await directChildKinds(turns.first().locator('.for-if'))).toEqual([ + 'user', + 'assistant', + 'error', + ]); + expect(await directChildKinds(turns.first().locator('.if-for'))).toEqual([ + 'error', + 'user', + 'assistant', + ]); + + await page.evaluate(() => { + (document.querySelector('test-structural-sibling-order') as HTMLElement & { + appendMinimalTerminal(): void; + }).appendMinimalTerminal(); + }); + + await expect(turns).toHaveCount(2); + expect(await directChildKinds(turns.nth(1).locator('.for-if'))).toEqual([ + 'user', + 'assistant', + 'error', + ]); + expect(await directChildKinds(turns.nth(1).locator('.if-for'))).toEqual([ + 'error', + 'user', + 'assistant', + ]); + }); + + test('hydrates an empty text slot before a following repeat', async ({ page }) => { + const container = page.locator('test-structural-sibling-order .text-before-repeat'); + await expect(container).toHaveText('itemtail'); + + await page.evaluate(() => { + (document.querySelector('test-structural-sibling-order') as HTMLElement & { + setBeforeRepeat(): void; + }).setBeforeRepeat(); + }); + + await expect(container).toHaveText('beforeitemtail'); + }); + + test('hydrates an empty text slot between conditional and repeat blocks', async ({ page }) => { + const container = page.locator('test-structural-sibling-order .text-between-blocks'); + await expect(container).toHaveText('ifitemtail'); + + await page.evaluate(() => { + (document.querySelector('test-structural-sibling-order') as HTMLElement & { + setBlockTexts(): void; + }).setBlockTexts(); + }); + + await expect(container).toHaveText('beforeifbetweenitemtail'); + }); +}); From 2f0419fc08a3b8b387c097cb88b2ae900fde215e Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 21 Aug 2026 23:19:10 -0700 Subject: [PATCH 2/5] Harden framework reconciliation fixtures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../fixtures/light-dom-structural/element.ts | 12 +++ .../light-dom-structural.spec.ts | 59 ++++++++++ .../light-dom-structural/src/index.html | 10 ++ .../test-light-dom-structural.html | 1 + .../fixtures/light-dom-structural/state.json | 5 + .../light-dom-structural/webui.config.json | 3 + .../tests/fixtures/repeat-append/element.ts | 14 +++ .../repeat-append/repeat-append.spec.ts | 102 ++++++++++++++++++ .../test-repeat-parent.html | 2 + .../fixtures/slot-shadow/slot-shadow.spec.ts | 16 +++ .../src/test-slot-btn/test-slot-btn.html | 7 +- 11 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 packages/webui-framework/tests/fixtures/light-dom-structural/element.ts create mode 100644 packages/webui-framework/tests/fixtures/light-dom-structural/light-dom-structural.spec.ts create mode 100644 packages/webui-framework/tests/fixtures/light-dom-structural/src/index.html create mode 100644 packages/webui-framework/tests/fixtures/light-dom-structural/src/test-light-dom-structural/test-light-dom-structural.html create mode 100644 packages/webui-framework/tests/fixtures/light-dom-structural/state.json create mode 100644 packages/webui-framework/tests/fixtures/light-dom-structural/webui.config.json create mode 100644 packages/webui-framework/tests/fixtures/repeat-append/repeat-append.spec.ts diff --git a/packages/webui-framework/tests/fixtures/light-dom-structural/element.ts b/packages/webui-framework/tests/fixtures/light-dom-structural/element.ts new file mode 100644 index 000000000..5b031c99a --- /dev/null +++ b/packages/webui-framework/tests/fixtures/light-dom-structural/element.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { WebUIElement, observable } from '../../../src/index.js'; + +export class TestLightDomStructural extends WebUIElement { + @observable show = true; + @observable conditionalText = 'client'; + @observable items = ['X']; +} + +TestLightDomStructural.define('test-light-dom-structural'); diff --git a/packages/webui-framework/tests/fixtures/light-dom-structural/light-dom-structural.spec.ts b/packages/webui-framework/tests/fixtures/light-dom-structural/light-dom-structural.spec.ts new file mode 100644 index 000000000..778e7d574 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/light-dom-structural/light-dom-structural.spec.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test } from '@playwright/test'; + +test.describe('light DOM structural bindings', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/light-dom-structural/fixture.html'); + await page.waitForFunction(() => { + const element = document.querySelector('test-light-dom-structural'); + return element && (element as unknown as { $ready?: boolean }).$ready === true; + }); + }); + + test('hydrates and updates text-only conditional and repeat blocks', async ({ page }) => { + const host = page.locator('test-light-dom-structural'); + + await expect(host).toHaveText('beforeconditionalABafter'); + expect(await host.evaluate((element) => element.shadowRoot)).toBeNull(); + + await host.evaluate((element) => { + const target = element as HTMLElement & { + conditionalText: string; + items: string[]; + show: boolean; + }; + target.show = false; + target.conditionalText = 'updated'; + target.items = ['C', 'D']; + }); + await expect(host).toHaveText('beforeCDafter'); + + await host.evaluate((element) => { + (element as HTMLElement & { show: boolean }).show = true; + }); + await expect(host).toHaveText('beforeupdatedCDafter'); + }); + + test('creates the same text-only structure entirely on the client', async ({ page }) => { + const result = await page.evaluate(() => { + const element = document.createElement('test-light-dom-structural') as HTMLElement & { + $ready?: boolean; + }; + element.id = 'client-light-structural'; + document.body.appendChild(element); + return { + hasShadow: element.shadowRoot !== null, + ready: element.$ready === true, + text: element.textContent, + }; + }); + + expect(result).toEqual({ + hasShadow: false, + ready: true, + text: 'beforeclientXafter', + }); + }); +}); diff --git a/packages/webui-framework/tests/fixtures/light-dom-structural/src/index.html b/packages/webui-framework/tests/fixtures/light-dom-structural/src/index.html new file mode 100644 index 000000000..9e2da2c38 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/light-dom-structural/src/index.html @@ -0,0 +1,10 @@ + + + + + Light DOM Structural Fixture + + + + + diff --git a/packages/webui-framework/tests/fixtures/light-dom-structural/src/test-light-dom-structural/test-light-dom-structural.html b/packages/webui-framework/tests/fixtures/light-dom-structural/src/test-light-dom-structural/test-light-dom-structural.html new file mode 100644 index 000000000..191fac8e0 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/light-dom-structural/src/test-light-dom-structural/test-light-dom-structural.html @@ -0,0 +1 @@ +before{{conditionalText}}{{item}}after diff --git a/packages/webui-framework/tests/fixtures/light-dom-structural/state.json b/packages/webui-framework/tests/fixtures/light-dom-structural/state.json new file mode 100644 index 000000000..0d7b6be9a --- /dev/null +++ b/packages/webui-framework/tests/fixtures/light-dom-structural/state.json @@ -0,0 +1,5 @@ +{ + "show": true, + "conditionalText": "conditional", + "items": ["A", "B"] +} diff --git a/packages/webui-framework/tests/fixtures/light-dom-structural/webui.config.json b/packages/webui-framework/tests/fixtures/light-dom-structural/webui.config.json new file mode 100644 index 000000000..b5b9610e2 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/light-dom-structural/webui.config.json @@ -0,0 +1,3 @@ +{ + "dom": "light" +} diff --git a/packages/webui-framework/tests/fixtures/repeat-append/element.ts b/packages/webui-framework/tests/fixtures/repeat-append/element.ts index 32114349f..83b0d7b70 100644 --- a/packages/webui-framework/tests/fixtures/repeat-append/element.ts +++ b/packages/webui-framework/tests/fixtures/repeat-append/element.ts @@ -36,6 +36,20 @@ export class TestRepeatParent extends WebUIElement { this.items = [{ id, title: `Item ${id}` }, ...this.items]; } + insertMiddleItem(): void { + const id = String(this.nextId); + this.nextId += 1; + this.items = [ + ...this.items.slice(0, 2), + { id, title: `Item ${id}` }, + ...this.items.slice(2), + ]; + } + + removeMiddleItem(): void { + this.items = [...this.items.slice(0, 2), ...this.items.slice(3)]; + } + removeItem(): void { this.items = this.items.slice(0, -1); } diff --git a/packages/webui-framework/tests/fixtures/repeat-append/repeat-append.spec.ts b/packages/webui-framework/tests/fixtures/repeat-append/repeat-append.spec.ts new file mode 100644 index 000000000..fabf4f986 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/repeat-append/repeat-append.spec.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { expect, test } from '@playwright/test'; + +test.describe('unkeyed repeat reconciliation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/repeat-append/fixture.html'); + await page.waitForFunction(() => { + const parent = document.querySelector('test-repeat-parent'); + const children = parent?.shadowRoot?.querySelectorAll('test-repeat-child'); + return ( + parent + && (parent as unknown as { $ready?: boolean }).$ready === true + && children?.length === 5 + && Array.from(children).every( + (child) => (child as unknown as { $ready?: boolean }).$ready === true, + ) + ); + }); + }); + + test('hydrates SSR children and appends at the tail', async ({ page }) => { + const labels = page.locator('test-repeat-child .label'); + await expect(labels).toHaveText([ + 'Item 1', + 'Item 2', + 'Item 3', + 'Item 4', + 'Item 5', + ]); + + await page.locator('test-repeat-parent .add').click(); + + await expect(labels).toHaveText([ + 'Item 1', + 'Item 2', + 'Item 3', + 'Item 4', + 'Item 5', + 'Item 6', + ]); + }); + + test('reuses blocks by position when prepending', async ({ page }) => { + await page.evaluate(() => { + const parent = document.querySelector('test-repeat-parent'); + const children = parent?.shadowRoot?.querySelectorAll('test-repeat-child'); + (window as unknown as { __unkeyedNodes?: Element[] }).__unkeyedNodes = + Array.from(children ?? []); + }); + + await page.locator('test-repeat-parent .prepend').click(); + + const result = await page.evaluate(() => { + const parent = document.querySelector('test-repeat-parent'); + const children = Array.from( + parent?.shadowRoot?.querySelectorAll('test-repeat-child') ?? [], + ); + const previous = + (window as unknown as { __unkeyedNodes?: Element[] }).__unkeyedNodes ?? []; + return { + count: children.length, + reusedByPosition: previous.every((node, index) => node === children[index]), + }; + }); + + expect(result).toEqual({ count: 6, reusedByPosition: true }); + await expect(page.locator('test-repeat-child .label')).toHaveText([ + 'Item 6', + 'Item 1', + 'Item 2', + 'Item 3', + 'Item 4', + 'Item 5', + ]); + }); + + test('updates nested children through middle insertion and deletion', async ({ page }) => { + const labels = page.locator('test-repeat-child .label'); + await page.locator('test-repeat-parent .insert-middle').click(); + + await expect(labels).toHaveText([ + 'Item 1', + 'Item 2', + 'Item 6', + 'Item 3', + 'Item 4', + 'Item 5', + ]); + + await page.locator('test-repeat-parent .remove-middle').click(); + + await expect(labels).toHaveText([ + 'Item 1', + 'Item 2', + 'Item 3', + 'Item 4', + 'Item 5', + ]); + }); +}); diff --git a/packages/webui-framework/tests/fixtures/repeat-append/src/test-repeat-parent/test-repeat-parent.html b/packages/webui-framework/tests/fixtures/repeat-append/src/test-repeat-parent/test-repeat-parent.html index 286949c41..99b7c5c4d 100644 --- a/packages/webui-framework/tests/fixtures/repeat-append/src/test-repeat-parent/test-repeat-parent.html +++ b/packages/webui-framework/tests/fixtures/repeat-append/src/test-repeat-parent/test-repeat-parent.html @@ -1,5 +1,7 @@ + +
diff --git a/packages/webui-framework/tests/fixtures/slot-shadow/slot-shadow.spec.ts b/packages/webui-framework/tests/fixtures/slot-shadow/slot-shadow.spec.ts index 3fceb62fd..58e6756e6 100644 --- a/packages/webui-framework/tests/fixtures/slot-shadow/slot-shadow.spec.ts +++ b/packages/webui-framework/tests/fixtures/slot-shadow/slot-shadow.spec.ts @@ -55,6 +55,8 @@ test.describe('slot-shadow: SPA partial regression', () => { lightDomChildren: el?.children.length, // Slot content should be projected slotText: el?.textContent?.trim(), + projected: + el?.querySelector('span:not(.appearance)')?.assignedSlot?.localName, }; }); @@ -63,6 +65,14 @@ test.describe('slot-shadow: SPA partial regression', () => { // Light DOM children (the icon span and label span) stay in place expect(result.lightDomChildren).toBeGreaterThanOrEqual(2); expect(result.slotText).toContain('Reply'); + expect(result.projected).toBe('slot'); + await expect(page.locator('#preloaded-child .appearance')).toHaveText('primary'); + + await page.locator('#preloaded-child').evaluate((element) => { + (element as HTMLElement & { appearance: string }).appearance = 'secondary'; + }); + await expect(page.locator('#preloaded-child .appearance')).toHaveText('secondary'); + await expect(page.locator('#preloaded-child')).toContainText('Reply'); }); test('dynamically spawned child with slot content gets a shadow root', async ({ page }) => { @@ -88,11 +98,17 @@ test.describe('slot-shadow: SPA partial regression', () => { hasShadow: !!child?.shadowRoot, shadowHasButton: !!child?.shadowRoot?.querySelector('button.btn'), slotText: child?.textContent?.trim(), + projected: + child?.querySelector('span:not(.appearance)')?.assignedSlot?.localName, }; }); expect(result.hasShadow).toBe(true); expect(result.shadowHasButton).toBe(true); expect(result.slotText).toContain('Reply'); + expect(result.projected).toBe('slot'); + await expect( + page.locator('test-slot-parent test-slot-btn .appearance'), + ).toHaveText('primary'); }); }); diff --git a/packages/webui-framework/tests/fixtures/slot-shadow/src/test-slot-btn/test-slot-btn.html b/packages/webui-framework/tests/fixtures/slot-shadow/src/test-slot-btn/test-slot-btn.html index 25daf9713..a93a47ba5 100644 --- a/packages/webui-framework/tests/fixtures/slot-shadow/src/test-slot-btn/test-slot-btn.html +++ b/packages/webui-framework/tests/fixtures/slot-shadow/src/test-slot-btn/test-slot-btn.html @@ -1 +1,6 @@ - + From 1e9b4d73e9a191c84ad62c888763bcdf5eaa9f45 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 21 Aug 2026 23:25:58 -0700 Subject: [PATCH 3/5] Format framework fixture HTML Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test-hydration-slots.html | 31 ++++++++++++++++++- .../test-light-dom-structural.html | 6 +++- .../test-structural-sibling-order.html | 30 +++++++++++++----- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/webui-framework/tests/fixtures/hydration-bench/src/test-hydration-slots/test-hydration-slots.html b/packages/webui-framework/tests/fixtures/hydration-bench/src/test-hydration-slots/test-hydration-slots.html index 7d183380a..b0de7c51f 100644 --- a/packages/webui-framework/tests/fixtures/hydration-bench/src/test-hydration-slots/test-hydration-slots.html +++ b/packages/webui-framework/tests/fixtures/hydration-bench/src/test-hydration-slots/test-hydration-slots.html @@ -1 +1,30 @@ -
{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f
+
{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f{{p0}}a{{p1}}b{{p2}}c{{p3}}d{{p4}}e{{p5}}f
diff --git a/packages/webui-framework/tests/fixtures/light-dom-structural/src/test-light-dom-structural/test-light-dom-structural.html b/packages/webui-framework/tests/fixtures/light-dom-structural/src/test-light-dom-structural/test-light-dom-structural.html index 191fac8e0..f0cc5c923 100644 --- a/packages/webui-framework/tests/fixtures/light-dom-structural/src/test-light-dom-structural/test-light-dom-structural.html +++ b/packages/webui-framework/tests/fixtures/light-dom-structural/src/test-light-dom-structural/test-light-dom-structural.html @@ -1 +1,5 @@ -before{{conditionalText}}{{item}}after +before{{conditionalText}}{{item}}after diff --git a/packages/webui-framework/tests/fixtures/structural-sibling-order/src/test-structural-sibling-order/test-structural-sibling-order.html b/packages/webui-framework/tests/fixtures/structural-sibling-order/src/test-structural-sibling-order/test-structural-sibling-order.html index 53972a5b3..003ceba43 100644 --- a/packages/webui-framework/tests/fixtures/structural-sibling-order/src/test-structural-sibling-order/test-structural-sibling-order.html +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/src/test-structural-sibling-order/test-structural-sibling-order.html @@ -3,7 +3,11 @@
- {{message.kind}} + {{message.kind}} @@ -19,16 +23,28 @@
- {{message}} -

error

+ + {{message}} + +

error

+
-

error

- {{message}} + +

error

+
+ {{message}} +
-
{{beforeRepeat}}{{item}}tail
-
{{beforeBlocks}}if{{betweenBlocks}}{{item}}tail
+
{{beforeRepeat}}{{item}}tail
+
{{beforeBlocks}}if{{betweenBlocks}}{{item}}tail
From 043d4b1348f6525497fb718a553a6a5381f350da Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Fri, 21 Aug 2026 23:36:54 -0700 Subject: [PATCH 4/5] Cover latent structural slot activation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../structural-sibling-order/element.ts | 14 +++++ .../structural-sibling-order.spec.ts | 63 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/packages/webui-framework/tests/fixtures/structural-sibling-order/element.ts b/packages/webui-framework/tests/fixtures/structural-sibling-order/element.ts index 49ddd3dd3..4c9318736 100644 --- a/packages/webui-framework/tests/fixtures/structural-sibling-order/element.ts +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/element.ts @@ -51,6 +51,20 @@ export class TestStructuralSiblingOrder extends WebUIElement { ]; } + appendMinimalTurn(messages: string[], failed: boolean): void { + this.minimalTurns = [ + ...this.minimalTurns, + { messages, failed }, + ]; + } + + updateLastMinimalTurn(patch: Partial): void { + const last = this.minimalTurns.length - 1; + this.minimalTurns = this.minimalTurns.map((turn, index) => ( + index === last ? { ...turn, ...patch } : turn + )); + } + setBeforeRepeat(): void { this.beforeRepeat = 'before'; } diff --git a/packages/webui-framework/tests/fixtures/structural-sibling-order/structural-sibling-order.spec.ts b/packages/webui-framework/tests/fixtures/structural-sibling-order/structural-sibling-order.spec.ts index a71c753b1..2af8177a4 100644 --- a/packages/webui-framework/tests/fixtures/structural-sibling-order/structural-sibling-order.spec.ts +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/structural-sibling-order.spec.ts @@ -68,6 +68,69 @@ test.describe('client-created structural sibling order', () => { ]); }); + test('keeps a conditional latent after an existing repeat', async ({ page }) => { + const turns = page.locator('test-structural-sibling-order .minimal-turn'); + await page.evaluate(() => { + (document.querySelector('test-structural-sibling-order') as HTMLElement & { + appendMinimalTurn(messages: string[], failed: boolean): void; + }).appendMinimalTurn(['user', 'assistant'], false); + }); + + await expect(turns).toHaveCount(2); + expect(await directChildKinds(turns.nth(1).locator('.for-if'))).toEqual([ + 'user', + 'assistant', + ]); + + await page.evaluate(() => { + (document.querySelector('test-structural-sibling-order') as HTMLElement & { + updateLastMinimalTurn(patch: { failed: boolean }): void; + }).updateLastMinimalTurn({ failed: true }); + }); + + expect(await directChildKinds(turns.nth(1).locator('.for-if'))).toEqual([ + 'user', + 'assistant', + 'error', + ]); + expect(await directChildKinds(turns.nth(1).locator('.if-for'))).toEqual([ + 'error', + 'user', + 'assistant', + ]); + }); + + test('keeps a late-growing repeat before an existing conditional', async ({ page }) => { + const turns = page.locator('test-structural-sibling-order .minimal-turn'); + await page.evaluate(() => { + (document.querySelector('test-structural-sibling-order') as HTMLElement & { + appendMinimalTurn(messages: string[], failed: boolean): void; + }).appendMinimalTurn([], true); + }); + + await expect(turns).toHaveCount(2); + expect(await directChildKinds(turns.nth(1).locator('.for-if'))).toEqual([ + 'error', + ]); + + await page.evaluate(() => { + (document.querySelector('test-structural-sibling-order') as HTMLElement & { + updateLastMinimalTurn(patch: { messages: string[] }): void; + }).updateLastMinimalTurn({ messages: ['user', 'assistant'] }); + }); + + expect(await directChildKinds(turns.nth(1).locator('.for-if'))).toEqual([ + 'user', + 'assistant', + 'error', + ]); + expect(await directChildKinds(turns.nth(1).locator('.if-for'))).toEqual([ + 'error', + 'user', + 'assistant', + ]); + }); + test('hydrates an empty text slot before a following repeat', async ({ page }) => { const container = page.locator('test-structural-sibling-order .text-before-repeat'); await expect(container).toHaveText('itemtail'); From 2140c6dd7aef63174b608c5ce343afa4f535d7d9 Mon Sep 17 00:00:00 2001 From: Mohamed Mansour Date: Mon, 24 Aug 2026 13:00:58 -0700 Subject: [PATCH 5/5] Refine structural slot placement Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../webui-framework/src/template-element.ts | 98 +++++++++++-------- 1 file changed, 55 insertions(+), 43 deletions(-) diff --git a/packages/webui-framework/src/template-element.ts b/packages/webui-framework/src/template-element.ts index f427d218a..e252e44f4 100644 --- a/packages/webui-framework/src/template-element.ts +++ b/packages/webui-framework/src/template-element.ts @@ -169,6 +169,34 @@ const RAW_MARKER_BOUNDARY_BASE = 0x40000000; */ const tplElementCache = new WeakMap>(); +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 { let cached = tplElementCache.get(tplRoot); if (!cached) { @@ -1748,20 +1776,13 @@ export class TemplateElement extends HTMLElement { // pre-order reproduces the indices the compiler assigned. const elements = collectTemplateElements(root); - type SlotRef = { - parent: Node; - ref: Node | null; - order: number; - first: Node; - second?: Node; - }; - const slotRefs = new Array( + const pendingSlots = new Array( (meta.tx?.length ?? 0) + (meta.c?.length ?? 0) + (meta.r?.length ?? 0), ); - let slotRefCount = 0; - let hasSharedSlots = false; + let pendingSlotCount = 0; + let needsSlotOrdering = false; - // Pre-resolve text binding slots + // 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++) { @@ -1771,11 +1792,11 @@ export class TemplateElement extends HTMLElement { const [parentIndex, beforeIndex, order = 0] = slot; const parent = elements[parentIndex]; if (!parent || (parent.nodeType !== 1 && parent.nodeType !== 11)) continue; - let first: Node; - let second: Node | undefined; + let node: Node; + let end: Comment | undefined; if (raw) { const start = document.createComment(rawMarker(rawIndex)); - const end = document.createComment(rawMarker(rawIndex, true)); + end = document.createComment(rawMarker(rawIndex, true)); rawIndex++; instance.texts.push({ node: start, @@ -1785,25 +1806,24 @@ export class TemplateElement extends HTMLElement { rawEnd: end, rawOwner: instance, }); - first = start; - second = end; + node = start; } else { const textNode = document.createTextNode(''); instance.texts.push({ node: textNode, parts, scope }); - first = textNode; + node = textNode; } - slotRefs[slotRefCount++] = { + pendingSlots[pendingSlotCount++] = { parent, - ref: parent.childNodes[beforeIndex] || null, + before: parent.childNodes[beforeIndex] || null, order, - first, - second, + node, + end, }; - if (order > 0) hasSharedSlots = true; + if (order > 0) needsSlotOrdering = true; } } - // Pre-resolve conditional slots + // 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]; @@ -1819,17 +1839,17 @@ export class TemplateElement extends HTMLElement { owner: instance, instance: null, }); - slotRefs[slotRefCount++] = { + pendingSlots[pendingSlotCount++] = { parent, - ref: parent.childNodes[beforeIndex] || null, + before: parent.childNodes[beforeIndex] || null, order, - first: anchor, + node: anchor, }; - if (order > 0) hasSharedSlots = true; + if (order > 0) needsSlotOrdering = true; } } - // Pre-resolve repeat slots + // 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]; @@ -1846,13 +1866,13 @@ export class TemplateElement extends HTMLElement { binding.keyState = createRepeatKeyState(keyPath); } instance.repeats.push(binding); - slotRefs[slotRefCount++] = { + pendingSlots[pendingSlotCount++] = { parent, - ref: parent.childNodes[beforeIndex] || null, + before: parent.childNodes[beforeIndex] || null, order, - first: anchor, + node: anchor, }; - if (order > 0) hasSharedSlots = true; + if (order > 0) needsSlotOrdering = true; } } @@ -1864,17 +1884,9 @@ export class TemplateElement extends HTMLElement { // insertions still shift childNode indices for sibling elements. this.$finalize(instance, root, meta, (_r, i) => elements[i] ?? null, scope); - // Co-located dynamic slots share one static insertion reference. The - // compiler's order field preserves source order across binding kinds. - if (hasSharedSlots) { - slotRefs.length = slotRefCount; - slotRefs.sort((left, right) => left.order - right.order); - } - for (let i = 0; i < slotRefCount; i++) { - const slot = slotRefs[i]; - slot.parent.insertBefore(slot.first, slot.ref); - if (slot.second) slot.parent.insertBefore(slot.second, slot.ref); - } + // 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); // Create conditional blocks immediately; the first full binding pass // reconciles repeats after this wiring step returns.