feat: connect application input and output boundaries

This commit is contained in:
donghyeon-ka
2026-07-26 13:52:35 +09:00
parent 38ad69236b
commit 2dda17cf19
43 changed files with 697 additions and 215 deletions
@@ -6,7 +6,7 @@ import { Component } from "react";
* boundaryName: string,
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* onRenderFailure?: (report: import("../../application/ports/in/application-api.js").RenderFailureReport) => void,
* fallback?: React.ReactNode
* }} RenderBoundaryProps
* @typedef {{ hasError: boolean }} RenderBoundaryState
@@ -26,13 +26,14 @@ export class RenderErrorBoundary extends Component {
componentDidCatch() {
try {
this.props.telemetry?.emit("ui.render.failed", {
route_id: this.props.routeId,
build_id: this.props.buildId,
component_boundary: this.props.boundaryName,
this.props.onRenderFailure?.({
routeId: this.props.routeId,
buildId: this.props.buildId,
boundaryName:
/** @type {"route" | "feature"} */ (this.props.boundaryName),
});
} catch {
// Telemetry must never recurse into another render failure.
// Diagnostics must never recurse into another render failure.
}
}
+24
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { PageHeader } from "../components/page-header.jsx";
import { useApplication } from "../providers/application-provider.js";
const READINESS_ITEMS = Object.freeze([
{
@@ -19,6 +21,23 @@ const READINESS_ITEMS = Object.freeze([
]);
export default function HomePage() {
const { runtime } = useApplication();
const [release, setRelease] = useState(
/** @type {Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null} */ (
null
),
);
useEffect(() => {
let active = true;
void runtime.getReleaseSummary().then((summary) => {
if (active) setRelease(summary);
});
return () => {
active = false;
};
}, [runtime]);
return (
<section className="ui-page">
<PageHeader
@@ -34,6 +53,11 @@ export default function HomePage() {
</article>
))}
</div>
<p className="ui-runtime-summary" aria-live="polite">
{release
? `빌드 ${release.buildId} · 릴리스 ${release.releaseId}`
: "검증된 런타임 정보를 확인하고 있습니다."}
</p>
<section className="ui-panel starter-actions" aria-labelledby="starter-title">
<div>
<h2 id="starter-title">준비된 화면 살펴보기</h2>
@@ -0,0 +1,31 @@
import {
createContext,
type ReactNode,
useContext,
} from "react";
import type { ApplicationApi } from "../../application/create-application.js";
const ApplicationContext = createContext<ApplicationApi | null>(null);
export function ApplicationProvider({
application,
children,
}: Readonly<{
application: ApplicationApi;
children: ReactNode;
}>) {
return (
<ApplicationContext.Provider value={application}>
{children}
</ApplicationContext.Provider>
);
}
export function useApplication(): ApplicationApi {
const application = useContext(ApplicationContext);
if (!application) {
throw new Error("ApplicationProvider is required");
}
return application;
}
+15 -16
View File
@@ -1,11 +1,12 @@
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { useApplication } from "./application-provider.js";
/**
* @typedef {{
* sessionState: import("../../application/ports/auth-session-port.js").SessionState,
* beginSignIn: import("../../application/ports/auth-session-port.js").AuthSessionPort["beginSignIn"],
* signOut: import("../../application/ports/auth-session-port.js").AuthSessionPort["signOut"],
* recover: import("../../application/ports/auth-session-port.js").AuthSessionPort["recover"]
* 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
*/
@@ -14,26 +15,24 @@ const SessionContext = createContext(
);
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* children: React.ReactNode
* }} props
* @param {{ children: React.ReactNode }} props
*/
export function SessionProvider({ authSession, children }) {
export function SessionProvider({ children }) {
const { session } = useApplication();
const sessionState = useSyncExternalStore(
authSession.subscribe,
authSession.getState,
authSession.getState,
session.subscribe,
session.getSnapshot,
session.getSnapshot,
);
const value = useMemo(
() =>
Object.freeze({
sessionState,
beginSignIn: authSession.beginSignIn,
signOut: authSession.signOut,
recover: authSession.recover,
beginSignIn: session.beginSignIn,
signOut: session.signOut,
recover: session.recover,
}),
[authSession, sessionState],
[session, sessionState],
);
return (
+9 -13
View File
@@ -11,6 +11,7 @@ import {
normalizeColorSchemePreference,
resolveColorScheme,
} from "../../application/policies/color-scheme.js";
import { useApplication } from "./application-provider.js";
/**
* @typedef {{
@@ -30,18 +31,13 @@ function systemPrefersDark() {
}
/**
* @param {{
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* children: React.ReactNode
* }} props
* @param {{ children: React.ReactNode }} props
*/
export function ThemeProvider({ storage, children }) {
const [preference, updatePreference] = useState(() => {
const result = storage?.read("COLOR_SCHEME");
return normalizeColorSchemePreference(
result?.ok ? result.value : undefined,
);
});
export function ThemeProvider({ children }) {
const { preferences } = useApplication();
const [preference, updatePreference] = useState(
preferences.getColorScheme,
);
const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark);
const resolvedTheme = resolveColorScheme(preference, darkSystemTheme);
@@ -70,10 +66,10 @@ export function ThemeProvider({ storage, children }) {
setPreference(next) {
const normalized = normalizeColorSchemePreference(next);
updatePreference(normalized);
storage?.write("COLOR_SCHEME", normalized);
preferences.setColorScheme(normalized);
},
}),
[preference, resolvedTheme, storage],
[preference, preferences, resolvedTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
+9 -21
View File
@@ -10,6 +10,7 @@ import { getRoute, routePath } from "../../contracts/routes.js";
import { RouteBoundary } from "../boundaries/render-error-boundary.jsx";
import { AppShell } from "../layouts/app-shell.jsx";
import { PageHeader } from "../components/page-header.jsx";
import { useApplication } from "../providers/application-provider.js";
import { SessionProvider, useSession } from "../providers/session-provider.jsx";
import { ThemeProvider } from "../providers/theme-provider.jsx";
import { decideRouteAccess } from "./navigation-policy.js";
@@ -54,16 +55,16 @@ function RouteFailureSurface() {
* @param {{
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode
* }} props
*/
function RouteSurface({ routeId, buildId, telemetry, children }) {
function RouteSurface({ routeId, buildId, children }) {
const { diagnostics } = useApplication();
return (
<RouteBoundary
routeId={routeId}
buildId={buildId}
telemetry={telemetry}
onRenderFailure={diagnostics.reportRenderFailure}
fallback={<RouteFailureSurface />}
>
<Suspense fallback={<RouteLoadingSurface routeId={routeId} />}>
@@ -155,13 +156,12 @@ function ProtectedRoute({ routeId, children }) {
* @param {{
* routeId: string,
* buildId: string,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort,
* children: React.ReactNode
* }} props
*/
function PublicRoute({ routeId, buildId, telemetry, children }) {
function PublicRoute({ routeId, buildId, children }) {
return (
<RouteSurface routeId={routeId} buildId={buildId} telemetry={telemetry}>
<RouteSurface routeId={routeId} buildId={buildId}>
{children}
</RouteSurface>
);
@@ -169,24 +169,18 @@ function PublicRoute({ routeId, buildId, telemetry, children }) {
/**
* @param {{
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
* basename?: string,
* buildId?: string,
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* telemetry?: import("../../application/ports/telemetry-port.js").TelemetryPort
* buildId?: string
* }} props
*/
export function AppRouter({
authSession,
basename = "/",
buildId = "local-build",
storage,
telemetry,
}) {
return (
<BrowserRouter basename={basename}>
<ThemeProvider storage={storage}>
<SessionProvider authSession={authSession}>
<ThemeProvider>
<SessionProvider>
<Routes>
<Route element={<AppShell />}>
<Route
@@ -195,7 +189,6 @@ export function AppRouter({
<PublicRoute
routeId="APP_HOME"
buildId={buildId}
telemetry={telemetry}
>
<HomePage />
</PublicRoute>
@@ -207,7 +200,6 @@ export function AppRouter({
<PublicRoute
routeId="EXAMPLES_UI"
buildId={buildId}
telemetry={telemetry}
>
<UiGalleryPage />
</PublicRoute>
@@ -219,7 +211,6 @@ export function AppRouter({
<PublicRoute
routeId="EXAMPLES_STATES"
buildId={buildId}
telemetry={telemetry}
>
<StateGalleryPage />
</PublicRoute>
@@ -231,7 +222,6 @@ export function AppRouter({
<PublicRoute
routeId="EXAMPLES_AUTH"
buildId={buildId}
telemetry={telemetry}
>
<AuthExamplePage />
</PublicRoute>
@@ -243,7 +233,6 @@ export function AppRouter({
<RouteSurface
routeId="SAMPLE_RESOURCE_LIST"
buildId={buildId}
telemetry={telemetry}
>
<ProtectedRoute routeId="SAMPLE_RESOURCE_LIST">
<SampleContractPage />
@@ -257,7 +246,6 @@ export function AppRouter({
<PublicRoute
routeId="NOT_FOUND"
buildId={buildId}
telemetry={telemetry}
>
<NotFoundPage />
</PublicRoute>
+1 -1
View File
@@ -2,7 +2,7 @@ import { getRoute } from "../../contracts/routes.js";
/**
* @param {string} routeId
* @param {import("../../application/ports/auth-session-port.js").SessionState} sessionState
* @param {import("../../application/ports/in/application-api.js").SessionState} sessionState
*/
export function decideRouteAccess(routeId, sessionState) {
const route = getRoute(routeId);