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.
*/
Comment on lines +8 to +15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quite verbose :)

Suggested change
/**
* 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.
*/
/**
* Tracks how many times a dynamic `import()` for a given lazy bundle has failed.
* Used to cache bust the retry attempt in connected-callback.ts
*/

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];
},
Comment on lines 71 to 77
(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.
Comment on lines +122 to +136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again - very verbose and repeated in constants.ts

Suggested change
// 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.
// init for this host element failed. Retry now that we're reconnecting.

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.
Comment on lines +58 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again - unnecessary - I think it's clear from context :)

Suggested change
// 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.
// The lazy bundle failed to load (e.g. a dropped network request).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there doesn't appear to be any coverage here on the actual network-facing fix(?) - the ?s-retry=N query-param logic (because you're using the testing platform's mock loadModule).
We probably need a more focused unit test; something to guard against regressions on the cacheBustParams join logic

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
*/
Comment on lines +7 to +12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary :)

Suggested change
/**
* 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.
*/
Comment on lines +59 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
* 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.
*/
/**
* Set when a lazy component's dynamic `import()` fails to resolve a a
* constructor. Distinct from `hasInitializedComponent` being unset, which
* is true while an initialization attempt is merely queued/in-flight.
*/

hasFailedLoad = 1 << 10,

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

/**
Expand Down
Loading