diff --git a/packages/webui-framework/src/template-content.ts b/packages/webui-framework/src/template-content.ts index caa71c720..bd3ade154 100644 --- a/packages/webui-framework/src/template-content.ts +++ b/packages/webui-framework/src/template-content.ts @@ -29,7 +29,12 @@ const templateContentCache = new WeakMap(); /** Clone cached template DOM for one client-created block instance. */ export function cloneTemplateContent(meta: TemplateBlockMeta): DocumentFragment { - return getTemplateContent(meta).fragment.cloneNode(true) as DocumentFragment; + return getTemplateFragment(meta).cloneNode(true) as DocumentFragment; +} + +/** Return cached, context-preserving template DOM for SSR path mapping. */ +export function getTemplateFragment(meta: TemplateBlockMeta): DocumentFragment { + return getTemplateContent(meta).fragment; } /** Return external stylesheet descriptors discovered during the template's single parse. */ diff --git a/packages/webui-framework/src/template-element.ts b/packages/webui-framework/src/template-element.ts index 8f9498fcd..5ea6ef19a 100644 --- a/packages/webui-framework/src/template-element.ts +++ b/packages/webui-framework/src/template-element.ts @@ -50,6 +50,7 @@ import { deferTemplateDefinition, getTemplate } from './template.js'; import { cloneTemplateContent, + getTemplateFragment, } from './template-content.js'; import type { TemplateMeta, @@ -151,9 +152,6 @@ const DEV: boolean = typeof __WEBUI_DEV__ === 'undefined' || __WEBUI_DEV__; /** Cached root tag name extracted from meta.h before it's released. */ const rootTagCache = new WeakMap(); -/** Parsed template DOM for SSR path mapping, keyed by TemplateBlockMeta. */ -const templateDOMCache = new WeakMap(); - /** Per-child node-type ordinals used when a text slot has no dynamic boundary. */ const tplOrdinalCache = new WeakMap>(); /** Encoded next marker boundary per text slot, built once per template. */ @@ -358,17 +356,6 @@ function childNodesArray(parent: Node): Node[] { return result; } -// ── Helper: parse template HTML into a temp container ──────────── - -function getTemplateDom(meta: TemplateBlockMeta): Element { - let cached = templateDOMCache.get(meta); - if (cached) return cached; - const div = document.createElement('div'); - div.innerHTML = meta.h; - templateDOMCache.set(meta, div); - return div; -} - /** * Merge authored `observedAttributes` with template-read roots. * @@ -856,7 +843,7 @@ export class TemplateElement extends HTMLElement { } if (isSSR) { - this.$root = this.$hydrate(root, meta, getTemplateDom(meta)); + this.$root = this.$hydrate(root, meta, getTemplateFragment(meta)); } else { clientRoot = this.$createStagingRoot(meta); @@ -1941,7 +1928,7 @@ export class TemplateElement extends HTMLElement { private $hydrate( ssrRoot: Node, meta: TemplateBlockMeta, - tplDom: Element, + tplDom: DocumentFragment, scope?: ScopeFrame, pathStart = 0, ): TemplateInstance { @@ -2129,7 +2116,7 @@ export class TemplateElement extends HTMLElement { const marker = repMarkers ? repMarkers[i] : null; const ssrParent = (marker ? marker.parentNode : ssrElements[parentIndex]) ?? ssrRoot; const blockMeta = this.$block(blockIndex); - const blockTplDom = blockMeta ? getTemplateDom(blockMeta) : null; + const blockTplDom = blockMeta ? getTemplateFragment(blockMeta) : null; const rootTag = blockMeta && blockTplDom?.childNodes.length === 1 && blockTplDom.children.length === 1 @@ -2313,7 +2300,7 @@ export class TemplateElement extends HTMLElement { scope: ScopeFrame | undefined, ): TemplateInstance | null { const rootTag = this.$rootTag(blockMeta); - const tplDom = getTemplateDom(blockMeta); + const tplDom = getTemplateFragment(blockMeta); if (rootTag && tplDom.children.length === 1 && !this.$hasRootStructuralSlot(blockMeta)) { // Single-root optimisation: hydrate the element in-place (pathStart=1). const el = nextElement(condAnchor); diff --git a/packages/webui-framework/tests/fixtures/slot-shadow/element.ts b/packages/webui-framework/tests/fixtures/slot-shadow/element.ts index 8d24d752e..bf410dd30 100644 --- a/packages/webui-framework/tests/fixtures/slot-shadow/element.ts +++ b/packages/webui-framework/tests/fixtures/slot-shadow/element.ts @@ -1,13 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { WebUIElement, attr } from '../../../src/index.js'; +import { WebUIElement, attr, observable } from '../../../src/index.js'; export class TestSlotBtn extends WebUIElement { @attr appearance = ''; } export class TestSlotParent extends WebUIElement { + @observable previews: Array<{ + preview_url: string; + can_view_logs: boolean; + }> = []; + spawnSlotChild(): void { const root = this.shadowRoot ?? this; const container = root.querySelector('.container'); @@ -28,5 +33,33 @@ export class TestSlotParent extends WebUIElement { } } +class MaiMenu extends HTMLElement { + connectedCallback(): void { + if (this.shadowRoot) return; + const root = this.attachShadow({ mode: 'open' }); + root.innerHTML = ''; + } +} + +class MaiMenuList extends HTMLElement { + connectedCallback(): void { + if (this.shadowRoot) return; + const root = this.attachShadow({ mode: 'open' }); + root.innerHTML = '
'; + } +} + +class MaiMenuItem extends HTMLElement { + connectedCallback(): void { + if (this.shadowRoot) return; + const root = this.attachShadow({ mode: 'open' }); + root.innerHTML = '
'; + } +} + +customElements.define('mai-menu', MaiMenu); +customElements.define('mai-button', class extends HTMLElement {}); +customElements.define('mai-menu-list', MaiMenuList); +customElements.define('mai-menu-item', MaiMenuItem); TestSlotBtn.define('test-slot-btn'); TestSlotParent.define('test-slot-parent'); 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 58e6756e6..0881eb4b8 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 @@ -13,6 +13,73 @@ import { expect, test } from '@playwright/test'; +interface ConditionalSlotOwnership { + rows: number; + menuItems: number; + previewItems: number; + logItems: number; + restartItems: number; + stopItems: number; + misplacedInTable: number; + allOwnedByMenu: boolean; + allAssignedToMenuSlot: boolean; + allMenusAssignedToOuterSlot: boolean; +} + +function readConditionalSlotOwnership(): ConditionalSlotOwnership { + const parent = document.querySelector('#parent'); + const root = parent?.shadowRoot; + const menus = Array.from(root?.querySelectorAll('mai-menu-list') ?? []); + const items = Array.from(root?.querySelectorAll('mai-menu-item') ?? []); + + return { + rows: root?.querySelectorAll('.resource-row').length ?? 0, + menuItems: items.length, + previewItems: root?.querySelectorAll('.preview-item').length ?? 0, + logItems: root?.querySelectorAll('.logs-item').length ?? 0, + restartItems: root?.querySelectorAll('.restart-item').length ?? 0, + stopItems: root?.querySelectorAll('.stop-item').length ?? 0, + misplacedInTable: + root?.querySelectorAll('table > mai-menu-item, tbody > mai-menu-item') + .length ?? 0, + allOwnedByMenu: items.every( + item => item.parentElement?.localName === 'mai-menu-list', + ), + allAssignedToMenuSlot: items.every( + item => item.assignedSlot?.localName === 'slot', + ), + allMenusAssignedToOuterSlot: menus.every( + menu => menu.assignedSlot?.localName === 'slot', + ), + }; +} + +const expectedConditionalSlotOwnership: ConditionalSlotOwnership = { + rows: 2, + menuItems: 7, + previewItems: 1, + logItems: 2, + restartItems: 2, + stopItems: 2, + misplacedInTable: 0, + allOwnedByMenu: true, + allAssignedToMenuSlot: true, + allMenusAssignedToOuterSlot: true, +}; + +const expectedSSRConditionalSlotOwnership: ConditionalSlotOwnership = { + ...expectedConditionalSlotOwnership, + allAssignedToMenuSlot: false, + allMenusAssignedToOuterSlot: false, +}; + +const expectedUpdatedConditionalSlotOwnership: ConditionalSlotOwnership = { + ...expectedConditionalSlotOwnership, + menuItems: 6, + previewItems: 1, + logItems: 1, +}; + test.describe('slot-shadow: SPA partial regression', () => { test.beforeEach(async ({ page }) => { await page.goto('/slot-shadow/fixture.html'); @@ -75,6 +142,49 @@ test.describe('slot-shadow: SPA partial regression', () => { await expect(page.locator('#preloaded-child')).toContainText('Reply'); }); + test('keeps conditional custom items owned by their slotted menu across hydration', async ({ + browser, + page, + }) => { + const ssrContext = await browser.newContext({ javaScriptEnabled: false }); + const ssrPage = await ssrContext.newPage(); + await ssrPage.goto('/slot-shadow/fixture.html'); + + expect(await ssrPage.evaluate(readConditionalSlotOwnership)).toEqual( + expectedSSRConditionalSlotOwnership, + ); + await ssrContext.close(); + + await page.waitForFunction(() => { + const parent = document.querySelector('#parent') as any; + const menus = parent?.shadowRoot?.querySelectorAll('mai-menu-list'); + const items = parent?.shadowRoot?.querySelectorAll('mai-menu-item'); + return menus?.length === 2 + && items?.length >= 7 + && Array.from(menus).every(menu => (menu as Element).shadowRoot) + && Array.from(items).every(item => (item as Element).shadowRoot); + }); + + expect(await page.evaluate(readConditionalSlotOwnership)).toEqual( + expectedConditionalSlotOwnership, + ); + await expect(page.locator('#parent .preview-item')).toBeVisible(); + await expect(page.locator('#parent .logs-item')).toHaveCount(2); + await expect(page.locator('#parent .restart-item')).toHaveCount(2); + await expect(page.locator('#parent .stop-item')).toHaveCount(2); + + await page.locator('#parent').evaluate((element) => { + (element as any).previews = [ + { preview_url: '', can_view_logs: false }, + { preview_url: '/preview/starting', can_view_logs: true }, + ]; + }); + + await expect.poll( + () => page.evaluate(readConditionalSlotOwnership), + ).toEqual(expectedUpdatedConditionalSlotOwnership); + }); + test('dynamically spawned child with slot content gets a shadow root', async ({ page }) => { // Trigger the parent to spawn a child with slot content await page.evaluate(() => { diff --git a/packages/webui-framework/tests/fixtures/slot-shadow/src/test-slot-parent/test-slot-parent.html b/packages/webui-framework/tests/fixtures/slot-shadow/src/test-slot-parent/test-slot-parent.html index 1760e65d3..76ebb2ae4 100644 --- a/packages/webui-framework/tests/fixtures/slot-shadow/src/test-slot-parent/test-slot-parent.html +++ b/packages/webui-framework/tests/fixtures/slot-shadow/src/test-slot-parent/test-slot-parent.html @@ -1 +1,26 @@ - + diff --git a/packages/webui-framework/tests/fixtures/slot-shadow/state.json b/packages/webui-framework/tests/fixtures/slot-shadow/state.json index 0967ef424..bb60e87c6 100644 --- a/packages/webui-framework/tests/fixtures/slot-shadow/state.json +++ b/packages/webui-framework/tests/fixtures/slot-shadow/state.json @@ -1 +1,12 @@ -{} +{ + "previews": [ + { + "preview_url": "/preview/running", + "can_view_logs": true + }, + { + "preview_url": "", + "can_view_logs": true + } + ] +}