fix: inject grouped route codecs

This commit is contained in:
DongHyeonka
2026-08-15 20:51:22 +09:00
parent adb8613cb9
commit 01316763e3
5 changed files with 149 additions and 37 deletions
+8 -8
View File
@@ -42,7 +42,6 @@ import {
buildRouteUrlFromDefinition, buildRouteUrlFromDefinition,
parseRouteInputFromContract, parseRouteInputFromContract,
type RouteCodecRegistry, type RouteCodecRegistry,
type RouteId,
} from "./route-codecs.ts"; } from "./route-codecs.ts";
import { import {
ROUTE_CODECS, ROUTE_CODECS,
@@ -142,12 +141,12 @@ function RouteLifecycle({
return null; return null;
} }
function CanonicalRouteRedirect({ function CanonicalRouteRedirect<RouteIdValue extends string>({
input, input,
definition, definition,
codecs, codecs,
}: { }: {
input: ParsedRouteInput; input: ParsedRouteInput<RouteIdValue>;
definition: RouteDefinition; definition: RouteDefinition;
codecs: RouteCodecRegistry; codecs: RouteCodecRegistry;
}) { }) {
@@ -251,14 +250,14 @@ function ProtectedRoute({
); );
} }
function RegisteredRoute({ function RegisteredRoute<RouteIdValue extends string>({
routeId, routeId,
buildId, buildId,
definition, definition,
runtime, runtime,
codecs, codecs,
}: { }: {
routeId: RouteId; routeId: RouteIdValue;
buildId: string; buildId: string;
definition: RouteDefinition; definition: RouteDefinition;
runtime: GroupedRouteRuntimeDefinition; runtime: GroupedRouteRuntimeDefinition;
@@ -276,7 +275,7 @@ function RegisteredRoute({
search, search,
); );
if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />; if (!parsed.success) return <InvalidRouteSurface code={parsed.code} />;
const routeInput = parsed.data as ParsedRouteInput; const routeInput = parsed.data;
const RuntimeComponent = runtime.Component; const RuntimeComponent = runtime.Component;
const content = ( const content = (
@@ -334,7 +333,7 @@ export function createGroupedRouteObjects(
runtime: GroupedRouteRuntime, runtime: GroupedRouteRuntime,
layouts: GroupedRouteLayouts, layouts: GroupedRouteLayouts,
buildId: string, buildId: string,
codecs: RouteCodecRegistry = ROUTE_CODECS, codecs: RouteCodecRegistry,
): RouteObject[] { ): RouteObject[] {
for (const routeId of Object.keys(runtime)) { for (const routeId of Object.keys(runtime)) {
if (!registry[routeId]) { if (!registry[routeId]) {
@@ -347,7 +346,7 @@ export function createGroupedRouteObjects(
STUDIO: [], STUDIO: [],
}; };
for (const definition of Object.values(registry)) { for (const definition of Object.values(registry)) {
const routeId = definition.routeId as RouteId; const routeId = definition.routeId;
const routeRuntime = runtime[definition.routeId]; const routeRuntime = runtime[definition.routeId];
if (!routeRuntime) { if (!routeRuntime) {
throw new TypeError(`Missing route runtime: ${definition.routeId}`); throw new TypeError(`Missing route runtime: ${definition.routeId}`);
@@ -411,6 +410,7 @@ export function AppRouter({
STUDIO: <Outlet />, STUDIO: <Outlet />,
}, },
buildId, buildId,
ROUTE_CODECS,
), ),
{ basename }, { basename },
), ),
+7 -17
View File
@@ -24,19 +24,9 @@ type RouteCodec = Readonly<{
}>; }>;
export type RouteCodecRegistry = Readonly<Record<string, RouteCodec>>; export type RouteCodecRegistry = Readonly<Record<string, RouteCodec>>;
export type ContractRouteInputResult = export type ContractRouteInputResult<
| Readonly<{ RouteIdValue extends string = string,
success: true; > = RouteInputResult<RouteIdValue>;
data: Readonly<{
routeId: string;
params: Readonly<Record<string, unknown>>;
search: Readonly<Record<string, unknown>>;
}>;
}>
| Readonly<{
success: false;
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
}>;
function codecById(codecId: string, codecs: RouteCodecRegistry = ROUTE_CODECS) { function codecById(codecId: string, codecs: RouteCodecRegistry = ROUTE_CODECS) {
const codec = codecs[codecId]; const codec = codecs[codecId];
@@ -55,16 +45,16 @@ export function parseRouteInput(
ROUTE_CODECS, ROUTE_CODECS,
rawParams, rawParams,
rawSearch, rawSearch,
) as RouteInputResult; );
} }
export function parseRouteInputFromContract( export function parseRouteInputFromContract<RouteIdValue extends string>(
routeId: string, routeId: RouteIdValue,
definition: Pick<RouteDefinition, "paramsSchema" | "searchSchema">, definition: Pick<RouteDefinition, "paramsSchema" | "searchSchema">,
codecs: RouteCodecRegistry, codecs: RouteCodecRegistry,
rawParams: Readonly<Record<string, string | undefined>>, rawParams: Readonly<Record<string, string | undefined>>,
rawSearch: URLSearchParams, rawSearch: URLSearchParams,
): ContractRouteInputResult { ): ContractRouteInputResult<RouteIdValue> {
const params = codecById( const params = codecById(
definition.paramsSchema ?? "none", definition.paramsSchema ?? "none",
codecs, codecs,
+4 -4
View File
@@ -2,14 +2,14 @@ import { ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contrac
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT; export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
export type ParsedRouteInput = Readonly<{ export type ParsedRouteInput<RouteIdValue extends string = RouteId> = Readonly<{
routeId: RouteId; routeId: RouteIdValue;
params: Readonly<Record<string, unknown>>; params: Readonly<Record<string, unknown>>;
search: Readonly<Record<string, unknown>>; search: Readonly<Record<string, unknown>>;
}>; }>;
export type RouteInputResult = export type RouteInputResult<RouteIdValue extends string = RouteId> =
| Readonly<{ success: true; data: ParsedRouteInput }> | Readonly<{ success: true; data: ParsedRouteInput<RouteIdValue> }>
| Readonly<{ | Readonly<{
success: false; success: false;
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID"; code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
+8 -6
View File
@@ -4,15 +4,15 @@ import {
useContext, useContext,
} from "react"; } from "react";
import type { ParsedRouteInput } from "./route-contract.ts"; import type { ParsedRouteInput, RouteId } from "./route-contract.ts";
const RouteInputContext = createContext<ParsedRouteInput | null>(null); const RouteInputContext = createContext<ParsedRouteInput<string> | null>(null);
export function RouteInputProvider({ export function RouteInputProvider<RouteIdValue extends string>({
input, input,
children, children,
}: Readonly<{ }: Readonly<{
input: ParsedRouteInput; input: ParsedRouteInput<RouteIdValue>;
children: ReactNode; children: ReactNode;
}>) { }>) {
return ( return (
@@ -22,8 +22,10 @@ export function RouteInputProvider({
); );
} }
export function useRouteInput(): ParsedRouteInput { export function useRouteInput<
RouteIdValue extends string = RouteId,
>(): ParsedRouteInput<RouteIdValue> {
const input = useContext(RouteInputContext); const input = useContext(RouteInputContext);
if (!input) throw new Error("Registered route input is required"); if (!input) throw new Error("Registered route input is required");
return input; return input as ParsedRouteInput<RouteIdValue>;
} }
+122 -2
View File
@@ -2,7 +2,8 @@
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest"; import { describe, expect, expectTypeOf, it } from "vitest";
import { z } from "zod";
import { import {
createMemoryRouter, createMemoryRouter,
matchRoutes, matchRoutes,
@@ -18,7 +19,10 @@ import {
} from "../../src/presentation/routes/app-router.tsx"; } from "../../src/presentation/routes/app-router.tsx";
import { createTestApplication } from "../helpers/create-test-application.ts"; import { createTestApplication } from "../helpers/create-test-application.ts";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts"; import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
import { ROUTE_RUNTIME } from "../../src/features/installed-feature-runtimes.tsx"; import {
ROUTE_CODECS,
ROUTE_RUNTIME,
} from "../../src/features/installed-feature-runtimes.tsx";
import { LocaleProvider } from "../../src/presentation/i18n/index.ts"; import { LocaleProvider } from "../../src/presentation/i18n/index.ts";
import { SessionProvider } from "../../src/presentation/providers/session-provider.tsx"; import { SessionProvider } from "../../src/presentation/providers/session-provider.tsx";
import { ThemeProvider } from "../../src/presentation/providers/theme-provider.tsx"; import { ThemeProvider } from "../../src/presentation/providers/theme-provider.tsx";
@@ -29,6 +33,72 @@ function StudioFallbackFixture() {
return <h1>{String(input.params["*"])}</h1>; return <h1>{String(input.params["*"])}</h1>;
} }
const reviewFixtureCodecs = Object.freeze({
ReviewFixtureParams: z
.object({ reviewId: z.string().min(1) })
.strict(),
ReviewFixtureSearch: z
.object({
filter: z.preprocess(
(value) => (Array.isArray(value) ? value[0] : value),
z.string().trim().min(1).optional(),
),
})
.strip(),
});
const groupedFixtureCodecs = Object.freeze({
none: ROUTE_CODECS.none,
NotFoundSplat: ROUTE_CODECS.NotFoundSplat,
});
const reviewFixtureRegistry = Object.freeze({
REVIEW_FIXTURE: Object.freeze({
...ROUTE_REGISTRY.APP_HOME,
routeId: "REVIEW_FIXTURE",
path: "/review/:reviewId",
paramsSchema: "ReviewFixtureParams",
searchSchema: "ReviewFixtureSearch",
}),
});
function ReviewRouteFixture() {
const input = useRouteInput<"REVIEW_FIXTURE">();
expectTypeOf(input.routeId).toEqualTypeOf<"REVIEW_FIXTURE">();
return (
<section>
<h1>{input.routeId}</h1>
<p data-testid="review-param">{String(input.params.reviewId)}</p>
<p data-testid="review-search">{JSON.stringify(input.search)}</p>
</section>
);
}
const reviewFixtureRuntime = Object.freeze({
REVIEW_FIXTURE: Object.freeze({
moduleId: "review-fixture",
Component: ReviewRouteFixture,
}),
});
function compileTimeGroupedRouteContract() {
// @ts-expect-error A grouped route composition must provide its codec registry.
createGroupedRouteObjects(
reviewFixtureRegistry,
reviewFixtureRuntime,
{ PUBLIC: <Outlet />, STUDIO: <Outlet /> },
"type-test-build",
);
return createGroupedRouteObjects(
reviewFixtureRegistry,
reviewFixtureRuntime,
{ PUBLIC: <Outlet />, STUDIO: <Outlet /> },
"type-test-build",
reviewFixtureCodecs,
);
}
void compileTimeGroupedRouteContract;
function renderRouter() { function renderRouter() {
return render( return render(
<ApplicationProvider <ApplicationProvider
@@ -42,6 +112,51 @@ function renderRouter() {
} }
describe("generic application router", () => { describe("generic application router", () => {
it("renders and canonicalizes an isolated non-installed route codec contract", async () => {
expect(ROUTE_REGISTRY).not.toHaveProperty("REVIEW_FIXTURE");
expect(ROUTE_CODECS).not.toHaveProperty("ReviewFixtureParams");
expect(ROUTE_CODECS).not.toHaveProperty("ReviewFixtureSearch");
const routes = createGroupedRouteObjects(
reviewFixtureRegistry,
reviewFixtureRuntime,
{ PUBLIC: <Outlet />, STUDIO: <Outlet /> },
"test-build",
reviewFixtureCodecs,
);
const router = createMemoryRouter(routes, {
initialEntries: [
"/review/non-empty?filter=%20first%20&filter=second&unknown=drop",
],
});
render(
<ApplicationProvider application={createTestApplication()}>
<LocaleProvider>
<ThemeProvider>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</ThemeProvider>
</LocaleProvider>
</ApplicationProvider>,
);
expect(
await screen.findByRole("heading", { name: "REVIEW_FIXTURE" }),
).toBeVisible();
expect(screen.getByTestId("review-param")).toHaveTextContent("non-empty");
expect(screen.getByTestId("review-search")).toHaveTextContent(
'{"filter":"first"}',
);
await waitFor(() =>
expect(router.state.location).toMatchObject({
pathname: "/review/non-empty",
search: "?filter=first",
}),
);
});
it("assembles generic Public and Studio parents with Studio catch-all precedence", () => { it("assembles generic Public and Studio parents with Studio catch-all precedence", () => {
const registry = { const registry = {
APP_HOME: ROUTE_REGISTRY.APP_HOME, APP_HOME: ROUTE_REGISTRY.APP_HOME,
@@ -66,6 +181,7 @@ describe("generic application router", () => {
STUDIO: <div data-layout="studio" />, STUDIO: <div data-layout="studio" />,
}, },
"test-build", "test-build",
groupedFixtureCodecs,
); );
expect(routes.map((route) => route.id)).toEqual([ expect(routes.map((route) => route.id)).toEqual([
@@ -87,6 +203,7 @@ describe("generic application router", () => {
STUDIO: <div data-layout="studio" />, STUDIO: <div data-layout="studio" />,
}, },
"test-build", "test-build",
groupedFixtureCodecs,
); );
expect(installedRoutes[1]?.children?.at(-1)?.id).toBe("NOT_FOUND"); expect(installedRoutes[1]?.children?.at(-1)?.id).toBe("NOT_FOUND");
}); });
@@ -101,6 +218,7 @@ describe("generic application router", () => {
STUDIO: <div data-layout="studio" />, STUDIO: <div data-layout="studio" />,
}, },
"test-build", "test-build",
groupedFixtureCodecs,
), ),
).toThrow("Missing route runtime: APP_HOME"); ).toThrow("Missing route runtime: APP_HOME");
}); });
@@ -130,6 +248,7 @@ describe("generic application router", () => {
), ),
}, },
"test-build", "test-build",
groupedFixtureCodecs,
); );
const router = createMemoryRouter(routes, { const router = createMemoryRouter(routes, {
initialEntries: ["/studio/unknown/path"], initialEntries: ["/studio/unknown/path"],
@@ -175,6 +294,7 @@ describe("generic application router", () => {
STUDIO: <Outlet />, STUDIO: <Outlet />,
}, },
"test-build", "test-build",
groupedFixtureCodecs,
); );
const router = createMemoryRouter(routes, { const router = createMemoryRouter(routes, {
initialEntries: ["/private-fixture"], initialEntries: ["/private-fixture"],