From 6514fa6dbed5a4975185eb20f42afd5f5f9ade5c Mon Sep 17 00:00:00 2001 From: Yasin Kocak <985902+yasinkocak@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:42:29 +0200 Subject: [PATCH 1/2] fix(runtime): retry lazy component load after a failed dynamic import A single failed dynamic import() of a lazy component's *.entry.js chunk (dropped network request, flaky mobile connection, etc.) permanently bricks that host element for the rest of the page's lifetime: no rendered content, no lifecycle callbacks, and no recovery -- not even for a brand-new element of the same tag -- until a full page reload. Root cause: HOST_FLAGS.hasInitializedComponent is set before the load attempt and never cleared on failure, and HOST_FLAGS.hasConnected (set unconditionally on first connect) blocks connected-callback.ts from ever retrying. On top of that, the browser's own per-document module map caches the failed import() specifier, so even a bare retry of the same bundle would never reach the network again. This clears hasInitializedComponent when the lazy constructor isn't found, lets connected-callback.ts retry initialization on the next reconnect, and cache-busts the retried import() URL (the same pattern already used for ?s-hmr=) so the retry actually reaches the network. Fixes #6771 --- src/client/client-load-module.ts | 23 ++++++- src/runtime/connected-callback.ts | 7 +++ src/runtime/initialize-component.ts | 5 ++ src/runtime/test/lazy-load-retry.spec.tsx | 74 +++++++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 src/runtime/test/lazy-load-retry.spec.tsx 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..678b561bef3 100644 --- a/src/runtime/connected-callback.ts +++ b/src/runtime/connected-callback.ts @@ -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)); } diff --git a/src/runtime/initialize-component.ts b/src/runtime/initialize-component.ts index 68220944d70..c0734cbf609 100644 --- a/src/runtime/initialize-component.ts +++ b/src/runtime/initialize-component.ts @@ -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) { 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); + }); +}); From deeab34d704b64abf9cd021195ac5b704f1e09e5 Mon Sep 17 00:00:00 2001 From: Yasin Kocak <985902+yasinkocak@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:52:18 +0200 Subject: [PATCH 2/2] fix(runtime): gate lazy-load retry on an explicit failure flag, not on hasInitializedComponent The reconnect-retry added in 6514fa6 keyed off `hasInitializedComponent` being unset to decide whether a previous load attempt had failed and should be retried. 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` (BUILD.initializeNextTick), or when `connectedCallback` fires again for the same host element before that queued attempt has run. Stencil's own server-side hydration does this deliberately: `serverSideConnected()` (update-component.ts) walks the rendered DOM and re-fires `connectedCallback()` on children regardless of whether they're already mid-initialization. Because the retry condition couldn't tell "failed" apart from "still pending", that second connectedCallback call triggered a premature, duplicate `initializeComponent()` call. The original queued call later ran too (finding `hasInitializedComponent` now set, skipping straight to a second `scheduleUpdate()`), so the component got initialized and rendered twice. This corrupted hydrate output for nested shadow-DOM components -- duplicated vdom annotation comments/text nodes and mismatched `c-id` numbering -- and broke ~30 existing tests across hydrate-shadow-child/-parent/-in-shadow/-no-encapsulation/-slot-fallback in CI (all 4 Unit Tests jobs), while the branch's own new lazy-load-retry.spec.tsx passed, since it doesn't exercise hydration. Root cause confirmed locally by instrumenting initializeComponent() to count/log invocations per hostRef: the duplicate call's stack traced directly to the `nextTick(() => initializeComponent(...))` callback in connected-callback.ts, with hasInitializedComponent already true and $lazyInstance$ still unset at that point. Fix: add a dedicated HOST_FLAGS.hasFailedLoad bit that is only set when a lazy bundle's dynamic import() genuinely fails to resolve a constructor, and only cleared when a fresh attempt starts. The reconnect-retry in connected-callback.ts now checks that flag instead of the absence of hasInitializedComponent, so it no longer fires for attempts that are simply still in flight. Verified: the previously-failing hydrate-shadow-child/-parent/ -in-shadow/-no-encapsulation/-slot-fallback suites and the new lazy-load-retry suite all pass, and the full `npm run test.jest -- --runInBand` suite is green (4085/4102 passed, 17 skipped, 0 failed). --- src/runtime/connected-callback.ts | 20 +++++++++++++++----- src/runtime/initialize-component.ts | 15 ++++++++++++--- src/utils/constants.ts | 12 ++++++++++-- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/runtime/connected-callback.ts b/src/runtime/connected-callback.ts index 678b561bef3..d5138886494 100644 --- a/src/runtime/connected-callback.ts +++ b/src/runtime/connected-callback.ts @@ -118,12 +118,22 @@ 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) { + } 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) 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. + // (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 c0734cbf609..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) { @@ -54,10 +56,17 @@ export const initializeComponent = async ( } 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. + // 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/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, } /**