116 lines
3.1 KiB
TypeScript
116 lines
3.1 KiB
TypeScript
import {
|
|
Component,
|
|
type ErrorInfo,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useLocale } from "../i18n/index.js";
|
|
|
|
type RecoveryResult =
|
|
| Readonly<{ action: "reload-once"; releasePair: string }>
|
|
| Readonly<{ action: "support"; reason: string }>;
|
|
|
|
type Props = Readonly<{
|
|
children: ReactNode;
|
|
chunkId: string;
|
|
recover(input: Readonly<{
|
|
chunkId: string;
|
|
failureKind: "CHUNK_LOAD_FAILURE";
|
|
}>): Promise<RecoveryResult>;
|
|
}>;
|
|
|
|
type State = Readonly<{
|
|
error: unknown | null;
|
|
recovery: "idle" | "checking" | "reload-requested" | "support";
|
|
reason?: string;
|
|
}>;
|
|
|
|
export function isChunkLoadFailure(error: unknown): boolean {
|
|
if (!(error instanceof Error)) return false;
|
|
const value = `${error.name} ${error.message}`.toLowerCase();
|
|
return (
|
|
value.includes("chunkloaderror") ||
|
|
value.includes("loading chunk") ||
|
|
value.includes("dynamically imported module") ||
|
|
value.includes("failed to fetch module script")
|
|
);
|
|
}
|
|
|
|
export class ChunkRecoveryBoundary extends Component<Props, State> {
|
|
state: State = { error: null, recovery: "idle" };
|
|
|
|
static getDerivedStateFromError(error: unknown): State {
|
|
return { error, recovery: "checking" };
|
|
}
|
|
|
|
componentDidCatch(error: unknown, _info: ErrorInfo) {
|
|
if (!isChunkLoadFailure(error)) return;
|
|
void this.props
|
|
.recover({
|
|
chunkId: this.props.chunkId,
|
|
failureKind: "CHUNK_LOAD_FAILURE",
|
|
})
|
|
.then((result) => {
|
|
this.setState({
|
|
error,
|
|
recovery:
|
|
result.action === "reload-once"
|
|
? "reload-requested"
|
|
: "support",
|
|
...(result.action === "support" ? { reason: result.reason } : {}),
|
|
});
|
|
})
|
|
.catch(() => {
|
|
this.setState({
|
|
error,
|
|
recovery: "support",
|
|
reason: "recovery-controller-failed",
|
|
});
|
|
});
|
|
}
|
|
|
|
render() {
|
|
const { error, recovery, reason } = this.state;
|
|
if (error && !isChunkLoadFailure(error)) throw error;
|
|
if (error && recovery === "checking") {
|
|
return <ChunkRecoverySurface recovery="checking" />;
|
|
}
|
|
if (error && recovery === "reload-requested") {
|
|
return <ChunkRecoverySurface recovery="reload-requested" />;
|
|
}
|
|
if (error && recovery === "support") {
|
|
return <ChunkRecoverySurface recovery="support" reason={reason} />;
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
function ChunkRecoverySurface({
|
|
recovery,
|
|
reason,
|
|
}: Readonly<{
|
|
recovery: Exclude<State["recovery"], "idle">;
|
|
reason?: string;
|
|
}>) {
|
|
const { message } = useLocale();
|
|
if (recovery === "checking") {
|
|
return (
|
|
<section className="ui-page" aria-live="polite" aria-busy="true">
|
|
{message("chunk.checking")}
|
|
</section>
|
|
);
|
|
}
|
|
if (recovery === "reload-requested") {
|
|
return (
|
|
<section className="ui-page" aria-live="polite">
|
|
{message("chunk.reloadOnce")}
|
|
</section>
|
|
);
|
|
}
|
|
return (
|
|
<section className="ui-page" role="alert" data-recovery-reason={reason}>
|
|
<h1>{message("chunk.failure.title")}</h1>
|
|
<p>{message("chunk.failure.description")}</p>
|
|
</section>
|
|
);
|
|
}
|