feat: connect application input and output boundaries
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user