The root error fallback was one unbounded scroller, so on a phone the title rendered under the status bar and a long stack trace pushed Retry off the bottom of the screen — the one action that recovers the app was unreachable on exactly the errors that need it most. The screen is now a fixed shell: a safe-area message block, an independently scrolling details region, and a Retry that stays put. `SafeAreaProvider` moved above the boundary in the root tree because the fallback renders when the app tree below it has already failed, and insets have to be available there. The hand-rolled Pressable and bordered message box are replaced by the shared `<Button>` and `<ScrollableCodeSurface>`, which gives the trace a monospace face and horizontal scrolling instead of wrapping mid-token. On desktop the trace is capped and the action sits with the message; a button pinned to the bottom of a tall window reads as detached from the content it belongs to. Dropped the "Something went wrong" kicker — it restated the title one line above it.
96 lines
3.2 KiB
TypeScript
96 lines
3.2 KiB
TypeScript
import React, { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { JSDOM } from "jsdom";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { RootErrorFallback } from "./root-error-boundary";
|
|
|
|
const { runtime, theme } = vi.hoisted(() => ({
|
|
runtime: { breakpoint: "xs" },
|
|
theme: {
|
|
spacing: { 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
|
borderWidth: { 1: 1 },
|
|
borderRadius: { md: 6, lg: 8, xl: 12 },
|
|
fontFamily: { ui: "system-ui", mono: "monospace" },
|
|
fontSize: { xs: 11, code: 12, sm: 13, base: 15, xl: 20 },
|
|
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
|
colors: {
|
|
accent: "#369",
|
|
accentForeground: "#fff",
|
|
border: "#333",
|
|
borderAccent: "#444",
|
|
destructive: "#c33",
|
|
foreground: "#fff",
|
|
foregroundMuted: "#aaa",
|
|
surface0: "#111",
|
|
surface1: "#181818",
|
|
surface2: "#222",
|
|
surface3: "#333",
|
|
},
|
|
opacity: { 50: 0.5 },
|
|
},
|
|
}));
|
|
|
|
vi.mock("react-native-unistyles", () => ({
|
|
StyleSheet: {
|
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
|
},
|
|
useUnistyles: () => ({ theme, rt: runtime }),
|
|
withUnistyles: (component: unknown) => component,
|
|
}));
|
|
|
|
vi.mock("react-native-safe-area-context", () => ({
|
|
useSafeAreaInsets: () => ({ top: 47, right: 3, bottom: 34, left: 5 }),
|
|
}));
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ t: (key: string) => key }),
|
|
}));
|
|
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
|
vi.stubGlobal("React", React);
|
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
vi.stubGlobal("window", dom.window);
|
|
vi.stubGlobal("document", dom.window.document);
|
|
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
|
vi.stubGlobal("Node", dom.window.Node);
|
|
vi.stubGlobal("navigator", dom.window.navigator);
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
root = null;
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("RootErrorFallback", () => {
|
|
for (const breakpoint of ["xs", "lg"]) {
|
|
it(`keeps the safe-area header and retry action outside the scrollable details on ${breakpoint}`, () => {
|
|
runtime.breakpoint = breakpoint;
|
|
act(() => {
|
|
root?.render(<RootErrorFallback error={"failure\n".repeat(200)} onRetry={vi.fn()} />);
|
|
});
|
|
|
|
const screen = document.querySelector('[data-testid="root-error-boundary"]');
|
|
const header = document.querySelector('[data-testid="root-error-boundary-header"]');
|
|
const details = document.querySelector('[data-testid="root-error-boundary-details"]');
|
|
const retry = document.querySelector('[data-testid="root-error-boundary-retry"]');
|
|
|
|
expect(screen).not.toBeNull();
|
|
expect(header).not.toBeNull();
|
|
expect(details).not.toBeNull();
|
|
expect(retry).not.toBeNull();
|
|
expect(screen?.getAttribute("style")).toContain("padding: 47px 3px 34px 5px");
|
|
expect(details?.contains(header)).toBe(false);
|
|
expect(details?.contains(retry)).toBe(false);
|
|
expect(screen?.contains(retry)).toBe(true);
|
|
});
|
|
}
|
|
});
|