fix: inject grouped route codecs
This commit is contained in:
@@ -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 },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user