feat: 기능 추가 과정중

This commit is contained in:
donghyeon-ka
2026-07-30 15:58:20 +09:00
parent d3ef801fe6
commit 6c52cdb916
648 changed files with 126325 additions and 6680 deletions
@@ -1,7 +1,7 @@
import {
useCallback,
useEffect,
useRef,
useMemo,
useState,
} from "react";
import {
@@ -13,17 +13,31 @@ import {
import {
deriveAsyncState,
type AsyncState,
} from "../../../application/view-models/async-state.js";
import type { ApiFailure } from "../../../contracts/errors.js";
} from "../../../application/view-models/async-state.ts";
import type { Result } from "../../../application/result.ts";
import {
createFailure,
normalizeUnknownFailure,
type AppFailure,
} from "../../../contracts/errors.ts";
import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts";
import type {
BoundMutation,
BoundQuery,
} from "../../../contracts/server-state.ts";
import { runtimeIdentityToken } from "../../../contracts/query-keys.ts";
import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx";
import {
createOptimisticLayerRuntime,
type OptimisticLayerLease,
} from "./optimistic-layer-runtime.ts";
export type ApplicationResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
export type ApplicationResult<Value> = Result<Value>;
class ApplicationQueryError extends Error {
readonly failure: ApiFailure;
readonly failure: AppFailure;
constructor(failure: ApiFailure) {
constructor(failure: AppFailure) {
super(failure.kind);
this.name = "ApplicationQueryError";
this.failure = failure;
@@ -31,31 +45,102 @@ class ApplicationQueryError extends Error {
}
export function useApplicationQuery<Value>(
options: Readonly<{
queryKey: readonly unknown[];
execute(context: Readonly<{ signal: AbortSignal }>): Promise<
ApplicationResult<Value>
>;
enabled?: boolean;
}>,
options:
| BoundQuery<Value>
| Readonly<{
queryKey: readonly unknown[];
execute(context: Readonly<{ signal: AbortSignal }>): Promise<
ApplicationResult<Value>
>;
enabled?: boolean;
}>,
): Readonly<{
data: Value | undefined;
state: AsyncState;
retry(): Promise<void>;
}> {
const { queryKey, execute, enabled = true } = options;
const { queryKey, execute } = options;
const enabled = "enabled" in options ? options.enabled ?? true : true;
const profile = "profile" in options ? options.profile : undefined;
const scope = "scope" in options ? options.scope : undefined;
const identity = "identity" in options ? options.identity : undefined;
const queryDefinitionId =
"definitionId" in options ? options.definitionId : "APPLICATION_QUERY";
const [staleFailure, setStaleFailure] = useState(false);
useEffect(() => {
identity?.acquire();
return () => identity?.release();
}, [identity]);
const query = useQuery<Value, ApplicationQueryError>({
queryKey,
enabled,
retry: false,
staleTime: profile?.staleTimeMs,
gcTime: profile?.gcTimeMs,
refetchOnMount: profile?.refetchOnMount,
refetchOnWindowFocus: profile?.refetchOnFocus,
refetchOnReconnect: profile?.refetchOnReconnect,
queryFn: async ({ signal }) => {
const result = await execute({ signal });
if (result.ok) return result.value;
if (signal.aborted || result.error.kind === "REQUEST_ABORTED") {
throw new DOMException("Query cancelled", "AbortError");
identity?.acquire();
try {
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
queryDefinitionId,
0,
{ code: "QUERY_SCOPE_STALE" },
),
);
}
const result = await execute({ signal });
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
queryDefinitionId,
0,
{ code: "QUERY_SCOPE_CHANGED" },
),
);
}
if (result.ok) {
if (
profile &&
!isAdmissibleResult(
result.value,
profile.maxResultItems,
profile.maxEstimatedResultBytes,
)
) {
throw new ApplicationQueryError(
createFailure(
"RESULT_LIMIT_EXCEEDED",
"APPLICATION_QUERY",
0,
{ code: "RESULT_ADMISSION_LIMIT_EXCEEDED" },
),
);
}
return result.value;
}
if (signal.aborted) {
throw new DOMException("Query cancelled", "AbortError");
}
throw new ApplicationQueryError(result.error);
} catch (error) {
if (signal.aborted) {
throw new DOMException("Query cancelled", "AbortError");
}
if (error instanceof ApplicationQueryError) throw error;
throw new ApplicationQueryError(
normalizeUnknownFailure(error, {
operationId: "APPLICATION_QUERY",
}),
);
} finally {
identity?.release();
}
throw new ApplicationQueryError(result.error);
},
});
const hasData = query.data !== undefined && query.data !== null;
@@ -91,28 +176,61 @@ export function useApplicationQuery<Value>(
}
export function useApplicationMutation<Input, Value>(
options: Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
invalidate?: readonly (readonly unknown[])[];
optimistic?: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
currentData?: unknown;
}>,
options:
| BoundMutation<Input, Value>
| Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
invalidate?: readonly QueryInvalidationTopic[];
optimistic?: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
currentData?: unknown;
}>,
): Readonly<{
state: AsyncState;
submit(input: Input): Promise<ApplicationResult<Value>>;
resolveConflict(): Promise<void>;
}> {
const queryClient = useQueryClient();
const { execute, invalidate = [], optimistic, currentData } = options;
const [conflict, setConflict] = useState<ApiFailure | null>(null);
const inFlight = useRef<Promise<ApplicationResult<Value>> | null>(null);
const invalidationCoordinator = useQueryInvalidationCoordinator();
const { execute } = options;
const invalidate = useMemo(
() => options.invalidate ?? [],
[options.invalidate],
);
const optimistic = "optimistic" in options ? options.optimistic : undefined;
const currentData = "currentData" in options ? options.currentData : undefined;
const definitionId =
"definitionId" in options ? options.definitionId : "LEGACY_MUTATION";
const duplicatePolicy =
"duplicatePolicy" in options ? options.duplicatePolicy : "JOIN_IDENTICAL";
const [conflict, setConflict] = useState<AppFailure | null>(null);
const scope = "scope" in options ? options.scope : undefined;
const mutation = useMutation<Value, ApplicationQueryError, Input>({
retry: false,
mutationFn: async (input) => {
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_STALE" },
),
);
}
const result = await execute(input);
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_CHANGED" },
),
);
}
if (result.ok) return result.value;
throw new ApplicationQueryError(result.error);
},
@@ -120,55 +238,172 @@ export function useApplicationMutation<Input, Value>(
const submit = useCallback(
(input: Input): Promise<ApplicationResult<Value>> => {
if (inFlight.current) return inFlight.current;
let identity: string;
let identityLease: ReturnType<
NonNullable<typeof scope>["identities"]["intern"]
> | null = null;
try {
if (scope) {
if (!scope.isCurrent()) {
return Promise.resolve({
ok: false,
error: createFailure(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_STALE" },
),
});
}
identityLease = scope.identities.intern(input);
identityLease.acquire();
identity = `${scope.fingerprint}:${definitionId}:${identityLease.token}`;
} else {
identity = `${definitionId}:${runtimeIdentityToken(input)}`;
}
} catch (error) {
return Promise.resolve({
ok: false,
error: normalizeUnknownFailure(error, {
operationId: definitionId,
}),
});
}
const active = mutationExecutions(queryClient).get(identity) as
| Promise<ApplicationResult<Value>>
| undefined;
if (active && duplicatePolicy === "JOIN_IDENTICAL") {
identityLease?.release();
return active;
}
if (active && duplicatePolicy === "REJECT_DUPLICATE") {
identityLease?.release();
return Promise.resolve({
ok: false,
error: createFailure(
"DUPLICATE_IN_FLIGHT",
definitionId,
0,
{ code: "DUPLICATE_IN_FLIGHT" },
),
});
}
setConflict(null);
mutation.reset();
const previous = optimistic
? queryClient.getQueryData(optimistic.queryKey)
: undefined;
if (optimistic) {
queryClient.setQueryData(
optimistic.queryKey,
optimistic.update(previous, input),
);
}
const pending = mutation
.mutateAsync(input)
.then(async (value) => {
for (const queryKey of invalidate) {
await queryClient.invalidateQueries({ queryKey, exact: false });
}
return { ok: true as const, value };
})
.catch((error: unknown) => {
const pending = (async (): Promise<ApplicationResult<Value>> => {
const mutationLease =
invalidate.length === 0
? null
: invalidationCoordinator?.beginMutation(invalidate);
if (invalidate.length > 0 && !mutationLease) {
throw new Error("Query invalidation coordinator is not installed.");
}
try {
let previous: unknown;
let hadPreviousData = false;
let optimisticLayer: OptimisticLayerLease | null = null;
if (optimistic) {
queryClient.setQueryData(optimistic.queryKey, previous);
await queryClient.cancelQueries({
queryKey: optimistic.queryKey,
exact: true,
});
if (scope) {
optimisticLayer = optimisticLayers(queryClient).begin(
optimistic.queryKey,
input,
optimistic.update,
scope,
);
} else {
previous = queryClient.getQueryData(optimistic.queryKey);
hadPreviousData = previous !== undefined;
queryClient.setQueryData(
optimistic.queryKey,
optimistic.update(previous, input),
);
}
}
const failure =
error instanceof ApplicationQueryError
? error.failure
: unexpectedMutationFailure();
let value: Value;
try {
value = await mutation.mutateAsync(input);
} catch (error: unknown) {
if (optimistic) {
if (optimisticLayer) {
optimisticLayer.rollback();
} else if (hadPreviousData) {
queryClient.setQueryData(optimistic.queryKey, previous);
} else {
queryClient.removeQueries({
queryKey: optimistic.queryKey,
exact: true,
});
}
}
const failure =
error instanceof ApplicationQueryError
? error.failure
: normalizeUnknownFailure(error, {
operationId: "APPLICATION_MUTATION",
});
if (failure.kind === "CONFLICT") setConflict(failure);
return { ok: false, error: failure };
}
optimisticLayer?.commit();
try {
await invalidationCoordinator?.invalidate(invalidate);
} catch {
// Cache refresh remains best effort after the server has committed.
}
return { ok: true, value };
} finally {
try {
await mutationLease?.release();
} catch {
// A cache coordination defect cannot change the committed command.
}
}
})()
.catch((error: unknown) => {
const failure = normalizeUnknownFailure(error, {
operationId: "APPLICATION_MUTATION",
});
if (failure.kind === "CONFLICT") setConflict(failure);
return { ok: false as const, error: failure };
})
.finally(() => {
inFlight.current = null;
identityLease?.release();
if (mutationExecutions(queryClient).get(identity) === pending) {
mutationExecutions(queryClient).delete(identity);
}
});
inFlight.current = pending;
if (duplicatePolicy !== "ALLOW_INDEPENDENT") {
mutationExecutions(queryClient).set(identity, pending);
}
return pending;
},
[invalidate, mutation, optimistic, queryClient],
[
invalidate,
invalidationCoordinator,
mutation,
optimistic,
queryClient,
definitionId,
duplicatePolicy,
scope,
],
);
const resolveConflict = useCallback(async () => {
setConflict(null);
mutation.reset();
for (const queryKey of invalidate) {
await queryClient.invalidateQueries({ queryKey, exact: false });
if (invalidate.length > 0 && !invalidationCoordinator) {
throw new Error("Query invalidation coordinator is not installed.");
}
}, [invalidate, mutation, queryClient]);
await invalidationCoordinator?.invalidate(invalidate);
}, [invalidate, invalidationCoordinator, mutation]);
return Object.freeze({
state: deriveAsyncState({
@@ -181,14 +416,65 @@ export function useApplicationMutation<Input, Value>(
});
}
function unexpectedMutationFailure(): ApiFailure {
return {
kind: "UNKNOWN_FAILURE",
code: "UNKNOWN_FAILURE",
retryable: false,
operationId: "APPLICATION_MUTATION",
attemptCount: 1,
userMessageKey: "error.unknown_failure",
action: "contact-support",
};
const RUNTIME_MUTATION_EXECUTIONS = new WeakMap<
object,
Map<string, Promise<ApplicationResult<unknown>>>
>();
function mutationExecutions(
owner: object,
): Map<string, Promise<ApplicationResult<unknown>>> {
const existing = RUNTIME_MUTATION_EXECUTIONS.get(owner);
if (existing) return existing;
const created = new Map<string, Promise<ApplicationResult<unknown>>>();
RUNTIME_MUTATION_EXECUTIONS.set(owner, created);
return created;
}
const OPTIMISTIC_LAYER_RUNTIMES = new WeakMap<
object,
ReturnType<typeof createOptimisticLayerRuntime>
>();
function optimisticLayers(
queryClient: Parameters<typeof createOptimisticLayerRuntime>[0],
): ReturnType<typeof createOptimisticLayerRuntime> {
const existing = OPTIMISTIC_LAYER_RUNTIMES.get(queryClient);
if (existing) return existing;
const created = createOptimisticLayerRuntime(queryClient);
OPTIMISTIC_LAYER_RUNTIMES.set(queryClient, created);
return created;
}
function isAdmissibleResult(
value: unknown,
maxItems: number,
maxBytes: number,
): boolean {
try {
const seen = new WeakSet<object>();
let items = 0;
const visit = (candidate: unknown): boolean => {
if (candidate === null || ["string", "number", "boolean"].includes(typeof candidate)) {
return true;
}
if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return false;
seen.add(candidate);
if (Array.isArray(candidate)) {
items += candidate.length;
return items <= maxItems && candidate.every(visit);
}
const prototype = Object.getPrototypeOf(candidate);
return (
(prototype === Object.prototype || prototype === null) &&
Object.values(candidate).every(visit)
);
};
return (
visit(value) &&
new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes
);
} catch {
return false;
}
}
+5 -1
View File
@@ -2,4 +2,8 @@ export {
useApplicationMutation,
useApplicationQuery,
type ApplicationResult,
} from "./application-query.js";
} from "./application-query.ts";
export {
QueryInvalidationProvider,
useQueryInvalidationCoordinator,
} from "./query-invalidation-provider.tsx";
@@ -0,0 +1,122 @@
import { hashKey, type QueryClient } from "@tanstack/react-query";
import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts";
export type OptimisticLayerLease = Readonly<{
commit(): void;
rollback(): void;
}>;
type Layer = {
id: number;
status: "pending" | "committed";
apply(value: unknown): unknown;
};
type EntryState = {
queryKey: readonly unknown[];
scope: CacheScopeSnapshot;
base: unknown;
layers: Layer[];
};
export function createOptimisticLayerRuntime(queryClient: QueryClient) {
const entries = new Map<string, EntryState>();
let nextId = 1;
let writing = false;
queryClient.getQueryCache().subscribe((event) => {
if (
writing ||
event.type !== "updated" ||
!entries.has(event.query.queryHash)
) {
return;
}
const entry = entries.get(event.query.queryHash);
if (!entry) return;
entry.base = event.query.state.data;
project(event.query.queryHash, entry);
});
function project(key: string, entry: EntryState): void {
if (!entry.scope.isCurrent()) {
entries.delete(key);
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
return;
}
let value = entry.base;
try {
for (const layer of entry.layers) value = layer.apply(value);
} catch {
entries.delete(key);
return;
}
writing = true;
try {
queryClient.setQueryData(entry.queryKey, value);
} finally {
writing = false;
}
}
function collapse(key: string, entry: EntryState): void {
while (entry.layers[0]?.status === "committed") {
const committed = entry.layers.shift();
if (!committed) break;
entry.base = committed.apply(entry.base);
}
project(key, entry);
if (entry.layers.length === 0) entries.delete(key);
}
return Object.freeze({
begin<Input>(
queryKey: readonly unknown[],
input: Input,
update: (previous: unknown, input: Input) => unknown,
scope: CacheScopeSnapshot,
): OptimisticLayerLease | null {
if (!scope.isCurrent()) return null;
const current = queryClient.getQueryData(queryKey);
if (current === undefined) return null;
const key = hashKey(queryKey);
let entry = entries.get(key);
if (!entry) {
entry = { queryKey, scope, base: current, layers: [] };
entries.set(key, entry);
} else if (entry.scope !== scope) {
return null;
}
const layer: Layer = {
id: nextId++,
status: "pending",
apply: (value) => update(value, input),
};
entry.layers.push(layer);
project(key, entry);
let settled = false;
return Object.freeze({
commit() {
if (settled) return;
settled = true;
const selected = entry?.layers.find(
(candidate) => candidate.id === layer.id,
);
if (!entry || !selected) return;
selected.status = "committed";
collapse(key, entry);
},
rollback() {
if (settled) return;
settled = true;
if (!entry) return;
entry.layers = entry.layers.filter(
(candidate) => candidate.id !== layer.id,
);
collapse(key, entry);
},
});
},
});
}
@@ -0,0 +1,30 @@
import {
createContext,
type ReactNode,
useContext,
} from "react";
import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts";
const QueryInvalidationContext =
createContext<QueryInvalidationCoordinator | null>(null);
export function QueryInvalidationProvider({
coordinator,
children,
}: Readonly<{
coordinator: QueryInvalidationCoordinator;
children: ReactNode;
}>) {
return (
<QueryInvalidationContext.Provider value={coordinator}>
{children}
</QueryInvalidationContext.Provider>
);
}
export function useQueryInvalidationCoordinator():
| QueryInvalidationCoordinator
| null {
return useContext(QueryInvalidationContext);
}
@@ -0,0 +1,38 @@
import {
createContext,
type ReactNode,
useContext,
useSyncExternalStore,
} from "react";
import type {
CacheScopeSnapshot,
ServerStateScopeRuntime,
} from "../../../contracts/server-state-scope.ts";
const ServerStateScopeContext =
createContext<ServerStateScopeRuntime | null>(null);
export function ServerStateScopeProvider({
runtime,
children,
}: Readonly<{
runtime: ServerStateScopeRuntime;
children: ReactNode;
}>) {
return (
<ServerStateScopeContext.Provider value={runtime}>
{children}
</ServerStateScopeContext.Provider>
);
}
export function useServerStateScope(): CacheScopeSnapshot {
const runtime = useContext(ServerStateScopeContext);
if (!runtime) throw new Error("ServerStateScopeProvider is required");
return useSyncExternalStore(
runtime.subscribe,
runtime.getSnapshot,
runtime.getSnapshot,
);
}
@@ -1,15 +1,14 @@
import { formatMessage } from "../i18n/index.js";
import { formatMessage } from "../i18n/index.ts";
export type BootErrorShellProps = Readonly<{
kind?: string;
code?: string;
buildId?: string;
configSchemaVersion?: string;
releaseId?: string;
supportReference: string;
}>;
/**
* @param {{
* kind?: string,
* code?: string,
* buildId?: string,
* configSchemaVersion?: string,
* releaseId?: string,
* supportReference: string
* }} props
*/
export function BootErrorShell({
kind = "BOOT_CONFIG_FAILURE",
code = "BOOT_FAILED",
@@ -17,7 +16,7 @@ export function BootErrorShell({
configSchemaVersion,
releaseId,
supportReference,
}) {
}: BootErrorShellProps) {
return (
<main role="alert">
<h1>{formatMessage("ko-KR", "boot.failure.title")}</h1>
@@ -3,7 +3,7 @@ import {
type ErrorInfo,
type ReactNode,
} from "react";
import { useLocale } from "../i18n/index.js";
import { useLocale } from "../i18n/index.ts";
type RecoveryResult =
| Readonly<{ action: "reload-once"; releasePair: string }>
@@ -1,81 +0,0 @@
import { Component } from "react";
import { formatMessage } from "../i18n/index.js";
/**
* @typedef {{
* children: React.ReactNode,
* boundaryName: string,
* routeId: string,
* buildId: string,
* resetKey?: string,
* onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void,
* 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.onRenderFailure?.({
routeId: this.props.routeId,
buildId: this.props.buildId,
boundaryName:
/** @type {"route" | "feature"} */ (this.props.boundaryName),
});
} catch {
// Diagnostics must never recurse into another render failure.
}
}
/** @param {RenderBoundaryProps} previous */
componentDidUpdate(previous) {
if (
this.state.hasError &&
previous.resetKey !== this.props.resetKey
) {
this.setState({ hasError: false });
}
}
reset = () => {
this.setState({ hasError: false });
};
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<section role="alert">
<p>{formatMessage("ko-KR", "error.render_failure")}</p>
<button type="button" onClick={this.reset}>
{formatMessage("ko-KR", "action.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,77 @@
import { Component, type ReactNode } from "react";
import type { RenderFailureReport } from "../../application/ports/in/application-api.ts";
import { formatMessage } from "../i18n/index.ts";
export type RenderBoundaryProps = Readonly<{
children: ReactNode;
boundaryName: RenderFailureReport["boundaryName"];
routeId: string;
buildId: string;
resetKey?: string;
onRenderFailure?: (report: RenderFailureReport) => void;
fallback?: ReactNode;
}>;
type RenderBoundaryState = Readonly<{ hasError: boolean }>;
export class RenderErrorBoundary extends Component<
RenderBoundaryProps,
RenderBoundaryState
> {
state: RenderBoundaryState = { hasError: false };
static getDerivedStateFromError(): RenderBoundaryState {
return { hasError: true };
}
componentDidCatch(): void {
try {
this.props.onRenderFailure?.({
routeId: this.props.routeId,
buildId: this.props.buildId,
boundaryName: this.props.boundaryName,
});
} catch {
// Diagnostics must never recurse into another render failure.
}
}
componentDidUpdate(previous: Readonly<RenderBoundaryProps>): void {
if (this.state.hasError && previous.resetKey !== this.props.resetKey) {
this.setState({ hasError: false });
}
}
reset = (): void => {
this.setState({ hasError: false });
};
render(): ReactNode {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<section role="alert">
<p>{formatMessage("ko-KR", "error.render_failure")}</p>
<button type="button" onClick={this.reset}>
{formatMessage("ko-KR", "action.retry")}
</button>
</section>
)
);
}
return this.props.children;
}
}
export function RouteBoundary(
props: Omit<RenderBoundaryProps, "boundaryName">,
) {
return <RenderErrorBoundary {...props} boundaryName="route" />;
}
export function FeatureBoundary(
props: Omit<RenderBoundaryProps, "boundaryName">,
) {
return <RenderErrorBoundary {...props} boundaryName="feature" />;
}
@@ -1,11 +1,12 @@
import { useId } from "react";
import { useId, type ReactNode } from "react";
import { errorMessage } from "./error-copy.js";
import { Button } from "./ui/button.jsx";
import { useLocale } from "../i18n/index.js";
import type { AsyncState } from "../../application/view-models/async-state.ts";
import type { AppFailure } from "../../contracts/errors.ts";
import { useLocale } from "../i18n/index.ts";
import { errorMessage } from "./error-copy.ts";
import { Button } from "./ui/button.ts";
/** @param {{ label?: string }} props */
export function LoadingSurface({ label }) {
export function LoadingSurface({ label }: Readonly<{ label?: string }>) {
const { message } = useLocale();
const accessibleLabel = label ?? message("async.loading");
return (
@@ -22,18 +23,17 @@ export function LoadingSurface({ label }) {
);
}
/**
* @param {{
* title?: string,
* description?: string,
* action?: React.ReactNode
* }} props
*/
export type EmptySurfaceProps = Readonly<{
title?: string;
description?: string;
action?: ReactNode;
}>;
export function EmptySurface({
title,
description,
action,
}) {
}: EmptySurfaceProps) {
const { message } = useLocale();
return (
<section className="ui-empty state-surface" aria-live="polite">
@@ -44,18 +44,22 @@ export function EmptySurface({
);
}
/**
* @param {{
* userMessageKey: string,
* action: "retry" | "reauth" | "navigate" | "reload-once" |
* "contact-support" | "none",
* onAction?: () => void
* }} props
*/
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
export type TerminalErrorSurfaceProps = Readonly<{
userMessageKey: string;
action: AppFailure["action"];
onAction?: () => void;
}>;
export function TerminalErrorSurface({
userMessageKey,
action,
onAction,
}: TerminalErrorSurfaceProps) {
const { locale, message } = useLocale();
const messageId = useId();
const actionLabels = Object.freeze({
const actionLabels: Readonly<
Record<Exclude<AppFailure["action"], "none">, string>
> = Object.freeze({
retry: message("action.retry"),
reauth: message("action.reauth"),
navigate: message("action.navigateSafe"),
@@ -77,22 +81,21 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
);
}
/**
* @param {{
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
* children?: React.ReactNode,
* onAction?: () => void,
* onRetry?: () => void,
* onResolveConflict?: () => void
* }} props
*/
export type AsyncSurfaceProps = Readonly<{
state: AsyncState;
children?: ReactNode;
onAction?: () => void;
onRetry?: () => void;
onResolveConflict?: () => void;
}>;
export function AsyncSurface({
state,
children,
onAction,
onRetry,
onResolveConflict,
}) {
}: AsyncSurfaceProps) {
const { message } = useLocale();
if (state.base === "initial-loading") return <LoadingSurface />;
if (state.base === "empty") return <EmptySurface />;
@@ -101,7 +104,11 @@ export function AsyncSurface({
<TerminalErrorSurface
userMessageKey={state.failure.userMessageKey}
action={state.failure.action}
onAction={onRetry ?? onAction}
onAction={
state.failure.action === "retry"
? onRetry ?? onAction
: onAction
}
/>
);
}
@@ -1,6 +0,0 @@
import { resolveMessage } from "../i18n/index.js";
/** @param {string} messageKey @param {string} [locale] */
export function errorMessage(messageKey, locale = "ko-KR") {
return resolveMessage(locale, messageKey);
}
@@ -0,0 +1,8 @@
import { resolveMessage } from "../i18n/index.ts";
export function errorMessage(
messageKey: string,
locale: string = "ko-KR",
): string {
return resolveMessage(locale, messageKey);
}
@@ -1,26 +0,0 @@
import { useEffect, useRef } from "react";
/**
* @param {{
* title: string,
* description?: string,
* eyebrow?: string
* }} props
*/
export function PageHeader({ title, description, eyebrow }) {
const headingRef = useRef(/** @type {HTMLHeadingElement | null} */ (null));
useEffect(() => {
headingRef.current?.focus();
}, [title]);
return (
<header className="page-header">
{eyebrow ? <p className="page-header__eyebrow">{eyebrow}</p> : null}
<h1 ref={headingRef} tabIndex={-1} data-route-heading>
{title}
</h1>
{description ? <p className="page-header__description">{description}</p> : null}
</header>
);
}
@@ -0,0 +1,40 @@
import { useEffect, useRef } from "react";
export type PageHeaderProps = Readonly<{
title: string;
description?: string;
eyebrow?: string;
}>;
export function PageHeader({
title,
description,
eyebrow,
}: PageHeaderProps) {
const headingRef = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
const activeElement = document.activeElement;
const main = document.getElementById("main-content");
const routeOwnsFocus =
activeElement === null ||
activeElement === document.body ||
activeElement === document.documentElement ||
activeElement === main;
if (routeOwnsFocus) {
headingRef.current?.focus();
}
}, [title]);
return (
<header className="page-header">
{eyebrow ? <p className="page-header__eyebrow">{eyebrow}</p> : null}
<h1 ref={headingRef} tabIndex={-1} data-route-heading>
{title}
</h1>
{description ? (
<p className="page-header__description">{description}</p>
) : null}
</header>
);
}
@@ -1,16 +1,15 @@
import { Button } from "./ui/button.jsx";
import { useLocale } from "../i18n/index.js";
import { useLocale } from "../i18n/index.ts";
import { Button } from "./ui/button.ts";
type StateSurfaceProps = Readonly<{
eyebrow: string;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
tone?: "neutral" | "danger" | "warning";
}>;
/**
* @param {{
* eyebrow: string,
* title: string,
* description: string,
* actionLabel?: string,
* onAction?: () => void,
* tone?: "neutral" | "danger" | "warning"
* }} props
*/
function StateSurface({
eyebrow,
title,
@@ -18,7 +17,7 @@ function StateSurface({
actionLabel,
onAction,
tone = "neutral",
}) {
}: StateSurfaceProps) {
return (
<section className={`state-surface state-surface--${tone}`}>
<p className="state-surface__eyebrow">{eyebrow}</p>
@@ -29,8 +28,9 @@ function StateSurface({
);
}
/** @param {{ onSignIn?: () => void }} props */
export function AuthRequiredSurface({ onSignIn }) {
export function AuthRequiredSurface({
onSignIn,
}: Readonly<{ onSignIn?: () => void }>) {
const { message } = useLocale();
return (
<StateSurface
@@ -43,8 +43,9 @@ export function AuthRequiredSurface({ onSignIn }) {
);
}
/** @param {{ onNavigate?: () => void }} props */
export function ForbiddenSurface({ onNavigate }) {
export function ForbiddenSurface({
onNavigate,
}: Readonly<{ onNavigate?: () => void }>) {
const { message } = useLocale();
return (
<StateSurface
@@ -58,8 +59,9 @@ export function ForbiddenSurface({ onNavigate }) {
);
}
/** @param {{ onNavigate?: () => void }} props */
export function NotFoundSurface({ onNavigate }) {
export function NotFoundSurface({
onNavigate,
}: Readonly<{ onNavigate?: () => void }>) {
const { message } = useLocale();
return (
<StateSurface
-1
View File
@@ -1 +0,0 @@
export { Alert } from "../../design-system/primitives/core.js";
+1
View File
@@ -0,0 +1 @@
export { Alert, type AlertProps } from "../../design-system/primitives/core.tsx";
-1
View File
@@ -1 +0,0 @@
export { Badge } from "../../design-system/primitives/core.js";
+1
View File
@@ -0,0 +1 @@
export { Badge, type BadgeProps } from "../../design-system/primitives/core.tsx";
@@ -1 +0,0 @@
export { Button } from "../../design-system/primitives/core.js";
+6
View File
@@ -0,0 +1,6 @@
export {
Button,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
} from "../../design-system/primitives/core.tsx";
-1
View File
@@ -1 +0,0 @@
export { Card } from "../../design-system/primitives/core.js";
+1
View File
@@ -0,0 +1 @@
export { Card, type CardProps } from "../../design-system/primitives/core.tsx";
@@ -1 +0,0 @@
export { Dialog } from "../../design-system/primitives/core.js";
+1
View File
@@ -0,0 +1 @@
export { Dialog, type DialogProps } from "../../design-system/primitives/core.tsx";
@@ -1 +0,0 @@
export { TextField } from "../../design-system/primitives/core.js";
@@ -0,0 +1,4 @@
export {
TextField,
type TextFieldProps,
} from "../../design-system/primitives/core.tsx";
@@ -14,7 +14,7 @@ import {
Tabs,
TextArea,
TextField,
} from "./index.js";
} from "./index.ts";
const meta = {
title: "Platform/Design System",
@@ -10,7 +10,7 @@ import {
SearchGlyph,
SuccessGlyph,
WarningGlyph,
} from "./vendors/lucide.js";
} from "./vendors/lucide.tsx";
export type SemanticIconProps = Readonly<{
label?: string;
+22 -22
View File
@@ -12,7 +12,7 @@ export {
Spinner,
TextField,
VisuallyHidden,
} from "./primitives/core.js";
} from "./primitives/core.tsx";
export type {
AlertProps,
BadgeProps,
@@ -25,7 +25,7 @@ export type {
LinkButtonProps,
SpinnerProps,
TextFieldProps,
} from "./primitives/core.js";
} from "./primitives/core.tsx";
export {
Checkbox,
RadioGroup,
@@ -33,7 +33,7 @@ export {
Select,
Switch,
TextArea,
} from "./primitives/forms.js";
} from "./primitives/forms.tsx";
export type {
CheckboxProps,
RadioGroupProps,
@@ -43,16 +43,16 @@ export type {
SelectProps,
SwitchProps,
TextAreaProps,
} from "./primitives/forms.js";
} from "./primitives/forms.tsx";
export {
ProgressBar,
Separator,
Skeleton,
} from "./primitives/feedback.js";
} from "./primitives/feedback.tsx";
export type {
ProgressBarProps,
SkeletonProps,
} from "./primitives/feedback.js";
} from "./primitives/feedback.tsx";
export {
ConfirmationDialog,
Drawer,
@@ -61,23 +61,23 @@ export {
ToastProvider,
Tooltip,
useToast,
} from "./primitives/overlays.js";
} from "./primitives/overlays.tsx";
export type {
DrawerProps,
MenuItemDefinition,
MenuProps,
PopoverProps,
TooltipProps,
} from "./primitives/overlays.js";
} from "./primitives/overlays.tsx";
export {
Breadcrumbs,
Pagination,
Tabs,
} from "./primitives/navigation.js";
} from "./primitives/navigation.tsx";
export type {
BreadcrumbItem,
TabDefinition,
} from "./primitives/navigation.js";
} from "./primitives/navigation.tsx";
export {
CloseIcon,
ErrorIcon,
@@ -88,48 +88,48 @@ export {
SearchIcon,
SuccessIcon,
WarningIcon,
} from "./icons/semantic-icons.js";
} from "./icons/semantic-icons.tsx";
export type {
SemanticIconProps,
} from "./icons/semantic-icons.js";
} from "./icons/semantic-icons.tsx";
export {
DESIGN_TOKEN_CONTRACT,
REQUIRED_COMPONENT_TOKENS,
REQUIRED_PRIMITIVE_TOKENS,
REQUIRED_SEMANTIC_TOKENS,
} from "./tokens/token-contract.js";
} from "./tokens/token-contract.ts";
export {
AccessSurface,
DataTable,
DisclosureGroup,
PaginationBar,
SearchFilterToolbar,
} from "./patterns/common-patterns.js";
} from "./patterns/common-patterns.tsx";
export type {
AccessSurfaceProps,
DataTableColumn,
DisclosureDefinition,
} from "./patterns/common-patterns.js";
} from "./patterns/common-patterns.tsx";
export {
AsyncSurface,
EmptySurface,
LoadingSurface,
TerminalErrorSurface,
} from "../components/async-surface.jsx";
export { PageHeader } from "../components/page-header.jsx";
} from "../components/async-surface.tsx";
export { PageHeader } from "../components/page-header.tsx";
export {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../components/state-surfaces.jsx";
} from "../components/state-surfaces.tsx";
export {
DetailPage,
CollectionPage,
FormPage,
StandardPage,
StatusPage,
} from "../templates/index.js";
} from "../templates/index.ts";
export {
DirtyNavigationDialog,
ErrorSummary,
@@ -138,9 +138,9 @@ export {
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../forms/index.js";
} from "../forms/index.ts";
export type {
PageActionDefinition,
PageHeading,
} from "../templates/page-templates.js";
export type { FormResult } from "../forms/form-contracts.js";
} from "../templates/page-templates.tsx";
export type { FormResult } from "../forms/form-contracts.ts";
@@ -4,9 +4,9 @@ import {
AuthRequiredSurface,
ForbiddenSurface,
NotFoundSurface,
} from "../../components/state-surfaces.jsx";
import { Button } from "../primitives/core.js";
import { Pagination } from "../primitives/navigation.js";
} from "../../components/state-surfaces.tsx";
import { Button } from "../primitives/core.tsx";
import { Pagination } from "../primitives/navigation.tsx";
export type DataTableColumn<Row> = Readonly<{
id: string;
@@ -7,8 +7,8 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { CloseIcon } from "../icons/semantic-icons.js";
import { useLocale } from "../../i18n/index.js";
import { CloseIcon } from "../icons/semantic-icons.tsx";
import { useLocale } from "../../i18n/index.ts";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "default" | "compact";
@@ -322,6 +322,7 @@ export type DialogProps = Readonly<{
children?: React.ReactNode;
actions?: React.ReactNode;
className?: string;
returnFocusRef?: React.RefObject<HTMLElement | null>;
}>;
export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
@@ -335,6 +336,7 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
children,
actions,
className = "",
returnFocusRef,
},
forwardedRef,
) {
@@ -354,9 +356,10 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
if (open) {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
returnFocusRef?.current ??
(document.activeElement instanceof HTMLElement
? document.activeElement
: null;
: null);
if (!dialog.open) {
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
@@ -382,12 +385,11 @@ export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
previousFocus.focus();
}
};
if (typeof globalThis.requestAnimationFrame === "function") {
const frame = globalThis.requestAnimationFrame(restoreFocus);
return () => globalThis.cancelAnimationFrame(frame);
}
queueMicrotask(restoreFocus);
}, [open]);
const timer = globalThis.setTimeout(restoreFocus, 0);
return () => {
globalThis.clearTimeout(timer);
};
}, [open, returnFocusRef]);
return (
<dialog
@@ -6,10 +6,10 @@ import {
useState,
} from "react";
import { CloseIcon, SearchIcon } from "../icons/semantic-icons.js";
import { Field, IconButton } from "./core.js";
import type { TextFieldProps } from "./core.js";
import { useLocale } from "../../i18n/index.js";
import { CloseIcon, SearchIcon } from "../icons/semantic-icons.tsx";
import { Field, IconButton } from "./core.tsx";
import type { TextFieldProps } from "./core.tsx";
import { useLocale } from "../../i18n/index.ts";
type FieldCopy = Readonly<{
id?: string;
@@ -1,8 +1,8 @@
import { useId, useRef, useState } from "react";
import { useLocale } from "../../i18n/index.js";
import { NextIcon, PreviousIcon } from "../icons/semantic-icons.js";
import { IconButton, LinkButton } from "./core.js";
import { useLocale } from "../../i18n/index.ts";
import { NextIcon, PreviousIcon } from "../icons/semantic-icons.tsx";
import { IconButton, LinkButton } from "./core.tsx";
export type BreadcrumbItem = Readonly<{
label: string;
@@ -9,9 +9,9 @@ import {
useState,
} from "react";
import { CloseIcon } from "../icons/semantic-icons.js";
import { Button, Dialog, IconButton } from "./core.js";
import { useLocale } from "../../i18n/index.js";
import { CloseIcon } from "../icons/semantic-icons.tsx";
import { Button, Dialog, IconButton } from "./core.tsx";
import { useLocale } from "../../i18n/index.ts";
export type DrawerProps = Readonly<{
open: boolean;
@@ -19,6 +19,7 @@ export type DrawerProps = Readonly<{
title: string;
closeLabel?: string;
placement?: "start" | "end";
returnFocusRef?: React.RefObject<HTMLElement | null>;
children: React.ReactNode;
}>;
@@ -28,6 +29,7 @@ export function Drawer({
title,
closeLabel,
placement = "start",
returnFocusRef,
children,
}: DrawerProps) {
return (
@@ -36,6 +38,7 @@ export function Drawer({
closeLabel={closeLabel}
onClose={onClose}
open={open}
returnFocusRef={returnFocusRef}
title={title}
>
{children}
@@ -1,8 +1,8 @@
import { useState } from "react";
import { useLocation } from "react-router-dom";
import { PageHeader } from "../design-system/index.js";
import { useSession } from "../providers/session-provider.jsx";
import { PageHeader } from "../design-system/index.ts";
import { useSession } from "../providers/session-provider.tsx";
export default function AuthExamplePage() {
const location = useLocation();
@@ -10,8 +10,7 @@ export default function AuthExamplePage() {
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
/** @param {() => Promise<unknown>} action */
async function execute(action) {
async function execute(action: () => Promise<unknown>): Promise<void> {
setPending(true);
setFailed(false);
try {
@@ -1,7 +1,7 @@
import { useState } from "react";
import { deriveAsyncState } from "../../application/view-models/async-state.js";
import { createFailure } from "../../contracts/errors.js";
import { deriveAsyncState } from "../../application/view-models/async-state.ts";
import { createFailure } from "../../contracts/errors.ts";
import {
AsyncSurface,
EmptySurface,
@@ -13,7 +13,7 @@ import {
Button,
Card,
PageHeader,
} from "../design-system/index.js";
} from "../design-system/index.ts";
export default function StateGalleryPage() {
const [lastAction, setLastAction] = useState(
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type FormEvent } from "react";
import {
Alert,
@@ -19,7 +19,7 @@ import {
ToastProvider,
Tooltip,
useToast,
} from "../design-system/index.js";
} from "../design-system/index.ts";
const COLOR_TOKENS = Object.freeze([
["Surface", "--color-surface"],
@@ -58,8 +58,7 @@ function UiGalleryContent() {
? "프로젝트 이름을 입력해 주세요."
: undefined;
/** @param {React.FormEvent<HTMLFormElement>} event */
function submitExample(event) {
function submitExample(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
setFieldTouched(true);
if (projectName.trim().length === 0) {
+3 -3
View File
@@ -1,12 +1,12 @@
import { useId, type FormHTMLAttributes, type ReactNode } from "react";
import { TextField } from "../components/ui/text-field.jsx";
import { useLocale } from "../i18n/index.js";
import { TextField } from "../components/ui/text-field.ts";
import { useLocale } from "../i18n/index.ts";
import type {
FieldErrors,
FieldName,
FormValues,
} from "./form-contracts.js";
} from "./form-contracts.ts";
export function Form(
props: FormHTMLAttributes<HTMLFormElement> & Readonly<{ pending?: boolean }>,
+5 -6
View File
@@ -1,8 +1,9 @@
import type { ApiFailure } from "../../contracts/errors.js";
import type { Result } from "../../application/result.ts";
import type { AppFailure } from "../../contracts/errors.ts";
import {
formatMessage,
type ParameterlessMessageKey,
} from "../i18n/index.js";
} from "../i18n/index.ts";
export type FormValues = Readonly<Record<string, unknown>>;
export type FieldName<Values extends FormValues> = Extract<keyof Values, string>;
@@ -10,9 +11,7 @@ export type FieldErrors<Values extends FormValues> = Readonly<
Partial<Record<FieldName<Values>, string>>
>;
export type FormResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
export type FormResult<Value> = Result<Value>;
export type FormResultState =
| "idle"
@@ -50,7 +49,7 @@ export function validationMessage(
}
export function mapValidationFailureToFields<Values extends FormValues>(
failure: ApiFailure,
failure: AppFailure,
allowedFields: readonly FieldName<Values>[],
message: MessageResolver = defaultMessage,
): MappedValidationFailure<Values> {
+4 -4
View File
@@ -1,4 +1,4 @@
export * from "./form-components.js";
export * from "./form-contracts.js";
export * from "./use-app-form.js";
export * from "./use-dirty-navigation-guard.js";
export * from "./form-components.tsx";
export * from "./form-contracts.ts";
export * from "./use-app-form.ts";
export * from "./use-dirty-navigation-guard.tsx";
+2 -2
View File
@@ -11,7 +11,7 @@ import type { ZodType, ZodIssue } from "zod";
import {
useLocale,
type ParameterlessMessageKey,
} from "../i18n/index.js";
} from "../i18n/index.ts";
import {
mapValidationFailureToFields,
@@ -21,7 +21,7 @@ import {
type FormResult,
type FormResultState,
type FormValues,
} from "./form-contracts.js";
} from "./form-contracts.ts";
type AppFormOptions<
Values extends FormValues,
@@ -1,9 +1,9 @@
import { useCallback } from "react";
import { useBeforeUnload, useBlocker } from "react-router-dom";
import { Button } from "../components/ui/button.jsx";
import { Dialog } from "../components/ui/dialog.jsx";
import { useLocale } from "../i18n/index.js";
import { Button } from "../components/ui/button.ts";
import { Dialog } from "../components/ui/dialog.ts";
import { useLocale } from "../i18n/index.ts";
export function useDirtyNavigationGuard(when: boolean) {
const blocker = useBlocker(when);
+1 -1
View File
@@ -1,4 +1,4 @@
import { INSTALLED_MESSAGE_CATALOGS } from "../../features/installed-feature-messages.js";
import { INSTALLED_MESSAGE_CATALOGS } from "../../features/installed-feature-messages.ts";
const PLATFORM_KO_MESSAGES = {
"common.unavailable": "요청한 문구를 표시할 수 없습니다.",
+1 -1
View File
@@ -1,4 +1,4 @@
import { normalizeLocale, type SupportedLocale } from "./message-contract.js";
import { normalizeLocale, type SupportedLocale } from "./message-contract.ts";
const FORMAT_FALLBACK = "—";
+5 -5
View File
@@ -1,4 +1,4 @@
export { LocaleProvider, useLocale } from "./locale-provider.js";
export { LocaleProvider, useLocale } from "./locale-provider.tsx";
export {
catalogKeys,
fallbackMessage,
@@ -9,15 +9,15 @@ export {
normalizeLocale,
resolveMessage,
SUPPORTED_LOCALES,
} from "./message-contract.js";
} from "./message-contract.ts";
export type {
MessageArguments,
MessageParameters,
ParameterlessMessageKey,
SupportedLocale,
TextDirection,
} from "./message-contract.js";
export type { MessageKey } from "./catalog.js";
} from "./message-contract.ts";
export type { MessageKey } from "./catalog.ts";
export {
FORMAT_FALLBACK,
formatDate,
@@ -26,4 +26,4 @@ export {
formatRelativeTime,
selectMessage,
selectPlural,
} from "./formatters.js";
} from "./formatters.ts";
+3 -3
View File
@@ -13,7 +13,7 @@ import {
formatRelativeTime,
selectMessage,
selectPlural,
} from "./formatters.js";
} from "./formatters.ts";
import {
formatMessage,
localeDirection,
@@ -22,8 +22,8 @@ import {
type MessageArguments,
type SupportedLocale,
type TextDirection,
} from "./message-contract.js";
import type { MessageKey } from "./catalog.js";
} from "./message-contract.ts";
import type { MessageKey } from "./catalog.ts";
type LocaleContextValue = Readonly<{
locale: SupportedLocale;
+1 -1
View File
@@ -3,7 +3,7 @@ import {
KO_MESSAGES,
MESSAGE_CATALOGS,
type MessageKey,
} from "./catalog.js";
} from "./catalog.ts";
export const SUPPORTED_LOCALES = Object.freeze([
"ko-KR",
@@ -1,27 +1,33 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import type { SessionState } from "../../application/ports/in/application-api.ts";
import { normalizeColorSchemePreference } from "../../application/policies/color-scheme.ts";
import {
NAVIGATION_ROUTES,
routePath,
} from "../../features/installed-feature-contracts.js";
} from "../../features/installed-feature-contracts.ts";
import {
Button,
Drawer,
IconButton,
MenuIcon,
Select,
} from "../design-system/index.js";
import { useLocale } from "../i18n/index.js";
import { useSession } from "../providers/session-provider.jsx";
import { useTheme } from "../providers/theme-provider.jsx";
} from "../design-system/index.ts";
import {
normalizeLocale,
useLocale,
type MessageKey,
} from "../i18n/index.ts";
import { useSession } from "../providers/session-provider.tsx";
import { useTheme } from "../providers/theme-provider.tsx";
const SESSION_MESSAGE_KEYS = Object.freeze({
authenticated: "shell.session.authenticated",
unauthenticated: "shell.session.unauthenticated",
"recovery-pending": "shell.session.recoveryPending",
"integration-failed": "shell.session.integrationFailed",
});
} satisfies Readonly<Record<SessionState, MessageKey>>);
export function AppShell() {
const location = useLocation();
@@ -29,6 +35,7 @@ export function AppShell() {
const { preference, setPreference } = useTheme();
const { locale, setLocale, message } = useLocale();
const [navigationOpen, setNavigationOpen] = useState(false);
const navigationTriggerRef = useRef<HTMLButtonElement | null>(null);
const [sessionActionPending, setSessionActionPending] = useState(false);
const [sessionActionFailed, setSessionActionFailed] = useState(false);
@@ -76,6 +83,7 @@ export function AppShell() {
aria-controls="mobile-primary-navigation"
aria-expanded={navigationOpen}
onClick={() => setNavigationOpen((open) => !open)}
ref={navigationTriggerRef}
variant="secondary"
>
<MenuIcon />
@@ -95,11 +103,7 @@ export function AppShell() {
]}
value={preference}
onChange={(event) =>
setPreference(
/** @type {"system" | "light" | "dark"} */ (
event.currentTarget.value
),
)
setPreference(normalizeColorSchemePreference(event.currentTarget.value))
}
/>
<Select
@@ -114,11 +118,7 @@ export function AppShell() {
]}
value={locale}
onChange={(event) =>
setLocale(
/** @type {import("../i18n/index.js").SupportedLocale} */ (
event.currentTarget.value
),
)
setLocale(normalizeLocale(event.currentTarget.value))
}
/>
<span className="session-status" data-state={sessionState}>
@@ -152,6 +152,7 @@ export function AppShell() {
closeLabel={message("shell.closeMenu")}
onClose={() => setNavigationOpen(false)}
open={navigationOpen}
returnFocusRef={navigationTriggerRef}
title={message("shell.menu")}
>
<PrimaryNavigation id="mobile-primary-navigation" />
@@ -163,10 +164,7 @@ export function AppShell() {
);
}
/**
* @param {{ id: string }} props
*/
function PrimaryNavigation({ id }) {
function PrimaryNavigation({ id }: Readonly<{ id: string }>) {
const { resolve, message } = useLocale();
return (
<nav id={id} aria-label={message("shell.primaryNavigation")}>
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../design-system/index.js";
import { useApplication } from "../providers/application-provider.js";
import { routePath } from "../../features/installed-feature-contracts.ts";
import { PageHeader } from "../design-system/index.ts";
import { useApplication } from "../providers/application-provider.tsx";
const READINESS_ITEMS = Object.freeze([
{
@@ -22,11 +22,9 @@ const READINESS_ITEMS = Object.freeze([
export default function HomePage() {
const { runtime } = useApplication();
const [release, setRelease] = useState(
/** @type {Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null} */ (
null
),
);
const [release, setRelease] = useState<
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
>(null);
useEffect(() => {
let active = true;
@@ -1,8 +1,8 @@
import { Link } from "react-router-dom";
import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../design-system/index.js";
import { useLocale } from "../i18n/index.js";
import { routePath } from "../../features/installed-feature-contracts.ts";
import { PageHeader } from "../design-system/index.ts";
import { useLocale } from "../i18n/index.ts";
export default function NotFoundPage() {
const { message } = useLocale();
@@ -4,7 +4,7 @@ import {
useContext,
} from "react";
import type { ApplicationApi } from "../../application/create-application.js";
import type { ApplicationApi } from "../../application/create-application.ts";
const ApplicationContext = createContext<ApplicationApi | null>(null);
@@ -1,49 +0,0 @@
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { useApplication } from "./application-provider.js";
/**
* @typedef {{
* sessionState: import("../../application/ports/in/application-api.js").SessionState,
* beginSignIn: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["beginSignIn"],
* signOut: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["signOut"],
* recover: import("../../application/ports/in/application-api.js").ApplicationApi["session"]["recover"]
* }} SessionContextValue
*/
const SessionContext = createContext(
/** @type {SessionContextValue | null} */ (null),
);
/**
* @param {{ children: React.ReactNode }} props
*/
export function SessionProvider({ children }) {
const { session } = useApplication();
const sessionState = useSyncExternalStore(
session.subscribe,
session.getSnapshot,
session.getSnapshot,
);
const value = useMemo(
() =>
Object.freeze({
sessionState,
beginSignIn: session.beginSignIn,
signOut: session.signOut,
recover: session.recover,
}),
[session, sessionState],
);
return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
);
}
export function useSession() {
const session = useContext(SessionContext);
if (!session) {
throw new Error("SessionProvider is required");
}
return session;
}
@@ -0,0 +1,55 @@
import {
createContext,
useContext,
useMemo,
useSyncExternalStore,
type ReactNode,
} from "react";
import type {
ApplicationApi,
SessionState,
} from "../../application/ports/in/application-api.ts";
import { useApplication } from "./application-provider.tsx";
export type SessionContextValue = Readonly<{
sessionState: SessionState;
beginSignIn: ApplicationApi["session"]["beginSignIn"];
signOut: ApplicationApi["session"]["signOut"];
recover: ApplicationApi["session"]["recover"];
}>;
const SessionContext = createContext<SessionContextValue | null>(null);
export function SessionProvider({
children,
}: Readonly<{ children: ReactNode }>) {
const { session } = useApplication();
const sessionState = useSyncExternalStore(
session.subscribe,
session.getSnapshot,
session.getSnapshot,
);
const value = useMemo<SessionContextValue>(
() =>
Object.freeze({
sessionState,
beginSignIn: session.beginSignIn,
signOut: session.signOut,
recover: session.recover,
}),
[session, sessionState],
);
return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
);
}
export function useSession(): SessionContextValue {
const session = useContext(SessionContext);
if (!session) {
throw new Error("SessionProvider is required");
}
return session;
}
@@ -5,37 +5,36 @@ import {
useLayoutEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { ColorSchemePreference } from "../../application/ports/in/application-api.ts";
import {
normalizeColorSchemePreference,
resolveColorScheme,
} from "../../application/policies/color-scheme.js";
import { useApplication } from "./application-provider.js";
} from "../../application/policies/color-scheme.ts";
import { useApplication } from "./application-provider.tsx";
/**
* @typedef {{
* preference: "system" | "light" | "dark",
* resolvedTheme: "light" | "dark",
* setPreference: (preference: "system" | "light" | "dark") => void
* }} ThemeContextValue
*/
export type ThemeContextValue = Readonly<{
preference: ColorSchemePreference;
resolvedTheme: "light" | "dark";
setPreference(preference: ColorSchemePreference): void;
}>;
const ThemeContext = createContext(/** @type {ThemeContextValue | null} */ (null));
const ThemeContext = createContext<ThemeContextValue | null>(null);
function systemPrefersDark() {
function systemPrefersDark(): boolean {
return (
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
/**
* @param {{ children: React.ReactNode }} props
*/
export function ThemeProvider({ children }) {
export function ThemeProvider({
children,
}: Readonly<{ children: ReactNode }>) {
const { preferences } = useApplication();
const [preference, updatePreference] = useState(
const [preference, updatePreference] = useState<ColorSchemePreference>(
preferences.getColorScheme,
);
const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark);
@@ -44,8 +43,8 @@ export function ThemeProvider({ children }) {
useEffect(() => {
if (typeof window.matchMedia !== "function") return undefined;
const query = window.matchMedia("(prefers-color-scheme: dark)");
/** @param {MediaQueryListEvent} event */
const update = (event) => setDarkSystemTheme(event.matches);
const update = (event: MediaQueryListEvent) =>
setDarkSystemTheme(event.matches);
setDarkSystemTheme(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
@@ -57,13 +56,12 @@ export function ThemeProvider({ children }) {
document.documentElement.style.colorScheme = resolvedTheme;
}, [preference, resolvedTheme]);
const value = useMemo(
const value = useMemo<ThemeContextValue>(
() =>
Object.freeze({
preference,
resolvedTheme,
/** @param {"system" | "light" | "dark"} next */
setPreference(next) {
setPreference(next: ColorSchemePreference) {
const normalized = normalizeColorSchemePreference(next);
updatePreference(normalized);
preferences.setColorScheme(normalized);
@@ -75,7 +73,7 @@ export function ThemeProvider({ children }) {
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
export function useTheme(): ThemeContextValue {
const theme = useContext(ThemeContext);
if (!theme) throw new Error("ThemeProvider is required");
return theme;
+17 -26
View File
@@ -1,8 +1,6 @@
import {
createContext,
type ReactNode,
Suspense,
useContext,
useEffect,
useMemo,
useRef,
@@ -21,38 +19,31 @@ import {
import {
getRoute,
ROUTE_REGISTRY,
} from "../../features/installed-feature-contracts.js";
import type { RouteDefinition } from "../../contracts/routes.js";
} from "../../features/installed-feature-contracts.ts";
import type { RouteDefinition } from "../../contracts/routes.ts";
import {
FeatureBoundary,
RouteBoundary,
} from "../boundaries/render-error-boundary.jsx";
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.js";
import { Button, PageHeader } from "../design-system/index.js";
import { LocaleProvider, useLocale } from "../i18n/index.js";
import { AppShell } from "../layouts/app-shell.jsx";
import { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
import { ThemeProvider } from "../providers/theme-provider.jsx";
} from "../boundaries/render-error-boundary.tsx";
import { ChunkRecoveryBoundary } from "../boundaries/chunk-recovery-boundary.tsx";
import { Button, PageHeader } from "../design-system/index.ts";
import { LocaleProvider, useLocale } from "../i18n/index.ts";
import { AppShell } from "../layouts/app-shell.tsx";
import { useApplication } from "../providers/application-provider.tsx";
import { SessionProvider, useSession } from "../providers/session-provider.tsx";
import { ThemeProvider } from "../providers/theme-provider.tsx";
import {
createRedirectLoopGuard,
decideRouteAccess,
} from "./navigation-policy.js";
} from "./navigation-policy.ts";
import {
buildRouteUrl,
parseRouteInput,
type ParsedRouteInput,
type RouteId,
} from "./route-codecs.js";
import { ROUTE_RUNTIME } from "../../features/installed-feature-runtimes.js";
const RouteInputContext = createContext<ParsedRouteInput | null>(null);
export function useRouteInput(): ParsedRouteInput {
const input = useContext(RouteInputContext);
if (!input) throw new Error("Registered route input is required");
return input;
}
} from "./route-codecs.ts";
import { ROUTE_RUNTIME } from "../../features/installed-feature-runtimes.tsx";
import type { ParsedRouteInput } from "./route-contract.ts";
import { RouteInputProvider } from "./route-input.tsx";
function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) {
const { message, resolve } = useLocale();
@@ -261,7 +252,7 @@ function RegisteredRoute({
if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />;
const content = (
<RouteInputContext.Provider value={parsed.data}>
<RouteInputProvider input={parsed.data}>
<CanonicalRouteRedirect input={parsed.data} />
<RouteLifecycle definition={definition} buildId={buildId} />
<Suspense fallback={<RouteLoadingSurface definition={definition} />}>
@@ -272,7 +263,7 @@ function RegisteredRoute({
<runtime.Component />
</ChunkRecoveryBoundary>
</Suspense>
</RouteInputContext.Provider>
</RouteInputProvider>
);
const protectedContent =
definition.access === "public" ? (
@@ -1,10 +1,17 @@
import { getRoute } from "../../features/installed-feature-contracts.js";
import type { SessionState } from "../../application/ports/in/application-api.ts";
import { getRoute } from "../../features/installed-feature-contracts.ts";
/**
* @param {string} routeId
* @param {import("../../application/ports/in/application-api.js").SessionState} sessionState
*/
export function decideRouteAccess(routeId, sessionState) {
export type RouteAccessDecision =
| Readonly<{ allowed: true; action: "none" }>
| Readonly<{
allowed: false;
action: "wait-for-session" | "show-sign-in";
}>;
export function decideRouteAccess(
routeId: string,
sessionState: SessionState,
): RouteAccessDecision {
const route = getRoute(routeId);
if (route.access === "public") return { allowed: true, action: "none" };
if (sessionState === "authenticated") {
@@ -16,34 +23,25 @@ export function decideRouteAccess(routeId, sessionState) {
return { allowed: false, action: "show-sign-in" };
}
/** @param {number} [maxHops] */
export function createRedirectLoopGuard(maxHops = 5) {
const visitedPairs = new Set();
export function createRedirectLoopGuard(maxHops: number = 5) {
const visitedPairs = new Set<string>();
let hops = 0;
return Object.freeze({
/**
* @param {string} source
* @param {string} target
*/
allow(source, target) {
allow(source: string, target: string): boolean {
const pair = `${source}->${target}`;
if (
source === target ||
visitedPairs.has(pair) ||
hops >= maxHops
) {
if (source === target || visitedPairs.has(pair) || hops >= maxHops) {
return false;
}
visitedPairs.add(pair);
hops += 1;
return true;
},
reset() {
reset(): void {
visitedPairs.clear();
hops = 0;
},
get hopCount() {
get hopCount(): number {
return hops;
},
});
+8 -16
View File
@@ -1,20 +1,12 @@
import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.js";
import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.js";
import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.ts";
import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.tsx";
import type {
ParsedRouteInput,
RouteId,
RouteInputResult,
} from "./route-contract.ts";
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
export type ParsedRouteInput = Readonly<{
routeId: RouteId;
params: Readonly<Record<string, unknown>>;
search: Readonly<Record<string, unknown>>;
}>;
export type RouteInputResult =
| Readonly<{ success: true; data: ParsedRouteInput }>
| Readonly<{
success: false;
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
}>;
export type { ParsedRouteInput, RouteId, RouteInputResult };
function codecById(codecId: string) {
const codec = ROUTE_CODECS[codecId as keyof typeof ROUTE_CODECS];
+16
View File
@@ -0,0 +1,16 @@
import { ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.ts";
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
export type ParsedRouteInput = Readonly<{
routeId: RouteId;
params: Readonly<Record<string, unknown>>;
search: Readonly<Record<string, unknown>>;
}>;
export type RouteInputResult =
| Readonly<{ success: true; data: ParsedRouteInput }>
| Readonly<{
success: false;
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
}>;
+29
View File
@@ -0,0 +1,29 @@
import {
createContext,
type ReactNode,
useContext,
} from "react";
import type { ParsedRouteInput } from "./route-contract.ts";
const RouteInputContext = createContext<ParsedRouteInput | null>(null);
export function RouteInputProvider({
input,
children,
}: Readonly<{
input: ParsedRouteInput;
children: ReactNode;
}>) {
return (
<RouteInputContext.Provider value={input}>
{children}
</RouteInputContext.Provider>
);
}
export function useRouteInput(): ParsedRouteInput {
const input = useContext(RouteInputContext);
if (!input) throw new Error("Registered route input is required");
return input;
}
+6 -6
View File
@@ -4,7 +4,7 @@ import {
type LazyExoticComponent,
} from "react";
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.ts";
type RouteModule = Readonly<{ default: ComponentType }>;
type RouteRuntime = Readonly<{
@@ -23,21 +23,21 @@ function runtime(
}
export const PLATFORM_ROUTE_RUNTIME = {
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.jsx")),
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.tsx")),
EXAMPLES_UI: runtime(
"EXAMPLES_UI",
() => import("../examples/ui-gallery-page.jsx"),
() => import("../examples/ui-gallery-page.tsx"),
),
EXAMPLES_STATES: runtime(
"EXAMPLES_STATES",
() => import("../examples/state-gallery-page.jsx"),
() => import("../examples/state-gallery-page.tsx"),
),
EXAMPLES_AUTH: runtime(
"EXAMPLES_AUTH",
() => import("../examples/auth-example-page.jsx"),
() => import("../examples/auth-example-page.tsx"),
),
NOT_FOUND: runtime(
"NOT_FOUND",
() => import("../pages/not-found-page.jsx"),
() => import("../pages/not-found-page.tsx"),
),
} satisfies Record<keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT, RouteRuntime>;
@@ -1,9 +1,7 @@
/**
* Untrusted content is rendered as a React text node. HTML interpretation is
* intentionally not offered by this template.
*
* @param {{ value: unknown }} props
*/
export function SafeText({ value }) {
export function SafeText({ value }: Readonly<{ value: unknown }>) {
return <span>{typeof value === "string" ? value : String(value ?? "")}</span>;
}
+1 -1
View File
@@ -1 +1 @@
export * from "./page-templates.js";
export * from "./page-templates.tsx";
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
import { PageHeader } from "../components/page-header.jsx";
import { Button } from "../components/ui/button.jsx";
import { useLocale } from "../i18n/index.js";
import { PageHeader } from "../components/page-header.tsx";
import { Button } from "../components/ui/button.ts";
import { useLocale } from "../i18n/index.ts";
export type PageHeading = Readonly<{
title: string;