Skip to content

fix(runtime): retry lazy component load after a failed dynamic import#6772

Open
yasinkocak wants to merge 3 commits into
stenciljs:mainfrom
yasinkocak:fix/lazy-load-retry-after-failure
Open

fix(runtime): retry lazy component load after a failed dynamic import#6772
yasinkocak wants to merge 3 commits into
stenciljs:mainfrom
yasinkocak:fix/lazy-load-retry-after-failure

Conversation

@yasinkocak

Copy link
Copy Markdown

What is the current behavior?

GitHub Issue Number: #6771

A host element is permanently "bricked" for the rest of the page's lifetime if its lazy bundle's dynamic import() fails a single time (dropped network request, flaky mobile connection, ad-blocker, stale CDN entry, etc.) — 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:

  1. HOST_FLAGS.hasInitializedComponent is set before the load attempt in initialize-component.ts and never cleared on failure.
  2. HOST_FLAGS.hasConnected (set unconditionally on first connect) makes connected-callback.ts skip re-initialization on every subsequent reconnect.
  3. The browser's own per-document module map caches the failed import() specifier, so even a bare retry of the exact same bundle URL never reaches the network again — confirmed in a live-browser repro (see Testing below).

What is the new behavior?

  • On a failed lazy load, HOST_FLAGS.hasInitializedComponent is cleared instead of staying stuck.
  • connected-callback.ts retries initializeComponent() the next time the host element is reconnected (disconnect + reconnect), instead of only handling the "already has a $lazyInstance$" case.
  • client-load-module.ts appends a cache-busting query param (?s-retry=N) on retries, the same pattern already used for ?s-hmr=, so the retry actually reaches the network instead of being short-circuited by the browser's module map.

No public API changes. No behavior change on the happy path — a retry only ever happens after a load has actually failed.

Documentation

N/A — this is an internal runtime resilience fix, no public API or documented behavior changes.

Does this introduce a breaking change?

  • Yes
  • No

Testing

  • Added src/runtime/test/lazy-load-retry.spec.tsx (2 new tests): verifies hasInitializedComponent is cleared on a failed load, and that a disconnect+reconnect after a failure re-attempts initialization and succeeds once the module becomes available.
  • Full existing suite (npm run test.jest -- --runInBand, 64 suites / 651 tests across src/runtime + src/client) passes with no regressions.
  • End-to-end verified against a real browser (Playwright + a minimal Stencil app + a tiny Node server that drops the *.entry.js request to simulate a blocked/flaky network request):
    • Initial load blocked → Constructor for "web-button#undefined" was not found, element stays un-hydrated (empty shadow root).
    • Network "recovers", element disconnected + reconnected:
      • Before this fix: still un-hydrated, zero new network requests (browser module-map caching of the failed specifier).
      • After this fix: GET /build/p-xxx.entry.js?s-retry=1 → 200, element renders correctly (class="hydrated", populated shadow DOM) — all without a page reload.

Other information

Per CONTRIBUTING.md, issue #6771 was filed first with a full reproducible write-up before starting this PR.

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 stenciljs#6771
Copilot AI review requested due to automatic review settings July 14, 2026 11:43
@yasinkocak
yasinkocak requested a review from a team as a code owner July 14, 2026 11:43

Copilot AI left a comment

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.

Pull request overview

This PR improves Stencil’s runtime resilience for lazy-loaded components by allowing recovery after a transient dynamic import() failure (e.g., flaky network / blocked request), instead of leaving the host element permanently unable to initialize for the lifetime of the page.

Changes:

  • Clear HOST_FLAGS.hasInitializedComponent when a lazy-load constructor cannot be resolved, enabling future retry attempts.
  • Retry initializeComponent() on disconnect+reconnect when a prior initialization attempt failed.
  • Add an ?s-retry=N cache-busting query param on repeated lazy-bundle import attempts to avoid browser module-map caching of failed imports.
  • Add regression tests covering flag reset and reconnect-triggered retry behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/runtime/test/lazy-load-retry.spec.tsx Adds regression tests for failed lazy-load recovery via flag clearing + reconnect retry.
src/runtime/initialize-component.ts Clears hasInitializedComponent when constructor resolution fails so the host can retry later.
src/runtime/connected-callback.ts Re-attempts initialization on reconnect after a failed prior initialization.
src/client/client-load-module.ts Tracks failed bundle imports and appends s-retry to bust browser module-map caching on retries.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/runtime/connected-callback.ts Outdated
@moessie

moessie commented Jul 14, 2026

Copy link
Copy Markdown

Thanks for working on this! I tested the proposed fix with a minimal reproduction by blocking the initial lazy-loaded *.entry.js request and then reconnecting the component after restoring the chunk.

With this change, the component successfully retries and renders instead of remaining stuck. This looks like a solid improvement for the recovery behavior

@t-model00291182

Copy link
Copy Markdown

We are running into the same issue in our Stencil-based component library.

A consumer recently reported this behavior to us, and after debugging we traced it back to this issue. This confirms that the problem is not only reproducible in isolated cases but can also affect real-world consumers of Stencil components.

We really appreciate the work the Stencil team is doing. A fix for this would have a meaningful impact for us and other teams building component libraries on top of Stencil.

Thanks for looking into this!

Copilot AI review requested due to automatic review settings July 22, 2026 20:01

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comment thread src/runtime/test/lazy-load-retry.spec.tsx
Comment thread src/runtime/test/lazy-load-retry.spec.tsx
Comment thread src/runtime/test/lazy-load-retry.spec.tsx
…n 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).
Copilot AI review requested due to automatic review settings July 23, 2026 21:52

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/runtime/connected-callback.ts:137

  • The retry path calls initializeComponent synchronously even when BUILD.initializeNextTick is enabled. On first connect, Stencil defers initialization with nextTick to accommodate frameworks that set attributes after connectedCallback (e.g. Angular); the retry should preserve the same ordering semantics to avoid reintroducing the original timing issues on reconnect.
        initializeComponent(elm, hostRef, cmpMeta);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants