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
12 changes: 8 additions & 4 deletions packages/webui-framework/src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,17 @@ export class WebUIElement extends HTMLElement {
// Shadow DOM SSR — declarative shadow root already has content
root = this.shadowRoot!;
isSSR = true;
} else if (this.childNodes.length > 0) {
// SSR — element already has server-rendered children (light DOM).
// Reuse existing DOM regardless of shadow preference.
} else if (this.childNodes.length > 0 && !meta.sd) {
// SSR light-DOM — element already has server-rendered children.
// Only treat as SSR when the template does NOT explicitly declare
// shadow DOM (meta.sd). When meta.sd is set, existing children
// are slot content from an SPA partial, not SSR output.
root = this;
isSSR = true;
} else if (wantShadow) {
// Shadow DOM client-created
// Shadow DOM client-created (or SPA partial with slot content).
// Existing children are slot content — they stay in light DOM
// and project through the template's <slot>.
root = this.attachShadow({ mode: 'open' });
const fragment = this.$parseTemplate(meta);
root.appendChild(fragment);
Expand Down
66 changes: 66 additions & 0 deletions packages/webui-framework/tests/fixtures/slot-shadow/element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/**
* Regression fixture for shadow-DOM components that receive pre-existing
* slot content (the SPA partial rendering scenario).
*
* When the server sends a partial response during SPA navigation, child
* components arrive with slot content already present as child nodes:
*
* <test-slot-btn><svg>…</svg><span>Reply</span></test-slot-btn>
*
* The framework must still create a shadow root and populate it from the
* compiled template — the existing children are slot content projected
* through <slot>, NOT SSR light-DOM output.
*/

import { WebUIElement, attr } from '../../../src/index.js';
import { registerCompiledTemplate } from '@microsoft/webui-test-support';

// A simple shadow-DOM button component with a <slot> for projected content.
registerCompiledTemplate('test-slot-btn', {
h: '<button class="btn"><slot></slot></button>',
sd: true,
});

// A parent component that programmatically creates a child with slot content,
// mimicking what the SPA router does when injecting a server partial.
registerCompiledTemplate('test-slot-parent', {
h: '<div class="container"></div>',
sd: true,
});

export class TestSlotBtn extends WebUIElement {
@attr appearance = '';
}

export class TestSlotParent extends WebUIElement {
/**
* Simulate SPA partial injection: create a child component element,
* give it slot content, then append to DOM (triggering connectedCallback).
*/
spawnSlotChild(): void {
const root = this.shadowRoot ?? this;
const container = root.querySelector('.container');
if (!container) return;

const btn = document.createElement('test-slot-btn');
btn.setAttribute('appearance', 'primary');

// Add slot content BEFORE appending to DOM — this is what the browser
// does when parsing innerHTML from a server partial response.
const icon = document.createElement('span');
icon.className = 'icon';
icon.textContent = '↩';
const label = document.createElement('span');
label.textContent = 'Reply';
btn.appendChild(icon);
btn.appendChild(label);

container.appendChild(btn);
}
}

TestSlotBtn.define('test-slot-btn');
TestSlotParent.define('test-slot-parent');
32 changes: 32 additions & 0 deletions packages/webui-framework/tests/fixtures/slot-shadow/fixture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Slot + Shadow DOM — SPA Partial Regression Fixture</title>
</head>
<body>
<!--
Scenario 1: child created with NO slot content (baseline).
The framework should create a shadow root.
-->
<test-slot-btn id="empty-child"></test-slot-btn>

<!--
Scenario 2: child created with PRE-EXISTING slot content.
This simulates what the browser produces when parsing an SPA partial
response from the server (innerHTML injection). The framework MUST
still create a shadow root — the existing children are slot content,
NOT SSR light-DOM output.
-->
<test-slot-btn id="preloaded-child" appearance="primary"><span class="icon">↩</span><span>Reply</span></test-slot-btn>

<!--
Scenario 3: parent that dynamically spawns a child with slot content
(programmatic equivalent of innerHTML from a partial).
-->
<test-slot-parent id="parent"></test-slot-parent>

<script>window.__webui_shadow=true;</script>
<script src="/dist/slot-shadow/element.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/**
* Regression test: shadow-DOM components with pre-existing slot content.
*
* When a shadow-DOM component (meta.sd = 1) is created with child nodes
* already present — as happens during SPA partial rendering — the framework
* must still create a shadow root. A prior bug caused $mount to
* misidentify slot children as SSR light-DOM content, skipping shadow root
* creation entirely.
*/

import { expect, test } from '@playwright/test';

test.describe('slot-shadow: SPA partial regression', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/slot-shadow/fixture.html');
await page.waitForSelector('test-slot-btn');
await page.waitForFunction(() => {
const el = document.querySelector('#empty-child') as any;
return el && el.$ready === true;
});
});

test('empty child gets a shadow root (baseline)', async ({ page }) => {
const hasShadow = await page.evaluate(() => {
const el = document.querySelector('#empty-child');
return !!el?.shadowRoot;
});
expect(hasShadow).toBe(true);
});

test('child with pre-existing slot content gets a shadow root', async ({ page }) => {
// Wait for the preloaded child to be ready
await page.waitForFunction(() => {
const el = document.querySelector('#preloaded-child') as any;
return el && el.$ready === true;
});

const result = await page.evaluate(() => {
const el = document.querySelector('#preloaded-child');
return {
hasShadow: !!el?.shadowRoot,
// The shadow root should contain the <button class="btn"><slot></slot></button>
shadowHasButton: !!el?.shadowRoot?.querySelector('button.btn'),
// The slot content should still be in the light DOM
lightDomChildren: el?.children.length,
// Slot content should be projected
slotText: el?.textContent?.trim(),
};
});

expect(result.hasShadow).toBe(true);
expect(result.shadowHasButton).toBe(true);
// Light DOM children (the icon span and label span) stay in place
expect(result.lightDomChildren).toBeGreaterThanOrEqual(2);
expect(result.slotText).toContain('Reply');
});

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(() => {
const parent = document.querySelector('#parent') as any;
parent.spawnSlotChild();
});

// Wait for the spawned child to be ready
await page.waitForFunction(() => {
const parent = document.querySelector('#parent') as any;
const root = parent?.shadowRoot;
if (!root) return false;
const child = root.querySelector('test-slot-btn') as any;
return child && child.$ready === true;
});

const result = await page.evaluate(() => {
const parent = document.querySelector('#parent') as any;
const child = parent?.shadowRoot?.querySelector('test-slot-btn');
return {
hasShadow: !!child?.shadowRoot,
shadowHasButton: !!child?.shadowRoot?.querySelector('button.btn'),
slotText: child?.textContent?.trim(),
};
});

expect(result.hasShadow).toBe(true);
expect(result.shadowHasButton).toBe(true);
expect(result.slotText).toContain('Reply');
});
});
Loading