fix: self-heal lazy routes on retryable split-bundle segment timeout#12032
Merged
Conversation
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.
huhuanming
requested review from
ezailWang,
originalix,
revan-zhang and
sidmorizon
as code owners
June 12, 2026 06:08
Contributor
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
sidmorizon
reviewed
Jun 12, 2026
sidmorizon
left a comment
Contributor
There was a problem hiding this comment.
Reviewed the self-heal boundary against the split-bundle loader's error contract; two interaction findings noted inline.
Generated by Claude Code
…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
previously approved these changes
Jun 12, 2026
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
originalix
approved these changes
Jun 12, 2026
originalix
disabled auto-merge
June 12, 2026 07:26
zhaono1
approved these changes
Jun 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
React.lazypermanently 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-firingSPLIT_BUNDLE_TIMEOUTon resume from suspension (see OneKeyHQ/app-modules#65), but also any genuineSPLIT_BUNDLE_NO_RUNTIME/SPLIT_BUNDLE_TIMEOUTon either platform — the route white-screens until the app is force-killed, even thoughinstallProdBundleLoaderalready marks the error retryable and the very next__loadBundleAsyncwould 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 aLazyRetryBoundary:SPLIT_BUNDLE_TIMEOUT/SPLIT_BUNDLE_NO_RUNTIME/.retryable === true/[SplitBundle] … eval timed outmessage). Non-retryable errors (EVAL_ERROR/IO_ERROR/NOT_FOUND/SHA256_MISMATCH) surface immediately, exactly as today.lazy()object per retry (via aretryKey/useMemobump) — the only way to drop React's cached rejected payload.MAX_LAZY_RETRIES = 2, under the loader's ownMAX_RETRYABLE_ATTEMPTS = 3).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.getDerivedStateFromErrorenters a non-throwingretryinghold;render()throws only in the terminalfatalstate, 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.lazyidentically). 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:stagedclean.