Skip to content

feat(hydrate): cache platform closure and add opt-in window reuse - #6794

Open
Armand-Lluka wants to merge 2 commits into
stenciljs:mainfrom
Armand-Lluka:test/hydrate-platform-reuse
Open

feat(hydrate): cache platform closure and add opt-in window reuse#6794
Armand-Lluka wants to merge 2 commits into
stenciljs:mainfrom
Armand-Lluka:test/hydrate-platform-reuse

Conversation

@Armand-Lluka

@Armand-Lluka Armand-Lluka commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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:

  1. 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.

  2. 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?

  • Yes
  • No

Testing

Other information

Copilot AI lite review requested due to automatic review settings July 27, 2026 12:12
@Armand-Lluka
Armand-Lluka requested a review from a team as a code owner July 27, 2026 12:12
@Armand-Lluka
Armand-Lluka marked this pull request as draft July 27, 2026 12:13
@Armand-Lluka
Armand-Lluka force-pushed the test/hydrate-platform-reuse branch from 872bc7a to 3a28bcb Compare July 27, 2026 12:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 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.reuseWindow to reuse a per-process MockWindow (keyed by serializeShadowRoot) for string-input hydrateDocument() / renderToString(), serializing renders through a promise queue.
  • Update public type declarations/documentation to describe the new reuseWindow option.

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

  • runRender only catches synchronous errors. If render(reusedWin, ...) ever rejects, the cached window won't be evicted/closed and the rejection will propagate to callers (unlike other error paths which return a HydrateResults). It would be safer to attach a .catch() to perform the same cleanup + renderCatchError and always resolve results.
          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.

Comment thread src/hydrate/runner/render.ts Outdated
Comment thread src/declarations/stencil-public-compiler.ts Outdated
Comment thread src/compiler/output-targets/dist-hydrate-script/hydrate-factory-closure.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 if serializeShadowRoot changes during render() (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();

Comment thread src/hydrate/runner/render.ts Outdated
@Armand-Lluka
Armand-Lluka force-pushed the test/hydrate-platform-reuse branch from 05a0488 to 3fb4315 Compare August 12, 2026 10:51
@Armand-Lluka
Armand-Lluka requested a lite review from Copilot August 13, 2026 07:39
@Armand-Lluka
Armand-Lluka marked this pull request as ready for review August 13, 2026 07:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 constrainTimeouts can 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();

Copilot AI review requested due to automatic review settings August 13, 2026 14:12
@Armand-Lluka
Armand-Lluka force-pushed the test/hydrate-platform-reuse branch from 3fb4315 to 3c5d23e Compare August 13, 2026 14:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

  • __stencilHydrateApp is 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 with typeof === '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 new MockWindow(false) on every reused render just to obtain default location/navigator objects. Even with html === false, MockWindow still constructs performance, customElements, console, and runs resetWindowDefaults/resetWindowDimensions (see src/mock-doc/window.ts:85-96), which can materially reduce the benefit of reuseWindow.
  const defaults = new MockWindow(false);
  resetObject(win.location, defaults.location);
  resetObject(win.navigator, defaults.navigator);

src/hydrate/runner/reusable-window.ts:23

  • When reuseWindow is enabled, destroyWindow is set to false, so MockWindow.close() is never called. That means any timers scheduled during a render remain in win.__timeouts and can fire during a later queued render, breaking the “serialized renders” concurrency guarantee and leaking work across renders (timeouts are only cleared in MockWindow.close() via resetWindow(); see src/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();

Copilot AI review requested due to automatic review settings August 15, 2026 12:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@codspeed-hq

codspeed-hq Bot commented Aug 15, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 2 untouched benchmarks


Comparing Armand-Lluka:test/hydrate-platform-reuse (bd5970a) with main (363682e)

Open in CodSpeed

@johnjenkins

johnjenkins commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

hey @Armand-Lluka - thanks for raising - I think the concept is a good one.
I get concerns around potential memory leaks with this kind of thing on the server, so I had an agent to test some scenarios and it found some unfortunately - https://github.com/johnjenkins/stencil-hydrate-reuse-leak-repro (excuse the long winded prologue) - please can you address those first, then I'll have a complete picture before doing a full code-review.

Copilot AI review requested due to automatic review settings August 20, 2026 08:10
@Armand-Lluka
Armand-Lluka force-pushed the test/hydrate-platform-reuse branch from a816432 to c63b1e2 Compare August 20, 2026 08:10
@Armand-Lluka
Armand-Lluka force-pushed the test/hydrate-platform-reuse branch from c63b1e2 to 35be571 Compare August 20, 2026 08:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

Copilot AI review requested due to automatic review settings August 20, 2026 08:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 __stencilHydrateApp by default. On MockWindow, 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() allocates new MockWindow(false) on every reused render just to read default location/navigator values. Constructing a MockWindow is relatively heavy (creates performance/customElements/console, etc.) and can eat into the intended speedup of reuseWindow. 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);

@Armand-Lluka
Armand-Lluka force-pushed the test/hydrate-platform-reuse branch from 35be571 to 3c9dbd7 Compare August 20, 2026 08:32
@Armand-Lluka

Armand-Lluka commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

hey @Armand-Lluka - thanks for raising - I think the concept is a good one. I get concerns around potential memory leaks with this kind of thing on the server, so I had an agent to test some scenarios and it found some unfortunately - https://github.com/johnjenkins/stencil-hydrate-reuse-leak-repro (excuse the long winded prologue) - please can you address those first, then I'll have a complete picture before doing a full code-review.

Updated the code, my results below. The delta has been reduced dramatically so the memory leak issue looks to be solved 👍

@johnjenkins


Scenario Renders measured Start heap End heap Change Rate per 1,000 reported original rate
A: Fresh-window control with listeners 2,800 6.66 MB 7.22 MB +0.56 MB +0.201 MB Flat/noise
B: Reused window with listeners 2,800 7.46 MB 8.17 MB +0.71 MB +0.253 MB +8.1 MB
C: Reused window without listeners 2,800 8.09 MB 8.22 MB +0.13 MB +0.048 MB Flat/noise
D: Varying serializeShadowRoot objects 750 8.09 MB 8.01 MB -0.08 MB -0.100 MB +44 MB

Copilot AI review requested due to automatic review settings August 20, 2026 11:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@github-actions

Copy link
Copy Markdown
Contributor

Runtime Benchmark

  • 9 untouched benchmarks
  • 0 improved benchmarks
  • 0 regressed benchmarks

@johnjenkins johnjenkins left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lookin' good :)

More generally, I'd like to see some e2e tests for this new behaviour (under test/end-to-end) 🙏

Comment thread src/hydrate/runner/render.ts Outdated
Comment thread src/declarations/stencil-public-compiler.ts
Comment thread src/hydrate/runner/render.ts
Comment thread src/hydrate/runner/reusable-window.ts Outdated
Copilot AI review requested due to automatic review settings August 20, 2026 19:11
@Armand-Lluka
Armand-Lluka force-pushed the test/hydrate-platform-reuse branch from bd5970a to 85b4592 Compare August 20, 2026 19:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 new MockWindow(false) on every reuse just to obtain default location/navigator values, which adds avoidable per-render overhead in the hot path. Cache the defaults window (or its location/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 reuseWindow code path (including the promise queue serialization and state reset via getReusableWindow) 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;

Comment thread src/compiler/output-targets/dist-hydrate-script/hydrate-factory-closure.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Comment thread src/hydrate/runner/reusable-window.ts Outdated
Comment thread src/declarations/stencil-public-compiler.ts
Comment thread src/compiler/output-targets/dist-hydrate-script/hydrate-factory-closure.ts Outdated
Copilot AI review requested due to automatic review settings August 24, 2026 09:03
@Armand-Lluka
Armand-Lluka force-pushed the test/hydrate-platform-reuse branch from 959cb1a to 7f43a8c Compare August 24, 2026 09:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

@Armand-Lluka
Armand-Lluka force-pushed the test/hydrate-platform-reuse branch from 7f43a8c to 06fb39a Compare August 24, 2026 09:39
Copilot AI review requested due to automatic review settings August 24, 2026 09:39
Comment thread src/hydrate/runner/reusable-window.ts Outdated
*/
const reusableWindows = new Map<string, MockWindow>();
const windowDefaults = new MockWindow(false);
const documentKeyKeepers = new Set([

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've demonstrated what a mock-dock refactor would look like, it can be dropped if we think it's out of scope.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

Copilot AI review requested due to automatic review settings August 24, 2026 09:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

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

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