diff --git a/src/presentation/routes/app-router.tsx b/src/presentation/routes/app-router.tsx index b9b8d27..887b5e3 100644 --- a/src/presentation/routes/app-router.tsx +++ b/src/presentation/routes/app-router.tsx @@ -42,7 +42,6 @@ import { buildRouteUrlFromDefinition, parseRouteInputFromContract, type RouteCodecRegistry, - type RouteId, } from "./route-codecs.ts"; import { ROUTE_CODECS, @@ -142,12 +141,12 @@ function RouteLifecycle({ return null; } -function CanonicalRouteRedirect({ +function CanonicalRouteRedirect({ input, definition, codecs, }: { - input: ParsedRouteInput; + input: ParsedRouteInput; definition: RouteDefinition; codecs: RouteCodecRegistry; }) { @@ -251,14 +250,14 @@ function ProtectedRoute({ ); } -function RegisteredRoute({ +function RegisteredRoute({ routeId, buildId, definition, runtime, codecs, }: { - routeId: RouteId; + routeId: RouteIdValue; buildId: string; definition: RouteDefinition; runtime: GroupedRouteRuntimeDefinition; @@ -276,7 +275,7 @@ function RegisteredRoute({ search, ); if (!parsed.success) return ; - const routeInput = parsed.data as ParsedRouteInput; + const routeInput = parsed.data; const RuntimeComponent = runtime.Component; const content = ( @@ -334,7 +333,7 @@ export function createGroupedRouteObjects( runtime: GroupedRouteRuntime, layouts: GroupedRouteLayouts, buildId: string, - codecs: RouteCodecRegistry = ROUTE_CODECS, + codecs: RouteCodecRegistry, ): RouteObject[] { for (const routeId of Object.keys(runtime)) { if (!registry[routeId]) { @@ -347,7 +346,7 @@ export function createGroupedRouteObjects( STUDIO: [], }; for (const definition of Object.values(registry)) { - const routeId = definition.routeId as RouteId; + const routeId = definition.routeId; const routeRuntime = runtime[definition.routeId]; if (!routeRuntime) { throw new TypeError(`Missing route runtime: ${definition.routeId}`); @@ -411,6 +410,7 @@ export function AppRouter({ STUDIO: , }, buildId, + ROUTE_CODECS, ), { basename }, ), diff --git a/src/presentation/routes/route-codecs.ts b/src/presentation/routes/route-codecs.ts index 9c2eb69..77d263d 100644 --- a/src/presentation/routes/route-codecs.ts +++ b/src/presentation/routes/route-codecs.ts @@ -24,19 +24,9 @@ type RouteCodec = Readonly<{ }>; export type RouteCodecRegistry = Readonly>; -export type ContractRouteInputResult = - | Readonly<{ - success: true; - data: Readonly<{ - routeId: string; - params: Readonly>; - search: Readonly>; - }>; - }> - | Readonly<{ - success: false; - code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID"; - }>; +export type ContractRouteInputResult< + RouteIdValue extends string = string, +> = RouteInputResult; function codecById(codecId: string, codecs: RouteCodecRegistry = ROUTE_CODECS) { const codec = codecs[codecId]; @@ -55,16 +45,16 @@ export function parseRouteInput( ROUTE_CODECS, rawParams, rawSearch, - ) as RouteInputResult; + ); } -export function parseRouteInputFromContract( - routeId: string, +export function parseRouteInputFromContract( + routeId: RouteIdValue, definition: Pick, codecs: RouteCodecRegistry, rawParams: Readonly>, rawSearch: URLSearchParams, -): ContractRouteInputResult { +): ContractRouteInputResult { const params = codecById( definition.paramsSchema ?? "none", codecs, diff --git a/src/presentation/routes/route-contract.ts b/src/presentation/routes/route-contract.ts index c97e438..9397265 100644 --- a/src/presentation/routes/route-contract.ts +++ b/src/presentation/routes/route-contract.ts @@ -2,14 +2,14 @@ import { ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contrac export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT; -export type ParsedRouteInput = Readonly<{ - routeId: RouteId; +export type ParsedRouteInput = Readonly<{ + routeId: RouteIdValue; params: Readonly>; search: Readonly>; }>; -export type RouteInputResult = - | Readonly<{ success: true; data: ParsedRouteInput }> +export type RouteInputResult = + | Readonly<{ success: true; data: ParsedRouteInput }> | Readonly<{ success: false; code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID"; diff --git a/src/presentation/routes/route-input.tsx b/src/presentation/routes/route-input.tsx index c61ed9a..8d87744 100644 --- a/src/presentation/routes/route-input.tsx +++ b/src/presentation/routes/route-input.tsx @@ -4,15 +4,15 @@ import { useContext, } from "react"; -import type { ParsedRouteInput } from "./route-contract.ts"; +import type { ParsedRouteInput, RouteId } from "./route-contract.ts"; -const RouteInputContext = createContext(null); +const RouteInputContext = createContext | null>(null); -export function RouteInputProvider({ +export function RouteInputProvider({ input, children, }: Readonly<{ - input: ParsedRouteInput; + input: ParsedRouteInput; children: ReactNode; }>) { return ( @@ -22,8 +22,10 @@ export function RouteInputProvider({ ); } -export function useRouteInput(): ParsedRouteInput { +export function useRouteInput< + RouteIdValue extends string = RouteId, +>(): ParsedRouteInput { const input = useContext(RouteInputContext); if (!input) throw new Error("Registered route input is required"); - return input; + return input as ParsedRouteInput; } diff --git a/tests/component/router.test.tsx b/tests/component/router.test.tsx index 95b41b2..a33bbd3 100644 --- a/tests/component/router.test.tsx +++ b/tests/component/router.test.tsx @@ -2,7 +2,8 @@ import { render, screen, waitFor } from "@testing-library/react"; 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 { createMemoryRouter, matchRoutes, @@ -18,7 +19,10 @@ import { } from "../../src/presentation/routes/app-router.tsx"; import { createTestApplication } from "../helpers/create-test-application.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 { SessionProvider } from "../../src/presentation/providers/session-provider.tsx"; import { ThemeProvider } from "../../src/presentation/providers/theme-provider.tsx"; @@ -29,6 +33,72 @@ function StudioFallbackFixture() { return

{String(input.params["*"])}

; } +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 ( +
+

{input.routeId}

+

{String(input.params.reviewId)}

+

{JSON.stringify(input.search)}

+
+ ); +} + +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: , STUDIO: }, + "type-test-build", + ); + return createGroupedRouteObjects( + reviewFixtureRegistry, + reviewFixtureRuntime, + { PUBLIC: , STUDIO: }, + "type-test-build", + reviewFixtureCodecs, + ); +} +void compileTimeGroupedRouteContract; + function renderRouter() { return render( { + 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: , STUDIO: }, + "test-build", + reviewFixtureCodecs, + ); + const router = createMemoryRouter(routes, { + initialEntries: [ + "/review/non-empty?filter=%20first%20&filter=second&unknown=drop", + ], + }); + + render( + + + + + + + + + , + ); + + 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", () => { const registry = { APP_HOME: ROUTE_REGISTRY.APP_HOME, @@ -66,6 +181,7 @@ describe("generic application router", () => { STUDIO:
, }, "test-build", + groupedFixtureCodecs, ); expect(routes.map((route) => route.id)).toEqual([ @@ -87,6 +203,7 @@ describe("generic application router", () => { STUDIO:
, }, "test-build", + groupedFixtureCodecs, ); expect(installedRoutes[1]?.children?.at(-1)?.id).toBe("NOT_FOUND"); }); @@ -101,6 +218,7 @@ describe("generic application router", () => { STUDIO:
, }, "test-build", + groupedFixtureCodecs, ), ).toThrow("Missing route runtime: APP_HOME"); }); @@ -130,6 +248,7 @@ describe("generic application router", () => { ), }, "test-build", + groupedFixtureCodecs, ); const router = createMemoryRouter(routes, { initialEntries: ["/studio/unknown/path"], @@ -175,6 +294,7 @@ describe("generic application router", () => { STUDIO: , }, "test-build", + groupedFixtureCodecs, ); const router = createMemoryRouter(routes, { initialEntries: ["/private-fixture"],