Skip to content

fix: self-heal lazy routes on retryable split-bundle segment timeout#12032

Merged
huhuanming merged 5 commits into
xfrom
fix/splitbundle-lazyload-self-heal
Jun 12, 2026
Merged

fix: self-heal lazy routes on retryable split-bundle segment timeout#12032
huhuanming merged 5 commits into
xfrom
fix/splitbundle-lazyload-self-heal

Conversation

@huhuanming

Copy link
Copy Markdown
Contributor

Problem

React.lazy permanently caches a rejected segment import on the lazy object. So when a split-bundle segment load fails with a transient/retryable error — most importantly the iOS watchdog false-firing SPLIT_BUNDLE_TIMEOUT on resume from suspension (see OneKeyHQ/app-modules#65), but also any genuine SPLIT_BUNDLE_NO_RUNTIME/SPLIT_BUNDLE_TIMEOUT on either platform — the route white-screens until the app is force-killed, even though installProdBundleLoader already marks the error retryable and the very next __loadBundleAsync would succeed in ~1ms. The only error boundary above lazy routes is the app-root Sentry boundary, which renders the full-screen error.

Fix

Wrap LazyLoad (packages/shared/src/lazyLoad/index.tsx) in a LazyRetryBoundary:

  • Retries only on retryable segment errors (SPLIT_BUNDLE_TIMEOUT / SPLIT_BUNDLE_NO_RUNTIME / .retryable === true / [SplitBundle] … eval timed out message). Non-retryable errors (EVAL_ERROR/IO_ERROR/NOT_FOUND/SHA256_MISMATCH) surface immediately, exactly as today.
  • Regenerates the lazy() object per retry (via a retryKey/useMemo bump) — the only way to drop React's cached rejected payload.
  • Bounded (MAX_LAZY_RETRIES = 2, under the loader's own MAX_RETRYABLE_ATTEMPTS = 3).
  • Foreground-gated via appVisibility: if backgrounded, the retry defers until the app returns to foreground, where the buffered native executor is about to flush — instead of burning the budget while suspended.
  • Boundary semantics: getDerivedStateFromError enters a non-throwing retrying hold; render() throws only in the terminal fatal state, so a retry never escalates to the root boundary first. Retry timer cleared on unmount.

Cross-platform

The amplifier is shared JS, so this fixes both iOS (where it pairs with the native watchdog fix) and Android (where a genuine retryable timeout otherwise poisons React.lazy identically). OTA-deployable — does not need a native build.

Review & tests

Two adversarial review rounds (Claude + Codex). 16 unit tests (index.test.tsx): retryable-classification matrix, retry-then-render, non-retryable escalation, max-retry escalation, background-defer→foreground-retry. tsc:staged + lint:staged clean.

React.lazy permanently caches a rejected segment import, so a transient
SPLIT_BUNDLE_TIMEOUT (e.g. the iOS watchdog false-firing on resume from
suspension) turned a route into a white screen until the app was killed.

Wrap LazyLoad in a LazyRetryBoundary that, for retryable segment errors
only (SPLIT_BUNDLE_TIMEOUT/NO_RUNTIME / .retryable), regenerates the
lazy() object and retries — bounded (MAX_LAZY_RETRIES=2) and gated on
app foreground so the retry lands when the buffered executor is about to
flush. Non-retryable errors surface immediately as before.
@revan-zhang

revan-zhang commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@sidmorizon sidmorizon 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.

Reviewed the self-heal boundary against the split-bundle loader's error contract; two interaction findings noted inline.


Generated by Claude Code

Comment thread packages/shared/src/lazyLoad/index.tsx
Comment thread packages/shared/src/lazyLoad/index.tsx Outdated
…anent-failure cache

installProdBundleLoader permanently caches a segment after
MAX_RETRYABLE_ATTEMPTS=3 native attempts. With 2 boundary retries a single
failed mount drove exactly 3 attempts and poisoned the route for the rest
of the process. Cap at 1 retry: a failed mount spends only 2 attempts, so a
later navigation still gets the 3rd once the transient clears. One retry
still recovers the target bug (the watchdog false-fire resolves the moment
the buffered executor flushes on resume).
…budget + bg re-check)

Three review findings on the lazy self-heal:

- P1: installProdBundleLoader permanently caches a budget-exhausted failure
  but left retryable=true on it, so a dead route burned a full retry round on
  every re-navigation. Clear retryable on the cached error, and make the
  boundary treat retryable===false as authoritative (over a transient-looking
  code/message) so it goes fatal at once.
- Codex P1: a PARENT segment charged/cached its own retry budget for a
  propagated DEPENDENCY retryable failure, poisoning the parent for a child's
  transient. Propagate dep errors without spending the parent's budget.
- P2: a retry deferred to foreground could still fire after the app flipped
  back to background within the backoff window. Re-check visibility at fire
  time and re-defer if hidden.

Tests: +2 loader (cache clears retryable; parent not charged for dep failure),
+2 boundary (retryable===false authoritative; re-defer on background flip).
Minnzen
Minnzen previously approved these changes Jun 12, 2026
originalix
originalix previously approved these changes Jun 12, 2026
…itbundle-lazyload-self-heal

# Conflicts:
#	apps/mobile/ios/Podfile.lock
#	apps/mobile/package.json
#	package.json
#	packages/components/package.json
#	yarn.lock
@huhuanming
huhuanming dismissed stale reviews from originalix and Minnzen via 40cd12d June 12, 2026 07:10
@huhuanming
huhuanming enabled auto-merge (squash) June 12, 2026 07:19
@originalix
originalix disabled auto-merge June 12, 2026 07:26
@huhuanming
huhuanming merged commit 448ffcf into x Jun 12, 2026
15 checks passed
@huhuanming
huhuanming deleted the fix/splitbundle-lazyload-self-heal branch June 12, 2026 07:26
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.

6 participants