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
23 changes: 20 additions & 3 deletions src/client/client-load-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import { consoleDevError, consoleError } from './client-log';

export const cmpModules = /*@__PURE__*/ new Map<string, { [exportName: string]: d.ComponentConstructor }>();

/**
* Tracks how many times the dynamic `import()` for a given lazy bundle has
* failed. A failed dynamic import is cached by the browser's own module map
* for the resolved specifier URL, so a bare retry of the exact same URL
* would be rejected immediately without ever reaching the network again.
* This lets a retry (see `connected-callback.ts`) bust that cache with a
* query param, the same way `hmrVersionId` already does for HMR.
*/
const failedLoadAttempts = /*@__PURE__*/ new Map<string, number>();

/**
* We need to separate out this prefix so that Esbuild doesn't try to resolve
* the below, but instead retains a dynamic `import()` statement in the
Expand Down Expand Up @@ -43,23 +53,30 @@ export const loadModule = (
if (module) {
return module[exportName];
}
const retryCount = failedLoadAttempts.get(bundleId) ?? 0;
const cacheBustParams = [
retryCount > 0 ? `s-retry=${retryCount}` : '',
BUILD.hotModuleReplacement && hmrVersionId ? `s-hmr=${hmrVersionId}` : '',
]
.filter(Boolean)
.join('&');
/*!__STENCIL_STATIC_IMPORT_SWITCH__*/
return import(
/* @vite-ignore */
/* webpackInclude: /\.entry\.js$/ */
/* webpackExclude: /\.system\.entry\.js$/ */
/* webpackMode: "lazy" */
`${MODULE_IMPORT_PREFIX}${bundleId}.entry.js${
BUILD.hotModuleReplacement && hmrVersionId ? '?s-hmr=' + hmrVersionId : ''
}`
`${MODULE_IMPORT_PREFIX}${bundleId}.entry.js${cacheBustParams ? '?' + cacheBustParams : ''}`
).then(
(importedModule) => {
failedLoadAttempts.delete(bundleId);
if (!BUILD.hotModuleReplacement) {
cmpModules.set(bundleId, importedModule);
}
return importedModule[exportName];
},
(e: Error) => {
failedLoadAttempts.set(bundleId, retryCount + 1);
consoleError(e, hostRef.$hostElement$);
},
);
Expand Down
17 changes: 17 additions & 0 deletions src/runtime/connected-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@ export const connectedCallback = (elm: d.HostElement) => {
// fire off connectedCallback() on component instance
if (hostRef?.$lazyInstance$) {
fireConnectedCallback(hostRef.$lazyInstance$, elm);
} else if (hostRef.$flags$ & HOST_FLAGS.hasFailedLoad) {
// A previous initialization attempt for this host element failed
// (e.g. the lazy bundle's dynamic import() was rejected) -- see
// `initialize-component.ts`. Retry now that we're reconnecting,
// rather than leaving the element permanently un-upgraded for the
// rest of the page's lifetime.
//
// This must key off the dedicated `hasFailedLoad` flag rather than
// `hasInitializedComponent` being unset: that flag is also
// (transiently) unset while an initialization attempt is merely
// queued/in-flight and hasn't failed at all -- e.g. behind
// `nextTick`, or when `connectedCallback` fires again for the same
// element before that queued attempt has run, which Stencil's own
// server-side hydration deliberately does (see
// `serverSideConnected()` in `update-component.ts`). Retrying in
// that case double-initializes the component.
initializeComponent(elm, hostRef, cmpMeta);
} else if (hostRef?.$onReadyPromise$) {
hostRef.$onReadyPromise$.then(() => fireConnectedCallback(hostRef.$lazyInstance$, elm));
}
Expand Down
14 changes: 14 additions & 0 deletions src/runtime/initialize-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export const initializeComponent = async (
if ((hostRef.$flags$ & HOST_FLAGS.hasInitializedComponent) === 0) {
// Let the runtime know that the component has been initialized
hostRef.$flags$ |= HOST_FLAGS.hasInitializedComponent;
// Starting a fresh attempt clears any failure recorded by a previous one.
hostRef.$flags$ &= ~HOST_FLAGS.hasFailedLoad;

const bundleId = cmpMeta.$lazyBundleId$;
if (BUILD.lazyLoad && bundleId) {
Expand All @@ -53,6 +55,18 @@ export const initializeComponent = async (
Cstr = CstrImport as d.ComponentConstructor | undefined;
}
if (!Cstr) {
// The lazy bundle failed to load (e.g. a dropped network request).
// Clear the "initialized" flag and mark the load as failed so a
// future `connectedCallback` (see `connected-callback.ts`) is able
// to retry loading this component instead of leaving the host
// element permanently un-upgraded. `hasFailedLoad` (rather than
// just the absence of `hasInitializedComponent`) is what gates the
// retry, since `hasInitializedComponent` is also unset while an
// attempt is merely queued/in-flight (e.g. behind `nextTick`, or
// between repeated `connectedCallback` invocations during
// server-side hydration) -- neither of which is a failure.
hostRef.$flags$ &= ~HOST_FLAGS.hasInitializedComponent;
hostRef.$flags$ |= HOST_FLAGS.hasFailedLoad;
throw new Error(`Constructor for "${cmpMeta.$tagName$}#${hostRef.$modeName$}" was not found`);
}
if (BUILD.member && !Cstr.isProxied) {
Expand Down
74 changes: 74 additions & 0 deletions src/runtime/test/lazy-load-retry.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { flushAll, flushLoadModule, getHostRef, registerInstance, registerModule, win } from '@platform';

import { LazyBundlesRuntimeData } from '../../internal';
import { HOST_FLAGS } from '../../utils';
import { bootstrapLazy } from '../bootstrap-lazy';

/**
* Regression tests for a bug where a host element would be permanently
* "bricked" (never rendered, no lifecycle callbacks) if its lazy bundle
* failed to load a single time (e.g. a dropped network request). See:
* https://github.com/stenciljs/core/issues/6771
*/
describe('lazy-load failure recovery', () => {
const bundleId = 'cmp-retry-bundle';
let lazyBundles: LazyBundlesRuntimeData;

beforeEach(() => {
lazyBundles = [[bundleId, [[0, 'cmp-retry', {}]]]];
});

it('clears HOST_FLAGS.hasInitializedComponent when the lazy module fails to load', async () => {
bootstrapLazy(lazyBundles);

const elm = win.document.createElement('cmp-retry');
win.document.body.appendChild(elm);

// No `registerModule(bundleId, ...)` call was made, so the testing
// platform's `loadModule()` resolves to `undefined` here -- simulating a
// failed dynamic import() of the real `*.entry.js` chunk.
await flushLoadModule(bundleId);
await flushAll().catch(() => {
/* initializeComponent's internal catch already logs/handles this */
});

const hostRef = getHostRef(elm);
expect(hostRef?.$lazyInstance$).toBeUndefined();
expect((hostRef?.$flags$ ?? 0) & HOST_FLAGS.hasInitializedComponent).toBe(0);
});

it('retries initialization when the host element is reconnected after a failed load', async () => {
bootstrapLazy(lazyBundles);

const elm = win.document.createElement('cmp-retry');
win.document.body.appendChild(elm);

// First attempt fails (module never registered).
await flushLoadModule(bundleId);
await flushAll().catch(() => {});

expect(getHostRef(elm)?.$lazyInstance$).toBeUndefined();

// "Network recovers": the module becomes available, then the element is
// reconnected (disconnect + reconnect is the retry trigger).
class CmpRetry {
constructor(hostRef: any) {
registerInstance(this, hostRef);
}
render() {
return null;
}
}
registerModule(bundleId, CmpRetry as any);

elm.remove();
win.document.body.appendChild(elm);

await flushLoadModule(bundleId);
await flushAll().catch(() => {});

const hostRef = getHostRef(elm);
expect(hostRef?.$lazyInstance$).toBeInstanceOf(CmpRetry);
expect((hostRef?.$flags$ ?? 0) & HOST_FLAGS.hasInitializedComponent).toBe(HOST_FLAGS.hasInitializedComponent);
});
});
12 changes: 10 additions & 2 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,18 @@ export const enum HOST_FLAGS {
isWatchReady = 1 << 7,
isListenReady = 1 << 8,
needsRerender = 1 << 9,
/**
* Set when a lazy component's dynamic `import()` fails to resolve a
* constructor. Distinct from `hasInitializedComponent` being unset, which
* is also (transiently) true while an initialization attempt is merely
* queued/in-flight (e.g. behind `nextTick`) and hasn't failed at all.
* Only this flag should gate a `connectedCallback` retry.
*/
hasFailedLoad = 1 << 10,

// DEV ONLY
devOnRender = 1 << 10,
devOnDidLoad = 1 << 11,
devOnRender = 1 << 11,
devOnDidLoad = 1 << 12,
}

/**
Expand Down
Loading