diff --git a/src/application/use-cases/decide-chunk-recovery.js b/src/application/use-cases/decide-chunk-recovery.js new file mode 100644 index 0000000..a2db87e --- /dev/null +++ b/src/application/use-cases/decide-chunk-recovery.js @@ -0,0 +1,34 @@ +const RECOVERABLE_KINDS = new Set(["CHUNK_LOAD_FAILURE", "DEPLOY_MISMATCH"]); + +/** + * @param {{ + * failureKind: string, + * manifestLoaded: boolean, + * currentBuildId: string, + * activeReleaseId: string, + * storage: import("../ports/storage-port.js").StoragePort + * }} input + */ +export function decideChunkRecovery(input) { + if (!RECOVERABLE_KINDS.has(input.failureKind)) { + return { action: "support", reason: "not-recoverable" }; + } + if (!input.manifestLoaded) { + return { action: "support", reason: "manifest-unavailable" }; + } + if (input.activeReleaseId === input.currentBuildId) { + return { action: "support", reason: "same-release" }; + } + + const releasePair = `${input.currentBuildId}->${input.activeReleaseId}`; + const guard = input.storage.read("CHUNK_RELOAD_GUARD"); + if (!guard.ok || guard.value === releasePair) { + return { action: "support", reason: "reload-already-attempted" }; + } + + const recorded = input.storage.write("CHUNK_RELOAD_GUARD", releasePair); + if (!recorded.ok) { + return { action: "support", reason: "guard-write-failed" }; + } + return { action: "reload-once", releasePair }; +} diff --git a/src/bootstrap/main.jsx b/src/bootstrap/main.jsx index 79d45aa..78469b5 100644 --- a/src/bootstrap/main.jsx +++ b/src/bootstrap/main.jsx @@ -2,19 +2,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js"; +import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { AppRouter } from "../presentation/routes/app-router.jsx"; import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js"; -/** @param {{ supportReference: string }} props */ -function BootErrorShell({ supportReference }) { - return ( - - 애플리케이션을 시작할 수 없습니다. - 지원 참조: {supportReference} - - ); -} - const rootElement = document.getElementById("root"); if (!rootElement) { @@ -35,12 +26,12 @@ async function boot() { , ); } catch (error) { - const supportReference = + const safe = error instanceof BootConfigError - ? error.safe.supportReference - : "boot:unknown"; + ? error.safe + : { supportReference: "boot:unknown" }; - root.render(); + root.render(); } } diff --git a/src/presentation/boundaries/boot-error-shell.jsx b/src/presentation/boundaries/boot-error-shell.jsx new file mode 100644 index 0000000..f2aaf78 --- /dev/null +++ b/src/presentation/boundaries/boot-error-shell.jsx @@ -0,0 +1,49 @@ +/** + * @param {{ + * kind?: string, + * code?: string, + * buildId?: string, + * configSchemaVersion?: string, + * releaseId?: string, + * supportReference: string + * }} props + */ +export function BootErrorShell({ + kind = "BOOT_CONFIG_FAILURE", + code = "BOOT_FAILED", + buildId, + configSchemaVersion, + releaseId, + supportReference, +}) { + return ( + + 애플리케이션을 시작할 수 없습니다. + + 오류 + {kind} + 코드 + {code} + {buildId && ( + <> + 빌드 + {buildId} + > + )} + {configSchemaVersion && ( + <> + 설정 스키마 + {configSchemaVersion} + > + )} + {releaseId && ( + <> + 릴리스 + {releaseId} + > + )} + + 지원 참조: {supportReference} + + ); +} diff --git a/src/presentation/boundaries/render-error-boundary.jsx b/src/presentation/boundaries/render-error-boundary.jsx new file mode 100644 index 0000000..de3872d --- /dev/null +++ b/src/presentation/boundaries/render-error-boundary.jsx @@ -0,0 +1,68 @@ +import { Component } from "react"; + +/** + * @typedef {{ + * children: React.ReactNode, + * boundaryName: string, + * routeId: string, + * buildId: string, + * telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort, + * fallback?: React.ReactNode + * }} RenderBoundaryProps + * @typedef {{ hasError: boolean }} RenderBoundaryState + */ + +/** @extends {Component} */ +export class RenderErrorBoundary extends Component { + /** @param {RenderBoundaryProps} props */ + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch() { + try { + this.props.telemetry?.emit("ui.render.failed", { + route_id: this.props.routeId, + build_id: this.props.buildId, + component_boundary: this.props.boundaryName, + }); + } catch { + // Telemetry must never recurse into another render failure. + } + } + + reset = () => { + this.setState({ hasError: false }); + }; + + render() { + if (this.state.hasError) { + return ( + this.props.fallback ?? ( + + error.render_failure + + retry + + + ) + ); + } + return this.props.children; + } +} + +/** @param {Omit} props */ +export function RouteBoundary(props) { + return ; +} + +/** @param {Omit} props */ +export function FeatureBoundary(props) { + return ; +} diff --git a/tests/component/render-boundary.test.jsx b/tests/component/render-boundary.test.jsx new file mode 100644 index 0000000..d61a43e --- /dev/null +++ b/tests/component/render-boundary.test.jsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { createFailure } from "../../src/contracts/errors.js"; +import { deriveAsyncState } from "../../src/application/view-models/async-state.js"; +import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx"; +import { BootErrorShell } from "../../src/presentation/boundaries/boot-error-shell.jsx"; +import { FeatureBoundary } from "../../src/presentation/boundaries/render-error-boundary.jsx"; + +function Defect() { + throw new Error("raw render stack"); +} + +describe("render recovery boundaries", () => { + it("catches programmer defects and emits best-effort safe telemetry", () => { + const telemetry = { emit: vi.fn() }; + render( + + + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent("error.render_failure"); + expect(telemetry.emit).toHaveBeenCalledWith("ui.render.failed", { + route_id: "APP_HOME", + build_id: "build-a", + component_boundary: "feature", + }); + }); + + it("keeps normalized operational failures in normal async state", () => { + const state = deriveAsyncState({ + failure: createFailure("SERVER_FAILURE", "LIST", 0), + }); + render( + + + , + ); + expect(screen.getByRole("alert")).toHaveTextContent("error.server_failure"); + }); + + it("renders a safe boot shell with no endpoint or stack", () => { + render( + , + ); + const shell = screen.getByRole("alert"); + expect(shell).toHaveTextContent("build-a:CONFIG_SCHEMA_INVALID"); + expect(shell).not.toHaveTextContent("https://"); + expect(shell).not.toHaveTextContent("stack"); + }); + + it("allows a boundary reset action without reloading the page", async () => { + let shouldThrow = true; + function Recoverable() { + if (shouldThrow) throw new Error("defect"); + return recovered; + } + const user = userEvent.setup(); + const view = render( + + + , + ); + shouldThrow = false; + await user.click(screen.getByRole("button", { name: "retry" })); + view.rerender( + + + , + ); + expect(screen.getByText("recovered")).toBeVisible(); + }); +}); diff --git a/tests/unit/chunk-recovery.test.js b/tests/unit/chunk-recovery.test.js new file mode 100644 index 0000000..41fd3ab --- /dev/null +++ b/tests/unit/chunk-recovery.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { decideChunkRecovery } from "../../src/application/use-cases/decide-chunk-recovery.js"; + +function memoryStorage() { + let value; + return { + read: () => ({ ok: true, value }), + write: (_key, next) => { + value = next; + return { ok: true }; + }, + remove: () => ({ ok: true }), + }; +} + +describe("controlled chunk recovery", () => { + it("records the release pair before allowing one reload", () => { + const storage = memoryStorage(); + const input = { + failureKind: "CHUNK_LOAD_FAILURE", + manifestLoaded: true, + currentBuildId: "build-a", + activeReleaseId: "release-b", + storage, + }; + expect(decideChunkRecovery(input)).toEqual({ + action: "reload-once", + releasePair: "build-a->release-b", + }); + expect(decideChunkRecovery(input)).toEqual({ + action: "support", + reason: "reload-already-attempted", + }); + }); + + it.each([ + [{ failureKind: "SERVER_FAILURE" }, "not-recoverable"], + [{ manifestLoaded: false }, "manifest-unavailable"], + [{ activeReleaseId: "build-a" }, "same-release"], + ])("stops when a recovery invariant fails: %#", (override, reason) => { + const result = decideChunkRecovery({ + failureKind: "DEPLOY_MISMATCH", + manifestLoaded: true, + currentBuildId: "build-a", + activeReleaseId: "release-b", + storage: memoryStorage(), + ...override, + }); + expect(result).toEqual({ action: "support", reason }); + }); +});
지원 참조: {supportReference}
error.render_failure
recovered