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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/webui-framework/src/template-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ const templateContentCache = new WeakMap<TemplateBlockMeta, TemplateContent>();

/** 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. */
Expand Down
23 changes: 5 additions & 18 deletions packages/webui-framework/src/template-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import { deferTemplateDefinition, getTemplate } from './template.js';
import {
cloneTemplateContent,
getTemplateFragment,
} from './template-content.js';
import type {
TemplateMeta,
Expand Down Expand Up @@ -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<TemplateBlockMeta, string | null>();

/** Parsed template DOM for SSR path mapping, keyed by TemplateBlockMeta. */
const templateDOMCache = new WeakMap<TemplateBlockMeta, Element>();

/** Per-child node-type ordinals used when a text slot has no dynamic boundary. */
const tplOrdinalCache = new WeakMap<Node, Map<number, [nodeType: number, ordinal: number]>>();
/** Encoded next marker boundary per text slot, built once per template. */
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1941,7 +1928,7 @@ export class TemplateElement extends HTMLElement {
private $hydrate(
ssrRoot: Node,
meta: TemplateBlockMeta,
tplDom: Element,
tplDom: DocumentFragment,
scope?: ScopeFrame,
pathStart = 0,
): TemplateInstance {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 34 additions & 1 deletion packages/webui-framework/tests/fixtures/slot-shadow/element.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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 = '<slot name="trigger"></slot><slot></slot>';
}
}

class MaiMenuList extends HTMLElement {
connectedCallback(): void {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = '<div role="menu"><slot></slot></div>';
}
}

class MaiMenuItem extends HTMLElement {
connectedCallback(): void {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = '<div role="menuitem"><slot></slot></div>';
}
}

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');
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,26 @@
<template shadowrootmode="open"><div class="container"></div></template>
<template shadowrootmode="open">
<div class="container"></div>
<table>
<tbody>
<for each="preview in previews">
<tr class="resource-row">
<td>
<mai-menu class="row-menu">
<mai-button slot="trigger">Actions</mai-button>
<mai-menu-list>
<if condition="preview.preview_url">
<mai-menu-item class="preview-item">Open preview</mai-menu-item>
</if>
<if condition="preview.can_view_logs">
<mai-menu-item class="logs-item">Logs</mai-menu-item>
</if>
<mai-menu-item class="restart-item">Restart</mai-menu-item>
<mai-menu-item class="stop-item">Stop</mai-menu-item>
</mai-menu-list>
</mai-menu>
</td>
</tr>
</for>
</tbody>
</table>
</template>
13 changes: 12 additions & 1 deletion packages/webui-framework/tests/fixtures/slot-shadow/state.json
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
{}
{
"previews": [
{
"preview_url": "/preview/running",
"can_view_logs": true
},
{
"preview_url": "",
"can_view_logs": true
}
]
}
Loading