-
Notifications
You must be signed in to change notification settings - Fork 6.6k
fix(mobile): recover from render errors in-place with scoped boundaries #13145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
bf13cdf
fix(mobile): recover from render errors in-place with scoped boundaries
juliusmarminge d330f4f
fix(mobile): track render failure by flag and exit cold-launch crashes
juliusmarminge 6babe9a
fix(mobile): close boundary coverage gaps found in review
juliusmarminge e5bdb01
fix(mobile): harden recovery view and cold-launch exit per review
juliusmarminge 21c3d34
fix(mobile): live-subscribe the Diagnostics render-error list
juliusmarminge a4e849c
fix(mobile): scope the inspector boundary inside the pane and harden …
juliusmarminge aeb3582
fix(mobile): guard every hostile read path and catch inspector-callba…
juliusmarminge 3e01a27
fix(mobile): stabilize inspector reset identity and finish hostile-th…
juliusmarminge 401d3c7
fix(mobile): content-accurate inspector identities and isolated subsc…
juliusmarminge d2c8618
fix(mobile): make every inspector identity fully workspace-bound
juliusmarminge 2232d2d
docs(mobile): correct the seam fallback's navigation comment
juliusmarminge 333b8fe
fix(mobile): keep never-painted Home crashes fatal and de-collide res…
juliusmarminge 5144e3e
fix(mobile): throw the cold-launch Home failure inside the failed ren…
juliusmarminge cb480a0
docs(mobile): state the expo-updates startup-error pipeline exactly
juliusmarminge fd62e5e
fix(mobile): disarm the first-paint valve once any frame has painted
juliusmarminge 796bbd7
refactor(mobile): drop the Home first-paint fatal valve — it could ne…
juliusmarminge f213db0
refactor(mobile): move the render-error log from features/diagnostics…
juliusmarminge File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| import { Component, type ComponentType, type ReactNode } from "react"; | ||
| import { View } from "react-native"; | ||
|
|
||
| import { SymbolView } from "./AppSymbol"; | ||
| import { AppText as Text } from "./AppText"; | ||
| import { MaterialButton } from "./MaterialButton"; | ||
| import { tryCopyTextWithHaptic } from "../lib/copyTextWithHaptic"; | ||
| import { describeRenderError, readErrorStack, recordRenderError } from "../lib/render-error-log"; | ||
| import { | ||
| boundaryResetFromProps, | ||
| failedBoundaryState, | ||
| healthyBoundaryState, | ||
| type BoundaryState, | ||
| } from "./render-error-boundary-model"; | ||
|
|
||
| interface RenderErrorBoundaryProps { | ||
| readonly children: ReactNode; | ||
| /** Where the error was caught, recorded into the diagnostics render-error log. */ | ||
| readonly scope: string; | ||
| /** Changed inputs reset a failed subtree without remounting the boundary. */ | ||
| readonly resetKeys?: ReadonlyArray<unknown> | undefined; | ||
| /** Subject noun for the default fallback's headline, e.g. "The conversation". */ | ||
| readonly subject?: string; | ||
| /** Forwarded to a custom `fallback` so it can adapt per route (screen seam). */ | ||
| readonly routeName?: string | undefined; | ||
| /** | ||
| * Recovery UI override, rendered as its own component so it can use hooks | ||
| * (e.g. navigation) even though the boundary itself is a class. | ||
| */ | ||
| readonly fallback?: ComponentType<RenderFallbackProps> | undefined; | ||
| } | ||
|
|
||
| export interface RenderFallbackProps { | ||
| readonly error: unknown; | ||
| readonly retry: () => void; | ||
| /** React's component stack when the runtime captured one; feeds "Copy details". */ | ||
| readonly componentStack?: string | undefined; | ||
| /** Route name for screen-seam fallbacks that pick their exit per route. */ | ||
| readonly routeName?: string | undefined; | ||
| } | ||
|
|
||
| type RenderErrorBoundaryState = BoundaryState; | ||
|
|
||
| /** | ||
| * Catches render errors in one subtree, records them for diagnostics, and | ||
| * shows an in-session recovery UI instead of letting the app die. Retrying | ||
| * unmounts the failed subtree and mounts a fresh one; changed `resetKeys` | ||
| * (e.g. a thread switch) clear the failure on their own. | ||
| * | ||
| * Failure is tracked by a dedicated flag, not the thrown value, so | ||
| * `throw undefined`/`null`/`""` still render the fallback. | ||
| * | ||
| * A caught error never reaches the global fatal handler, so expo-updates' | ||
| * ErrorRecovery startup log stays exclusively for process-ending fatals — | ||
| * `recordRenderError` is the sole report path here. Once a boundary recovers, | ||
| * the throw stops bubbling, so only the innermost boundary records it. | ||
| */ | ||
| export class RenderErrorBoundary extends Component< | ||
| RenderErrorBoundaryProps, | ||
| RenderErrorBoundaryState | ||
| > { | ||
| override state = healthyBoundaryState(this.props.resetKeys); | ||
|
|
||
| // A changed thread/environment underneath a persistent boundary is new input: | ||
| // retry without waiting for the user to press Try again. | ||
| static getDerivedStateFromProps( | ||
| { resetKeys }: RenderErrorBoundaryProps, | ||
| state: RenderErrorBoundaryState, | ||
| ) { | ||
| return boundaryResetFromProps(resetKeys, state); | ||
| } | ||
|
|
||
| static getDerivedStateFromError(error: unknown) { | ||
| return failedBoundaryState(error); | ||
| } | ||
|
|
||
| override componentDidCatch(error: unknown, info: { componentStack?: string }) { | ||
| recordRenderError(error, this.props.scope, { componentStack: info.componentStack }); | ||
| // Keep the component path for the recovery view's "Copy details" too — | ||
| // in release builds it may be the only component stack anyone ever sees. | ||
| if (info.componentStack !== undefined) { | ||
| this.setState({ componentStack: info.componentStack }); | ||
| } | ||
| } | ||
|
|
||
| private readonly retry = () => { | ||
| this.setState(healthyBoundaryState(this.state.resetKeys)); | ||
| }; | ||
|
|
||
| override render() { | ||
| if (this.state.failed) { | ||
| if (this.props.fallback) { | ||
| const Fallback = this.props.fallback; | ||
| return ( | ||
| <Fallback | ||
| error={this.state.error} | ||
| retry={this.retry} | ||
| componentStack={this.state.componentStack} | ||
| routeName={this.props.routeName} | ||
| /> | ||
| ); | ||
| } | ||
| return ( | ||
| <RenderFailureView | ||
| subject={this.props.subject} | ||
| error={this.state.error} | ||
| retry={this.retry} | ||
| componentStack={this.state.componentStack} | ||
| /> | ||
| ); | ||
| } | ||
| return this.props.children; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * The recovery UI itself: retry, copy diagnostics, and (where navigation gives | ||
| * a way out) go back. It renders without a connection — recovery must work the | ||
| * same locally and over a tunnel, where the crash itself may have arrived with | ||
| * remote data. | ||
| */ | ||
| export function RenderFailureView(props: { | ||
| readonly subject?: string; | ||
| readonly error: unknown; | ||
| readonly retry: () => void; | ||
| readonly componentStack?: string | undefined; | ||
| readonly onGoBack?: (() => void) | undefined; | ||
| /** Escape exit for a cold-launch crash where there is no route to go back to. */ | ||
| readonly onOpenSettings?: (() => void) | undefined; | ||
| /** Escape exit when even Settings is the broken route (replace stack with Home). */ | ||
| readonly onGoHome?: (() => void) | undefined; | ||
| }) { | ||
| // Safe even for hostile throws (throwing `toString`, primitives, symbols). | ||
| const message = describeRenderError(props.error); | ||
| const copy = async () => { | ||
| // readErrorStack guards hostile stack getters too — copying must never | ||
| // itself crash the recovery view. | ||
| const stack = readErrorStack(props.error) ?? message; | ||
| const detail = | ||
| props.componentStack !== undefined | ||
| ? `${stack}\nComponent stack:\n${props.componentStack}` | ||
| : stack; | ||
| await tryCopyTextWithHaptic(detail, { target: "render error details" }); | ||
| }; | ||
| return ( | ||
| <View className="flex-1 items-center justify-center gap-4 px-8 py-10"> | ||
| <SymbolView | ||
| name="exclamationmark.triangle" | ||
| size={30} | ||
| tintColorClassName="accent-icon" | ||
| type="monochrome" | ||
| weight="regular" | ||
| /> | ||
| <View className="items-center gap-2"> | ||
| <Text className="text-center text-xl font-t3-bold text-foreground"> | ||
| {props.subject ?? "This screen"} couldn’t be displayed | ||
| </Text> | ||
| <Text className="text-center font-sans text-sm leading-relaxed text-foreground-muted"> | ||
| Try again to re-render it. If it keeps happening, copy the details — they help us fix it. | ||
| </Text> | ||
| <Text | ||
| selectable | ||
| className="text-center font-mono text-xs leading-snug text-danger-foreground" | ||
| > | ||
| {message.slice(0, 300)} | ||
| </Text> | ||
| </View> | ||
| <View className="w-full max-w-xs items-stretch gap-2"> | ||
| <MaterialButton label="Try again" onPress={props.retry} tone="primary" fullWidth /> | ||
| <MaterialButton label="Copy details" onPress={() => void copy()} fullWidth /> | ||
| {props.onGoBack ? ( | ||
| <MaterialButton label="Go back" onPress={props.onGoBack} tone="text" fullWidth /> | ||
| ) : null} | ||
| {props.onOpenSettings ? ( | ||
| <MaterialButton | ||
| label="Open settings" | ||
| onPress={props.onOpenSettings} | ||
| tone="text" | ||
| fullWidth | ||
| /> | ||
| ) : null} | ||
| {props.onGoHome ? ( | ||
| <MaterialButton label="Return home" onPress={props.onGoHome} tone="text" fullWidth /> | ||
| ) : null} | ||
| </View> | ||
| </View> | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.