Skip to content

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

Draft
Armand-Lluka wants to merge 1 commit into
stenciljs:mainfrom
Armand-Lluka:test/hydrate-platform-reuse
Draft

feat(hydrate): cache platform closure and add opt-in window reuse#6794
Armand-Lluka wants to merge 1 commit 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?

Documentation

Does this introduce a breaking change?

  • Yes
  • No

Testing

Other information

Copilot AI 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
}

if (typeof doc === 'string') {
if (opts.reuseWindow) {
Comment on lines +958 to +963
* The reused window is not concurrency safe, so renders are serialized through
* an internal queue. Intended for rendering HTML fragments
* (`fullDocument: false`); one known output difference is that hydration
* annotation counters (e.g. `<!--r.N-->`) become unique across the process
* instead of restarting at 1 per call. Defaults to `false`.
*/
Comment on lines +154 to 158
if (!$stencilWindow.__stencilHydrateApp) {
$stencilWindow.__stencilHydrateApp = hydrateAppClosure($stencilWindow);
}
$stencilWindow.__stencilHydrateApp($stencilWindow, $stencilHydrateOpts, $stencilHydrateResults, $stencilAfterHydrate, $stencilHydrateResolve);
}

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
Comment on lines +43 to +55
if (win) {
const document = win.document;
/**
* Swap in fresh `<head>`/`<body>` ELEMENTS for every render (never reset via
* `innerHTML = ''`): the runtime's `rootAppliedStyles` WeakMap is keyed on
* the head node, so keeping the node identity would silently drop scoped
* `sc-` styles after the first render.
*/
const newHead = document.createElement('head');
document.documentElement.replaceChild(newHead, document.head);
const newBody = document.createElement('body');
newBody.innerHTML = doc;
document.documentElement.replaceChild(newBody, document.body);
@Armand-Lluka
Armand-Lluka force-pushed the test/hydrate-platform-reuse branch from 05a0488 to 3fb4315 Compare August 12, 2026 10:51
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.

2 participants