From 438dc1154859f06a43df838eac7b7643b08ed838 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 21:00:21 +0900 Subject: [PATCH] feat: model complete async UI surface states --- src/application/view-models/async-state.js | 81 +++++++++++++++++++ src/contracts/errors.js | 11 ++- src/presentation/components/async-surface.jsx | 72 +++++++++++++++++ tests/component/async-surface.test.jsx | 76 +++++++++++++++++ 4 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 src/application/view-models/async-state.js create mode 100644 src/presentation/components/async-surface.jsx create mode 100644 tests/component/async-surface.test.jsx diff --git a/src/application/view-models/async-state.js b/src/application/view-models/async-state.js new file mode 100644 index 0000000..4d6db8c --- /dev/null +++ b/src/application/view-models/async-state.js @@ -0,0 +1,81 @@ +export const ASYNC_BASE_STATES = Object.freeze([ + "initial-loading", + "success", + "empty", + "terminal-error", +]); + +export const ASYNC_OVERLAYS = Object.freeze([ + "refreshing", + "stale-degraded", + "mutation-pending", + "mutation-conflict", +]); + +/** + * @typedef {{ + * data?: unknown, + * isInitialLoading?: boolean, + * failure?: import("../../contracts/errors.js").ApiFailure, + * isFetching?: boolean, + * isStale?: boolean, + * isDegraded?: boolean, + * isMutationPending?: boolean, + * hasMutationConflict?: boolean + * }} AsyncSignals + */ + +/** @param {AsyncSignals} signals */ +export function deriveAsyncState(signals) { + const hasData = signals.data !== undefined && signals.data !== null; + const empty = + hasData && + ((Array.isArray(signals.data) && signals.data.length === 0) || + signals.data === ""); + + let base; + if (signals.isInitialLoading && !hasData) { + base = "initial-loading"; + } else if (signals.failure && !hasData) { + base = "terminal-error"; + } else if (empty) { + base = "empty"; + } else if (hasData) { + base = "success"; + } else { + base = "initial-loading"; + } + + const overlay = Object.freeze({ + refreshing: Boolean(signals.isFetching && hasData), + staleDegraded: Boolean(signals.isStale && signals.isDegraded && hasData), + mutationPending: Boolean(signals.isMutationPending && hasData), + mutationConflict: Boolean(signals.hasMutationConflict && hasData), + }); + + const state = { + base, + data: base === "success" || base === "empty" ? signals.data : undefined, + failure: base === "terminal-error" ? signals.failure : undefined, + overlay, + indicator: selectOverlayIndicator(overlay), + }; + + return Object.freeze(state); +} + +/** + * @param {{ + * refreshing: boolean, + * staleDegraded: boolean, + * mutationPending: boolean, + * mutationConflict: boolean + * }} overlay + */ +export function selectOverlayIndicator(overlay) { + if (overlay.mutationConflict) return "mutation-conflict"; + if (overlay.mutationPending) return "mutation-pending"; + if (overlay.staleDegraded) return "stale-degraded"; + if (overlay.refreshing) return "refreshing"; + return null; +} diff --git a/src/contracts/errors.js b/src/contracts/errors.js index ee69137..8c0af28 100644 --- a/src/contracts/errors.js +++ b/src/contracts/errors.js @@ -9,13 +9,18 @@ const DROP_SENSITIVE = Object.freeze([ "storageValue", ]); +/** + * @typedef {"retry" | "reauth" | "navigate" | "reload-once" | + * "contact-support" | "none"} ErrorAction + */ + /** * @typedef {{ * kind: string, * defaultRetryable: boolean, * severity: string, * userMessageKey: string, - * action: string, + * action: ErrorAction, * telemetryEvent: string, * redaction: readonly string[] * }} ErrorDefinition @@ -25,7 +30,7 @@ const DROP_SENSITIVE = Object.freeze([ * @param {string} kind * @param {boolean} defaultRetryable * @param {string} severity - * @param {string} action + * @param {ErrorAction} action * @param {string} [telemetryEvent] * @returns {Readonly} */ @@ -156,7 +161,7 @@ export const ERROR_REGISTRY = Object.freeze({ * traceId?: string, * retryAfterMs?: number, * userMessageKey: string, - * action: string, + * action: ErrorAction, * causeClass?: string * }} ApiFailure */ diff --git a/src/presentation/components/async-surface.jsx b/src/presentation/components/async-surface.jsx new file mode 100644 index 0000000..06cc735 --- /dev/null +++ b/src/presentation/components/async-surface.jsx @@ -0,0 +1,72 @@ +/** @param {{ label?: string }} props */ +export function LoadingSurface({ label = "불러오는 중" }) { + return ( +
+
+ ); +} + +/** @param {{ title?: string, action?: React.ReactNode }} props */ +export function EmptySurface({ title = "표시할 항목이 없습니다.", action }) { + return ( +
+

{title}

+ {action} +
+ ); +} + +/** + * @param {{ + * userMessageKey: string, + * action: "retry" | "reauth" | "navigate" | "reload-once" | + * "contact-support" | "none", + * onAction?: () => void + * }} props + */ +export function TerminalErrorSurface({ userMessageKey, action, onAction }) { + return ( +
+

{userMessageKey}

+ {action !== "none" && ( + + )} +
+ ); +} + +/** + * @param {{ + * state: ReturnType, + * children?: React.ReactNode, + * onAction?: () => void + * }} props + */ +export function AsyncSurface({ state, children, onAction }) { + if (state.base === "initial-loading") return ; + if (state.base === "empty") return ; + if (state.base === "terminal-error" && state.failure) { + return ( + + ); + } + + return ( +
+ {state.indicator && ( +

+ {state.indicator} +

+ )} + {children} +
+ ); +} diff --git a/tests/component/async-surface.test.jsx b/tests/component/async-surface.test.jsx new file mode 100644 index 0000000..6537b32 --- /dev/null +++ b/tests/component/async-surface.test.jsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { deriveAsyncState } from "../../src/application/view-models/async-state.js"; +import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx"; +import { createFailure } from "../../src/contracts/errors.js"; + +describe("async UI state matrix", () => { + it.each([ + [{ isInitialLoading: true }, "initial-loading"], + [{ data: [{ id: "1" }] }, "success"], + [{ data: [] }, "empty"], + [ + { failure: createFailure("SERVER_FAILURE", "LIST", 0) }, + "terminal-error", + ], + ])("derives base state %#", (signals, expected) => { + expect(deriveAsyncState(signals).base).toBe(expected); + }); + + it.each([ + [{ data: ["value"], isFetching: true }, "refreshing"], + [{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"], + [{ data: ["value"], isMutationPending: true }, "mutation-pending"], + [{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"], + ])("derives overlay state %#", (signals, indicator) => { + expect(deriveAsyncState(signals).indicator).toBe(indicator); + }); + + it("uses deterministic overlay priority for crossed states", () => { + const state = deriveAsyncState({ + data: ["value"], + isFetching: true, + isMutationPending: true, + hasMutationConflict: true, + }); + expect(state.indicator).toBe("mutation-conflict"); + expect(state.overlay).toMatchObject({ + refreshing: true, + mutationPending: true, + mutationConflict: true, + }); + }); + + it("keeps content visible while a non-blocking refresh runs", () => { + const state = deriveAsyncState({ data: ["value"], isFetching: true }); + render(existing content); + + expect(screen.getByText("existing content")).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent("refreshing"); + }); + + it("renders only safe error vocabulary", () => { + const failure = createFailure("SERVER_FAILURE", "LIST", 0, { + code: "SERVER_FAILURE", + }); + const state = deriveAsyncState({ failure }); + render(); + + expect(screen.getByRole("alert")).toHaveTextContent(failure.userMessageKey); + expect(screen.getByRole("button")).toHaveTextContent("retry"); + expect(screen.getByRole("alert")).not.toHaveTextContent("stack"); + }); + + it("does not retain a terminal error after usable data is restored", () => { + const failed = deriveAsyncState({ + failure: createFailure("SERVER_FAILURE", "LIST", 0), + }); + const recovered = deriveAsyncState({ data: ["value"] }); + expect(failed.base).toBe("terminal-error"); + expect(recovered.base).toBe("success"); + expect(recovered.failure).toBeUndefined(); + }); +});