Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions src/runtime/connected-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ 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.hasInitializedComponent) === 0) {
// A previous initialization attempt for this host element failed
// (e.g. the lazy bundle's dynamic import() was rejected) and cleared
// `hasInitializedComponent` (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.
initializeComponent(elm, hostRef, cmpMeta);
} else if (hostRef?.$onReadyPromise$) {
hostRef.$onReadyPromise$.then(() => fireConnectedCallback(hostRef.$lazyInstance$, elm));
}
Comment thread
yasinkocak marked this conversation as resolved.
Outdated
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/initialize-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ 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 so a future `connectedCallback` (see
// `connected-callback.ts`) is able to retry loading this component
// instead of leaving the host element permanently un-upgraded.
hostRef.$flags$ &= ~HOST_FLAGS.hasInitializedComponent;
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 */
});
Comment thread
yasinkocak marked this conversation as resolved.

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(() => {});
Comment thread
yasinkocak marked this conversation as resolved.

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(() => {});
Comment thread
yasinkocak marked this conversation as resolved.

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