mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 03:54:41 +08:00
fix(tanstack-start): address review findings
This commit is contained in:
@@ -541,7 +541,13 @@ const app = createNextApp({ spec });
|
||||
|
||||
```tsx
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { PageRenderer, type StartAppSpec } from "@json-render/tanstack-start";
|
||||
import {
|
||||
PageRenderer,
|
||||
StartErrorBoundary,
|
||||
StartLoading,
|
||||
StartNotFound,
|
||||
type StartAppSpec,
|
||||
} from "@json-render/tanstack-start";
|
||||
import { createStartApp } from "@json-render/tanstack-start/server";
|
||||
|
||||
const spec: StartAppSpec = {
|
||||
@@ -569,9 +575,16 @@ export const Route = createFileRoute("/$")({
|
||||
},
|
||||
head: ({ match }) => getHead({ pathname: match.pathname }),
|
||||
component: () => <PageRenderer {...Route.useLoaderData()} />,
|
||||
pendingComponent: StartLoading,
|
||||
errorComponent: StartErrorBoundary,
|
||||
notFoundComponent: StartNotFound,
|
||||
});
|
||||
```
|
||||
|
||||
Wrap the root route's outlet with `<StartAppProvider spec={spec}>` so route
|
||||
fallback components can resolve the current route. Pass named `$computed`
|
||||
implementations through its `functions` prop.
|
||||
|
||||
### shadcn-svelte (Svelte)
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -213,6 +213,10 @@ loader, loading/error/not-found specs, and static parameters.
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Loader parameters are URL-decoded before they reach named loaders. Parameter
|
||||
values supplied through `staticParams` are URL-encoded in the paths returned by
|
||||
`getStaticPaths()`.
|
||||
|
||||
For prerendered dynamic routes, provide `staticParams`:
|
||||
|
||||
```typescript
|
||||
@@ -241,7 +245,12 @@ Wire the helpers to a file-based `$` splat route:
|
||||
```tsx
|
||||
// src/routes/$.tsx
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { PageRenderer } from "@json-render/tanstack-start";
|
||||
import {
|
||||
PageRenderer,
|
||||
StartErrorBoundary,
|
||||
StartLoading,
|
||||
StartNotFound,
|
||||
} from "@json-render/tanstack-start";
|
||||
import { getHead, getPageData } from "@/lib/json-app";
|
||||
|
||||
export const Route = createFileRoute("/$")({
|
||||
@@ -252,6 +261,9 @@ export const Route = createFileRoute("/$")({
|
||||
},
|
||||
head: ({ match }) => getHead({ pathname: match.pathname }),
|
||||
component: Page,
|
||||
pendingComponent: StartLoading,
|
||||
errorComponent: StartErrorBoundary,
|
||||
notFoundComponent: StartNotFound,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
@@ -277,6 +289,7 @@ import {
|
||||
Scripts,
|
||||
} from "@tanstack/react-router";
|
||||
import { StartAppProvider } from "@json-render/tanstack-start";
|
||||
import { spec } from "@/lib/spec";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
@@ -285,7 +298,11 @@ export const Route = createRootRoute({
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<StartAppProvider registry={registry} handlers={handlers}>
|
||||
<StartAppProvider
|
||||
registry={registry}
|
||||
handlers={handlers}
|
||||
spec={spec}
|
||||
>
|
||||
<Outlet />
|
||||
</StartAppProvider>
|
||||
<Scripts />
|
||||
@@ -295,13 +312,32 @@ export const Route = createRootRoute({
|
||||
});
|
||||
```
|
||||
|
||||
Passing `spec` lets `StartLoading`, `StartErrorBoundary`, and `StartNotFound`
|
||||
automatically select the matched route's fallback specs. Their explicit
|
||||
`loadingSpec`, `errorSpec`, and `notFoundSpec` props take precedence. For a
|
||||
server-only application spec, omit `spec` and pass client-safe fallback specs
|
||||
explicitly.
|
||||
|
||||
Pass named functions through `functions` when props use `$computed`:
|
||||
|
||||
```tsx
|
||||
<StartAppProvider
|
||||
registry={registry}
|
||||
spec={spec}
|
||||
functions={{ uppercase: ({ value }) => String(value).toUpperCase() }}
|
||||
>
|
||||
<Outlet />
|
||||
</StartAppProvider>
|
||||
```
|
||||
|
||||
## Built-ins
|
||||
|
||||
- `Slot` inserts page content into a JSON-defined layout.
|
||||
- `Link` wraps TanStack Router's `Link`; generated specs use an `href` prop.
|
||||
- `navigate` performs client-side navigation from action bindings.
|
||||
- `StartLoading`, `StartErrorBoundary`, and `StartNotFound` can be used for
|
||||
TanStack Router's route fallback component options.
|
||||
- `StartLoading`, `StartErrorBoundary`, and `StartNotFound` resolve the matched
|
||||
route's fallback specs when they are used as TanStack Router boundary
|
||||
components and the provider receives `spec`.
|
||||
|
||||
`Slot` and `Link` are automatically added to the page registry.
|
||||
|
||||
|
||||
@@ -112,7 +112,12 @@ export const { getPageData, getHead, getStaticPaths } = createStartApp({
|
||||
```tsx
|
||||
// src/routes/$.tsx
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { PageRenderer } from "@json-render/tanstack-start";
|
||||
import {
|
||||
PageRenderer,
|
||||
StartErrorBoundary,
|
||||
StartLoading,
|
||||
StartNotFound,
|
||||
} from "@json-render/tanstack-start";
|
||||
import { getHead, getPageData } from "@/lib/json-app";
|
||||
|
||||
export const Route = createFileRoute("/$")({
|
||||
@@ -123,6 +128,9 @@ export const Route = createFileRoute("/$")({
|
||||
},
|
||||
head: ({ match }) => getHead({ pathname: match.pathname }),
|
||||
component: Page,
|
||||
pendingComponent: StartLoading,
|
||||
errorComponent: StartErrorBoundary,
|
||||
notFoundComponent: StartNotFound,
|
||||
});
|
||||
|
||||
function Page() {
|
||||
@@ -147,6 +155,7 @@ import {
|
||||
} from "@tanstack/react-router";
|
||||
import { StartAppProvider } from "@json-render/tanstack-start";
|
||||
import { registry, handlers } from "@/lib/registry";
|
||||
import { spec } from "@/lib/spec";
|
||||
|
||||
export const Route = createRootRoute({ component: Root });
|
||||
|
||||
@@ -157,7 +166,11 @@ function Root() {
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<StartAppProvider registry={registry} handlers={handlers}>
|
||||
<StartAppProvider
|
||||
registry={registry}
|
||||
handlers={handlers}
|
||||
spec={spec}
|
||||
>
|
||||
<Outlet />
|
||||
</StartAppProvider>
|
||||
<Scripts />
|
||||
@@ -167,6 +180,25 @@ function Root() {
|
||||
}
|
||||
```
|
||||
|
||||
Passing `spec` lets the Router boundary components automatically render the
|
||||
matched route's `loading`, `error`, and `notFound` specs. An explicit
|
||||
`loadingSpec`, `errorSpec`, or `notFoundSpec` prop overrides this lookup. If the
|
||||
application spec is server-only, omit `spec` from the provider and supply those
|
||||
explicit props from client-safe fallback specs.
|
||||
|
||||
Pass named functions through the provider when generated props use
|
||||
`$computed`:
|
||||
|
||||
```tsx
|
||||
<StartAppProvider
|
||||
registry={registry}
|
||||
spec={spec}
|
||||
functions={{ uppercase: ({ value }) => String(value).toUpperCase() }}
|
||||
>
|
||||
<Outlet />
|
||||
</StartAppProvider>
|
||||
```
|
||||
|
||||
## Route Patterns
|
||||
|
||||
| Pattern | Matches | Loader params |
|
||||
@@ -177,7 +209,8 @@ function Root() {
|
||||
| `/docs/$` | `/docs/a/b` | `{ _splat: ["a", "b"] }` |
|
||||
|
||||
Static routes are included in `getStaticPaths()`. Dynamic routes are included
|
||||
when their route spec supplies `staticParams`.
|
||||
when their route spec supplies `staticParams`. Loader parameters are URL-decoded,
|
||||
and parameter values emitted by `getStaticPaths()` are URL-encoded.
|
||||
|
||||
Map the paths to TanStack Start's top-level `pages` option when prerendering:
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from "react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { PageRenderer } from "./page-renderer";
|
||||
import { resolveRouteFallback } from "./route-fallback";
|
||||
import { useOptionalStartApp } from "./provider";
|
||||
|
||||
/** Props accepted by TanStack Router's `errorComponent`. */
|
||||
export interface StartErrorBoundaryProps {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
/** Explicit fallback override; otherwise the matched route's spec is used. */
|
||||
errorSpec?: Spec | null;
|
||||
}
|
||||
|
||||
@@ -17,8 +19,14 @@ export function StartErrorBoundary({
|
||||
errorSpec,
|
||||
}: StartErrorBoundaryProps) {
|
||||
const context = useOptionalStartApp();
|
||||
if (errorSpec && context) {
|
||||
return <PageRenderer spec={errorSpec} />;
|
||||
const resolvedSpec = resolveRouteFallback(
|
||||
context?.spec,
|
||||
context?.pathname,
|
||||
"error",
|
||||
errorSpec,
|
||||
);
|
||||
if (resolvedSpec && context) {
|
||||
return <PageRenderer spec={resolvedSpec} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import React from "react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { PageRenderer } from "./page-renderer";
|
||||
import { resolveRouteFallback } from "./route-fallback";
|
||||
import { useOptionalStartApp } from "./provider";
|
||||
|
||||
export interface StartLoadingProps {
|
||||
/** Explicit fallback override; otherwise the matched route's spec is used. */
|
||||
loadingSpec?: Spec | null;
|
||||
}
|
||||
|
||||
/** Render a route-specific pending spec or a small default spinner. */
|
||||
export function StartLoading({ loadingSpec }: StartLoadingProps = {}) {
|
||||
const context = useOptionalStartApp();
|
||||
if (loadingSpec && context) {
|
||||
return <PageRenderer spec={loadingSpec} loading />;
|
||||
const resolvedSpec = resolveRouteFallback(
|
||||
context?.spec,
|
||||
context?.pathname,
|
||||
"loading",
|
||||
loadingSpec,
|
||||
);
|
||||
if (resolvedSpec && context) {
|
||||
return <PageRenderer spec={resolvedSpec} loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,17 +2,25 @@ import React from "react";
|
||||
import type { NotFoundRouteProps } from "@tanstack/react-router";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { PageRenderer } from "./page-renderer";
|
||||
import { resolveRouteFallback } from "./route-fallback";
|
||||
import { useOptionalStartApp } from "./provider";
|
||||
|
||||
export interface StartNotFoundProps extends Partial<NotFoundRouteProps> {
|
||||
/** Explicit fallback override; otherwise the matched route's spec is used. */
|
||||
notFoundSpec?: Spec | null;
|
||||
}
|
||||
|
||||
/** Render a route-specific not-found spec or a small default 404 view. */
|
||||
export function StartNotFound({ notFoundSpec }: StartNotFoundProps = {}) {
|
||||
const context = useOptionalStartApp();
|
||||
if (notFoundSpec && context) {
|
||||
return <PageRenderer spec={notFoundSpec} />;
|
||||
const resolvedSpec = resolveRouteFallback(
|
||||
context?.spec,
|
||||
context?.pathname,
|
||||
"notFound",
|
||||
notFoundSpec,
|
||||
);
|
||||
if (resolvedSpec && context) {
|
||||
return <PageRenderer spec={resolvedSpec} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,13 @@ export function PageRenderer({
|
||||
layoutSpec,
|
||||
loading,
|
||||
}: PageRendererProps) {
|
||||
const { registry, handlers, navigate } = useStartApp();
|
||||
const {
|
||||
registry,
|
||||
handlers,
|
||||
spec: appSpec,
|
||||
functions,
|
||||
navigate,
|
||||
} = useStartApp();
|
||||
const augmentedRegistry: ComponentRegistry = useMemo(
|
||||
() => ({ ...registry, Link, Slot }),
|
||||
[registry],
|
||||
@@ -42,6 +48,11 @@ export function PageRenderer({
|
||||
}),
|
||||
[handlers, navigate],
|
||||
);
|
||||
const resolvedInitialState = useMemo(() => {
|
||||
if (initialState !== undefined) return initialState;
|
||||
if (!appSpec?.state && !spec.state) return undefined;
|
||||
return { ...appSpec?.state, ...spec.state };
|
||||
}, [appSpec?.state, initialState, spec.state]);
|
||||
|
||||
const page = (
|
||||
<Renderer spec={spec} registry={augmentedRegistry} loading={loading} />
|
||||
@@ -50,9 +61,10 @@ export function PageRenderer({
|
||||
return (
|
||||
<JSONUIProvider
|
||||
registry={augmentedRegistry}
|
||||
initialState={initialState}
|
||||
initialState={resolvedInitialState}
|
||||
handlers={actionHandlers}
|
||||
navigate={navigate}
|
||||
functions={functions}
|
||||
>
|
||||
{layoutSpec ? (
|
||||
<LayoutWithSlot
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
} from "@tanstack/react-router";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import type { ComponentRenderProps } from "@json-render/react";
|
||||
import type { StartAppSpec } from "../types";
|
||||
import { StartLoading } from "./loading-renderer";
|
||||
import { PageRenderer } from "./page-renderer";
|
||||
import { StartAppProvider } from "./provider";
|
||||
|
||||
afterEach(cleanup);
|
||||
beforeAll(() => {
|
||||
window.scrollTo = () => {};
|
||||
});
|
||||
|
||||
function Text({ element }: ComponentRenderProps<{ value: unknown }>) {
|
||||
return <span>{String(element.props.value)}</span>;
|
||||
}
|
||||
|
||||
async function renderInRouter(component: React.ReactNode) {
|
||||
const rootRoute = createRootRoute({ component: () => component });
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute,
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
});
|
||||
await router.load();
|
||||
return render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
||||
describe("StartAppProvider", () => {
|
||||
it("forwards named functions to $computed expressions", async () => {
|
||||
const page: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Text",
|
||||
props: {
|
||||
value: { $computed: "uppercase", args: { value: "hello" } },
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderInRouter(
|
||||
<StartAppProvider
|
||||
registry={{ Text }}
|
||||
functions={{ uppercase: ({ value }) => String(value).toUpperCase() }}
|
||||
>
|
||||
<PageRenderer spec={page} />
|
||||
</StartAppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("HELLO")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the matched route's loading spec", async () => {
|
||||
const loading: Spec = {
|
||||
root: "root",
|
||||
state: { message: "Loading route" },
|
||||
elements: {
|
||||
root: {
|
||||
type: "Text",
|
||||
props: { value: { $state: "/message" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const spec: StartAppSpec = {
|
||||
state: { message: "Application" },
|
||||
routes: {
|
||||
"/": {
|
||||
page: loading,
|
||||
loading,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await renderInRouter(
|
||||
<StartAppProvider registry={{ Text }} spec={spec}>
|
||||
<StartLoading />
|
||||
</StartAppProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading route")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { createContext, useContext, type ReactNode } from "react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useLocation, useRouter } from "@tanstack/react-router";
|
||||
import type { ComputedFunction } from "@json-render/core";
|
||||
import type { ComponentRegistry } from "@json-render/react";
|
||||
import type { StartAppSpec } from "../types";
|
||||
|
||||
export interface StartAppContextValue {
|
||||
registry: ComponentRegistry;
|
||||
@@ -8,6 +10,9 @@ export interface StartAppContextValue {
|
||||
string,
|
||||
(params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
>;
|
||||
spec?: StartAppSpec;
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
pathname: string;
|
||||
navigate: (href: string) => void;
|
||||
}
|
||||
|
||||
@@ -19,16 +24,23 @@ export interface StartAppProviderProps {
|
||||
string,
|
||||
(params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
>;
|
||||
/** Application spec used to resolve route-specific fallback components. */
|
||||
spec?: StartAppSpec;
|
||||
/** Named functions available to `$computed` prop expressions. */
|
||||
functions?: Record<string, ComputedFunction>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Provide the component registry, actions, and TanStack navigation. */
|
||||
/** Provide rendering dependencies, route fallbacks, and TanStack navigation. */
|
||||
export function StartAppProvider({
|
||||
registry,
|
||||
handlers,
|
||||
spec,
|
||||
functions,
|
||||
children,
|
||||
}: StartAppProviderProps) {
|
||||
const router = useRouter();
|
||||
const pathname = useLocation({ select: (location) => location.pathname });
|
||||
const navigate = React.useCallback(
|
||||
(href: string) => {
|
||||
void router.navigate({ to: href });
|
||||
@@ -36,8 +48,8 @@ export function StartAppProvider({
|
||||
[router],
|
||||
);
|
||||
const value = React.useMemo(
|
||||
() => ({ registry, handlers, navigate }),
|
||||
[registry, handlers, navigate],
|
||||
() => ({ registry, handlers, spec, functions, pathname, navigate }),
|
||||
[registry, handlers, spec, functions, pathname, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import type { StartAppSpec } from "../types";
|
||||
import { resolveRouteFallback } from "./route-fallback";
|
||||
|
||||
function page(type: string): Spec {
|
||||
return {
|
||||
root: "root",
|
||||
elements: { root: { type, props: {}, children: [] } },
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveRouteFallback", () => {
|
||||
const loading = page("Loading");
|
||||
const error = page("Error");
|
||||
const notFound = page("NotFound");
|
||||
const spec: StartAppSpec = {
|
||||
routes: {
|
||||
"/blog/$slug": {
|
||||
page: page("Page"),
|
||||
loading,
|
||||
error,
|
||||
notFound,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("resolves each fallback from the matched route", () => {
|
||||
expect(resolveRouteFallback(spec, "/blog/post", "loading", undefined)).toBe(
|
||||
loading,
|
||||
);
|
||||
expect(resolveRouteFallback(spec, "/blog/post", "error", undefined)).toBe(
|
||||
error,
|
||||
);
|
||||
expect(
|
||||
resolveRouteFallback(spec, "/blog/post", "notFound", undefined),
|
||||
).toBe(notFound);
|
||||
});
|
||||
|
||||
it("prefers an explicit fallback and allows null to disable one", () => {
|
||||
const explicit = page("Explicit");
|
||||
expect(resolveRouteFallback(spec, "/blog/post", "loading", explicit)).toBe(
|
||||
explicit,
|
||||
);
|
||||
expect(
|
||||
resolveRouteFallback(spec, "/blog/post", "loading", null),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { matchRoute } from "../router";
|
||||
import type { StartAppSpec } from "../types";
|
||||
|
||||
export type RouteFallbackKind = "loading" | "error" | "notFound";
|
||||
|
||||
/** Resolve an explicit fallback or the fallback on the currently matched route. */
|
||||
export function resolveRouteFallback(
|
||||
spec: StartAppSpec | undefined,
|
||||
pathname: string | undefined,
|
||||
kind: RouteFallbackKind,
|
||||
explicitSpec: Spec | null | undefined,
|
||||
): Spec | null | undefined {
|
||||
if (explicitSpec !== undefined) return explicitSpec;
|
||||
if (!spec || pathname === undefined) return undefined;
|
||||
return matchRoute(spec, pathname)?.route[kind];
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export type {
|
||||
SetState,
|
||||
StateModel,
|
||||
} from "./catalog-types";
|
||||
export type { Spec, StateStore } from "@json-render/core";
|
||||
export type { ComputedFunction, Spec, StateStore } from "@json-render/core";
|
||||
export { createStateStore } from "@json-render/core";
|
||||
export type {
|
||||
ComponentRegistry,
|
||||
|
||||
@@ -35,6 +35,23 @@ describe("matchRoute", () => {
|
||||
expect(matched?.params).toEqual({ slug: "hello" });
|
||||
});
|
||||
|
||||
it("decodes dynamic and splat parameters", () => {
|
||||
expect(
|
||||
matchRoute(specWith({ "/blog/$slug": {} }), "/blog/hello%20world")
|
||||
?.params,
|
||||
).toEqual({ slug: "hello world" });
|
||||
expect(
|
||||
matchRoute(specWith({ "/docs/$": {} }), "/docs/guides/hello%20world")
|
||||
?.params,
|
||||
).toEqual({ _splat: ["guides", "hello world"] });
|
||||
});
|
||||
|
||||
it("does not match malformed encoded parameters", () => {
|
||||
expect(
|
||||
matchRoute(specWith({ "/blog/$slug": {} }), "/blog/%E0%A4%A"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("captures zero or more splat segments under _splat", () => {
|
||||
const spec = specWith({ "/docs/$": {} });
|
||||
expect(matchRoute(spec, "/docs")?.params).toEqual({ _splat: [] });
|
||||
@@ -54,6 +71,28 @@ describe("matchRoute", () => {
|
||||
expect(matchRoute(spec, "/blog/2026/post")?.pattern).toBe("/blog/$");
|
||||
});
|
||||
|
||||
it("ranks earlier static segments ahead of later static segments", () => {
|
||||
const spec = specWith({
|
||||
"/$type/edit": {},
|
||||
"/posts/$id": {},
|
||||
});
|
||||
expect(matchRoute(spec, "/posts/edit")?.pattern).toBe("/posts/$id");
|
||||
});
|
||||
|
||||
it("lets an earlier static segment outrank a later splat", () => {
|
||||
const spec = specWith({
|
||||
"/$type/edit": {},
|
||||
"/posts/$": {},
|
||||
});
|
||||
expect(matchRoute(spec, "/posts/edit")?.pattern).toBe("/posts/$");
|
||||
});
|
||||
|
||||
it("matches static segments case-insensitively like TanStack Router", () => {
|
||||
expect(matchRoute(specWith({ "/about": {} }), "/ABOUT")?.pattern).toBe(
|
||||
"/about",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an unmatched path", () => {
|
||||
expect(matchRoute(specWith({ "/": {} }), "/missing")).toBeNull();
|
||||
});
|
||||
@@ -73,6 +112,7 @@ describe("static paths", () => {
|
||||
staticParams: [{ slug: "hello" }, { slug: "world" }],
|
||||
},
|
||||
"/docs/$": { staticParams: [{ _splat: "guides/intro" }] },
|
||||
"/search/$query": { staticParams: [{ query: "hello world" }] },
|
||||
"/users/$id": {},
|
||||
});
|
||||
expect(collectStaticPaths(spec)).toEqual([
|
||||
@@ -81,6 +121,7 @@ describe("static paths", () => {
|
||||
"/blog/hello",
|
||||
"/blog/world",
|
||||
"/docs/guides/intro",
|
||||
"/search/hello%20world",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,40 +4,44 @@ interface CompiledRoute {
|
||||
pattern: string;
|
||||
regex: RegExp;
|
||||
paramNames: string[];
|
||||
splat: boolean;
|
||||
specificity: number;
|
||||
segmentRanks: number[];
|
||||
}
|
||||
|
||||
const SPLAT_PARAM = "_splat";
|
||||
const STATIC_SEGMENT_RANK = 3;
|
||||
const DYNAMIC_SEGMENT_RANK = 2;
|
||||
const SPLAT_SEGMENT_RANK = 1;
|
||||
|
||||
/** Compile a TanStack Router pattern into a pathname matcher. */
|
||||
function compileRoute(pattern: string): CompiledRoute {
|
||||
const paramNames: string[] = [];
|
||||
let splat = false;
|
||||
let specificity = 0;
|
||||
const segments = pattern === "/" ? [""] : pattern.split("/").slice(1);
|
||||
const regexParts: string[] = [];
|
||||
const segmentRanks: number[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment === "$") {
|
||||
paramNames.push(SPLAT_PARAM);
|
||||
splat = true;
|
||||
segmentRanks.push(SPLAT_SEGMENT_RANK);
|
||||
regexParts.push("(?:/(.+))?");
|
||||
} else if (segment.startsWith("$") && segment.length > 1) {
|
||||
paramNames.push(segment.slice(1));
|
||||
segmentRanks.push(DYNAMIC_SEGMENT_RANK);
|
||||
regexParts.push("/([^/]+)");
|
||||
} else {
|
||||
specificity++;
|
||||
segmentRanks.push(STATIC_SEGMENT_RANK);
|
||||
regexParts.push(`/${escapeRegExp(segment)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pattern,
|
||||
regex: new RegExp(pattern === "/" ? "^/$" : `^${regexParts.join("")}$`),
|
||||
regex: new RegExp(
|
||||
pattern === "/" ? "^/$" : `^${regexParts.join("")}$`,
|
||||
"i",
|
||||
),
|
||||
paramNames,
|
||||
splat,
|
||||
specificity,
|
||||
segmentRanks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,11 +58,13 @@ export function matchRoute(
|
||||
const compiled = Object.keys(spec.routes).map(compileRoute);
|
||||
|
||||
compiled.sort((a, b) => {
|
||||
if (a.splat !== b.splat) return a.splat ? 1 : -1;
|
||||
if (a.specificity !== b.specificity) {
|
||||
return b.specificity - a.specificity;
|
||||
const segmentCount = Math.max(a.segmentRanks.length, b.segmentRanks.length);
|
||||
for (let index = 0; index < segmentCount; index++) {
|
||||
const aRank = a.segmentRanks[index] ?? 0;
|
||||
const bRank = b.segmentRanks[index] ?? 0;
|
||||
if (aRank !== bRank) return bRank - aRank;
|
||||
}
|
||||
return a.paramNames.length - b.paramNames.length;
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const candidate of compiled) {
|
||||
@@ -66,12 +72,23 @@ export function matchRoute(
|
||||
if (!match) continue;
|
||||
|
||||
const params: Record<string, string | string[]> = {};
|
||||
let validParams = true;
|
||||
for (let index = 0; index < candidate.paramNames.length; index++) {
|
||||
const name = candidate.paramNames[index]!;
|
||||
const value = match[index + 1];
|
||||
params[name] =
|
||||
name === SPLAT_PARAM ? (value ? value.split("/") : []) : (value ?? "");
|
||||
try {
|
||||
params[name] =
|
||||
name === SPLAT_PARAM
|
||||
? value
|
||||
? value.split("/").map(decodeURIComponent)
|
||||
: []
|
||||
: decodeURIComponent(value ?? "");
|
||||
} catch {
|
||||
validParams = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!validParams) continue;
|
||||
|
||||
return {
|
||||
route: spec.routes[candidate.pattern]!,
|
||||
@@ -117,11 +134,11 @@ function buildPathFromPattern(
|
||||
for (const segment of pattern.split("/").slice(1)) {
|
||||
if (segment === "$") {
|
||||
const value = params[SPLAT_PARAM];
|
||||
if (value) result.push(...value.split("/"));
|
||||
if (value) result.push(...value.split("/").map(encodeURIComponent));
|
||||
} else if (segment.startsWith("$") && segment.length > 1) {
|
||||
const value = params[segment.slice(1)];
|
||||
if (!value) return null;
|
||||
result.push(value);
|
||||
result.push(encodeURIComponent(value));
|
||||
} else {
|
||||
result.push(segment);
|
||||
}
|
||||
|
||||
@@ -113,13 +113,19 @@ State merge precedence is application state, page state, then loader data.
|
||||
`getHead` merges app and route metadata into TanStack `meta` and `links`
|
||||
descriptors. `getStaticPaths` includes static routes plus dynamic routes with
|
||||
`staticParams`. Convert its strings to `{ path }` objects for TanStack Start's
|
||||
top-level `pages` plugin option.
|
||||
top-level `pages` plugin option. Loader params are URL-decoded, while values
|
||||
from `staticParams` are URL-encoded in generated paths.
|
||||
|
||||
## Route Wiring
|
||||
|
||||
```tsx
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { PageRenderer } from "@json-render/tanstack-start";
|
||||
import {
|
||||
PageRenderer,
|
||||
StartErrorBoundary,
|
||||
StartLoading,
|
||||
StartNotFound,
|
||||
} from "@json-render/tanstack-start";
|
||||
import { getHead, getPageData } from "@/lib/json-app";
|
||||
|
||||
export const Route = createFileRoute("/$")({
|
||||
@@ -130,6 +136,9 @@ export const Route = createFileRoute("/$")({
|
||||
},
|
||||
head: ({ match }) => getHead({ pathname: match.pathname }),
|
||||
component: () => <PageRenderer {...Route.useLoaderData()} />,
|
||||
pendingComponent: StartLoading,
|
||||
errorComponent: StartErrorBoundary,
|
||||
notFoundComponent: StartNotFound,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -151,6 +160,7 @@ import {
|
||||
Scripts,
|
||||
} from "@tanstack/react-router";
|
||||
import { StartAppProvider } from "@json-render/tanstack-start";
|
||||
import { spec } from "@/lib/spec";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
@@ -159,7 +169,11 @@ export const Route = createRootRoute({
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<StartAppProvider registry={registry} handlers={handlers}>
|
||||
<StartAppProvider
|
||||
registry={registry}
|
||||
handlers={handlers}
|
||||
spec={spec}
|
||||
>
|
||||
<Outlet />
|
||||
</StartAppProvider>
|
||||
<Scripts />
|
||||
@@ -171,7 +185,9 @@ export const Route = createRootRoute({
|
||||
|
||||
Use `StartLoading`, `StartErrorBoundary`, and `StartNotFound` for TanStack
|
||||
Router's `pendingComponent`, `errorComponent`, and `notFoundComponent` options.
|
||||
Each accepts an optional json-render fallback spec.
|
||||
When `StartAppProvider` receives `spec`, each component selects the matched
|
||||
route's corresponding fallback. Explicit fallback props override that lookup.
|
||||
Pass named `$computed` implementations through `StartAppProvider.functions`.
|
||||
|
||||
Import React components from `@json-render/tanstack-start`. Import `schema`,
|
||||
`createStartApp`, `matchRoute`, `resolveMetadata`, and static path helpers from
|
||||
|
||||
Reference in New Issue
Block a user