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
23 changes: 22 additions & 1 deletion packages/webui-framework/src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,14 @@ export class WebUIElement extends HTMLElement {
this.$hydrated = true;
this.$ready = true;

// Client-created components: flush current attr/observable values
// into the freshly-wired template DOM. Call $updateInstance directly
// to avoid the $update() path-index build — it will be lazy-built
// on the first reactive change instead.
if (!isSSR) {
this.$updateInstance(this.$root);
}

hydrationEnd();
}

Expand All @@ -269,14 +277,21 @@ export class WebUIElement extends HTMLElement {
);
}

/** Populate @observable properties from router state. */
/** Populate @observable properties from router state.
*
* Each property is set through its reactive setter, which coalesces
* updates into a single pending microtask. We then synchronously
* flush those pending path updates so the DOM is current before any
* view-transition snapshot captures it.
*/
setInitialState(state: Record<string, unknown>): void {
const names = getObservableNames(this.constructor as Function);
for (const key of Object.keys(state)) {
if (names.has(key)) {
(this as Record<string, unknown>)[key] = state[key];
}
}
this.$flushUpdates();
}

/**
Expand Down Expand Up @@ -1194,6 +1209,12 @@ export class WebUIElement extends HTMLElement {
case ATTR_KIND_COMPLEX: {
const v = this.$resolveValue(b.path!, b.scope);
(el as unknown as Record<string, unknown>)[b.name] = v;
// If the target is a WebUIElement, flush its pending updates
// synchronously so child <for> loops re-render immediately.
// Without this, the child's microtask-coalesced update runs
// too late for view transitions that snapshot the DOM.
const flush = (el as unknown as Record<string, unknown>)['$flushUpdates'];
if (typeof flush === 'function') (flush as () => void).call(el);
break;
}
case ATTR_KIND_BOOLEAN: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/**
* Regression test: client-created components (no SSR) must render
* their initial observable values immediately.
*/

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

test.describe('client-wire: client-created components render initial values', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/client-wire/fixture.html');
await page.waitForFunction(() => {
const el = document.querySelector('#host') as any;
return el && el.$ready === true;
});
});

test('renders initial observable values in shadow DOM', async ({ page }) => {
const result = await page.evaluate(() => {
const host = document.querySelector('#host') as any;
const greeting = host?.shadowRoot?.querySelector('.greeting');
const count = host?.shadowRoot?.querySelector('.count');
return {
greeting: greeting?.textContent,
count: count?.textContent,
};
});

expect(result.greeting).toBe('Hello');
expect(result.count).toBe('42');
});

test('updates reactively after initial render', async ({ page }) => {
await page.evaluate(() => {
const host = document.querySelector('#host') as any;
host.greeting = 'World';
host.count = 99;
});

await page.waitForFunction(() => {
const host = document.querySelector('#host') as any;
return host?.shadowRoot?.querySelector('.greeting')?.textContent === 'World';
});

const result = await page.evaluate(() => {
const host = document.querySelector('#host') as any;
return {
greeting: host?.shadowRoot?.querySelector('.greeting')?.textContent,
count: host?.shadowRoot?.querySelector('.count')?.textContent,
};
});

expect(result.greeting).toBe('World');
expect(result.count).toBe('99');
});

test('initial values are available synchronously after appendChild', async ({ page }) => {
// Create a NEW element and verify values are flushed immediately
const result = await page.evaluate(() => {
const el = document.createElement('test-client-wire') as any;
document.body.appendChild(el);

// Check IMMEDIATELY — no await
const greeting = el.shadowRoot?.querySelector('.greeting');
const count = el.shadowRoot?.querySelector('.count');
return {
greeting: greeting?.textContent,
count: count?.textContent,
ready: el.$ready,
};
});

expect(result.ready).toBe(true);
expect(result.greeting).toBe('Hello');
expect(result.count).toBe('42');
});
});
36 changes: 36 additions & 0 deletions packages/webui-framework/tests/fixtures/client-wire/element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/**
* Regression fixture: client-created components (no SSR) must render
* their initial observable values immediately after connectedCallback.
*
* Without the $updateInstance call after $wire, client-created
* components would show empty/default values until the first
* reactive change triggers an update.
*/

import { WebUIElement, observable } from '../../../src/index.js';
import {
bindText,
dynamic,
nodePath,
registerCompiledTemplate,
slot,
} from '@microsoft/webui-test-support';

registerCompiledTemplate('test-client-wire', {
h: '<span class="greeting"></span><span class="count"></span>',
sd: true,
text: [
bindText(slot({ parent: nodePath(0), before: 0 }), dynamic('greeting')),
bindText(slot({ parent: nodePath(1), before: 0 }), dynamic('count')),
],
});

export class TestClientWire extends WebUIElement {
@observable greeting = 'Hello';
@observable count = 42;
}

TestClientWire.define('test-client-wire');
19 changes: 19 additions & 0 deletions packages/webui-framework/tests/fixtures/client-wire/fixture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Client-Wire — No SSR Initial Render</title>
</head>
<body>
<!-- No pre-rendered content — component is created purely client-side -->
<script>window.__webui_shadow=true;</script>
<script src="/dist/client-wire/element.js"></script>

<script>
// Create component dynamically after scripts load
const el = document.createElement('test-client-wire');
el.id = 'host';
document.body.appendChild(el);
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/**
* Regression test: complex property bindings (:prop) propagate parent
* observable changes to child <for> loops.
*
* When a parent's @observable array is replaced (e.g. via setInitialState
* during SPA navigation), the complex binding `:items="{{items}}"` must
* push the new array to the child, causing the child's <for> loop to
* re-render with the updated data.
*/

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

test.describe('complex-prop: parent array changes propagate to child for-loop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/complex-prop/fixture.html');
await page.waitForFunction(() => {
const el = document.querySelector('#host') as any;
return el && el.$ready === true;
});
});

test('initial items render in child for-loop', async ({ page }) => {
const items = await page.evaluate(() => {
const host = document.querySelector('#host') as any;
const list = host?.shadowRoot?.querySelector('test-item-list');
const lis = list?.shadowRoot?.querySelectorAll('.item');
return Array.from(lis ?? []).map((li: any) => li.textContent);
});

expect(items).toEqual(['Alpha', 'Beta', 'Gamma']);
});

test('replacing parent items updates child for-loop', async ({ page }) => {
await page.evaluate(() => {
const host = document.querySelector('#host') as any;
host.replaceItems();
});

// Wait for microtask flush
await page.waitForFunction(() => {
const host = document.querySelector('#host') as any;
const list = host?.shadowRoot?.querySelector('test-item-list');
const lis = list?.shadowRoot?.querySelectorAll('.item');
return lis?.length === 2;
}, null, { timeout: 2000 });

const items = await page.evaluate(() => {
const host = document.querySelector('#host') as any;
const list = host?.shadowRoot?.querySelector('test-item-list');
const lis = list?.shadowRoot?.querySelectorAll('.item');
return Array.from(lis ?? []).map((li: any) => li.textContent);
});

expect(items).toEqual(['One', 'Two']);
});

test('clearing parent items empties child for-loop', async ({ page }) => {
await page.evaluate(() => {
const host = document.querySelector('#host') as any;
host.clearItems();
});

await page.waitForFunction(() => {
const host = document.querySelector('#host') as any;
const list = host?.shadowRoot?.querySelector('test-item-list');
const lis = list?.shadowRoot?.querySelectorAll('.item');
return lis?.length === 0;
}, null, { timeout: 2000 });

const count = await page.evaluate(() => {
const host = document.querySelector('#host') as any;
const list = host?.shadowRoot?.querySelector('test-item-list');
return list?.shadowRoot?.querySelectorAll('.item')?.length;
});

expect(count).toBe(0);
});

test('setInitialState on parent propagates to child for-loop', async ({ page }) => {
// Simulate what the router does during SPA navigation
await page.evaluate(() => {
const host = document.querySelector('#host') as any;
host.setInitialState({ items: [{ name: 'X' }, { name: 'Y' }] });
});

await page.waitForFunction(() => {
const host = document.querySelector('#host') as any;
const list = host?.shadowRoot?.querySelector('test-item-list');
const lis = list?.shadowRoot?.querySelectorAll('.item');
return lis?.length === 2;
}, null, { timeout: 2000 });

const items = await page.evaluate(() => {
const host = document.querySelector('#host') as any;
const list = host?.shadowRoot?.querySelector('test-item-list');
const lis = list?.shadowRoot?.querySelectorAll('.item');
return Array.from(lis ?? []).map((li: any) => li.textContent);
});

expect(items).toEqual(['X', 'Y']);
});

test('setInitialState propagates to child synchronously (no microtask needed)', async ({ page }) => {
// The critical case: after setInitialState, the child DOM must be
// updated synchronously — no microtask wait. This matters for view
// transitions which snapshot the DOM right after the sync callback.
const result = await page.evaluate(() => {
const host = document.querySelector('#host') as any;
host.setInitialState({ items: [{ name: 'Sync1' }, { name: 'Sync2' }, { name: 'Sync3' }] });

// Check IMMEDIATELY — no await, no microtask, no setTimeout
const list = host?.shadowRoot?.querySelector('test-item-list');
const lis = list?.shadowRoot?.querySelectorAll('.item');
return {
count: lis?.length,
items: Array.from(lis ?? []).map((li: any) => li.textContent),
};
});

expect(result.count).toBe(3);
expect(result.items).toEqual(['Sync1', 'Sync2', 'Sync3']);
});
});
68 changes: 68 additions & 0 deletions packages/webui-framework/tests/fixtures/complex-prop/element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/**
* Regression fixture: complex property bindings (:prop) must propagate
* changes from parent to child, causing the child's <for> loop to re-render.
*
* Scenario: parent has @observable items = [...]. Template binds
* :items="{{items}}" on a child. When parent.items is replaced via
* setInitialState (simulating SPA partial), the child's <for> loop
* must re-render with the new array.
*/

import { WebUIElement, observable } from '../../../src/index.js';
import {
bindProp,
bindText,
dynamic,
nodePath,
registerCompiledTemplate,
repeat,
slot,
attrTarget,
} from '@microsoft/webui-test-support';

// Child: renders a <for> loop over its items
registerCompiledTemplate('test-item-list', {
h: '<ul class="list"></ul>',
sd: true,
repeats: [repeat('items', 'item', { blockIndex: 0, slot: { parent: nodePath(0), before: 0 } })],
blocks: [{
h: '<li class="item"></li>',
text: [bindText(slot({ parent: nodePath(0), before: 0 }), dynamic('item.name'))],
}],
});

// Parent: owns the items array, passes via complex binding
registerCompiledTemplate('test-item-host', {
h: '<div class="controls"><button class="replace">Replace</button><button class="clear">Clear</button></div><test-item-list></test-item-list>',
sd: true,
attrs: [
bindProp('items', 'items'),
],
attrGroups: [attrTarget(nodePath(1), { startIndex: 0, bindingCount: 1 })],
});

export class TestItemList extends WebUIElement {
@observable items: Array<{ name: string }> = [];
}

export class TestItemHost extends WebUIElement {
@observable items: Array<{ name: string }> = [
{ name: 'Alpha' },
{ name: 'Beta' },
{ name: 'Gamma' },
];

replaceItems(): void {
this.items = [{ name: 'One' }, { name: 'Two' }];
}

clearItems(): void {
this.items = [];
}
}

TestItemList.define('test-item-list');
TestItemHost.define('test-item-host');
13 changes: 13 additions & 0 deletions packages/webui-framework/tests/fixtures/complex-prop/fixture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Complex Property Binding — For Loop Propagation</title>
</head>
<body>
<test-item-host id="host"></test-item-host>

<script>window.__webui_shadow=true;</script>
<script src="/dist/complex-prop/element.js"></script>
</body>
</html>
Loading