Repository navigation
fix: prevent dynamic component stylesheet flashes - #454
Mohamed Mansour (mohamedmansour) merged 11 commits into
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Prevents flashes of unstyled content for dynamically registered / lazily mounted components by bridging router template registration with framework stylesheet readiness, and by guarding first Link-mode shadow mounts until native link CSS is usable (while still keeping the browser as the authority for CSP/MIME/redirect/SW behavior).
Changes:
- Router: template registration now supports a synchronous
waitUntil()collector and awaits readiness (with abort-aware cancellation) before committing JSON/NDJSON navigations; NDJSON chunk-two state reading is deferred until after commit and is cancellable/cleanup-safe. - Framework: adds Link-mode stylesheet preparation + guarded mounts, promoting authorized native CSSOM to shared constructable stylesheets when safe, with broad fallback cases preserved.
- Tests/docs/spec: expands Playwright and unit coverage for Link promotion/guarding, readiness waiting, and streaming cleanup; updates DESIGN/docs to describe the new contracts.
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/webui-router/tests/router-e2e.spec.ts | Adds e2e coverage ensuring ensureLoaded() waits for link stylesheet readiness. |
| packages/webui-router/src/templates.ts | Makes template registration dispatch optionally awaitable via waitUntil(); adds abort-aware readiness helper. |
| packages/webui-router/src/templates.test.ts | New unit tests for readiness collection, abort behavior, and streaming/readiness interactions. |
| packages/webui-router/src/streaming.ts | Refactors NDJSON handling to defer chunk-two consumption until after commit; adds cancellable deferred stream control. |
| packages/webui-router/src/router.ts | Integrates readiness waits and deferred-stream lifecycle into navigation commit flow. |
| packages/webui-router/src/router.test.ts | Extends router unit tests to ensure deferred readers don’t start before caching and can be started explicitly. |
| packages/webui-router/src/preload.ts | Ensures speculative preloads can start/cancel deferred NDJSON tails after caching chunk one. |
| packages/webui-router/README.md | Documents readiness waiting behavior and deferred-stream semantics. |
| packages/webui-framework/tests/fixtures/css-link/sw-css.js | Adds SW fixture to validate service-worker-backed stylesheets remain native. |
| packages/webui-framework/tests/fixtures/css-link/styles/relative.css | Adds fixture CSS exercising relative asset URL rewriting behavior. |
| packages/webui-framework/tests/fixtures/css-link/state.json | Expands fixture state for new coverage scenarios. |
| packages/webui-framework/tests/fixtures/css-link/src/test-style-block-child/test-style-block-child.html | Adds mixed <link> + <style> fixture to validate cascade preservation/fallback. |
| packages/webui-framework/tests/fixtures/css-link/src/test-link-event-child/test-link-event-child.html | Adds compiled-event-on-link fixture to force native fallback behavior. |
| packages/webui-framework/tests/fixtures/css-link/src/test-link-dynamic-child/test-link-dynamic-child.html | Adds dynamically bound link attribute fixture to validate native retention and re-arming. |
| packages/webui-framework/tests/fixtures/css-link/src/test-link-child/test-link-child.html | Updates base child fixture to include a relative asset probe and parameterized content. |
| packages/webui-framework/tests/fixtures/css-link/src/test-lifecycle-style-child/test-lifecycle-style-child.html | Adds lifecycle-added-style fixture to validate cascade boundaries and warm-mount fallback. |
| packages/webui-framework/tests/fixtures/css-link/src/index.html | Extends fixture page to mount additional test components by state. |
| packages/webui-framework/tests/fixtures/css-link/relative-marker.svg | Adds relative asset used to prove correct URL resolution behavior. |
| packages/webui-framework/tests/fixtures/css-link/alternate.css | Adds alternate CSS used in dynamic-href swap readiness test. |
| packages/webui-framework/tests/fixtures/css-link/element.ts | Expands fixture elements to support hydration/lifecycle assertions and dynamic link attributes. |
| packages/webui-framework/tests/fixtures/css-link/css-link.spec.ts | Large Playwright expansion covering guarding, promotion safety constraints, CSP cases, and cancellation semantics. |
| packages/webui-framework/src/template.ts | Hooks template registration event to prepare Link styles and register readiness via waitUntil(). |
| packages/webui-framework/src/template-events.ts | Extends template registration detail parsing to include optional waitUntil. |
| packages/webui-framework/src/template-element.ts | Integrates guarded Link-mode mounts and deferred hydration completion for CSP-blocked guard scenarios. |
| packages/webui-framework/src/template-content.ts | New shared template-content cache + stylesheet descriptor extraction for link analysis. |
| packages/webui-framework/src/element/styles.ts | Updates Link strategy docs to reflect guarded native loading + promotion model. |
| packages/webui-framework/src/element/link-styles.ts | New implementation: cache warmup, safety heuristics, guard install, native-CSSOM construction, and promotion/adoption logic. |
| packages/webui-framework/src/element/link-styles.test.ts | New unit tests for top-level @import detection and safe URL rewriting behavior. |
| packages/webui-framework/src/component-asset/loader.ts | Ensures component-asset registration awaits link-style preparation when present. |
| packages/webui-framework/README.md | Documents guarded Link behavior and lifecycle implications ($ready, hydratedCallback) under CSP/native fallback. |
| examples/app/component-assets/tests/component-assets.spec.ts | Adds browser coverage ensuring lazy asset insertion waits for link warmup before mount. |
| docs/guide/concepts/routing.md | Documents waitUntil() readiness bridge and its interaction with JSON/NDJSON commits and deferred state. |
| docs/ai/SKILL.md | Updates AI guidance/examples to reflect ensureLoaded() readiness semantics with the framework present. |
| DESIGN.md | Updates spec for readiness waiting, NDJSON streaming ordering, and Link promotion constraints/guarantees. |
| deny.toml | Adds an advisory ignore entry and normalizes formatting. |
Suppressed comments (1)
packages/webui-router/src/streaming.ts:171
- The
_deferredStreammarker is never cleared when the deferred reader finishes naturally. Since router/preload use this marker to decide whether to importstreaming.js, leaving it set causes avoidable dynamic imports for already-consumed cached entries.
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Start Link CSS through the existing component asset preload lifecycle without exposing generated stylesheet hashes. Preserve Light DOM cascade order and deduplicate metadata emission across document boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 62 out of 64 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/webui-framework/src/element/link-styles.ts:578
releaseStylesheetPreload()leaves a tombstone entry inspeculativeStylesheetPreloadsby calling.set(preload.href, undefined). BecausepreloadComponentAssetStyles()usespreloads.has(href)to dedupe, a stylesheet that was cleaned up after the TTL can never be speculatively preloaded again (and the Map can only grow over time). Deleting the key avoids the permanent dedupe and keeps the cache bounded.
if (speculative.cleanupTimer !== undefined) {
clearTimeout(speculative.cleanupTimer);
}
speculativeStylesheetPreloads?.set(preload.href, undefined);
}
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
4583987
into
microsoft:main
## Release Bumps WebUI to `0.0.26`. Previous release tag: `v0.0.25` ## Changes since `v0.0.25` Features: - Python hosts now have an official typed PyO3 renderer package with buffered, partial, template, token, and host-driven streaming APIs (feat: add official Python renderer package #453 by @mohamedmansour). - Progressive SSR can place transport-flushed streaming boundaries inside reusable components while preserving component ownership and continuation state (feat: support component-local streaming boundaries #460 by @mohamedmansour). - Components can defer compiler-owned hydration until interaction, with router preload handoff and a combined lazy-render policy (feat: add compiler-driven interaction hydration #485 by @mohamedmansour). - Builds can opt into global Light DOM CSS while preserving explicit authored Shadow roots and deterministic style closures (feat: make Light DOM a global CSS opt-in #429 by @mohamedmansour). - State projection supports application-owned TypeScript 7.0.2 while retaining the TypeScript 6 migration path (feat: support TypeScript 7 projection compilation #452 by @mohamedmansour). - WebUI Press supports layout-scoped compile-time named regions with fallback HTML, state, components, and scripts (feat(press): add compile-time named regions #484 by @mohamedmansour). - FAST v2 and v3 gain plugin-owned local and npm component discovery with validated FAST template transformation (feat: add plugin-owned FAST component discovery #378 by @janechu). Fixes: - Missing condition identifiers are treated as falsy before negation and logical evaluation, aligning SSR with the browser runtime (fix: negate missing condition paths correctly #449 by @mohamedmansour). - FAST route state now scales through escaped scalar kebab-case attributes shared by FAST v2 and v3 (fix: scale FAST route state with scalar attributes #450 by @janechu). - Dynamic Link-mode components wait for native stylesheet readiness across navigation and component assets, preventing unstyled flashes (fix: prevent dynamic component stylesheet flashes #454 by @mohamedmansour). - Authored component definitions defer whenever compiled template metadata has not arrived, including ordinary router navigation (fix: defer authored define() whenever template metadata is missing #461 by @mohamedmansour). - Client structural updates preserve authored order and sibling ownership for shared slots and raw HTML ranges (fix: preserve source order for shared structural slots #465 by @mohamedmansour, fix: preserve siblings around raw HTML updates #466 by @mohamedmansour). - Native-element attributes no longer leak into the local state of a later component (perf(handler): stop native attributes leaking into component state #469 by @mohamedmansour). - Templates-and-state-only SSR bootstrap payloads no longer require component style metadata (fix: allow SSR bootstrap without component styles #475 by @mohamedmansour). - The high-level Rust `serve_request` path now preserves complete render options, including CSP nonces, while sharing the same entry and request path with partial rendering (fix: forward CSP nonces through serve_request #488 by @mohamedmansour). - Projection compilation bounds source reads, excludes binary and unsupported-loader inputs from semantic analysis, and preserves deterministic cleanup under large esbuild graphs (fix: bound projection adapter source reads #489 by @mohamedmansour). - Router pending UI remains mounted through pre-commit work and settles at the synchronous DOM commit, with stale, aborted, and re-entrant navigation cleanup kept generation-safe (fix: settle router pending UI during navigation commits #491 by @mohamedmansour). Docs: - The documentation site adds accessible responsive navigation, improved reading layouts, stronger search behavior, and Playground recovery states (feat: polish WebUI documentation experience #459 by @mohamedmansour). - Slot-resolution implementation guidance now documents pre-order lookup, pending placement ownership, and marker handling (chore: clarify slot resolution comments #471 by @mohamedmansour). - Repeated `w-ref` behavior is now explicit: refs are scalar and the last wired occurrence wins, while stable authored IDs or item components provide identity-based lookup (chore: clarify repeated w-ref behavior #490 by @mohamedmansour). Maintenance: - Release and package policy metadata now constrains the transitive h2 advisory, uses SPDX NuGet licensing, and classifies publishing jobs correctly (chore: allow constrained h2 advisory #451 by @janechu, fix: use modern NuGet license metadata #457 by @janechu, chore: mark publishing jobs as release jobs #458 by @janechu). - The development and CI Rust toolchain is updated to 1.98 with the resulting warnings resolved (Update rust toolchain version and fix clippy warning #462 by @telecos). - Release builds use Thin LTO to retain cross-crate optimization with faster linking (perf: switch release builds to Thin LTO #464 by @mohamedmansour). - Handler and expression hot paths reduce attribute vtable calls, render lookup and scope allocations, and single-term condition overhead (perf(handler): centralize HTML attribute writing in ResponseWriter #467 by @mohamedmansour, perf(expressions): fast-path single-term conditions #470 by @mohamedmansour, perf(handler): reduce render lookup and scope allocations #472 by @mohamedmansour). - Node rendering can reuse immutable prepared-state snapshots and bounded per-route output capacity hints (perf(node): reuse prepared state across renders #477 by @mohamedmansour, perf: reuse Node render output capacity #478 by @mohamedmansour). - Framework hydration releases bootstrap data sooner and reduces allocations for bindings, empty hosts, and visible conditionals (perf: reduce framework hydration allocations #479 by @mohamedmansour, perf: release SSR bootstrap memory after hydration #480 by @mohamedmansour, perf: reduce empty template host overhead #482 by @mohamedmansour, perf: remove visible conditional anchors #483 by @mohamedmansour). - Benchmark tooling now renders the full contact workload, runs Criterion baselines per target, and restores the streaming hydration fixture (fix(bench): render contacts in contact-book benchmark #468 by @mohamedmansour, fix(xtask): run Criterion benchmarks per target #473 by @mohamedmansour, fix: repair streaming hydration benchmark fixture #476 by @mohamedmansour). ## Validation - `cargo xtask check` --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every client mount of a component that uses Link CSS paid for its own copy of the stylesheet: a new `<link>`, a guard `<style>` that hides the host, a `load` wait, a re-read of `link.sheet.cssRules`, and a separately parsed `CSSStyleSheet` adopted by that root alone. Router soft navigations, opened lazy regions and any client-created component therefore paid N parses, N guards and N load waits for N instances. Template-owned links already cache their sheets per template; component catalog links had no cross-instance cache. ## Approach Component Link stylesheets are now shared per Document and resolved href, and promoted on their second use. What users see does not change. - **First use stays native.** The first newly attached Shadow root to mount a stylesheet keeps its native `<link>` and only records whether that load is authorized under the existing #454 rule (unredirected, not served by a service worker, successful). A component used once pays no CSSOM serialization, second parse or duplicate contents. - **Second use promotes once.** The next root builds one constructable sheet from the CSSOM of any loaded, authorized link for that href and caches it. Every later root adopts the cached sheet synchronously, before its template is appended, with no link, guard or load wait. This covers a later root in the same pass, a reopened lazy region, and a cold load that settles. - **No visible switch.** A guarded root that is still hidden may switch to the shared sheet before it is revealed. A visible root never switches. - **One cascade per component.** Adopted sheets cascade after tree styles, so roots whose cascade includes other author styles promote on first use instead: templates with their own `<style>` or `<link>`, server-rendered or reused roots, and Light descendants. All instances of such a component adopt the same sheet, and a template-link reset that clears the root can no longer drop a native catalog link. - **Authorization is remembered** per Document and href, failures included. Memory-cache hits add no resource timing entry, so a repeated check could not pass. - **Less per-root bookkeeping.** One `TargetStyleState` record per target replaces five WeakMaps, and resource IDs stay plain strings until a second ID arrives. When `TemplateElement` attached the Shadow root in the current mount, adoption writes `adoptedStyleSheets` without reading it. A read makes Blink build and retain a wrapper for the root's lifetime. The public `installComponentStyles` still reads, so sheets an author adopted are kept. - Smaller wins: URL rewriting is skipped when the CSS has no `url(`, `image-set(` or escape, and resolved hrefs are cached per base URL. Cold loads, unpromotable stylesheets and failures keep the existing native-link, guard and error handling. ## Performance Edge 156 with 4x CPU throttling, measured with local Playwright harnesses that bundle the framework's real `installComponentStyles` and `prepareComponentStyleLinks`. Memory is `performance.measureUserAgentSpecificMemory()` after GC, minus a no-CSS page with the same roots. "This PR" is the path component mounts take. **N instances of one card component (~3 KB of CSS)**, time to styled and CSS memory, medians of 10 runs: | Rows | main | This PR | One shared sheet (ideal) | |---:|---:|---:|---:| | 20 | 107 ms, 170 KB | 18 ms, 92 KB | 8 ms, 7 KB | | 200 | 409 ms, 562 KB | 32 ms, 117 KB | 17 ms, 18 KB | | 1,000 | 1,738 ms, 2,227 KB | 88 ms, 193 KB | 69 ms, 30 KB | | Reopen 1,000 | 473 ms | 71 ms | 65 ms | **200 component types (~1 MB of CSS), 1,199 Shadow roots** (a 1,000-item list plus one instance of each other type), medians of 5 runs. No component's rules leak into another's shadow root. | Variant | Time to styled | CSS memory | |---|---:|---:| | main | 3,099 ms | 3,105 KB | | This PR | 261 ms | 602 KB | | Native `<link>` per root | 254 ms | 613 KB | | One sheet per stylesheet (ideal) | 226 ms | 551 KB | **Commerce app, Link CSS.** Two 30-sample runs with the server ports swapped, averaged to cancel a measured 5-17 ms per-port bias. Mean +/- standard error: | Metric | main | This PR | Change | |---|---:|---:|---:| | Soft nav home -> product: soft FCP and LCP | 272.5 ms | 257.5 ms | -15.0 +/- 5.2 | | Soft nav home -> product: main-thread busy | 311.4 ms | 296.0 ms | -15.4 +/- 3.2 | | Soft nav product -> product: soft FCP | 164.0 ms | 160.0 ms | -3.9 +/- 2.8 | | Full loads of home and product: FCP, LCP, hydration | | | each within 1 SE | The same crossover with Module CSS shows no change on full loads. The soft-nav gain comes from no longer promoting four single-use components into four separate sheets: all 49 roots now keep native links. ## Review notes - **Bundle size:** +1.0 KB gzip (+0.9 KB brotli) in the shared framework chunk, about 4% of commerce's main chunk. - **CSS loaded by the server-rendered page gets no gain.** Commerce's repeated product cards stay on native links, as on main: their only timing entry predates the mount, so the #454 rule cannot authorize them. I did not relax that rule. - **Stable responses are assumed.** Sharing assumes a stylesheet URL returns the same CSS for the Document's lifetime, which holds for compiler-emitted assets. Shared sheets and authorization results live as long as the Document's style catalog. - First-use roots remain slightly above native at scale (261 vs 254 ms), within run-to-run variance. - Direct callers of the public `installComponentStyles` keep the reading path: 100 ms / 294 KB for 1,000 rows and 266 ms / 747 KB at scale. - `link-styles.ts` grows from 1,245 to 1,561 lines. ## Tests - New `component-link-sharing` e2e fixture (7 tests): one native link then one shared sheet across a mount pass, sharing after the preload is released, second use after a lone first mount, promotion from a later root after the first is gone, guarded cold mounts, unpromotable stylesheets, and eager promotion for components with template styles. The sharing tests fail on main, where every instance gets its own link and sheet. - `component-assets` e2e: a single lazy panel now pins `{adopted: 0, disabled: false, links: 1}` (first use stays native), and a new reopen case pins `{adopted: 1, disabled: true, links: 1}`. - Unit tests for sharing, eager promotion, write-only adoption on fresh roots, preserving existing adopted sheets, remembered failed authorization, base URL changes, template author-style detection and the URL-rewrite fast path. - `cargo xtask check` passes. Framework e2e: 391 passed. component-assets: 5 of 5. Router: 42 passed (4 pre-existing `fixme`). `specs/hydration.md` and the framework README describe the new Link behavior. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Dynamic Link-mode components could become visible before their stylesheet was ready during lazy creation or soft navigation. This change keeps the browser's native stylesheet link authoritative, prevents unstyled paint on the first client-created mount, and reuses an authorized constructable sheet for later mounts.
High-level design
The solution covers the three ways WebUI content reaches the browser:
Router.ensureLoaded()template registration now expose a readiness handshake. The framework prepares Link styles before the router commits the new component, while stale navigations can still abort promptly.defineComponentAssets().preload(tag)lifecycle. CSS, the root asset, the component module, and optional data can therefore start in parallel without application code knowing generated hashes.For a first client-created Shadow DOM instance, the framework:
<link rel="preload" as="style">only to warm the matching style request.<link rel="stylesheet">authoritative for CSP, MIME, integrity, CORS, redirects, and service workers.Light-DOM component assets receive real, deduplicated document stylesheets because global CSS must be applied rather than merely preloaded. Failed asset or authored-module loads are evicted so a later preload or create call can retry.
Developer experience
There is no public raw-href API and no generated CSS filename in application code:
Bundlers may instead own the root asset import:
Router-owned components continue using router template loading; component assets remain for non-routed deferred UI emitted through
--emit-component-assets.Measured impact
Independent Edge NTP retest of
<ntp-waffle-menu>, with a fresh browser context per run, 4x CPU throttle, and 150 ms RTT:Every tested path made exactly one CSS request. Cold and warm runs produced 0/10 unstyled first paints, and constructable-sheet adoption succeeded in 10/10 cold runs. Instrumentation placed the framework CSS request start within about 1.6 ms of a directly injected preload; compiler-manifest parsing was not measurable.
Against
origin/mainin the 500-component responsiveness harness:JavaScript footprint is +4,514 gzip bytes in the main framework entry versus
origin/main; the optional component-asset entry adds 271 gzip bytes versus the prior PR head.Important behavior
Protocolrendered HTML containing the compiler-owned manifest. Using build artifacts without rendering that protocol remains paint-safe, but CSS starts after the root asset reveals its template metadata.Validation
@microsoft/webui-frameworkunit and Playwright suitescargo xtask check