Files
paseo/packages/app/src/components/root-error-details.ts
T
Mohamed BoudraandGitHub cab0e4b3f8 Show a recovery screen for app render errors (#1924)
* fix(app): show recovery screen for render failures

Render a retryable fallback with useful error details when the routed app tree throws during render instead of leaving the app blank.

* test(app): cover root error details without mounting

Move caught-value formatting into a pure module, cover AggregateError fields without duplicate output, and remove the component-mounted test.

* fix(app): keep empty error details caught

* fix(app): preserve nullable aggregate errors

* fix(app): guard malformed error details

* fix(app): handle recursive error causes

* fix(app): guard error detail formatting failures

* fix(app): localize root error fallback

* fix(app): keep root error fallback on existing i18n
2026-07-09 00:06:29 +08:00

156 lines
4.3 KiB
TypeScript

export function formatCaughtValue(value: unknown): string {
try {
return formatCaughtValueWithSeenErrors(value, new WeakSet<Error>());
} catch (formattingError) {
return formatFormattingFailure(value, formattingError);
}
}
function formatCaughtValueWithSeenErrors(value: unknown, seenErrors: WeakSet<Error>): string {
if (value instanceof Error) {
return formatError(value, seenErrors);
}
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return safeString(value);
}
if (typeof value !== "object" && typeof value !== "function") {
return safeString(value);
}
return stringifyJson(value, seenErrors) ?? safeString(value);
}
function formatError(error: Error, seenErrors: WeakSet<Error>): string {
if (seenErrors.has(error)) {
return "[Circular Error]";
}
seenErrors.add(error);
const sections: string[] = [];
const name = formatErrorTextProperty(Reflect.get(error, "name"), seenErrors);
const message = formatErrorTextProperty(Reflect.get(error, "message"), seenErrors);
const stack = formatErrorTextProperty(Reflect.get(error, "stack"), seenErrors);
if (name) {
sections.push(`Name: ${name}`);
}
if (message) {
sections.push(`Message: ${message}`);
}
if (stack) {
sections.push(`Stack:\n${stack}`);
}
const errorCause = getErrorCause(error);
if (errorCause.hasCause) {
sections.push(`Cause:\n${formatCaughtValueWithSeenErrors(errorCause.value, seenErrors)}`);
}
const aggregateErrors = getAggregateErrors(error);
if (aggregateErrors.hasErrors) {
sections.push(`Errors:\n${formatCaughtValueWithSeenErrors(aggregateErrors.value, seenErrors)}`);
}
const fields = getErrorFields(error);
if (fields !== null) {
sections.push(`Fields:\n${stringifyJson(fields, seenErrors) ?? safeString(fields)}`);
}
seenErrors.delete(error);
return sections.join("\n\n") || safeString(error);
}
function formatErrorTextProperty(value: unknown, seenErrors: WeakSet<Error>): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || null;
}
if (value === undefined) {
return null;
}
return stringifyJson(value, seenErrors) ?? safeString(value);
}
function getErrorCause(error: Error): { hasCause: boolean; value: unknown } {
if (!Reflect.has(error, "cause")) {
return { hasCause: false, value: null };
}
return { hasCause: true, value: Reflect.get(error, "cause") };
}
function getAggregateErrors(error: Error): { hasErrors: boolean; value: unknown } {
if (!Reflect.has(error, "errors")) {
return { hasErrors: false, value: null };
}
return { hasErrors: true, value: Reflect.get(error, "errors") };
}
function getErrorFields(error: Error): Record<string, unknown> | null {
const fields: Record<string, unknown> = {};
for (const key of Object.keys(error)) {
if (
key === "name" ||
key === "message" ||
key === "stack" ||
key === "cause" ||
key === "errors"
) {
continue;
}
fields[key] = Reflect.get(error, key);
}
return Object.keys(fields).length > 0 ? fields : null;
}
function stringifyJson(value: unknown, seenErrors: WeakSet<Error>): string | null {
const seen = new WeakSet<object>();
try {
const serialized = JSON.stringify(
value,
(_key, nestedValue: unknown) => {
if (nestedValue instanceof Error) {
return formatError(nestedValue, seenErrors);
}
if (typeof nestedValue === "bigint") {
return String(nestedValue);
}
if (nestedValue !== null && typeof nestedValue === "object") {
if (seen.has(nestedValue)) {
return "[Circular]";
}
seen.add(nestedValue);
}
return nestedValue;
},
2,
);
return typeof serialized === "string" ? serialized : null;
} catch {
return null;
}
}
function safeString(value: unknown): string {
try {
return String(value);
} catch {
return "[Unserializable value]";
}
}
function formatFormattingFailure(value: unknown, formattingError: unknown): string {
const valueText = safeString(value);
const formattingErrorText = safeString(formattingError);
if (formattingErrorText === "[Unserializable value]") {
return valueText;
}
return `${valueText}\n\nDetails unavailable:\n${formattingErrorText}`;
}