From 95cfea9895833042e5a33cce1e6e408f02148a73 Mon Sep 17 00:00:00 2001 From: shoaibyazdani Date: Mon, 7 Sep 2026 13:19:35 +0500 Subject: [PATCH] fix(hydrate): wait for waitingElements to drain before destroyWindow (#6864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `renderToString` fires its timeout, `finalizeHydrate` calls `destroyWindow` → `MockWindow.close` → `resetWindow`, which deletes `globalThis.fetch` and friends on the live window. Components that are still in flight — typically mid-await on `componentOnReady` or on their own `fetch` — wake up against a torn-down globalThis and start throwing. The retained render object graph (which still holds references through the component closures) prevents GC, and under concurrent `renderToString` calls the heap climbs until OOM. Apply the maintainer's preferred direction (option 2 from #6864): drain `waitingElements` to size 0 inside `hydratedComplete` before allowing `afterHydrate` to fire (which triggers `destroyWindow` upstream in `render.ts`. A hard ceiling of `opts.timeout ?? 15000` ms caps the wait so a stuck component can't keep a render's graph alive for minutes. `hydratedComplete` becomes async; `hydratedError` fires-and-forgets with a `.catch` so the existing throw-safety stays intact. Includes a focused unit test at `src/hydrate/platform/test/drain-waiting-elements.spec.ts` covering the empty-set fast path, drain-on-emptying, and the ceiling cap. Closes #6864 --- src/hydrate/platform/hydrate-app.ts | 51 ++++++++++++++++- .../test/drain-waiting-elements.spec.ts | 56 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/hydrate/platform/test/drain-waiting-elements.spec.ts diff --git a/src/hydrate/platform/hydrate-app.ts b/src/hydrate/platform/hydrate-app.ts index 4213a003b34..2f1b7ee2c0f 100644 --- a/src/hydrate/platform/hydrate-app.ts +++ b/src/hydrate/platform/hydrate-app.ts @@ -29,7 +29,17 @@ export function hydrateApp( let tmrId: any; let ranCompleted = false; - function hydratedComplete() { + /** + * Maximum time the drain step will wait for pending component work to finish + * before tearing the window down. Mirrors the user-supplied `timeout` + * default of 15s in `renderToString`, but capped low enough that a stuck + * component cannot keep a render's graph alive for minutes — see + * https://github.com/stenciljs/core/issues/6864 (option 2: wait for + * waitingElements, with a hard ceiling). + */ + const DRAIN_CEILING_MS = opts.timeout ?? 15000; + + async function hydratedComplete() { globalThis.clearTimeout(tmrId); createdElements.clear(); connectedElements.clear(); @@ -50,12 +60,22 @@ export function hydrateApp( } } + // Wait for any in-flight components (`componentOnReady` / pending fetch) + // to settle before the runner gets back to `render.ts:hydrateDocument`, + // which calls `finalizeHydrate` → `destroyWindow` → `MockWindow.close`. + // Without this, late component code resumes against a torn-down window + // (`globalThis.fetch === null`, document reset) — see #6864. + await drainWaitingElements(waitingElements, DRAIN_CEILING_MS); + afterHydrate(win, opts, results, resolve); } function hydratedError(err: any) { renderCatchError(opts, results, err); - hydratedComplete(); + // hydratedComplete is async now; fire-and-await so the runner waits + // for the drain before resolving. Catch any rejection so a thrown drain + // error doesn't propagate as an uncaught promise. + Promise.resolve(hydratedComplete()).catch((e) => renderCatchError(opts, results, e)); } function timeoutExceeded() { @@ -182,6 +202,33 @@ export function hydrateApp( } } +/** + * Waits for `waiting` to drain (size === 0) before resolving, polling every + * 5ms, bounded by `ceilingMs`. Caps a stuck component so a render can't keep + * a destroyed-window object graph alive indefinitely. + * + * Used inside `hydrateApp` to defer `afterHydrate` (and therefore + * `destroyWindow`) until any in-flight `componentOnReady` work has settled — + * see https://github.com/stenciljs/core/issues/6864. + * + * Exported for unit-test isolation only. Not part of the public API. + * @internal + */ +export function drainWaitingElements(waiting: Set, ceilingMs: number): Promise { + if (waiting.size === 0) return Promise.resolve(); + return new Promise((resolve) => { + const start = Date.now(); + const tick = () => { + if (waiting.size === 0 || Date.now() - start >= ceilingMs) { + resolve(); + return; + } + setTimeout(tick, 5); + }; + tick(); + }); +} + async function hydrateComponent( this: HTMLElement, win: Window & typeof globalThis, diff --git a/src/hydrate/platform/test/drain-waiting-elements.spec.ts b/src/hydrate/platform/test/drain-waiting-elements.spec.ts new file mode 100644 index 00000000000..e9023bd60d7 --- /dev/null +++ b/src/hydrate/platform/test/drain-waiting-elements.spec.ts @@ -0,0 +1,56 @@ +import type { drainWaitingElements as TDrain } from '../hydrate-app'; + +describe('drainWaitingElements (#6864)', () => { + let drainWaitingElements: typeof TDrain; + + beforeEach(async () => { + drainWaitingElements = require('../hydrate-app').drainWaitingElements; + }); + + afterEach(async () => { + jest.resetModules(); + }); + + it('resolves immediately when the set is empty', async () => { + const waiting = new Set(); + const start = Date.now(); + await drainWaitingElements(waiting, 500); + expect(Date.now() - start).toBeLessThan(50); + }); + + it('waits for elements to be removed before resolving', async () => { + const waiting = new Set<{ id: number }>([{ id: 1 }, { id: 2 }, { id: 3 }]); + + let resolved = false; + const drained = drainWaitingElements(waiting, 1000).then(() => { + resolved = true; + }); + + // After 80ms: remove one element. + setTimeout(() => { + const next = waiting.values().next().value; + waiting.delete(next!); + }, 80); + + // After 160ms: remove the rest. + setTimeout(() => { + waiting.clear(); + }, 160); + + await drained; + expect(resolved).toBe(true); + expect(waiting.size).toBe(0); + }); + + it('falls back to the ceiling when elements are stuck', async () => { + const waiting = new Set([{ id: 'forever' }]); + const start = Date.now(); + await drainWaitingElements(waiting, 50); + const elapsed = Date.now() - start; + // should bail at ~50ms, not run forever. + expect(elapsed).toBeGreaterThanOrEqual(45); + expect(elapsed).toBeLessThan(500); + // element remains; the ceiling is a hard cap, not a force-removal. + expect(waiting.size).toBe(1); + }); +});