Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37d571eb3 | ||
|
|
eb16c2ffe7 |
@@ -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 };
|
||||
}
|
||||
+5
-14
@@ -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 (
|
||||
<main role="alert">
|
||||
<h1>애플리케이션을 시작할 수 없습니다.</h1>
|
||||
<p>지원 참조: {supportReference}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
@@ -35,12 +26,12 @@ async function boot() {
|
||||
</StrictMode>,
|
||||
);
|
||||
} catch (error) {
|
||||
const supportReference =
|
||||
const safe =
|
||||
error instanceof BootConfigError
|
||||
? error.safe.supportReference
|
||||
: "boot:unknown";
|
||||
? error.safe
|
||||
: { supportReference: "boot:unknown" };
|
||||
|
||||
root.render(<BootErrorShell supportReference={supportReference} />);
|
||||
root.render(<BootErrorShell {...safe} />);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<main role="alert">
|
||||
<h1>애플리케이션을 시작할 수 없습니다.</h1>
|
||||
<dl>
|
||||
<dt>오류</dt>
|
||||
<dd>{kind}</dd>
|
||||
<dt>코드</dt>
|
||||
<dd>{code}</dd>
|
||||
{buildId && (
|
||||
<>
|
||||
<dt>빌드</dt>
|
||||
<dd>{buildId}</dd>
|
||||
</>
|
||||
)}
|
||||
{configSchemaVersion && (
|
||||
<>
|
||||
<dt>설정 스키마</dt>
|
||||
<dd>{configSchemaVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{releaseId && (
|
||||
<>
|
||||
<dt>릴리스</dt>
|
||||
<dd>{releaseId}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
<p>지원 참조: {supportReference}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<RenderBoundaryProps, RenderBoundaryState>} */
|
||||
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 ?? (
|
||||
<section role="alert">
|
||||
<p>error.render_failure</p>
|
||||
<button type="button" onClick={this.reset}>
|
||||
retry
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Omit<RenderBoundaryProps, "boundaryName">} props */
|
||||
export function RouteBoundary(props) {
|
||||
return <RenderErrorBoundary {...props} boundaryName="route" />;
|
||||
}
|
||||
|
||||
/** @param {Omit<RenderBoundaryProps, "boundaryName">} props */
|
||||
export function FeatureBoundary(props) {
|
||||
return <RenderErrorBoundary {...props} boundaryName="feature" />;
|
||||
}
|
||||
@@ -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(
|
||||
<FeatureBoundary
|
||||
routeId="APP_HOME"
|
||||
buildId="build-a"
|
||||
telemetry={telemetry}
|
||||
>
|
||||
<Defect />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<AsyncSurface state={state} />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("error.server_failure");
|
||||
});
|
||||
|
||||
it("renders a safe boot shell with no endpoint or stack", () => {
|
||||
render(
|
||||
<BootErrorShell
|
||||
kind="BOOT_CONFIG_FAILURE"
|
||||
code="CONFIG_SCHEMA_INVALID"
|
||||
buildId="build-a"
|
||||
configSchemaVersion="1"
|
||||
supportReference="build-a:CONFIG_SCHEMA_INVALID"
|
||||
/>,
|
||||
);
|
||||
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 <p>recovered</p>;
|
||||
}
|
||||
const user = userEvent.setup();
|
||||
const view = render(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<Recoverable />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
shouldThrow = false;
|
||||
await user.click(screen.getByRole("button", { name: "retry" }));
|
||||
view.rerender(
|
||||
<FeatureBoundary routeId="APP_HOME" buildId="build-a">
|
||||
<Recoverable />
|
||||
</FeatureBoundary>,
|
||||
);
|
||||
expect(screen.getByText("recovered")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user