diff --git a/src/client/client-load-module.ts b/src/client/client-load-module.ts index 63651bda846..621c73f4e0c 100644 --- a/src/client/client-load-module.ts +++ b/src/client/client-load-module.ts @@ -5,6 +5,16 @@ import { consoleDevError, consoleError } from './client-log'; export const cmpModules = /*@__PURE__*/ new Map(); +/** + * 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(); + /** * 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 @@ -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$); }, ); diff --git a/src/runtime/connected-callback.ts b/src/runtime/connected-callback.ts index ab83326c8f4..d5138886494 100644 --- a/src/runtime/connected-callback.ts +++ b/src/runtime/connected-callback.ts @@ -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)); } diff --git a/src/runtime/initialize-component.ts b/src/runtime/initialize-component.ts index 68220944d70..72aebcbf435 100644 --- a/src/runtime/initialize-component.ts +++ b/src/runtime/initialize-component.ts @@ -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) { @@ -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) { diff --git a/src/runtime/test/lazy-load-retry.spec.tsx b/src/runtime/test/lazy-load-retry.spec.tsx new file mode 100644 index 00000000000..b78f7df7750 --- /dev/null +++ b/src/runtime/test/lazy-load-retry.spec.tsx @@ -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); + }); +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 71eacb5f9af..34bb2704a21 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -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, } /**