feat(hydrate): cache platform closure and add opt-in window reuse - #6794
feat(hydrate): cache platform closure and add opt-in window reuse#6794Armand-Lluka wants to merge 2 commits into
Conversation
872bc7a to
3a28bcb
Compare
There was a problem hiding this comment.
Pull request overview
This PR optimizes hydrate SSR performance by avoiding repeated evaluation of the hydrate platform closure and (optionally) reusing a process-global mock window for string-based renders.
Changes:
- Cache the evaluated
hydrateAppClosure()result on the provided window and reuse it when the same window is used again. - Add
HydrateDocumentOptions.reuseWindowto reuse a per-processMockWindow(keyed byserializeShadowRoot) for string-inputhydrateDocument()/renderToString(), serializing renders through a promise queue. - Update public type declarations/documentation to describe the new
reuseWindowoption.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/hydrate/runner/render.ts | Adds reusable MockWindow caching and a serialized render queue behind reuseWindow. |
| src/declarations/stencil-public-compiler.ts | Documents/exposes the new reuseWindow?: boolean public option. |
| src/compiler/output-targets/dist-hydrate-script/hydrate-factory-closure.ts | Changes generated hydrate factory to cache the evaluated hydrate closure on window. |
Comments suppressed due to low confidence (1)
src/hydrate/runner/render.ts:135
runRenderonly catches synchronous errors. Ifrender(reusedWin, ...)ever rejects, the cached window won't be evicted/closed and the rejection will propagate to callers (unlike other error paths which return aHydrateResults). It would be safer to attach a.catch()to perform the same cleanup +renderCatchErrorand always resolveresults.
reusedWin = getReusableWindow(doc, opts);
return render(reusedWin, opts, results).then(() => results);
} catch (e) {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
3a28bcb to
f150d12
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/hydrate/runner/render.ts:140
- The reusable window cache key is recomputed in the catch block using the (potentially mutated)
opts.serializeShadowRoot, which can cause the wrong entry to be deleted ifserializeShadowRootchanges duringrender()(or if its object key order differs). Compute the key once per render attempt and reuse it for both lookup and cleanup.
} catch (e) {
if (reusedWin) {
reusableWindows.delete(JSON.stringify(opts.serializeShadowRoot ?? null));
if (reusedWin.close) {
reusedWin.close();
05a0488 to
3fb4315
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/compiler/output-targets/dist-hydrate-script/hydrate-factory-closure.ts:157
- The cache guard uses a truthiness check for
__stencilHydrateApp, but the code later assumes it is callable. If something (or a previous run) sets this property to a non-function truthy value, hydration will throw when invoked. Consider guarding by function type and defining the property as non-enumerable to reduce accidental collisions.
if (!$stencilWindow.__stencilHydrateApp) {
$stencilWindow.__stencilHydrateApp = hydrateAppClosure($stencilWindow);
}
$stencilWindow.__stencilHydrateApp($stencilWindow, $stencilHydrateOpts, $stencilHydrateResults, $stencilAfterHydrate, $stencilHydrateResolve);
src/hydrate/runner/reusable-window.ts:25
- When reusing a MockWindow, this reset currently leaves MockWindow timers/event listeners and constrainTimeouts-related internal flags intact. That means a prior render can leak pending timeouts/intervals and window/document listeners into the next render, and
constrainTimeoutscan remain enabled/disabled across calls unexpectedly.
const defaults = new MockWindow(false);
resetObject(win.location, defaults.location);
resetObject(win.navigator, defaults.navigator);
win.localStorage.clear();
win.sessionStorage.clear();
3fb4315 to
3c5d23e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/compiler/output-targets/dist-hydrate-script/hydrate-factory-closure.ts:157
__stencilHydrateAppis only checked for truthiness before invoking. If a consumer-provided window already has a truthy non-function value at__stencilHydrateApp, this will throw at runtime when called. Guarding withtypeof === 'function'makes the caching logic robust to collisions.
if (!$stencilWindow.__stencilHydrateApp) {
$stencilWindow.__stencilHydrateApp = hydrateAppClosure($stencilWindow);
}
$stencilWindow.__stencilHydrateApp($stencilWindow, $stencilHydrateOpts, $stencilHydrateResults, $stencilAfterHydrate, $stencilHydrateResolve);
src/hydrate/runner/reusable-window.ts:21
getReusableWindow()creates a newMockWindow(false)on every reused render just to obtain defaultlocation/navigatorobjects. Even withhtml === false,MockWindowstill constructsperformance,customElements,console, and runsresetWindowDefaults/resetWindowDimensions(seesrc/mock-doc/window.ts:85-96), which can materially reduce the benefit ofreuseWindow.
const defaults = new MockWindow(false);
resetObject(win.location, defaults.location);
resetObject(win.navigator, defaults.navigator);
src/hydrate/runner/reusable-window.ts:23
- When
reuseWindowis enabled,destroyWindowis set tofalse, soMockWindow.close()is never called. That means any timers scheduled during a render remain inwin.__timeoutsand can fire during a later queued render, breaking the “serialized renders” concurrency guarantee and leaking work across renders (timeouts are only cleared inMockWindow.close()viaresetWindow(); seesrc/mock-doc/window.ts:850-858).
const document = win.document;
const defaults = new MockWindow(false);
resetObject(win.location, defaults.location);
resetObject(win.navigator, defaults.navigator);
win.localStorage.clear();
|
hey @Armand-Lluka - thanks for raising - I think the concept is a good one. |
a816432 to
c63b1e2
Compare
c63b1e2 to
35be571
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/compiler/output-targets/dist-hydrate-script/hydrate-factory-closure.ts:160
Object.defineProperty(..., { configurable: true, value: ... })creates a non-enumerable, non-writable__stencilHydrateAppby default. OnMockWindow,close()/resetWindow()only deletes enumerable own properties, so a closed window will retain the cached hydrate closure and potentially keep a large platform graph alive (and/or accidentally reuse stale state). Make the property enumerable (so MockWindow.close clears it) and writable (so it can be overridden in test/mocking scenarios).
if (typeof $stencilWindow.__stencilHydrateApp !== 'function') {
Object.defineProperty($stencilWindow, '__stencilHydrateApp', {
configurable: true,
value: hydrateAppClosure($stencilWindow),
});
src/hydrate/runner/reusable-window.ts:25
getReusableWindow()allocatesnew MockWindow(false)on every reused render just to read defaultlocation/navigatorvalues. Constructing aMockWindowis relatively heavy (creates performance/customElements/console, etc.) and can eat into the intended speedup ofreuseWindow. Hoist a single defaults instance (or otherwise cache the defaults) and reuse it across calls.
const document = win.document;
resetReusableWindow(win);
const defaults = new MockWindow(false);
resetObject(win.location, defaults.location);
35be571 to
3c9dbd7
Compare
Updated the code, my results below. The delta has been reduced dramatically so the memory leak issue looks to be solved 👍
|
Runtime Benchmark
|
johnjenkins
left a comment
There was a problem hiding this comment.
lookin' good :)
More generally, I'd like to see some e2e tests for this new behaviour (under test/end-to-end) 🙏
bd5970a to
85b4592
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/hydrate/runner/reusable-window.ts:25
getReusableWindow()creates a newMockWindow(false)on every reuse just to obtain defaultlocation/navigatorvalues, which adds avoidable per-render overhead in the hot path. Cache the defaults window (or itslocation/navigator) and reuse it across calls.
const document = win.document;
resetReusableWindow(win);
const defaults = new MockWindow(false);
resetObject(win.location, defaults.location);
resetObject(win.navigator, defaults.navigator);
src/hydrate/runner/render.ts:98
- The new
reuseWindowcode path (including the promise queue serialization and state reset viagetReusableWindow) isn’t covered by an integration test. A regression here could silently reintroduce cross-render state leakage or concurrency issues.
if (opts.reuseWindow && opts.fullDocument === false && canReuseWindow(opts.serializeShadowRoot)) {
opts.destroyWindow = false;
opts.destroyDocument = false;
959cb1a to
7f43a8c
Compare
7f43a8c to
06fb39a
Compare
| */ | ||
| const reusableWindows = new Map<string, MockWindow>(); | ||
| const windowDefaults = new MockWindow(false); | ||
| const documentKeyKeepers = new Set([ |
There was a problem hiding this comment.
I’m not sure the hydrate runner is the right place to define which document and window properties survive a reset; that responsibility may belong in mock-doc. For now, I’ve kept the minimal set needed to preserve the identities captured by the cached hydrate closure.
There was a problem hiding this comment.
I've demonstrated what a mock-dock refactor would look like, it can be dropped if we think it's out of scope.
The hydrate factory wraps the entire platform (runtime, vdom and every component class) in hydrateAppClosure() so it lexically captures the per-call window, and re-executes that closure on EVERY renderToString call. For component libraries this is the dominant fixed cost per render (framework output targets call renderToString once per component instance).
Two changes:
The factory now caches the evaluated closure on the window object and reuses it whenever the same window is passed again. Transparent for fresh-window renders.
New opt-in HydrateDocumentOptions.reuseWindow: the string-input path of hydrateDocument()/renderToString() reuses a process-global MockWindow, one per serializeShadowRoot mode (scoped serialization permanently ORs shadowNeedsScopedCss into component metadata inside the cached closure). Fresh head/body ELEMENTS are swapped in per render (never innerHTML='') because rootAppliedStyles is keyed on the head node. Renders are serialized through an internal promise queue since the shared window is not concurrency-safe.
Measured 2.2x per-render speedup on a 2-component test app; scales with component count (~4.3x on a 475-component library).
What is the current behavior?
GitHub Issue Number: N/A
What is the new behavior?
Closes #6794
Documentation
Does this introduce a breaking change?
Testing
Other information