Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions src/hydrate/platform/hydrate-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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() {
Expand Down Expand Up @@ -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<unknown>, ceilingMs: number): Promise<void> {
if (waiting.size === 0) return Promise.resolve();
return new Promise<void>((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,
Expand Down
56 changes: 56 additions & 0 deletions src/hydrate/platform/test/drain-waiting-elements.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading