Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export const MODE_RESOLUTION_CHAIN_DECLARATION = `modeResolutionChain = [];`;
export const HYDRATE_FACTORY_INTRO = `
// const ${MODE_RESOLUTION_CHAIN_DECLARATION}

// captured here, at true module scope, before hydrateFactory shadows the
// AbortController identifier for component code below.
var $stencilNativeAbortController = AbortController;

export function hydrateFactory($stencilWindow, $stencilHydrateOpts, $stencilHydrateResults, $stencilAfterHydrate, $stencilHydrateResolve) {
var globalThis = $stencilWindow;
var self = $stencilWindow;
Expand Down Expand Up @@ -103,8 +107,46 @@ export function hydrateFactory($stencilWindow, $stencilHydrateOpts, $stencilHydr

var fetch, FetchError, Headers, Request, Response;

// Aborted when the render times out or errors, so in-flight fetch() calls
// made by component code stop holding the render's window/results alive
// instead of running to completion against a torn-down window. See #6864.
var $stencilAbortController = new $stencilNativeAbortController();

// Any AbortController a component creates itself is transparently wired to
// cascade-abort when the render times out too, so component code using the
// standard AbortController convention for its own cancellable work (axios,
// aws-sdk v3, the mongodb driver, etc.) gets cancelled automatically.
var AbortController = function () {
var controller = new $stencilNativeAbortController();
$stencilAbortController.signal.addEventListener(
'abort',
function () {
controller.abort();
},
{ once: true },
);
return controller;
};

function $stencilFetchSignal(callerSignal) {
if (!callerSignal) {
return $stencilAbortController.signal;
}
if (callerSignal.aborted || $stencilAbortController.signal.aborted) {
return callerSignal.aborted ? callerSignal : $stencilAbortController.signal;
}
var merged = new $stencilNativeAbortController();
var onAbort = function () { merged.abort(); };
callerSignal.addEventListener('abort', onAbort, { once: true });
$stencilAbortController.signal.addEventListener('abort', onAbort, { once: true });
return merged.signal;
}

if (typeof $stencilWindow.fetch === 'function') {
fetch = $stencilWindow.fetch;
var $stencilRawFetch = $stencilWindow.fetch;
fetch = $stencilWindow.fetch = function(input, init) {
return $stencilRawFetch(input, Object.assign({}, init, { signal: $stencilFetchSignal(init && init.signal) }));
};
} else {
fetch = $stencilWindow.fetch = function() { throw new Error('fetch() is not implemented'); };
}
Expand Down Expand Up @@ -141,7 +183,7 @@ export function hydrateFactory($stencilWindow, $stencilHydrateOpts, $stencilHydr

export const HYDRATE_FACTORY_OUTRO = `
/*hydrateAppClosure end*/
hydrateApp(window, $stencilHydrateOpts, $stencilHydrateResults, $stencilAfterHydrate, $stencilHydrateResolve);
hydrateApp(window, $stencilHydrateOpts, $stencilHydrateResults, $stencilAfterHydrate, $stencilHydrateResolve, $stencilAbortController);
}

hydrateAppClosure($stencilWindow);
Expand Down
52 changes: 38 additions & 14 deletions src/hydrate/platform/hydrate-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function hydrateApp(
resolve: (results: d.HydrateResults) => void,
) => void,
resolve: (results: d.HydrateResults) => void,
abortController: AbortController,
) {
const connectedElements = new Set<any>();
const createdElements = new Set<HTMLElement>();
Expand All @@ -28,6 +29,18 @@ export function hydrateApp(

let tmrId: any;
let ranCompleted = false;
// Resolves once the render is finalizing (error or timeout), so components
// still mid-`await` can stop waiting instead of resuming against a window
// that's about to be torn down, and so their own in-flight `fetch()` calls
// (wrapped in the hydrate factory closure) get aborted rather than running
// to completion against it. See #6864.
const abortedPromise = new Promise<void>((res) => {
if (abortController.signal.aborted) {
res();
} else {
abortController.signal.addEventListener('abort', () => res(), { once: true });
}
});

function hydratedComplete() {
globalThis.clearTimeout(tmrId);
Expand Down Expand Up @@ -55,6 +68,10 @@ export function hydrateApp(

function hydratedError(err: any) {
renderCatchError(opts, results, err);
// let any component still awaiting `componentOnReady()` bail out, and
// abort any of their in-flight `fetch()` calls, instead of resuming
// after `hydratedComplete` tears the window down.
abortController.abort();
hydratedComplete();
}

Expand Down Expand Up @@ -140,7 +157,7 @@ export function hydrateApp(

// add it to our Set so we know it's already being connected
connectedElements.add(elm);
return hydrateComponent.call(elm, win, results, elm.nodeName, elm, waitingElements);
return hydrateComponent.call(elm, win, results, elm.nodeName, elm, waitingElements, abortedPromise);
}
}

Expand Down Expand Up @@ -189,6 +206,7 @@ async function hydrateComponent(
tagName: string,
elm: d.HostElement,
waitingElements: Set<HTMLElement>,
aborted: Promise<void>,
) {
tagName = tagName.toLowerCase();
const Cstr = loadModule(
Expand All @@ -212,19 +230,25 @@ async function hydrateComponent(

try {
connectedCallback(elm);
await elm.componentOnReady();

results.hydratedCount++;

const ref = getHostRef(elm);
const modeName = !ref?.$modeName$ ? '$' : ref?.$modeName$;
if (!results.components.some((c) => c.tag === tagName && c.mode === modeName)) {
results.components.push({
tag: tagName,
mode: modeName,
count: 0,
depth: -1,
});

// race `componentOnReady` against the render finishing: if the
// render times out first, stop waiting rather than resuming this
// continuation once the window has been destroyed (see #6864).
const wasAborted = await Promise.race([elm.componentOnReady().then(() => false), aborted.then(() => true)]);

if (!wasAborted) {
results.hydratedCount++;

const ref = getHostRef(elm);
const modeName = !ref?.$modeName$ ? '$' : ref?.$modeName$;
if (!results.components.some((c) => c.tag === tagName && c.mode === modeName)) {
results.components.push({
tag: tagName,
mode: modeName,
count: 0,
depth: -1,
});
}
}
} catch (e) {
win.console.error(e);
Expand Down
16 changes: 10 additions & 6 deletions src/mock-doc/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,12 +878,16 @@ function resetWindow(win: MockWindow) {
} catch (e) {}
}

// ensure we don't hold onto nodeFetch values
(win as any).fetch = null;
(win as any).Headers = null;
(win as any).Request = null;
(win as any).Response = null;
(win as any).FetchError = null;
// ensure we don't hold onto nodeFetch values; fail loudly instead of a
// bare null-deref if anything still touches the window after this point
const windowDestroyed = () => {
throw new Error('MockWindow was already destroyed');
};
(win as any).fetch = windowDestroyed;
(win as any).Headers = windowDestroyed;
(win as any).Request = windowDestroyed;
(win as any).Response = windowDestroyed;
(win as any).FetchError = windowDestroyed;
}
}

Expand Down
78 changes: 78 additions & 0 deletions test/end-to-end/custom-elements-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,35 @@
}
]
},
{
"kind": "javascript-module",
"path": "src/hydrate-timeout/own-controller-cmp.tsx",
"declarations": [
{
"kind": "class",
"customElement": true,
"tagName": "own-controller-cmp",
"name": "OwnControllerCmp",
"description": "Used by hydrate-timeout.e2e.ts to verify the AbortController shim: a\ncomponent that creates its own AbortController has that controller cascade-aborted\nautomatically when the render times out\n\nThe outcome is reported via `global` (not observable through\nrenderToString's result) so the test can assert on it."
}
],
"exports": [
{
"kind": "js",
"name": "OwnControllerCmp",
"declaration": {
"name": "OwnControllerCmp"
}
},
{
"kind": "custom-element-definition",
"name": "own-controller-cmp",
"declaration": {
"name": "OwnControllerCmp"
}
}
]
},
{
"kind": "javascript-module",
"path": "src/path-alias-cmp/path-alias-cmp.tsx",
Expand Down Expand Up @@ -2279,6 +2308,55 @@
}
]
},
{
"kind": "javascript-module",
"path": "src/hydrate-timeout/slow-fetch-cmp.tsx",
"declarations": [
{
"kind": "class",
"customElement": true,
"tagName": "slow-fetch-cmp",
"name": "SlowFetchCmp",
"description": "Used by hydrate-timeout.e2e.ts to reproduce stenciljs/core#6864: a\ncomponent whose `componentWillLoad` is still awaiting `fetch()` when the\nrender's `opts.timeout` fires.",
"attributes": [
{
"name": "url",
"type": {
"text": "string"
},
"fieldName": "url"
}
],
"members": [
{
"kind": "field",
"name": "url",
"type": {
"text": "string"
},
"readonly": true,
"attribute": "url"
}
]
}
],
"exports": [
{
"kind": "js",
"name": "SlowFetchCmp",
"declaration": {
"name": "SlowFetchCmp"
}
},
{
"kind": "custom-element-definition",
"name": "slow-fetch-cmp",
"declaration": {
"name": "SlowFetchCmp"
}
}
]
},
{
"kind": "javascript-module",
"path": "src/declarative-shadow-dom/ssr-shadow-cmp.tsx",
Expand Down
Loading
Loading