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..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,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( + (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(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(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; } } @@ -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; 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..b0de7c51f --- /dev/null +++ b/packages/webui-framework/tests/fixtures/hydration-bench/src/test-hydration-slots/test-hydration-slots.html @@ -0,0 +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
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..f0cc5c923 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/light-dom-structural/src/test-light-dom-structural/test-light-dom-structural.html @@ -0,0 +1,5 @@ +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 @@ - + 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..4c9318736 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/element.ts @@ -0,0 +1,78 @@ +// 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 }, + ]; + } + + 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'; + } + + 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..003ceba43 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/src/test-structural-sibling-order/test-structural-sibling-order.html @@ -0,0 +1,50 @@ +
+ +
+ + + {{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..2af8177a4 --- /dev/null +++ b/packages/webui-framework/tests/fixtures/structural-sibling-order/structural-sibling-order.spec.ts @@ -0,0 +1,159 @@ +// 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('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'); + + 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'); + }); +});