Skip to content

fix(framework): synchronous DOM flush for view transitions and client created components#222

Merged
mohamedmansour merged 2 commits intomainfrom
mmansour/fix-sync-flush-view-transitions
Apr 13, 2026
Merged

fix(framework): synchronous DOM flush for view transitions and client created components#222
mohamedmansour merged 2 commits intomainfrom
mmansour/fix-sync-flush-view-transitions

Conversation

@mohamedmansour
Copy link
Copy Markdown
Contributor

WebUIElement was missing synchronous flush behavior in two critical paths, causing stale DOM in view-transition snapshots and blank client-created components.

Problem

  1. setInitialState() called by the router during SPA navigation, set observable properties through reactive setters that coalesce updates into a pending microtask. The DOM was not updated until the microtask drained, but view transitions (document.startViewTransition) snapshot the DOM synchronously. Result: the snapshot captured stale content.

  2. Client-created components (no SSR, created via document.createElement) wired their template DOM but never flushed the initial observable values into the bindings. The component rendered with empty default text until the first reactive change triggered an update.

  3. Complex property bindings (:prop) on child custom elements set the child's property via the reactive setter, which queued a microtask on the child. The parent's flush completed but the child's DOM was still stale, breaking synchronous consistency for view-transition snapshots.

Fix

  • setInitialState(): call after setting all properties. This synchronously drains the pending microtask queue so the DOM is current before the function returns.

  • Post-wiring render: after completes for client-created components, to flush initial observable values into the freshly-wired template DOM. Uses \ (not ) to avoid the path-index build which is deferred to the first reactive change.

  • **ATTR_KIND_COMPLEX in **: after setting a complex property on a child element, duck-type check for \ and call it. This ensures child for-loops re-render synchronously when the parent flushes. The typeof check is necessary because the target may be a third-party custom element that is not a WebUIElement.

Tests

  • complex-prop fixture (5 tests): parent with @observable array passes :items to a child that renders a loop. Verifies initial render, reactive updates, clearing, setInitialState propagation, and critically: synchronous propagation without microtask wait.

  • client-wire fixture (3 tests): client-created component with shadow DOM verifies initial observable values render immediately after appendChild, reactive updates work, and values are available synchronously.

…-created components

WebUIElement was missing synchronous flush behavior in two critical paths,
causing stale DOM in view-transition snapshots and blank client-created
components.

## Problem

1. **setInitialState()** — called by the router during SPA navigation — set
   observable properties through reactive setters that coalesce updates into
   a pending microtask. The DOM was not updated until the microtask drained,
   but view transitions (document.startViewTransition) snapshot the DOM
   synchronously. Result: the snapshot captured stale content.

2. **Client-created components** (no SSR, created via document.createElement)
   wired their template DOM via \() but never flushed the initial
   observable values into the bindings. The component rendered with empty/
   default text until the first reactive change triggered an update.

3. **Complex property bindings** (:prop) on child custom elements set the
   child's property via the reactive setter, which queued a microtask on
   the child. The parent's flush completed but the child's DOM was still
   stale — breaking synchronous consistency for view-transition snapshots.

## Fix

- **setInitialState()**: call \() after setting all properties.
  This synchronously drains the pending microtask queue so the DOM is
  current before the function returns.

- **Post-wiring render**: after \() completes for client-created
  components, call \() to flush initial observable values
  into the freshly-wired template DOM. Uses \ (not \)
  to avoid the path-index build which is deferred to the first reactive
  change.

- **ATTR_KIND_COMPLEX in \**: after setting a complex property
  on a child element, duck-type check for \ and call it.
  This ensures child for-loops re-render synchronously when the parent
  flushes. The typeof check is necessary because the target may be a
  third-party custom element that is not a WebUIElement.

## Tests

- **complex-prop** fixture (5 tests): parent with @observable array passes
  :items to a child that renders a <for> loop. Verifies initial render,
  reactive updates, clearing, setInitialState propagation, and critically:
  synchronous propagation without microtask wait.

- **client-wire** fixture (3 tests): client-created component with shadow
  DOM verifies initial observable values render immediately after
  appendChild, reactive updates work, and values are available
  synchronously.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread packages/webui-framework/tests/fixtures/complex-prop/element.ts Fixed
@mohamedmansour mohamedmansour requested review from a team and Copilot April 12, 2026 04:53
…tion or class'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes synchronous DOM consistency issues in WebUIElement that affected view-transition snapshots and initial rendering of client-created (non-SSR) components, and adds Playwright regression fixtures to verify the behavior.

Changes:

  • Flush pending reactive updates at the end of setInitialState() so router-driven state application updates the DOM synchronously.
  • For non-SSR mounts, immediately patch bindings after $wire() via $updateInstance() so initial observable/attr values render without waiting for a later reactive change.
  • After complex (:prop) property bindings, synchronously flush child WebUIElement pending updates (duck-typed via $flushUpdates) to keep nested renders consistent during synchronous snapshots.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/webui-framework/src/element.ts Adds synchronous flush points for router state, client-created initial render, and complex-prop child propagation.
packages/webui-framework/tests/fixtures/complex-prop/fixture.html New fixture page for complex :prop propagation scenario.
packages/webui-framework/tests/fixtures/complex-prop/element.ts Defines parent/child elements and compiled templates to exercise complex prop + <for> rendering.
packages/webui-framework/tests/fixtures/complex-prop/complex-prop.spec.ts Regression tests for child <for> loop updates, including synchronous setInitialState() propagation.
packages/webui-framework/tests/fixtures/client-wire/fixture.html New fixture page for client-created component mounting (no SSR).
packages/webui-framework/tests/fixtures/client-wire/element.ts Defines a client-created component with initial observable values bound into shadow DOM.
packages/webui-framework/tests/fixtures/client-wire/client-wire.spec.ts Regression tests ensuring client-created components render initial values synchronously and update reactively.

@mohamedmansour mohamedmansour merged commit 39610c4 into main Apr 13, 2026
21 checks passed
@mohamedmansour mohamedmansour deleted the mmansour/fix-sync-flush-view-transitions branch April 13, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants