Compare commits

...
Author SHA1 Message Date
Chris Tate 4ad20d3ede fix(core): reject prototype-polluting paths 2026-09-13 20:40:58 -05:00
Chris Tate 6c7164342a feat(tanstack-start): add renderer (#334)
* feat(tanstack-start): add renderer

- Add full-app routing, SSR data helpers, metadata, layouts, and navigation.
- Validate with focused tests and the current TanStack Router type contract.
- Document the package across the API reference, renderer guide, and agent skill.

* fix(tanstack-start): address review findings

* fix(tanstack-start): align runtime contracts

- Merge layout state into page rendering with explicit precedence.
- Match TanStack Router string splat parameters across runtime, types, and docs.
- Preserve the client boundary and add regression coverage.

* fix(tanstack-start): match empty splats

- Align root and trailing-slash splat matching with TanStack Router.
- Cover empty splat paths with regression tests.

* fix(tanstack-start): harden route transitions

- Scope page state to the rendered route during pending navigation.
- Normalize route matching across empty splats and trailing slashes.
- Preserve client boundaries in CommonJS and ESM builds.

* fix(tanstack-start): handle route recovery edge cases

- Retry failed loaders by invalidating the router.
- Normalize trailing-slash and encoded route pathnames.
- Add regression coverage and document corrected behavior.

* fix(tanstack-start): preserve encoded percent params

- Preserve encoded percent signs through pathname normalization.
- Cover dynamic, splat, and static-path round trips.
2026-09-08 16:53:45 -05:00
Railly Hugo ea3326046f fix(react): stabilize streaming renders (#325)
* fix(react): stabilize streaming renders

* fix(react): tolerate incomplete streamed props

* fix(react): preserve nested prop identities
2026-08-30 18:20:02 -03:00
Railly Hugoandwotnak a4d033cf04 feat(vue): support named slots (#323)
Recreate the Vue named slots implementation from #322 and add regression coverage for raw registries, lazy slots, diagnostics, loading, repeat scope, and prompt output.

Co-authored-by: wotnak <wotnak@pm.me>
2026-08-19 12:28:05 -03:00
58 changed files with 5373 additions and 52 deletions
+49
View File
@@ -131,6 +131,7 @@ function Dashboard({ spec }) {
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (20 built-in components, including GaussianSplat) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/next` | Next.js renderer — JSON becomes full apps with routes, layouts, SSR |
| `@json-render/tanstack-start` | TanStack Start renderer — full apps with routes, layouts, SSR, and head metadata |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
@@ -536,6 +537,54 @@ const app = createNextApp({ spec });
// </NextAppProvider>
```
### TanStack Start (Full Apps)
```tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
type StartAppSpec,
} from "@json-render/tanstack-start";
import { createStartApp } from "@json-render/tanstack-start/server";
const spec: StartAppSpec = {
metadata: { title: { default: "My App", template: "%s | My App" } },
routes: {
"/": {
metadata: { title: "Home" },
page: {
root: "hero",
elements: {
hero: { type: "Card", props: { title: "Welcome" }, children: [] },
},
},
},
},
};
const { getPageData, getHead } = createStartApp({ spec });
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
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
@@ -451,6 +451,8 @@ const value = getByPath(state, '/user/name'); // "Alice"
setByPath(state, '/user/email', 'alice@example.com');
```
For prototype safety, path utilities, state stores, and SpecStream patches reject JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens. Compound patches validate both `path` and `from` before mutating data.
### resolveDynamicValue
```typescript
@@ -0,0 +1,393 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("docs/api/tanstack-start");
# @json-render/tanstack-start
TanStack Start renderer for JSON-defined applications with routes, layouts,
head metadata, SSR loaders, prerender paths, and client navigation.
## Installation
```bash
npm install @json-render/core @json-render/react @json-render/tanstack-start
```
## schema
Use the Start application schema to generate full multi-page specs.
```typescript
import { defineCatalog } from "@json-render/core";
import {
schema,
startComponentDefinitions,
} from "@json-render/tanstack-start/server";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...startComponentDefinitions,
Card: {
props: z.object({ title: z.string() }),
description: "Card container",
},
NavBar: {
props: z.object({}),
slots: ["default"],
description: "Application navigation",
},
},
actions: {},
});
```
The generation prompt teaches TanStack Router's `$param` and `$` splat route
syntax, reusable layouts, escaped JSON Patch route keys, and the built-in
`Slot`, `Link`, and `navigate` capabilities.
Include `startComponentDefinitions` in the catalog so generated `Slot` and
`Link` elements pass validation. `PageRenderer` supplies their React
implementations automatically.
## createStartApp
Create helpers for a TanStack Start splat route.
```typescript
import { createStartApp } from "@json-render/tanstack-start/server";
export const { getPageData, getHead, getStaticPaths } = createStartApp({
spec,
loaders: {
post: async ({ slug }) => ({
post: await getPost(slug as string),
}),
},
});
```
### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>spec</code>
</td>
<td>
<code>
{"StartAppSpec | (() => StartAppSpec | Promise<StartAppSpec>)"}
</code>
</td>
<td>A static application spec or an async spec factory</td>
</tr>
<tr>
<td>
<code>loaders</code>
</td>
<td>
<code>{"Record<string, LoaderFn>"}</code>
</td>
<td>Named data loaders referenced by route specs</td>
</tr>
</tbody>
</table>
### Returns
<table>
<thead>
<tr>
<th>Helper</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>getPageData</code>
</td>
<td>
Matches a pathname, runs its loader, and returns serializable page and
layout data
</td>
</tr>
<tr>
<td>
<code>getHead</code>
</td>
<td>
Returns TanStack Router <code>meta</code> and <code>links</code>{" "}
descriptors
</td>
</tr>
<tr>
<td>
<code>getStaticPaths</code>
</td>
<td>Returns concrete paths for TanStack Start prerendering</td>
</tr>
</tbody>
</table>
State is merged in this order: application state, layout state, page state,
then loader data. Later sources override earlier values.
## StartAppSpec
```typescript
interface StartAppSpec {
metadata?: StartMetadata;
routes: Record<string, StartRouteSpec>;
layouts?: Record<string, Spec>;
state?: Record<string, unknown>;
}
```
Each route requires a `page` spec and can select a layout, metadata, a named
loader, loading/error/not-found specs, and static parameters.
### Route Patterns
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Example</th>
<th>Params</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>/</code>
</td>
<td>
<code>/</code>
</td>
<td>
<code>{"{}"}</code>
</td>
</tr>
<tr>
<td>
<code>/about</code>
</td>
<td>
<code>/about</code>
</td>
<td>
<code>{"{}"}</code>
</td>
</tr>
<tr>
<td>
<code>{"/blog/$slug"}</code>
</td>
<td>
<code>/blog/hello</code>
</td>
<td>
<code>{'{ slug: "hello" }'}</code>
</td>
</tr>
<tr>
<td>
<code>{"/docs/$"}</code>
</td>
<td>
<code>/docs/guides/intro</code>
</td>
<td>
<code>{'{ _splat: "guides/intro" }'}</code>
</td>
</tr>
</tbody>
</table>
Loader parameters are URL-decoded before they reach named loaders. Splat
content is a slash-delimited string under `_splat`. Parameter values supplied
through `staticParams` are URL-encoded in the paths returned by
`getStaticPaths()`.
Route matching treats trailing slashes as optional and accepts both encoded and
decoded pathname representations. This keeps loader data and route metadata in
sync for static paths containing spaces or non-ASCII characters.
For prerendered dynamic routes, provide `staticParams`:
```typescript
routes: {
'/blog/$slug': {
page,
staticParams: [{ slug: 'hello' }, { slug: 'world' }],
},
'/docs/$': {
page: docsPage,
staticParams: [{ _splat: 'guides/intro' }],
},
}
```
Map `getStaticPaths()` into TanStack Start's top-level `pages` configuration:
```typescript
const pages = (await getStaticPaths()).map((path) => ({ path }));
```
## TanStack Route Setup
Wire the helpers to a file-based `$` splat route:
```tsx
// src/routes/$.tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
} from "@json-render/tanstack-start";
import { getHead, getPageData } from "@/lib/json-app";
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => getHead({ pathname: match.pathname }),
component: Page,
pendingComponent: StartLoading,
errorComponent: StartErrorBoundary,
notFoundComponent: StartNotFound,
});
function Page() {
return <PageRenderer {...Route.useLoaderData()} />;
}
```
TanStack Router loaders are isomorphic. When a spec factory or named loader
uses database clients, credentials, or server-only imports, invoke
`getPageData` and `getHead` inside a TanStack Start `createServerFn` and call
that server function from the route loader.
## StartAppProvider
Provide component implementations and action handlers around the root
`Outlet`. Render `HeadContent` for route metadata.
```tsx
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router";
import { StartAppProvider } from "@json-render/tanstack-start";
import { spec } from "@/lib/spec";
export const Route = createRootRoute({
component: () => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<StartAppProvider
registry={registry}
handlers={handlers}
spec={spec}
>
<Outlet />
</StartAppProvider>
<Scripts />
</body>
</html>
),
});
```
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` resolve the matched
route's fallback specs when they are used as TanStack Router boundary
components and the provider receives `spec`.
The default `StartErrorBoundary` fallback invalidates the router and reruns the
failed loader when the user selects **Try again**.
`Slot` and `Link` are automatically added to the page registry.
## Server Utilities
```typescript
import {
collectStaticPaths,
matchRoute,
metadataToHead,
resolveMetadata,
splatToPath,
} from "@json-render/tanstack-start/server";
```
## Entry Points
<table>
<thead>
<tr>
<th>Import</th>
<th>Contents</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>@json-render/tanstack-start</code>
</td>
<td>Provider, page renderer, Link, and route fallback components</td>
</tr>
<tr>
<td>
<code>@json-render/tanstack-start/server</code>
</td>
<td>App factory, schema, matcher, metadata, and prerender helpers</td>
</tr>
<tr>
<td>
<code>@json-render/tanstack-start/catalog</code>
</td>
<td>Server-safe definitions for built-in Slot and Link components</td>
</tr>
</tbody>
</table>
+25 -4
View File
@@ -95,7 +95,7 @@ store.set("/count", 1);
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `slots`, `emit`, `on`, and `loading` with catalog-inferred types.
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient.
@@ -105,8 +105,12 @@ import { defineRegistry } from "@json-render/vue";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Layout: ({ slots }) =>
h("div", { class: "layout" }, [
h("header", null, slots.header?.()),
h("main", null, slots.default?.()),
h("footer", null, slots.footer?.()),
]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
@@ -136,11 +140,12 @@ const { registry } = defineRegistry(catalog, {
### Component Props (via defineRegistry)
```typescript
import type { VNode } from "vue";
import type { Slots, VNode } from "vue";
interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: VNode | VNode[]; // Rendered children (for container components)
slots: Slots; // Vue-native slot functions
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean;
@@ -154,6 +159,22 @@ interface EventHandle {
}
```
Use `children` for the default slot. For other slots declared by the catalog, add a top-level `slots` map to the spec element:
```json
{
"type": "Layout",
"props": {},
"children": ["main-content"],
"slots": {
"header": ["page-heading"],
"footer": ["page-actions"]
}
}
```
The component renders these regions with Vue's native slot functions: `slots.header?.()`, `slots.footer?.()`, and so on. `slots.default?.()` renders the spec's `children`; `children` is a convenience alias for that rendered result. In the JSON spec, keep default content in `children` rather than adding a `default` entry to `slots`.
Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`:
```typescript
@@ -62,6 +62,15 @@ All renderers share the same workflow:
</td>
<td>Native mobile views</td>
</tr>
<tr>
<td>TanStack Start</td>
<td>
<code>@json-render/tanstack-start</code>
</td>
<td>
Full React applications with routes, layouts, SSR, and head metadata
</td>
</tr>
<tr>
<td>Image</td>
<td>
@@ -213,6 +222,32 @@ const { registry } = defineRegistry(catalog, { components: {} });
See the [@json-render/react-native API reference](/docs/api/react-native) for details.
## TanStack Start
Define complete TanStack Start applications with route specs, reusable layouts,
loader-backed state, head metadata, and prerender paths. The integration uses
the React renderer for each page and TanStack Router for navigation.
```tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import { PageRenderer } from "@json-render/tanstack-start";
import { createStartApp } from "@json-render/tanstack-start/server";
const { getPageData, getHead } = createStartApp({ spec });
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => getHead({ pathname: match.pathname }),
component: () => <PageRenderer {...Route.useLoaderData()} />,
});
```
See the [@json-render/tanstack-start API reference](/docs/api/tanstack-start) for details.
## Image
Generate SVG and PNG images from JSON specs using Satori. Ideal for OG images, social cards, and banners.
+6
View File
@@ -10,6 +10,7 @@ json-render ships with skills that teach AI coding agents how to use each packag
- **core** — Core schemas, catalogs, and AI prompt generation.
- **react** — React renderer that turns JSON specs into React component trees.
- **tanstack-start** — Full TanStack Start applications with routes, layouts, SSR loaders, and head metadata.
- **react-pdf** — PDF renderer using `@react-pdf/renderer`.
- **react-email** — Email renderer that produces HTML or plain-text emails.
- **react-native** — React Native renderer for native mobile UIs.
@@ -31,6 +32,7 @@ json-render ships with skills that teach AI coding agents how to use each packag
```bash
npx skills add vercel-labs/json-render --skill core
npx skills add vercel-labs/json-render --skill react
npx skills add vercel-labs/json-render --skill tanstack-start
npx skills add vercel-labs/json-render --skill react-pdf
npx skills add vercel-labs/json-render --skill react-email
npx skills add vercel-labs/json-render --skill react-native
@@ -58,6 +60,10 @@ The foundational skill. Teaches agents how to define catalogs, create schemas, b
Teaches agents how to render JSON specs as React component trees using `JsonRender`, `JsonRenderClient`, and `useJsonRender`. Covers custom component registries, client-side interactivity, state management, and streaming integration.
## tanstack-start
Teaches agents how to build JSON-defined TanStack Start applications with splat routes, reusable layouts, SSR-safe loaders, head metadata, prerender paths, and client navigation.
## react-pdf
Teaches agents how to generate PDFs from JSON specs using `@react-pdf/renderer`. Covers the PDF-specific component registry, page layout, and styling.
+1 -1
View File
@@ -206,7 +206,7 @@ Each element in the map has a consistent shape:
- `type` — Component type from your catalog
- `props` — Component properties
- `children` — Array of child element keys
- `slots`: Optional map of named slots to child element keys. Use `children` for the default slot. Named slot rendering is currently supported by `@json-render/react`.
- `slots`: Optional map of named slots to child element keys. Use `children` for the default slot. Named slot rendering is supported by `@json-render/react` and `@json-render/vue`.
### Dynamic Data
+2 -2
View File
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/directives, @json-render/codegen, @json-render/devtools, @json-render/devtools-react, @json-render/devtools-vue, @json-render/devtools-svelte, @json-render/devtools-solid, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, directives, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/tanstack-start, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/directives, @json-render/codegen, @json-render/devtools, @json-render/devtools-react, @json-render/devtools-vue, @json-render/devtools-svelte, @json-render/devtools-solid, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, tanstack-start, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, directives, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
+4
View File
@@ -72,6 +72,10 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/next", href: "/docs/api/next" },
{
title: "@json-render/tanstack-start",
href: "/docs/api/tanstack-start",
},
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
{ title: "@json-render/react-email", href: "/docs/api/react-email" },
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
+1
View File
@@ -46,6 +46,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/next": "@json-render/next API",
"docs/api/tanstack-start": "@json-render/tanstack-start API",
"docs/api/vue": "@json-render/vue API",
"docs/api/solid": "@json-render/solid API",
"docs/api/react-pdf": "@json-render/react-pdf API",
+2
View File
@@ -126,6 +126,8 @@ SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/ht
All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
For prototype safety, JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens are rejected by path utilities, state stores, and SpecStream. This applies to both `path` and `from` in compound patches.
### Low-Level Utilities
```typescript
+35 -1
View File
@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { createStateStore, flattenToPointers } from "./state-store";
import {
createStateStore,
flattenToPointers,
immutableSetByPath,
} from "./state-store";
describe("createStateStore", () => {
it("creates a store with initial state", () => {
@@ -135,6 +139,36 @@ describe("createStateStore", () => {
store.set("/x", 2);
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
});
it.each(["__proto__", "constructor", "prototype"])(
"rejects %s state paths without publishing a snapshot",
(token) => {
const store = createStateStore({ safe: true });
const listener = vi.fn();
const snapshot = store.getSnapshot();
store.subscribe(listener);
store.set(`/${token}/polluted`, "value");
store.update({ [`/safe/${token}/polluted`]: "value" });
expect(store.getSnapshot()).toBe(snapshot);
expect(listener).not.toHaveBeenCalled();
},
);
});
describe("immutableSetByPath", () => {
it.each(["__proto__", "constructor", "prototype"])(
"rejects %s without changing snapshot identity or its prototype",
(token) => {
const state = { safe: true };
const result = immutableSetByPath(state, `/${token}/polluted`, "value");
expect(result).toBe(state);
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
expect(result).toEqual({ safe: true });
},
);
});
describe("flattenToPointers", () => {
+7
View File
@@ -1,5 +1,6 @@
import {
getByPath,
isSafeJsonPointerPath,
parseJsonPointer,
type StateModel,
type StateStore,
@@ -15,6 +16,8 @@ export function immutableSetByPath(
path: string,
value: unknown,
): StateModel {
if (!isSafeJsonPointerPath(path)) return root;
const segments = parseJsonPointer(path);
if (segments.length === 0) return root;
@@ -72,6 +75,7 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
},
set(path: string, value: unknown): void {
if (!isSafeJsonPointerPath(path)) return;
if (getByPath(state, path) === value) return;
state = immutableSetByPath(state, path, value);
notify();
@@ -81,6 +85,7 @@ export function createStateStore(initialState: StateModel = {}): StateStore {
let changed = false;
let next = state;
for (const [path, value] of Object.entries(updates)) {
if (!isSafeJsonPointerPath(path)) continue;
if (getByPath(next, path) !== value) {
next = immutableSetByPath(next, path, value);
changed = true;
@@ -137,6 +142,7 @@ export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
},
set(path: string, value: unknown): void {
if (!isSafeJsonPointerPath(path)) return;
const current = config.getSnapshot();
if (getByPath(current, path) === value) return;
config.setSnapshot(immutableSetByPath(current, path, value));
@@ -146,6 +152,7 @@ export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
let next = config.getSnapshot();
let changed = false;
for (const [path, value] of Object.entries(updates)) {
if (!isSafeJsonPointerPath(path)) continue;
if (getByPath(next, path) !== value) {
next = immutableSetByPath(next, path, value);
changed = true;
+61
View File
@@ -215,6 +215,67 @@ describe("JSON Pointer escaping (RFC 6901)", () => {
});
});
// =============================================================================
// JSON Pointer prototype safety
// =============================================================================
describe("JSON Pointer prototype safety", () => {
const blockedTokens = ["__proto__", "constructor", "prototype"];
it.each(blockedTokens)("rejects %s in path utility writes", (token) => {
const pollutionKey = "__json_render_pollution_probe__";
const data: Record<string, unknown> = {};
try {
setByPath(data, `/${token}/${pollutionKey}`, "set");
addByPath(data, `/safe/${token}/${pollutionKey}`, "add");
expect(data).toEqual({});
expect(Object.prototype).not.toHaveProperty(pollutionKey);
} finally {
delete (Object.prototype as Record<string, unknown>)[pollutionKey];
}
});
it("does not read or remove values through Object.prototype", () => {
const pollutionKey = "__json_render_inherited_probe__";
(Object.prototype as Record<string, unknown>)[pollutionKey] = "keep";
try {
expect(getByPath({}, `/__proto__/${pollutionKey}`)).toBeUndefined();
removeByPath({}, `/__proto__/${pollutionKey}`);
expect((Object.prototype as Record<string, unknown>)[pollutionKey]).toBe(
"keep",
);
} finally {
delete (Object.prototype as Record<string, unknown>)[pollutionKey];
}
});
it.each(blockedTokens)(
"rejects compound patches containing %s before mutation",
(token) => {
const destination: Record<string, unknown> = { source: "one" };
applySpecStreamPatch(destination, {
op: "move",
from: "/source",
path: `/${token}/moved`,
});
const source: Record<string, unknown> = {};
applySpecStreamPatch(source, {
op: "copy",
from: `/${token}/value`,
path: "/copy",
});
expect(destination).toEqual({ source: "one" });
expect(source).toEqual({});
expect(Object.hasOwn(source, "copy")).toBe(false);
},
);
});
// =============================================================================
// addByPath (RFC 6902 "add" semantics)
// =============================================================================
+33 -3
View File
@@ -280,6 +280,26 @@ export function parseJsonPointer(path: string): string[] {
return raw.map(unescapeJsonPointer);
}
const blockedJsonPointerTokens = new Set([
"__proto__",
"constructor",
"prototype",
]);
/**
* Reject tokens that can traverse or modify JavaScript prototype chains.
* Validation happens after JSON Pointer unescaping so encoded paths cannot
* bypass it.
*/
function hasBlockedJsonPointerToken(segments: string[]): boolean {
return segments.some((segment) => blockedJsonPointerTokens.has(segment));
}
/** @internal Shared by JSON Pointer-based state stores. */
export function isSafeJsonPointerPath(path: string): boolean {
return !hasBlockedJsonPointerToken(parseJsonPointer(path));
}
/**
* Get a value from an object by JSON Pointer path (RFC 6901)
*/
@@ -289,6 +309,7 @@ export function getByPath(obj: unknown, path: string): unknown {
}
const segments = parseJsonPointer(path);
if (hasBlockedJsonPointerToken(segments)) return undefined;
let current: unknown = obj;
@@ -362,7 +383,7 @@ export function setByPath(
): void {
const segments = parseJsonPointer(path);
if (segments.length === 0) return;
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
let current: Record<string, unknown> | unknown[] = obj;
@@ -412,7 +433,7 @@ export function addByPath(
): void {
const segments = parseJsonPointer(path);
if (segments.length === 0) return;
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
let current: Record<string, unknown> | unknown[] = obj;
@@ -458,7 +479,7 @@ export function addByPath(
export function removeByPath(obj: Record<string, unknown>, path: string): void {
const segments = parseJsonPointer(path);
if (segments.length === 0) return;
if (segments.length === 0 || hasBlockedJsonPointerToken(segments)) return;
let current: Record<string, unknown> | unknown[] = obj;
@@ -617,6 +638,15 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
obj: T,
patch: SpecStreamLine,
): T {
if (!isSafeJsonPointerPath(patch.path)) return obj;
if (
(patch.op === "move" || patch.op === "copy") &&
patch.from !== undefined &&
!isSafeJsonPointerPath(patch.from)
) {
return obj;
}
switch (patch.op) {
case "add":
addByPath(obj, patch.path, patch.value);
+2 -1
View File
@@ -50,6 +50,7 @@ export interface ValidationContextValue {
}
const ValidationContext = createContext<ValidationContextValue | null>(null);
const EMPTY_VALIDATION_FUNCTIONS: Record<string, ValidationFunction> = {};
/**
* Props for ValidationProvider
@@ -127,7 +128,7 @@ function validationConfigEqual(
* Provider for validation
*/
export function ValidationProvider({
customFunctions = {},
customFunctions = EMPTY_VALIDATION_FUNCTIONS,
children,
}: ValidationProviderProps) {
const { state, getSnapshot } = useStateStore();
@@ -0,0 +1,361 @@
import { act, render } from "@testing-library/react";
import type { Spec } from "@json-render/core";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { useStateStore } from "./contexts/state";
import { JSONUIProvider, Renderer, type ComponentRegistry } from "./renderer";
function renderValue(value: unknown) {
const registry: ComponentRegistry = {
Value: ({ element }) => <span>{String(element.props.value)}</span>,
};
const spec: Spec = {
root: "value",
elements: { value: { type: "Value", props: { value } } },
};
return {
registry,
spec,
view: render(
<JSONUIProvider registry={registry}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
),
};
}
describe("JSON Render PR #325 contract probes", () => {
it("preserves nested resolved prop identity across unrelated state writes", () => {
let effects = 0;
function Value({
element,
}: React.ComponentProps<ComponentRegistry[string]>) {
const { set } = useStateStore();
React.useEffect(() => {
effects += 1;
if (effects < 3) set("/unrelated", effects);
}, [element.props.options, set]);
return null;
}
const registry: ComponentRegistry = { Value };
const spec: Spec = {
root: "value",
elements: {
value: {
type: "Value",
props: { options: { layout: { gap: 8 } } },
},
},
};
render(
<JSONUIProvider registry={registry}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(effects).toBe(1);
});
it("shares unchanged resolved subtrees when a sibling changes", () => {
const observed: Array<Record<string, unknown>> = [];
const registry: ComponentRegistry = {
Value: ({ element }) => {
observed.push(element.props);
return null;
},
};
const first: Spec = {
root: "value",
state: { revision: 1 },
elements: {
value: {
type: "Value",
props: {
options: { layout: { gap: 8 }, columns: [1, 2] },
revision: { $state: "/revision" },
},
},
},
};
let setRevision: ((value: number) => void) | undefined;
function Controls() {
const { set } = useStateStore();
setRevision = (value) => set("/revision", value);
return null;
}
render(
<JSONUIProvider registry={registry} initialState={first.state}>
<Controls />
<Renderer spec={first} registry={registry} />
</JSONUIProvider>,
);
act(() => setRevision?.(2));
expect(observed).toHaveLength(2);
expect(observed[1]).not.toBe(observed[0]);
expect(observed[1]?.options).toBe(observed[0]?.options);
expect((observed[1]?.options as { layout: unknown }).layout).toBe(
(observed[0]?.options as { layout: unknown }).layout,
);
expect((observed[1]?.options as { columns: unknown }).columns).toBe(
(observed[0]?.options as { columns: unknown }).columns,
);
expect(observed[1]?.revision).toBe(2);
});
it("does not mutate containers delivered by reference from state", () => {
const observed: unknown[] = [];
const registry: ComponentRegistry = {
Value: ({ element }) => {
observed.push(element.props.user);
return null;
},
};
const spec: Spec = {
root: "value",
state: { user: { profile: { name: "a" }, revision: 1 } },
elements: {
value: { type: "Value", props: { user: { $state: "/user" } } },
},
};
let setUser: ((value: unknown) => void) | undefined;
function Controls() {
const { set } = useStateStore();
setUser = (value) => set("/user", value);
return null;
}
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Controls />
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
const replacementProfile = { name: "a" };
const replacement = { profile: replacementProfile, revision: 2 };
act(() => setUser?.(replacement));
expect(replacement.profile).toBe(replacementProfile);
expect((observed.at(-1) as { revision: number }).revision).toBe(2);
const frozen = Object.freeze({
profile: Object.freeze({ name: "a" }),
revision: 3,
});
expect(() => act(() => setUser?.(frozen))).not.toThrow();
expect((observed.at(-1) as { revision: number }).revision).toBe(3);
});
it("delivers changed resolved object and array subtrees as fresh values", () => {
const observed: unknown[] = [];
const registry: ComponentRegistry = {
Value: ({ element }) => {
observed.push(element.props.options);
return null;
},
};
const spec: Spec = {
root: "value",
state: { gap: 8 },
elements: {
value: {
type: "Value",
props: {
options: {
layout: { gap: { $state: "/gap" } },
columns: [{ $state: "/gap" }],
},
},
},
},
};
let setGap: ((value: number) => void) | undefined;
function Controls() {
const { set } = useStateStore();
setGap = (value) => set("/gap", value);
return null;
}
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Controls />
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
act(() => setGap?.(12));
const first = observed[0] as {
layout: Record<string, unknown>;
columns: unknown[];
};
const second = observed[1] as typeof first;
expect(second).not.toBe(first);
expect(second.layout).not.toBe(first.layout);
expect(second.columns).not.toBe(first.columns);
expect(second.layout.gap).toBe(12);
expect(second.columns[0]).toBe(12);
});
it.each([
[
"nested present undefined to absent",
{ value: { nested: undefined } },
{ value: {} },
],
[
"nested absent to present undefined",
{ value: {} },
{ value: { nested: undefined } },
],
["function identity", { value: () => "first" }, { value: () => "second" }],
[
"symbol identity",
{ value: Symbol("first") },
{ value: Symbol("second") },
],
["BigInt value", { value: 1n }, { value: 2n }],
])("distinguishes changed %s", (_label, firstProps, secondProps) => {
const display = (value: unknown) => {
if (typeof value === "function") return value();
if (value && typeof value === "object") {
return Object.keys(value).join(",");
}
return String(value);
};
const registry: ComponentRegistry = {
Value: ({ element }) => (
<span>{`${Object.keys(element.props).join(",")}:${display(element.props.value)}`}</span>
),
};
const initial: Spec = {
root: "value",
elements: { value: { type: "Value", props: firstProps } },
};
const view = render(
<JSONUIProvider registry={registry}>
<Renderer spec={initial} registry={registry} />
</JSONUIProvider>,
);
const next: Spec = {
root: "value",
elements: { value: { type: "Value", props: secondProps } },
};
view.rerender(
<JSONUIProvider registry={registry}>
<Renderer spec={next} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe(
`${Object.keys(secondProps).join(",")}:${display(secondProps.value)}`,
);
});
it("accepts BigInt prop values without throwing", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderValue(1n)).not.toThrow();
error.mockRestore();
});
it("does not invalidate an unchanged nested NaN value", () => {
const component = vi.fn(() => null);
const registry: ComponentRegistry = { Value: component };
const first: Spec = {
root: "value",
elements: {
value: { type: "Value", props: { value: { nested: Number.NaN } } },
},
};
const view = render(
<JSONUIProvider registry={registry}>
<Renderer spec={first} registry={registry} />
</JSONUIProvider>,
);
const second: Spec = {
root: "value",
elements: {
value: { type: "Value", props: { value: { nested: Number.NaN } } },
},
};
view.rerender(
<JSONUIProvider registry={registry}>
<Renderer spec={second} registry={registry} />
</JSONUIProvider>,
);
expect(component).toHaveBeenCalledTimes(1);
});
it("invalidates a nested negative zero to zero transition", () => {
const component = vi.fn(
({ element }: React.ComponentProps<ComponentRegistry[string]>) => (
<span>
{Object.is((element.props.value as { nested: number }).nested, -0)
? "negative-zero"
: "zero"}
</span>
),
);
const registry: ComponentRegistry = { Value: component };
const first: Spec = {
root: "value",
elements: {
value: { type: "Value", props: { value: { nested: -0 } } },
},
};
const view = render(
<JSONUIProvider registry={registry}>
<Renderer spec={first} registry={registry} />
</JSONUIProvider>,
);
const second: Spec = {
root: "value",
elements: {
value: { type: "Value", props: { value: { nested: 0 } } },
},
};
view.rerender(
<JSONUIProvider registry={registry}>
<Renderer spec={second} registry={registry} />
</JSONUIProvider>,
);
expect(component).toHaveBeenCalledTimes(2);
expect(view.container.textContent).toBe("zero");
});
it.each([
["function", () => "stable"],
["symbol", Symbol("stable")],
["BigInt", 1n],
])("does not invalidate an unchanged %s value", (_label, value) => {
const component = vi.fn(() => null);
const registry: ComponentRegistry = { Value: component };
const first: Spec = {
root: "value",
elements: { value: { type: "Value", props: { value } } },
};
const view = render(
<JSONUIProvider registry={registry}>
<Renderer spec={first} registry={registry} />
</JSONUIProvider>,
);
const second: Spec = {
root: "value",
elements: { value: { type: "Value", props: { value } } },
};
view.rerender(
<JSONUIProvider registry={registry}>
<Renderer spec={second} registry={registry} />
</JSONUIProvider>,
);
expect(component).toHaveBeenCalledTimes(1);
});
});
+313 -8
View File
@@ -93,6 +93,7 @@ const registryMetadata = new WeakMap<
ComponentRegistry,
Record<string, { slots?: string[] }>
>();
const EMPTY_ELEMENT_PROPS: Record<string, unknown> = {};
/**
* Props for the Renderer component
@@ -115,6 +116,7 @@ export interface RendererProps {
interface ElementErrorBoundaryProps {
elementType: string;
resetKey: number | undefined;
children: ReactNode;
}
@@ -143,6 +145,12 @@ class ElementErrorBoundary extends React.Component<
);
}
componentDidUpdate(previous: ElementErrorBoundaryProps) {
if (this.state.hasError && previous.resetKey !== this.props.resetKey) {
this.setState({ hasError: false });
}
}
render() {
if (this.state.hasError) {
// Render nothing – the element silently disappears rather than
@@ -186,8 +194,262 @@ interface ElementRendererProps {
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
signatures: Record<string, number>;
}
function stabilizeRecord<T extends Record<string, unknown> | undefined>(
value: T,
ref: React.MutableRefObject<T>,
): T {
const previous = ref.current;
if (previous === value) return previous;
if (previous && value) {
const keys = Object.keys(value);
if (
keys.length === Object.keys(previous).length &&
keys.every(
(key) =>
Object.prototype.hasOwnProperty.call(previous, key) &&
previous[key] === value[key],
)
) {
return previous;
}
}
ref.current = value;
return value;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object") return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function structurallyEqual(previous: unknown, next: unknown): boolean {
if (Object.is(previous, next)) return true;
const previousIsArray = Array.isArray(previous);
if (previousIsArray !== Array.isArray(next)) return false;
if (
!previousIsArray &&
(previous === null ||
next === null ||
typeof previous !== "object" ||
typeof next !== "object")
) {
return false;
}
const previousRecord = previous as Record<string, unknown>;
const nextRecord = next as Record<string, unknown>;
const keys = Object.keys(nextRecord);
if (
(previousIsArray &&
(previous as unknown[]).length !== (next as unknown[]).length) ||
keys.length !== Object.keys(previousRecord).length
) {
return false;
}
return keys.every(
(key) =>
Object.prototype.hasOwnProperty.call(previousRecord, key) &&
structurallyEqual(previousRecord[key], nextRecord[key]),
);
}
function snapshotStructuralValue(value: unknown): unknown {
if (value === null || typeof value !== "object") return value;
const snapshot: Record<string, unknown> | unknown[] = Array.isArray(value)
? new Array(value.length)
: {};
for (const key of Object.keys(value)) {
(snapshot as Record<string, unknown>)[key] = snapshotStructuralValue(
(value as Record<string, unknown>)[key],
);
}
return snapshot;
}
function shareResolvedValue(previous: unknown, next: unknown): unknown {
if (Object.is(previous, next)) return previous;
const previousIsArray = Array.isArray(previous);
if (previousIsArray !== Array.isArray(next)) return next;
if (!previousIsArray && (!isPlainRecord(previous) || !isPlainRecord(next))) {
return next;
}
// `next` can hold containers owned by the state model, directives, or
// computed functions, so share into a copy instead of writing into it.
const previousRecord = previous as Record<string, unknown>;
const nextRecord = next as Record<string, unknown>;
const keys = Object.keys(nextRecord);
const shared: Record<string, unknown> | unknown[] = previousIsArray
? new Array((next as unknown[]).length)
: {};
let unchanged =
(!previousIsArray ||
(previous as unknown[]).length === (next as unknown[]).length) &&
keys.length === Object.keys(previousRecord).length;
for (const key of keys) {
let value = nextRecord[key];
if (Object.prototype.hasOwnProperty.call(previousRecord, key)) {
value = shareResolvedValue(previousRecord[key], value);
if (!Object.is(previousRecord[key], value)) unchanged = false;
} else {
unchanged = false;
}
(shared as Record<string, unknown>)[key] = value;
}
return unchanged ? previous : shared;
}
interface ElementSignatureEntry {
own: UIElement;
children: Array<[string, number]>;
version: number;
}
interface ElementSignatureFrame {
key: string;
element: UIElement;
children: string[];
childIndex: number;
childVersions: Array<[string, number]>;
}
function useElementSignatures(spec: Spec | null): Record<string, number> {
const entriesRef = useRef<Record<string, ElementSignatureEntry>>({});
const versionRef = useRef(0);
if (!spec) return {};
const previous = entriesRef.current;
const next: Record<string, ElementSignatureEntry> = {};
const signatures: Record<string, number> = {};
const visiting = new Set<string>();
for (const key of Object.keys(spec.elements)) {
if (signatures[key] !== undefined) continue;
const element = spec.elements[key];
if (!element) continue;
visiting.add(key);
const stack: ElementSignatureFrame[] = [
{
key,
element,
children: [
...(element.children ?? []),
...Object.values(element.slots ?? {}).flat(),
],
childIndex: 0,
childVersions: [],
},
];
while (stack.length > 0) {
const frame = stack[stack.length - 1]!;
const childKey = frame.children[frame.childIndex];
if (childKey !== undefined) {
const childVersion = signatures[childKey];
if (childVersion !== undefined) {
frame.childVersions.push([childKey, childVersion]);
frame.childIndex += 1;
continue;
}
const childElement = spec.elements[childKey];
if (!childElement || visiting.has(childKey)) {
frame.childVersions.push([childKey, -1]);
frame.childIndex += 1;
continue;
}
visiting.add(childKey);
stack.push({
key: childKey,
element: childElement,
children: [
...(childElement.children ?? []),
...Object.values(childElement.slots ?? {}).flat(),
],
childIndex: 0,
childVersions: [],
});
continue;
}
const prior = previous[frame.key];
const version =
prior &&
structurallyEqual(prior.own, frame.element) &&
structurallyEqual(prior.children, frame.childVersions)
? prior.version
: ++versionRef.current;
visiting.delete(frame.key);
next[frame.key] = {
own: snapshotStructuralValue(frame.element) as UIElement,
children: frame.childVersions,
version,
};
signatures[frame.key] = version;
stack.pop();
}
}
entriesRef.current = next;
return signatures;
}
interface CatalogComponentBoundaryProps {
Component: ComponentRenderer;
signature: number | undefined;
execute: ReturnType<typeof useActions>["execute"];
actionContext: ReturnType<typeof useRepeatScope>;
functions: Record<string, ComputedFunction>;
directives: DirectiveRegistry | undefined;
element: UIElement;
slots?: Record<string, ReactNode>;
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
loading?: boolean;
children?: ReactNode;
}
const CatalogComponentBoundary = React.memo(
function CatalogComponentBoundary({
Component,
element,
slots,
emit,
on,
bindings,
loading,
children,
}: CatalogComponentBoundaryProps) {
return (
<Component
element={element}
slots={slots}
emit={emit}
on={on}
bindings={bindings}
loading={loading}
>
{children}
</Component>
);
},
(previous, next) =>
previous.Component === next.Component &&
previous.signature === next.signature &&
previous.execute === next.execute &&
previous.actionContext?.item === next.actionContext?.item &&
previous.actionContext?.index === next.actionContext?.index &&
previous.actionContext?.basePath === next.actionContext?.basePath &&
previous.functions === next.functions &&
previous.directives === next.directives &&
previous.element.props === next.element.props &&
previous.bindings === next.bindings &&
previous.loading === next.loading,
);
/**
* Subscribe to whether any devtools is mounted so the renderer can add a
* `data-jr-key` wrapper for the picker. Trivially cheap when inactive.
@@ -204,13 +466,14 @@ function useDevtoolsActive(): boolean {
* Element renderer component.
* Memoized to prevent re-rendering all repeat children when state changes.
*/
const ElementRenderer = React.memo(function ElementRenderer({
function ReactiveElementRenderer({
element,
elementKey,
spec,
registry,
loading,
fallback,
signatures,
}: ElementRendererProps) {
const devtoolsActive = useDevtoolsActive();
const repeatScope = useRepeatScope();
@@ -311,6 +574,10 @@ const ElementRenderer = React.memo(function ElementRenderer({
const watchConfig = element.watch;
const prevWatchValues = useRef<Record<string, unknown> | null>(null);
const stableWatchRef = useRef<Record<string, unknown> | undefined>(undefined);
const stableBindingsRef = useRef<Record<string, string> | undefined>(
undefined,
);
const stableResolvedPropsRef = useRef<Record<string, unknown>>({});
const watchedValues = useMemo(() => {
if (!watchConfig) return undefined;
@@ -384,11 +651,20 @@ const ElementRenderer = React.memo(function ElementRenderer({
}
// Resolve $bindState/$bindItem expressions → bindings map (prop name → state path)
const rawProps = element.props as Record<string, unknown>;
const elementBindings = resolveBindings(rawProps, fullCtx);
const rawProps =
(element.props as Record<string, unknown> | undefined) ??
EMPTY_ELEMENT_PROPS;
const elementBindings = stabilizeRecord(
resolveBindings(rawProps, fullCtx),
stableBindingsRef,
);
// Resolve dynamic prop expressions ($state, $item, $index, $bindState, $bindItem, $cond/$then/$else)
const resolvedProps = resolveElementProps(rawProps, fullCtx);
const resolvedProps = shareResolvedValue(
stableResolvedPropsRef.current,
resolveElementProps(rawProps, fullCtx),
) as Record<string, unknown>;
stableResolvedPropsRef.current = resolvedProps;
const resolvedElement =
resolvedProps !== element.props
@@ -442,6 +718,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
registry={registry}
loading={loading}
fallback={fallback}
signatures={signatures}
/>
);
});
@@ -454,6 +731,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
loading={loading}
fallback={fallback}
itemFilter={repeatItemFilter}
signatures={signatures}
/>
) : resolvedElement.children ? (
renderChildKeys(resolvedElement.children)
@@ -469,7 +747,13 @@ const ElementRenderer = React.memo(function ElementRenderer({
: undefined;
const rendered = (
<Component
<CatalogComponentBoundary
Component={Component}
signature={signatures[elementKey ?? ""]}
execute={execute}
actionContext={repeatScope}
functions={functions}
directives={directives}
element={resolvedElement}
slots={slots}
emit={emit}
@@ -478,7 +762,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
loading={loading}
>
{children}
</Component>
</CatalogComponentBoundary>
);
// When devtools is mounted, wrap each element in a transparent span so the
@@ -494,11 +778,27 @@ const ElementRenderer = React.memo(function ElementRenderer({
);
return (
<ElementErrorBoundary elementType={resolvedElement.type}>
<ElementErrorBoundary
elementType={resolvedElement.type}
resetKey={signatures[elementKey ?? ""]}
>
{tagged}
</ElementErrorBoundary>
);
});
}
const ElementRenderer = React.memo(
function ElementRenderer(props: ElementRendererProps) {
return <ReactiveElementRenderer {...props} />;
},
(previous, next) =>
previous.elementKey === next.elementKey &&
previous.signatures[previous.elementKey ?? ""] ===
next.signatures[next.elementKey ?? ""] &&
previous.registry === next.registry &&
previous.loading === next.loading &&
previous.fallback === next.fallback,
);
// ---------------------------------------------------------------------------
// RepeatChildren -- renders child elements once per item in a state array.
@@ -511,6 +811,7 @@ function RepeatChildren({
registry,
loading,
fallback,
signatures,
itemFilter,
}: {
element: UIElement;
@@ -518,6 +819,7 @@ function RepeatChildren({
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
signatures: Record<string, number>;
itemFilter?: UIElement["visible"];
}) {
const { state } = useStateStore();
@@ -589,6 +891,7 @@ function RepeatChildren({
registry={registry}
loading={loading}
fallback={fallback}
signatures={signatures}
/>
);
})}
@@ -603,6 +906,7 @@ function RepeatChildren({
* Main renderer component
*/
export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
const signatures = useElementSignatures(spec);
if (!spec || !spec.root) {
return null;
}
@@ -620,6 +924,7 @@ export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
registry={registry}
loading={loading}
fallback={fallback}
signatures={signatures}
/>
);
}
@@ -0,0 +1,689 @@
import { act, render } from "@testing-library/react";
import {
defineDirective,
resolvePropValue,
type Spec,
} from "@json-render/core";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { buildSpecFromParts, type DataPart } from "./hooks";
import { JSONUIProvider, Renderer, type ComponentRegistry } from "./renderer";
import { useStateStore } from "./contexts/state";
const ELEMENT_COUNT = 26;
const PATCH_COUNT = 200;
function makeSpec(): Spec {
const children = Array.from(
{ length: ELEMENT_COUNT },
(_, index) => `metric-${index}`,
);
return {
root: "root",
state: Object.fromEntries(children.map((key, index) => [key, index])),
elements: {
root: { type: "Stack", props: {}, children },
...Object.fromEntries(
children.map((key) => [
key,
{
type: "Metric",
props: { value: { $bindState: `/${key}` }, revision: 0 },
},
]),
),
},
};
}
function patchPart(revision: number): DataPart {
return {
type: "data-spec",
data: {
type: "patch",
patch: {
op: "replace",
path: "/elements/metric-0/props/revision",
value: revision,
},
},
};
}
describe("streaming render stability", () => {
it("does not execute untouched catalog components for each patch", async () => {
const parts: DataPart[] = [
{ type: "data-spec", data: { type: "flat", spec: makeSpec() } },
];
let stackRenders = 0;
let metricRenders = 0;
const registry: ComponentRegistry = {
Stack: ({ children }) => {
stackRenders += 1;
return <>{children}</>;
},
Metric: () => {
metricRenders += 1;
return null;
},
};
const initialSpec = buildSpecFromParts(parts);
const view = render(
<JSONUIProvider registry={registry} initialState={initialSpec?.state}>
<Renderer spec={initialSpec} registry={registry} />
</JSONUIProvider>,
);
for (let revision = 1; revision <= PATCH_COUNT; revision += 1) {
await act(async () => {
parts.push(patchPart(revision));
const spec = buildSpecFromParts(parts);
view.rerender(
<JSONUIProvider registry={registry} initialState={spec?.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
});
}
expect(stackRenders).toBe(PATCH_COUNT + 1);
expect(metricRenders).toBe(ELEMENT_COUNT + PATCH_COUNT);
expect(initialSpec?.elements["metric-1"]?.props.revision).toBe(0);
});
it("does not execute untouched element renderers for each patch", async () => {
const parts: DataPart[] = [
{ type: "data-spec", data: { type: "flat", spec: makeSpec() } },
];
for (const [key, element] of Object.entries(parts[0]!.data.spec.elements)) {
if (key !== "root") element.props.probe = { $count: key };
}
let elementExecutions = 0;
const countDirective = defineDirective({
name: "$count",
resolve(value) {
elementExecutions += 1;
return value.$count;
},
});
const directives = [countDirective];
const registry: ComponentRegistry = {
Stack: ({ children }) => <>{children}</>,
Metric: () => null,
};
const initialSpec = buildSpecFromParts(parts);
const view = render(
<JSONUIProvider
registry={registry}
initialState={initialSpec?.state}
directives={directives}
>
<Renderer spec={initialSpec} registry={registry} />
</JSONUIProvider>,
);
for (let revision = 1; revision <= PATCH_COUNT; revision += 1) {
await act(async () => {
parts.push(patchPart(revision));
const spec = buildSpecFromParts(parts);
view.rerender(
<JSONUIProvider
registry={registry}
initialState={spec?.state}
directives={directives}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
});
}
expect(elementExecutions).toBe(ELEMENT_COUNT + PATCH_COUNT);
});
it("keeps binding identity stable across state writes", () => {
let writes = 0;
const error = vi.spyOn(console, "error").mockImplementation(() => {});
function WritingMetric({
bindings,
}: React.ComponentProps<ComponentRegistry[string]>) {
const { set } = useStateStore();
React.useEffect(() => {
writes += 1;
set("/metric-0", writes);
}, [bindings, set]);
return null;
}
const spec = makeSpec();
const registry: ComponentRegistry = {
Stack: ({ children }) => <>{children}</>,
Metric: WritingMetric,
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
const output = error.mock.calls.flat().map(String).join("\n");
expect(output).not.toMatch(/Maximum update depth exceeded/);
expect(writes).toBe(ELEMENT_COUNT);
error.mockRestore();
});
it("preserves state context updates for catalog components", async () => {
let metricRenders = 0;
let write: (() => void) | undefined;
function Metric() {
metricRenders += 1;
const { set } = useStateStore();
write = () => set("/metric-0", 999);
return null;
}
const spec = makeSpec();
const registry: ComponentRegistry = {
Stack: ({ children }) => <>{children}</>,
Metric,
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(metricRenders).toBe(ELEMENT_COUNT);
await act(async () => write?.());
expect(metricRenders).toBe(ELEMENT_COUNT * 2);
});
it("renders a child that becomes available in a later complete spec", () => {
const registry: ComponentRegistry = {
Stack: ({ children }) => <>{children}</>,
Metric: ({ element }) => <span>{String(element.props.revision)}</span>,
};
const initial: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: ["late"] },
},
};
const view = render(
<JSONUIProvider registry={registry}>
<Renderer spec={initial} registry={registry} loading />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("");
const complete: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: ["late"] },
late: { type: "Metric", props: { revision: 1 } },
},
};
view.rerender(
<JSONUIProvider registry={registry}>
<Renderer spec={complete} registry={registry} loading={false} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("1");
});
it("updates direct renderer consumers with fresh complete specs", () => {
const registry: ComponentRegistry = {
Stack: ({ children }) => <>{children}</>,
Metric: ({ element }) => <span>{String(element.props.revision)}</span>,
};
const first = makeSpec();
const view = render(
<JSONUIProvider registry={registry} initialState={first.state}>
<Renderer spec={first} registry={registry} />
</JSONUIProvider>,
);
const next = structuredClone(first);
next.elements["metric-0"]!.props.revision = 9;
view.rerender(
<JSONUIProvider registry={registry} initialState={next.state}>
<Renderer spec={next} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent?.startsWith("9")).toBe(true);
});
it("does not preserve stale prop keys with undefined values", () => {
const registry: ComponentRegistry = {
Value: ({ element }) => (
<span>{Object.keys(element.props).join(",")}</span>
),
};
const first: Spec = {
root: "value",
elements: { value: { type: "Value", props: { foo: undefined } } },
};
const view = render(
<JSONUIProvider registry={registry}>
<Renderer spec={first} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("foo");
const second: Spec = {
root: "value",
elements: { value: { type: "Value", props: { bar: undefined } } },
};
view.rerender(
<JSONUIProvider registry={registry}>
<Renderer spec={second} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("bar");
});
it("recovers when props arrive after the element type", () => {
const parts: DataPart[] = [
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "value" },
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/value",
value: { type: "Value" },
},
},
},
];
const registry: ComponentRegistry = {
Value: ({ element }) => (
<span>{String(element.props.label ?? "waiting")}</span>
),
};
const incomplete = buildSpecFromParts(parts);
const view = render(
<JSONUIProvider registry={registry}>
<Renderer spec={incomplete} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("waiting");
parts.push({
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/value/props",
value: { label: "ready" },
},
},
});
const complete = buildSpecFromParts(parts);
view.rerender(
<JSONUIProvider registry={registry}>
<Renderer spec={complete} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("ready");
});
it("keeps repeated event callbacks bound to the current item", async () => {
let updateItem: (() => void) | undefined;
const received: unknown[] = [];
function Controls() {
const { set } = useStateStore();
updateItem = () => set("/items/0", { id: "one", label: "updated" });
return null;
}
const spec: Spec = {
root: "list",
state: { items: [{ id: "one", label: "initial" }] },
elements: {
list: {
type: "List",
props: {},
repeat: { statePath: "/items", key: "id" },
children: ["button"],
},
button: {
type: "Button",
props: {},
on: {
press: {
action: "select",
params: {
label: {
$computed: "itemLabel",
args: { label: { $item: "label" } },
},
},
},
},
},
},
};
const registry: ComponentRegistry = {
List: ({ children }) => <>{children}</>,
Button: ({ emit }) => <button onClick={() => emit("press")}>pick</button>,
};
const view = render(
<JSONUIProvider
registry={registry}
initialState={spec.state}
functions={{ itemLabel: ({ label }) => label }}
handlers={{ select: ({ label }) => received.push(label) }}
>
<Controls />
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
await act(async () => updateItem?.());
await act(async () => view.getByRole("button").click());
expect(received).toEqual(["updated"]);
});
it("refreshes resolved props when functions and directives change", () => {
const spec: Spec = {
root: "value",
elements: {
value: {
type: "Value",
props: {
computed: { $computed: "label" },
directed: { $prefix: "value" },
},
},
},
};
const registry: ComponentRegistry = {
Value: ({ element }) => (
<span>{`${element.props.computed}:${element.props.directed}`}</span>
),
};
const firstDirective = defineDirective({
name: "$prefix",
resolve(value, ctx) {
return `first-${resolvePropValue(value.$prefix, ctx)}`;
},
});
const secondDirective = defineDirective({
name: "$prefix",
resolve(value, ctx) {
return `second-${resolvePropValue(value.$prefix, ctx)}`;
},
});
const view = render(
<JSONUIProvider
registry={registry}
functions={{ label: () => "first" }}
directives={[firstDirective]}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("first:first-value");
view.rerender(
<JSONUIProvider
registry={registry}
functions={{ label: () => "second" }}
directives={[secondDirective]}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("second:second-value");
});
it("keeps event callbacks fresh when functions and directives change", async () => {
const received: unknown[] = [];
const spec: Spec = {
root: "button",
elements: {
button: {
type: "Button",
props: {},
on: {
press: {
action: "select",
params: {
computed: { $computed: "label" },
directed: { $prefix: "value" },
},
},
},
},
},
};
const registry: ComponentRegistry = {
Button: ({ emit }) => <button onClick={() => emit("press")}>pick</button>,
};
const firstDirective = defineDirective({
name: "$prefix",
resolve(value) {
return `first-${String(value.$prefix)}`;
},
});
const secondDirective = defineDirective({
name: "$prefix",
resolve(value) {
return `second-${String(value.$prefix)}`;
},
});
const handlers = {
select: (params: Record<string, unknown>) => received.push(params),
};
const view = render(
<JSONUIProvider
registry={registry}
functions={{ label: () => "first" }}
directives={[firstDirective]}
handlers={handlers}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
view.rerender(
<JSONUIProvider
registry={registry}
functions={{ label: () => "second" }}
directives={[secondDirective]}
handlers={handlers}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
await act(async () => view.getByRole("button").click());
expect(received).toEqual([
{ computed: "second", directed: "second-value" },
]);
});
it("supports non-serializable resolved state and repeat items", () => {
const cyclic: Record<string, unknown> = { value: 1 };
cyclic.self = cyclic;
const spec: Spec = {
root: "list",
state: { cyclic, items: [{ id: 1n }] },
elements: {
list: {
type: "List",
props: { value: { $state: "/cyclic" } },
repeat: { statePath: "/items" },
children: ["item"],
},
item: { type: "Item", props: { id: { $item: "id" } } },
},
};
const registry: ComponentRegistry = {
List: ({ children }) => <>{children}</>,
Item: ({ element }) => <span>{String(element.props.id)}</span>,
};
const view = render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("1");
});
it("propagates shared-child updates through a DAG", () => {
const registry: ComponentRegistry = {
Stack: ({ children }) => <>{children}</>,
Value: ({ element }) => <span>{String(element.props.revision)}</span>,
};
const first: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: ["left", "right"] },
left: { type: "Stack", props: {}, children: ["shared"] },
right: { type: "Stack", props: {}, children: ["shared"] },
shared: { type: "Value", props: { revision: 1 } },
},
};
const view = render(
<JSONUIProvider registry={registry}>
<Renderer spec={first} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("11");
const second = structuredClone(first);
second.elements.shared!.props.revision = 2;
view.rerender(
<JSONUIProvider registry={registry}>
<Renderer spec={second} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("22");
});
it("terminates signature computation for cyclic element graphs", () => {
const spec: Spec = {
root: "",
elements: {
first: { type: "Node", props: {}, children: ["second"] },
second: { type: "Node", props: {}, children: ["first"] },
},
};
expect(() =>
render(<Renderer spec={spec} registry={{ Node: () => null }} />),
).not.toThrow();
});
it("stops a watch action chain when its element unmounts", async () => {
let setValue: (() => void) | undefined;
let release: (() => void) | undefined;
const firstAction = vi.fn(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const secondAction = vi.fn();
function Controls() {
const { set } = useStateStore();
setValue = () => set("/value", "changed");
return null;
}
const registry: ComponentRegistry = {
Stack: ({ children }) => <>{children}</>,
Watcher: () => null,
};
const first: Spec = {
root: "root",
state: { value: "initial" },
elements: {
root: { type: "Stack", props: {}, children: ["watcher"] },
watcher: {
type: "Watcher",
props: {},
watch: {
"/value": [{ action: "first" }, { action: "second" }],
},
},
},
};
const handlers = { first: firstAction, second: secondAction };
const view = render(
<JSONUIProvider
registry={registry}
initialState={first.state}
handlers={handlers}
>
<Controls />
<Renderer spec={first} registry={registry} />
</JSONUIProvider>,
);
await act(async () => setValue?.());
expect(firstAction).toHaveBeenCalledTimes(1);
const second: Spec = {
...first,
elements: {
...first.elements,
root: { type: "Stack", props: {}, children: [] },
},
};
view.rerender(
<JSONUIProvider
registry={registry}
initialState={first.state}
handlers={handlers}
>
<Controls />
<Renderer spec={second} registry={registry} />
</JSONUIProvider>,
);
await act(async () => release?.());
expect(secondAction).not.toHaveBeenCalled();
});
it("recovers a catalog component after a corrective patch", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const registry: ComponentRegistry = {
Value: ({ element }) => {
if (element.props.revision === 0) throw new Error("incomplete");
return <span>{String(element.props.revision)}</span>;
},
};
const first: Spec = {
root: "value",
elements: { value: { type: "Value", props: { revision: 0 } } },
};
const view = render(
<JSONUIProvider registry={registry}>
<Renderer spec={first} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("");
const second: Spec = {
root: "value",
elements: { value: { type: "Value", props: { revision: 1 } } },
};
view.rerender(
<JSONUIProvider registry={registry}>
<Renderer spec={second} registry={registry} />
</JSONUIProvider>,
);
expect(view.container.textContent).toBe("1");
error.mockRestore();
});
it("computes signatures for deep element graphs without recursion", () => {
const elements: Spec["elements"] = {};
for (let index = 0; index < 12_000; index += 1) {
elements[`node-${index}`] = {
type: "Node",
props: {},
children: index === 11_999 ? [] : [`node-${index + 1}`],
};
}
const spec: Spec = { root: "", elements };
expect(() =>
render(<Renderer spec={spec} registry={{ Node: () => null }} />),
).not.toThrow();
});
});
+8
View File
@@ -0,0 +1,8 @@
# @json-render/tanstack-start
## 0.20.0
### Minor Changes
- Add TanStack Start support for full JSON-defined applications with routes,
layouts, head metadata, SSR loaders, prerender paths, and client navigation.
+240
View File
@@ -0,0 +1,240 @@
# @json-render/tanstack-start
TanStack Start renderer for [@json-render/core](https://json-render.dev).
Define routes, layouts, head metadata, state, and loader-backed pages as JSON,
then render them through TanStack Router and Start SSR.
## Installation
```bash
npm install @json-render/core @json-render/react @json-render/tanstack-start
```
## Quick Start
### 1. Define the catalog
Include the definitions for the built-in `Slot` and `Link` components. Their
React implementations are added automatically by `PageRenderer`.
```typescript
import { defineCatalog } from "@json-render/core";
import {
schema,
startComponentDefinitions,
} from "@json-render/tanstack-start/server";
export const catalog = defineCatalog(schema, {
components: {
...startComponentDefinitions,
Card: cardDefinition,
Container: containerDefinition,
NavBar: navBarDefinition,
Post: postDefinition,
},
actions: {},
});
```
### 2. Define the application
```typescript
import type { StartAppSpec } from "@json-render/tanstack-start";
export const spec: StartAppSpec = {
metadata: {
title: { default: "My App", template: "%s | My App" },
},
layouts: {
main: {
root: "shell",
elements: {
shell: {
type: "Container",
props: {},
children: ["nav", "slot"],
},
nav: { type: "NavBar", props: {}, children: [] },
slot: { type: "Slot", props: {}, children: [] },
},
},
},
routes: {
"/": {
layout: "main",
metadata: { title: "Home" },
page: {
root: "hero",
elements: {
hero: {
type: "Card",
props: { title: "Welcome" },
children: [],
},
},
},
},
"/blog/$slug": {
layout: "main",
loader: "post",
page: {
root: "post",
elements: {
post: {
type: "Post",
props: { post: { $state: "/post" } },
children: [],
},
},
},
},
},
};
```
### 3. Create the route helpers
```typescript
// src/lib/json-app.ts
import { createStartApp } from "@json-render/tanstack-start/server";
import { spec } from "./spec";
export const { getPageData, getHead, getStaticPaths } = createStartApp({
spec,
loaders: {
post: async ({ slug }) => ({ post: await getPost(slug as string) }),
},
});
```
### 4. Wire a splat route
```tsx
// src/routes/$.tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
} from "@json-render/tanstack-start";
import { getHead, getPageData } from "@/lib/json-app";
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => getHead({ pathname: match.pathname }),
component: Page,
pendingComponent: StartLoading,
errorComponent: StartErrorBoundary,
notFoundComponent: StartNotFound,
});
function Page() {
return <PageRenderer {...Route.useLoaderData()} />;
}
```
TanStack Router loaders are isomorphic. If your spec factory or named loaders
contain secrets or server-only imports, call `getPageData` and `getHead` from a
TanStack Start `createServerFn` and return the resulting data from the route
loader.
### 5. Provide the registry
```tsx
// src/routes/__root.tsx
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} 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 });
function Root() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<StartAppProvider
registry={registry}
handlers={handlers}
spec={spec}
>
<Outlet />
</StartAppProvider>
<Scripts />
</body>
</html>
);
}
```
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.
The default `StartErrorBoundary` fallback invalidates the router and reruns the
failed loader when the user selects **Try again**.
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 |
| ------------- | ------------- | ------------------------ |
| `/` | `/` | `{}` |
| `/about` | `/about` | `{}` |
| `/blog/$slug` | `/blog/hello` | `{ slug: "hello" }` |
| `/docs/$` | `/docs/a/b` | `{ _splat: "a/b" }` |
Static routes are included in `getStaticPaths()`. Dynamic routes are included
when their route spec supplies `staticParams`. Loader parameters are URL-decoded,
and splat content is a slash-delimited string under `_splat`. Parameter values
emitted by `getStaticPaths()` are URL-encoded.
Route matching treats trailing slashes as optional and accepts both encoded and
decoded pathname representations. This keeps loader data and route metadata in
sync for static paths containing spaces or non-ASCII characters.
Initial state is merged in this order: application state, layout state, page
state, then loader data. Later sources override earlier values.
Map the paths to TanStack Start's top-level `pages` option when prerendering:
```typescript
const pages = (await getStaticPaths()).map((path) => ({ path }));
```
## Entry Points
| Import | Description |
| ------------------------------------- | ------------------------------------------------------------- |
| `@json-render/tanstack-start` | Provider, page renderer, Link, and route fallback components |
| `@json-render/tanstack-start/server` | App factory, schema, matcher, metadata, and prerender helpers |
| `@json-render/tanstack-start/catalog` | Server-safe definitions for built-in Slot and Link components |
See the [full API reference](https://json-render.dev/docs/api/tanstack-start).
+79
View File
@@ -0,0 +1,79 @@
{
"name": "@json-render/tanstack-start",
"version": "0.20.0",
"license": "Apache-2.0",
"description": "TanStack Start renderer for @json-render/core. JSON becomes full TanStack Start applications with routes, layouts, head metadata, and SSR.",
"keywords": [
"json",
"ui",
"tanstack",
"tanstack-start",
"tanstack-router",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"ssr",
"pages",
"routing"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/tanstack-start"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.mjs",
"require": "./dist/server.js"
},
"./catalog": {
"types": "./dist/catalog.d.ts",
"import": "./dist/catalog.mjs",
"require": "./dist/catalog.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "rm -rf dist && tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@tanstack/react-router": "1.170.32",
"@types/react": "19.2.14",
"tsup": "^8.5.1",
"typescript": "^5.4.5",
"zod": "^4.3.6"
},
"peerDependencies": {
"@tanstack/react-router": "^1.170.32",
"react": "^19.2.3",
"zod": "^4.0.0"
}
}
@@ -0,0 +1,12 @@
/** Catalog-aware types shared with the React renderer. */
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
ActionFn,
Actions,
} from "@json-render/react";
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod";
/**
* Server-safe catalog definitions for components built into PageRenderer.
* Include these in every TanStack Start catalog that generates layouts or links.
*/
export const startComponentDefinitions = {
Slot: {
props: z.object({}),
slots: ["default"],
description:
"Layout placeholder where the matched route's page content is rendered.",
example: {},
},
Link: {
props: z.object({
href: z.string(),
replace: z.boolean().optional(),
prefetch: z.boolean().optional(),
className: z.string().optional(),
style: z.record(z.string(), z.unknown()).optional(),
}),
slots: ["default"],
description: "Client-side link to another route in the application.",
example: { href: "/about" },
},
};
export type StartComponentDefinitions = typeof startComponentDefinitions;
@@ -0,0 +1,65 @@
import React from "react";
import {
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from "@tanstack/react-router";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { StartErrorBoundary } from "./error-boundary";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
beforeAll(() => {
window.scrollTo = () => {};
});
describe("StartErrorBoundary", () => {
it("reruns a failed loader when the user tries again", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
let attempts = 0;
const rootRoute = createRootRoute({ component: Outlet });
const route = createRoute({
getParentRoute: () => rootRoute,
path: "$",
loader: () => {
attempts++;
if (attempts === 1) throw new Error("Temporary failure");
return { message: "Loaded" };
},
component: Page,
errorComponent: StartErrorBoundary,
});
function Page() {
return <div>{route.useLoaderData().message}</div>;
}
const router = createRouter({
routeTree: rootRoute.addChildren([route]),
history: createMemoryHistory({ initialEntries: ["/retry"] }),
});
await router.load();
render(<RouterProvider router={router} />);
expect(attempts).toBe(1);
expect(screen.getByText("Temporary failure")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
await waitFor(() => expect(screen.getByText("Loaded")).toBeTruthy());
expect(attempts).toBe(2);
});
});
@@ -0,0 +1,57 @@
import React from "react";
import { useRouter } 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";
/** 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;
}
/** Render a route-specific error spec or a small default error view. */
export function StartErrorBoundary({
error,
reset,
errorSpec,
}: StartErrorBoundaryProps) {
const router = useRouter();
const context = useOptionalStartApp();
const retry = React.useCallback(() => {
void router.invalidate().then(reset, reset);
}, [reset, router]);
const resolvedSpec = resolveRouteFallback(
context?.spec,
context?.pathname,
"error",
errorSpec,
);
if (resolvedSpec && context) {
return <PageRenderer spec={resolvedSpec} />;
}
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<h2 style={{ marginBottom: "1rem" }}>Something went wrong</h2>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
{error.message || "An unexpected error occurred."}
</p>
<button
onClick={retry}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: "1px solid #ccc",
background: "#fff",
cursor: "pointer",
}}
>
Try again
</button>
</div>
);
}
@@ -0,0 +1,30 @@
import React from "react";
import { Link as RouterLink } from "@tanstack/react-router";
import type { ComponentRenderProps } from "@json-render/react";
export interface LinkProps {
href: string;
replace?: boolean;
/** Preload the target route on intent. */
prefetch?: boolean;
className?: string;
style?: React.CSSProperties;
}
/** Built-in client navigation component for generated specs. */
export function Link({ element, children }: ComponentRenderProps<LinkProps>) {
const { href, replace, prefetch, className, style } =
element.props as LinkProps;
return (
<RouterLink
to={href}
replace={replace}
preload={prefetch === undefined ? undefined : prefetch ? "intent" : false}
className={className}
style={style}
>
{children}
</RouterLink>
);
}
@@ -0,0 +1,47 @@
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();
const resolvedSpec = resolveRouteFallback(
context?.spec,
context?.pathname,
"loading",
loadingSpec,
);
if (resolvedSpec && context) {
return <PageRenderer spec={resolvedSpec} loading />;
}
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "200px",
}}
>
<div
style={{
width: "2rem",
height: "2rem",
border: "2px solid #e5e7eb",
borderTopColor: "#3b82f6",
borderRadius: "50%",
animation: "jr-spin 0.6s linear infinite",
}}
/>
<style>{`@keyframes jr-spin { to { transform: rotate(360deg) } }`}</style>
</div>
);
}
@@ -0,0 +1,44 @@
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();
const resolvedSpec = resolveRouteFallback(
context?.spec,
context?.pathname,
"notFound",
notFoundSpec,
);
if (resolvedSpec && context) {
return <PageRenderer spec={resolvedSpec} />;
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "400px",
padding: "2rem",
textAlign: "center",
}}
>
<h1 style={{ fontSize: "4rem", fontWeight: 700, margin: 0 }}>404</h1>
<p style={{ color: "#666", marginTop: "0.5rem", fontSize: "1.125rem" }}>
This page could not be found.
</p>
</div>
);
}
@@ -0,0 +1,117 @@
import React, { useMemo, type ReactNode } from "react";
import { useMatch } from "@tanstack/react-router";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRegistry,
type ComponentRenderProps,
} from "@json-render/react";
import { Link } from "./link";
import { useStartApp } from "./provider";
export interface PageRendererProps {
spec: Spec;
initialState?: Record<string, unknown>;
layoutSpec?: Spec | null;
loading?: boolean;
}
function Slot({ children }: ComponentRenderProps) {
return <>{children}</>;
}
/** Render page data returned by `createStartApp` inside an optional layout. */
export function PageRenderer({
spec,
initialState,
layoutSpec,
loading,
}: PageRendererProps) {
const {
registry,
handlers,
spec: appSpec,
functions,
navigate,
} = useStartApp();
const renderedPathname = useMatch({
strict: false,
select: (match) => match.pathname,
});
const augmentedRegistry: ComponentRegistry = useMemo(
() => ({ ...registry, Link, Slot }),
[registry],
);
const actionHandlers = useMemo(
() => ({
...handlers,
navigate: (params: Record<string, unknown>) => {
const href = params.href;
if (typeof href === "string") navigate(href);
},
}),
[handlers, navigate],
);
const resolvedInitialState = useMemo(() => {
if (initialState !== undefined) return initialState;
if (!appSpec?.state && !layoutSpec?.state && !spec.state) return undefined;
return { ...appSpec?.state, ...layoutSpec?.state, ...spec.state };
}, [appSpec?.state, initialState, layoutSpec?.state, spec.state]);
const page = (
<Renderer spec={spec} registry={augmentedRegistry} loading={loading} />
);
// Key from the rendered match rather than the global location. During a
// pending navigation, TanStack advances the location while keeping the
// previous match mounted until the next page is ready.
return (
<JSONUIProvider
key={renderedPathname}
registry={augmentedRegistry}
initialState={resolvedInitialState}
handlers={actionHandlers}
navigate={navigate}
functions={functions}
>
{layoutSpec ? (
<LayoutWithSlot
layoutSpec={layoutSpec}
registry={augmentedRegistry}
loading={loading}
>
{page}
</LayoutWithSlot>
) : (
page
)}
</JSONUIProvider>
);
}
function LayoutWithSlot({
layoutSpec,
registry,
loading,
children,
}: {
layoutSpec: Spec;
registry: ComponentRegistry;
loading?: boolean;
children: ReactNode;
}) {
const layoutRegistry: ComponentRegistry = useMemo(
() => ({
...registry,
Slot: function LayoutSlot() {
return <>{children}</>;
},
}),
[children, registry],
);
return (
<Renderer spec={layoutSpec} registry={layoutRegistry} loading={loading} />
);
}
@@ -0,0 +1,258 @@
import React from "react";
import {
Outlet,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import {
act,
cleanup,
fireEvent,
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 { createStartApp } from "../create-app";
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>;
}
function Container({ children }: ComponentRenderProps) {
return <div>{children}</div>;
}
function Button({ emit }: ComponentRenderProps) {
return <button onClick={() => emit("press")}>Change</button>;
}
function LabeledText({
element,
}: ComponentRenderProps<{ label: string; value: unknown }>) {
return <span>{`${element.props.label}:${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();
});
it("uses layout state when page data is rendered directly", async () => {
const page: Spec = {
root: "page",
elements: {
page: { type: "Text", props: { value: "Page" }, children: [] },
},
};
const layout: Spec = {
root: "layout",
state: { message: "Layout state" },
elements: {
layout: {
type: "Container",
props: {},
children: ["message", "slot"],
},
message: {
type: "Text",
props: { value: { $state: "/message" } },
children: [],
},
slot: { type: "Slot", props: {}, children: [] },
},
};
await renderInRouter(
<StartAppProvider registry={{ Container, Text }}>
<PageRenderer spec={page} layoutSpec={layout} />
</StartAppProvider>,
);
expect(screen.getByText("Layout state")).toBeTruthy();
expect(screen.getByText("Page")).toBeTruthy();
});
it("resets page state when the catch-all route changes", async () => {
let resolveNextPage!: () => void;
const nextPage = new Promise<void>((resolve) => {
resolveNextPage = resolve;
});
let markNextPageStarted!: () => void;
const nextPageStarted = new Promise<void>((resolve) => {
markNextPageStarted = resolve;
});
const spec: StartAppSpec = {
routes: {
"/a": {
page: {
root: "container",
state: { count: 0 },
elements: {
container: {
type: "Container",
props: {},
children: ["text", "button"],
},
text: {
type: "LabeledText",
props: { label: "A", value: { $state: "/count" } },
children: [],
},
button: {
type: "Button",
props: {},
on: {
press: {
action: "setState",
params: { statePath: "/count", value: 5 },
},
},
children: [],
},
},
},
},
"/b": {
page: {
root: "text",
state: { count: 0 },
elements: {
text: {
type: "LabeledText",
props: { label: "B", value: { $state: "/count" } },
children: [],
},
},
},
},
},
};
const app = createStartApp({ spec });
const rootRoute = createRootRoute({
component: () => (
<StartAppProvider registry={{ Button, Container, LabeledText }}>
<Outlet />
</StartAppProvider>
),
});
const route = createRoute({
getParentRoute: () => rootRoute,
path: "$",
loader: async ({ location }) => {
if (location.pathname === "/b") {
markNextPageStarted();
await nextPage;
}
return app.getPageData({ pathname: location.pathname });
},
component: Page,
});
function Page() {
const data = route.useLoaderData();
return data ? <PageRenderer {...data} /> : null;
}
const router = createRouter({
routeTree: rootRoute.addChildren([route]),
history: createMemoryHistory({ initialEntries: ["/a"] }),
});
await router.load();
render(<RouterProvider router={router} />);
fireEvent.click(screen.getByRole("button", { name: "Change" }));
expect(screen.getByText("A:5")).toBeTruthy();
let navigation!: Promise<void>;
await act(async () => {
navigation = router.navigate({ to: "/b" });
await nextPageStarted;
});
const pendingPageValue = screen.getByText(/^A:/).textContent;
await act(async () => {
resolveNextPage();
await navigation;
});
expect(pendingPageValue).toBe("A:5");
expect(screen.getByText("B:0")).toBeTruthy();
});
});
@@ -0,0 +1,76 @@
import React, { createContext, useContext, type ReactNode } from "react";
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;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
spec?: StartAppSpec;
functions?: Record<string, ComputedFunction>;
pathname: string;
navigate: (href: string) => void;
}
const StartAppContext = createContext<StartAppContextValue | null>(null);
export interface StartAppProviderProps {
registry: ComponentRegistry;
handlers?: Record<
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 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 });
},
[router],
);
const value = React.useMemo(
() => ({ registry, handlers, spec, functions, pathname, navigate }),
[registry, handlers, spec, functions, pathname, navigate],
);
return (
<StartAppContext.Provider value={value}>
{children}
</StartAppContext.Provider>
);
}
/** Access the current TanStack Start json-render application context. */
export function useStartApp(): StartAppContextValue {
const context = useContext(StartAppContext);
if (!context) {
throw new Error(
"[json-render/tanstack-start] useStartApp must be used within a " +
"<StartAppProvider>.",
);
}
return context;
}
export function useOptionalStartApp(): StartAppContextValue | null {
return useContext(StartAppContext);
}
@@ -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];
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { createStartApp } from "./create-app";
import type { StartAppSpec } from "./types";
function page(state?: Record<string, unknown>): Spec {
return {
root: "root",
elements: { root: { type: "Card", props: {}, children: [] } },
...(state ? { state } : {}),
};
}
describe("createStartApp", () => {
it("resolves page and layout data", async () => {
const layout = page();
const spec: StartAppSpec = {
layouts: { main: layout },
routes: { "/": { page: page(), layout: "main" } },
};
const data = await createStartApp({ spec }).getPageData({ pathname: "/" });
expect(data?.spec).toEqual(page());
expect(data?.layoutSpec).toEqual(layout);
});
it("returns null for an unmatched pathname", async () => {
const spec: StartAppSpec = { routes: { "/": { page: page() } } };
const data = await createStartApp({ spec }).getPageData({
pathname: "/missing",
});
expect(data).toBeNull();
});
it("merges global, layout, page, and loader state in order", async () => {
const spec: StartAppSpec = {
state: { a: 1, b: 1, c: 1 },
layouts: { main: page({ b: 2, c: 2, layout: true }) },
routes: {
"/blog/$slug": {
page: page({ c: 3, page: true }),
layout: "main",
loader: "post",
},
},
};
const app = createStartApp({
spec,
loaders: {
post: (params) => ({ c: 4, slug: params.slug }),
},
});
expect(
(await app.getPageData({ pathname: "/blog/hello" }))?.initialState,
).toEqual({
a: 1,
b: 2,
c: 4,
layout: true,
page: true,
slug: "hello",
});
});
it("supports async spec factories", async () => {
const spec: StartAppSpec = { routes: { "/": { page: page() } } };
const data = await createStartApp({ spec: async () => spec }).getPageData({
pathname: "/",
});
expect(data).not.toBeNull();
});
it("resolves head metadata and static paths", async () => {
const spec: StartAppSpec = {
metadata: { title: { default: "Site", template: "%s | Site" } },
routes: {
"/about": {
page: page(),
metadata: { title: "About", description: "About us" },
},
"/blog/$slug": {
page: page(),
staticParams: [{ slug: "hello" }],
},
},
};
const app = createStartApp({ spec });
const head = await app.getHead({ pathname: "/about" });
expect(head.meta).toContainEqual({ title: "About | Site" });
expect(head.meta).toContainEqual({
name: "description",
content: "About us",
});
expect(await app.getStaticPaths()).toEqual(["/about", "/blog/hello"]);
});
it("resolves route metadata from an encoded static pathname", async () => {
const spec: StartAppSpec = {
metadata: { title: "Global" },
routes: {
"/café": {
page: page(),
metadata: { title: "Café" },
},
},
};
const app = createStartApp({ spec });
expect(await app.getPageData({ pathname: "/café" })).not.toBeNull();
expect((await app.getHead({ pathname: "/caf%C3%A9" })).meta).toContainEqual(
{
title: "Café",
},
);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { metadataToHead, resolveMetadata } from "./metadata";
import { collectStaticPaths, matchRoute } from "./router";
import type {
CreateStartAppOptions,
HeadDescriptors,
PageData,
StartAppExports,
StartAppSpec,
} from "./types";
async function resolveSpec(
specOrFactory: StartAppSpec | (() => StartAppSpec | Promise<StartAppSpec>),
): Promise<StartAppSpec> {
return typeof specOrFactory === "function"
? await specOrFactory()
: specOrFactory;
}
function mergeState(
...sources: (Record<string, unknown> | null | undefined)[]
): Record<string, unknown> {
return Object.assign({}, ...sources.filter((source) => source != null));
}
/**
* Create the route-loader, head, and prerender helpers for a TanStack Start
* catch-all route.
*/
export function createStartApp(
options: CreateStartAppOptions,
): StartAppExports {
const { spec: specOrFactory, loaders } = options;
async function getPageData({
pathname,
}: {
pathname: string;
}): Promise<PageData | null> {
const spec = await resolveSpec(specOrFactory);
const matched = matchRoute(spec, pathname);
if (!matched) return null;
const { route } = matched;
const loader = route.loader ? loaders?.[route.loader] : undefined;
const loaderData = loader ? await loader(matched.params) : undefined;
const layoutSpec =
route.layout && spec.layouts
? (spec.layouts[route.layout] ?? null)
: null;
const initialState = mergeState(
spec.state,
layoutSpec?.state,
route.page.state,
loaderData,
);
return {
spec: route.page,
initialState:
Object.keys(initialState).length > 0 ? initialState : undefined,
layoutSpec,
};
}
async function getHead({
pathname,
}: {
pathname: string;
}): Promise<HeadDescriptors> {
const spec = await resolveSpec(specOrFactory);
const matched = matchRoute(spec, pathname);
return metadataToHead(resolveMetadata(spec, matched?.route));
}
async function getStaticPaths(): Promise<string[]> {
return collectStaticPaths(await resolveSpec(specOrFactory));
}
return { getPageData, getHead, getStaticPaths };
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
// React components for TanStack Start applications.
export {
StartAppProvider,
useStartApp,
type StartAppContextValue,
type StartAppProviderProps,
} from "./components/provider";
export {
PageRenderer,
type PageRendererProps,
} from "./components/page-renderer";
export {
StartErrorBoundary,
type StartErrorBoundaryProps,
} from "./components/error-boundary";
export {
StartLoading,
type StartLoadingProps,
} from "./components/loading-renderer";
export {
StartNotFound,
type StartNotFoundProps,
} from "./components/not-found-renderer";
export { Link, type LinkProps } from "./components/link";
export type {
CreateStartAppOptions,
HeadDescriptors,
LoaderFn,
MatchedRoute,
PageData,
StartAppExports,
StartAppSpec,
StartMetadata,
StartRouteSpec,
} from "./types";
export type {
ActionFn,
Actions,
BaseComponentProps,
ComponentContext,
ComponentFn,
Components,
EventHandle,
SetState,
StateModel,
} from "./catalog-types";
export type { ComputedFunction, Spec, StateStore } from "@json-render/core";
export { createStateStore } from "@json-render/core";
export type {
ComponentRegistry,
ComponentRenderProps,
} from "@json-render/react";
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { metadataToHead, resolveMetadata } from "./metadata";
import type { StartAppSpec, StartMetadata } from "./types";
const page: Spec = {
root: "root",
elements: { root: { type: "Card", props: {}, children: [] } },
};
function withMetadata(global?: StartMetadata, route?: StartMetadata) {
const routeSpec = { page, metadata: route };
return {
spec: { metadata: global, routes: { "/": routeSpec } } as StartAppSpec,
route: routeSpec,
};
}
describe("metadata", () => {
it("applies title templates safely to every placeholder", () => {
const { spec, route } = withMetadata(
{ title: { default: "Site", template: "%s | Site | %s" } },
{ title: "Cash $& Carry" },
);
expect(resolveMetadata(spec, route).title).toBe(
"Cash $& Carry | Site | Cash $& Carry",
);
});
it("honors absolute titles and shallow-merges social metadata", () => {
const { spec, route } = withMetadata(
{
title: { default: "Site", template: "%s | Site" },
openGraph: { siteName: "Site", type: "website" },
},
{
title: { default: "Page", absolute: "Standalone" },
openGraph: { title: "Page", type: "article" },
},
);
expect(resolveMetadata(spec, route)).toMatchObject({
title: "Standalone",
openGraph: { siteName: "Site", title: "Page", type: "article" },
});
});
it("builds TanStack head meta and link descriptors", () => {
const head = metadataToHead({
title: "Home",
description: "Welcome",
keywords: ["json", "render"],
openGraph: { images: ["/a.png", "/b.png"] },
twitter: { card: "summary_large_image", images: "/c.png" },
robots: { index: false },
alternates: { canonical: "https://example.com" },
icons: { icon: "/favicon.ico", apple: "/apple.png" },
});
expect(head.meta).toContainEqual({ title: "Home" });
expect(head.meta).toContainEqual({
property: "og:image",
content: "/a.png",
});
expect(head.meta).toContainEqual({
name: "robots",
content: "noindex, follow",
});
expect(head.links).toEqual([
{ rel: "canonical", href: "https://example.com" },
{ rel: "icon", href: "/favicon.ico" },
{ rel: "apple-touch-icon", href: "/apple.png" },
]);
});
});
+196
View File
@@ -0,0 +1,196 @@
import type {
HeadDescriptors,
StartAppSpec,
StartMetadata,
StartRouteSpec,
} from "./types";
export type ResolvedMetadata = Record<string, unknown>;
/** Merge application metadata with a route's overrides. */
export function resolveMetadata(
spec: StartAppSpec,
route?: StartRouteSpec | null,
): ResolvedMetadata {
const globalMetadata = spec.metadata;
const routeMetadata = route?.metadata;
if (!globalMetadata && !routeMetadata) return {};
const result: ResolvedMetadata = {};
const title = resolveTitle(globalMetadata?.title, routeMetadata?.title);
if (title !== undefined) result.title = title;
const description = routeMetadata?.description ?? globalMetadata?.description;
if (description) result.description = description;
const keywords = routeMetadata?.keywords ?? globalMetadata?.keywords;
if (keywords) result.keywords = keywords;
const openGraph = mergeObject(
globalMetadata?.openGraph,
routeMetadata?.openGraph,
);
if (openGraph) result.openGraph = openGraph;
const twitter = mergeObject(globalMetadata?.twitter, routeMetadata?.twitter);
if (twitter) result.twitter = twitter;
const robots = routeMetadata?.robots ?? globalMetadata?.robots;
if (robots) result.robots = robots;
const alternates = routeMetadata?.alternates ?? globalMetadata?.alternates;
if (alternates) result.alternates = alternates;
const icons = routeMetadata?.icons ?? globalMetadata?.icons;
if (icons) result.icons = icons;
return result;
}
/** Convert resolved metadata to TanStack Router `head` descriptors. */
export function metadataToHead(metadata: ResolvedMetadata): HeadDescriptors {
const meta: Record<string, string>[] = [];
const links: Record<string, string>[] = [];
const title = titleToString(metadata.title);
if (title) meta.push({ title });
if (typeof metadata.description === "string") {
meta.push({ name: "description", content: metadata.description });
}
if (Array.isArray(metadata.keywords) && metadata.keywords.length > 0) {
meta.push({ name: "keywords", content: metadata.keywords.join(", ") });
}
const openGraph = metadata.openGraph as
| StartMetadata["openGraph"]
| undefined;
if (openGraph) {
if (openGraph.title) {
meta.push({ property: "og:title", content: openGraph.title });
}
if (openGraph.description) {
meta.push({ property: "og:description", content: openGraph.description });
}
if (openGraph.type) {
meta.push({ property: "og:type", content: openGraph.type });
}
if (openGraph.url)
meta.push({ property: "og:url", content: openGraph.url });
if (openGraph.siteName) {
meta.push({ property: "og:site_name", content: openGraph.siteName });
}
if (openGraph.locale) {
meta.push({ property: "og:locale", content: openGraph.locale });
}
for (const image of toArray(openGraph.images)) {
meta.push({ property: "og:image", content: image });
}
}
const twitter = metadata.twitter as StartMetadata["twitter"] | undefined;
if (twitter) {
if (twitter.card) {
meta.push({ name: "twitter:card", content: twitter.card });
}
if (twitter.title) {
meta.push({ name: "twitter:title", content: twitter.title });
}
if (twitter.description) {
meta.push({ name: "twitter:description", content: twitter.description });
}
if (twitter.creator) {
meta.push({ name: "twitter:creator", content: twitter.creator });
}
if (twitter.site) {
meta.push({ name: "twitter:site", content: twitter.site });
}
for (const image of toArray(twitter.images)) {
meta.push({ name: "twitter:image", content: image });
}
}
const robots = metadata.robots as StartMetadata["robots"] | undefined;
if (robots) {
const content =
typeof robots === "string"
? robots
: [
robots.index === false ? "noindex" : "index",
robots.follow === false ? "nofollow" : "follow",
].join(", ");
meta.push({ name: "robots", content });
}
const alternates = metadata.alternates as
| StartMetadata["alternates"]
| undefined;
if (alternates?.canonical) {
links.push({ rel: "canonical", href: alternates.canonical });
}
const icons = metadata.icons as StartMetadata["icons"] | undefined;
if (typeof icons === "string") {
links.push({ rel: "icon", href: icons });
} else if (icons) {
if (icons.icon) links.push({ rel: "icon", href: icons.icon });
if (icons.apple) {
links.push({ rel: "apple-touch-icon", href: icons.apple });
}
if (icons.shortcut) {
links.push({ rel: "shortcut icon", href: icons.shortcut });
}
}
return { meta, links };
}
function resolveTitle(
globalTitle: StartMetadata["title"],
routeTitle: StartMetadata["title"],
): unknown {
if (!routeTitle && !globalTitle) return undefined;
if (!routeTitle) {
if (typeof globalTitle === "string") return globalTitle;
return globalTitle?.default;
}
const template =
typeof globalTitle === "object" ? globalTitle.template : undefined;
if (typeof routeTitle === "object") {
if (routeTitle.absolute) return routeTitle.absolute;
return applyTitleTemplate(template, routeTitle.default);
}
return applyTitleTemplate(template, routeTitle);
}
function applyTitleTemplate(
template: string | undefined,
title: string,
): string {
return template ? template.replace(/%s/g, () => title) : title;
}
function titleToString(title: unknown): string | undefined {
if (typeof title === "string") return title;
if (typeof title === "object" && title !== null) {
const value = title as { absolute?: string; default?: string };
return value.absolute ?? value.default;
}
return undefined;
}
function toArray(value: string | string[] | undefined): string[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
function mergeObject(
base: Record<string, unknown> | undefined,
override: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!base && !override) return undefined;
return { ...base, ...override };
}
+200
View File
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { collectStaticPaths, matchRoute, splatToPath } from "./router";
import type { StartAppSpec, StartRouteSpec } from "./types";
function page(): Spec {
return {
root: "root",
elements: { root: { type: "Card", props: {}, children: [] } },
};
}
function specWith(
routes: Record<string, Partial<StartRouteSpec>>,
): StartAppSpec {
return {
routes: Object.fromEntries(
Object.entries(routes).map(([pattern, route]) => [
pattern,
{ page: page(), ...route },
]),
),
};
}
describe("matchRoute", () => {
it("matches root and static routes", () => {
const spec = specWith({ "/": {}, "/about": {} });
expect(matchRoute(spec, "")?.pattern).toBe("/");
expect(matchRoute(spec, "/about")?.pattern).toBe("/about");
});
it("extracts dynamic parameters", () => {
const matched = matchRoute(specWith({ "/blog/$slug": {} }), "/blog/hello");
expect(matched?.params).toEqual({ slug: "hello" });
});
it("matches static and dynamic routes with trailing slashes", () => {
const spec = specWith({ "/about": {}, "/blog/$slug": {} });
expect(matchRoute(spec, "/about/")?.pattern).toBe("/about");
expect(matchRoute(spec, "/blog/hello/")?.params).toEqual({
slug: "hello",
});
});
it("matches route patterns that end in trailing slashes", () => {
const spec = specWith({ "/about/": {}, "/blog/$slug/": {} });
expect(matchRoute(spec, "/about/")?.pattern).toBe("/about/");
expect(matchRoute(spec, "/blog/hello/")?.params).toEqual({
slug: "hello",
});
});
it("matches encoded and decoded static pathnames", () => {
const spec = specWith({ "/café": {} });
expect(matchRoute(spec, "/café")?.pattern).toBe("/café");
expect(matchRoute(spec, "/caf%C3%A9")?.pattern).toBe("/café");
});
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("decodes percent signs in dynamic and splat parameters exactly once", () => {
expect(
matchRoute(specWith({ "/coupon/$code": {} }), "/coupon/100%25")?.params,
).toEqual({ code: "100%" });
expect(
matchRoute(specWith({ "/coupon/$code": {} }), "/coupon/%2525")?.params,
).toEqual({ code: "%25" });
expect(
matchRoute(specWith({ "/docs/$": {} }), "/docs/rates/100%25")?.params,
).toEqual({ _splat: "rates/100%" });
});
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: "" });
expect(matchRoute(spec, "/docs/")?.params).toEqual({ _splat: "" });
expect(matchRoute(spec, "/docs/guides/intro")?.params).toEqual({
_splat: "guides/intro",
});
});
it("matches an empty top-level splat at the root pathname", () => {
expect(matchRoute(specWith({ "/$": {} }), "/")?.params).toEqual({
_splat: "",
});
});
it("ranks static, dynamic, then splat routes", () => {
const spec = specWith({
"/blog/$": {},
"/blog/$slug": {},
"/blog/featured": {},
});
expect(matchRoute(spec, "/blog/featured")?.pattern).toBe("/blog/featured");
expect(matchRoute(spec, "/blog/post")?.pattern).toBe("/blog/$slug");
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();
});
});
describe("static paths", () => {
it("converts splat content to pathnames", () => {
expect(splatToPath(undefined)).toBe("/");
expect(splatToPath("docs/intro")).toBe("/docs/intro");
});
it("includes static routes and expands dynamic route params", () => {
const spec = specWith({
"/": {},
"/about": {},
"/blog/$slug": {
staticParams: [{ slug: "hello" }, { slug: "world" }],
},
"/docs/$": { staticParams: [{ _splat: "guides/intro" }] },
"/search/$query": { staticParams: [{ query: "hello world" }] },
"/users/$id": {},
});
expect(collectStaticPaths(spec)).toEqual([
"/",
"/about",
"/blog/hello",
"/blog/world",
"/docs/guides/intro",
"/search/hello%20world",
]);
});
it("emits matchable paths for route patterns with trailing slashes", () => {
const spec = specWith({
"/about/": {},
"/blog/$slug/": { staticParams: [{ slug: "hello" }] },
});
const paths = collectStaticPaths(spec);
expect(paths).toEqual(["/about/", "/blog/hello/"]);
expect(
paths.map((pathname) => matchRoute(spec, pathname)?.pattern),
).toEqual(["/about/", "/blog/$slug/"]);
});
it("round trips percent signs in static params", () => {
const spec = specWith({
"/coupon/$code": {
staticParams: [{ code: "100%" }, { code: "%25" }],
},
"/docs/$": { staticParams: [{ _splat: "rates/100%" }] },
});
const paths = collectStaticPaths(spec);
expect(paths).toEqual([
"/coupon/100%25",
"/coupon/%2525",
"/docs/rates/100%25",
]);
expect(paths.map((pathname) => matchRoute(spec, pathname)?.params)).toEqual(
[{ code: "100%" }, { code: "%25" }, { _splat: "rates/100%" }],
);
});
});
+156
View File
@@ -0,0 +1,156 @@
import type { MatchedRoute, StartAppSpec } from "./types";
interface CompiledRoute {
pattern: string;
regex: RegExp;
paramNames: string[];
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[] = [];
const normalizedPattern = normalizePathname(pattern);
const segments =
normalizedPattern === "/" ? [""] : normalizedPattern.split("/").slice(1);
const regexParts: string[] = [];
const segmentRanks: number[] = [];
for (const segment of segments) {
if (segment === "$") {
paramNames.push(SPLAT_PARAM);
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 {
segmentRanks.push(STATIC_SEGMENT_RANK);
regexParts.push(`/${escapeRegExp(segment)}`);
}
}
return {
pattern,
regex: new RegExp(
normalizedPattern === "/" ? "^/$" : `^${regexParts.join("")}$`,
"i",
),
paramNames,
segmentRanks,
};
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Match a pathname using TanStack Router's static, dynamic, and splat forms. */
export function matchRoute(
spec: StartAppSpec,
pathname: string,
): MatchedRoute | null {
const normalizedPath = normalizePathname(pathname);
const compiled = Object.keys(spec.routes).map(compileRoute);
compiled.sort((a, b) => {
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 0;
});
for (const candidate of compiled) {
const match = candidate.regex.exec(normalizedPath);
if (!match) continue;
const params: Record<string, string> = {};
let validParams = true;
for (let index = 0; index < candidate.paramNames.length; index++) {
const name = candidate.paramNames[index]!;
const value = match[index + 1];
try {
params[name] = decodeURIComponent(value ?? "");
} catch {
validParams = false;
break;
}
}
if (!validParams) continue;
return {
route: spec.routes[candidate.pattern]!,
pattern: candidate.pattern,
params,
};
}
return null;
}
function normalizePathname(pathname: string): string {
const withoutTrailingSlash = pathname.replace(/\/+$/, "") || "/";
try {
// TanStack preserves encoded percent signs in pathnames so route params
// can decode them exactly once. Shield them while decoding static text.
return decodeURI(withoutTrailingSlash.replace(/%25/gi, "%2525"));
} catch {
return withoutTrailingSlash;
}
}
/** Convert TanStack Router splat content to a pathname. */
export function splatToPath(splat: string | undefined): string {
if (!splat) return "/";
return `/${splat}`;
}
/** Collect concrete pathnames suitable for TanStack Start prerendering. */
export function collectStaticPaths(spec: StartAppSpec): string[] {
const results: string[] = [];
for (const [pattern, route] of Object.entries(spec.routes)) {
if (route.staticParams) {
for (const params of route.staticParams) {
const pathname = buildPathFromPattern(pattern, params);
if (pathname) results.push(pathname);
}
} else if (!pattern.includes("$")) {
results.push(pattern);
}
}
return results;
}
function buildPathFromPattern(
pattern: string,
params: Record<string, string>,
): string | null {
if (pattern === "/") return "/";
const result: string[] = [];
for (const segment of pattern.split("/").slice(1)) {
if (segment === "$") {
const value = params[SPLAT_PARAM];
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(encodeURIComponent(value));
} else {
result.push(segment);
}
}
return result.length === 0 ? "/" : `/${result.join("/")}`;
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { startComponentDefinitions } from "./catalog";
import { schema, type StartSpec } from "./schema";
const catalog = schema.createCatalog({ components: {}, actions: {} });
describe("@json-render/tanstack-start schema", () => {
it("accepts a minimal Start app spec", () => {
const spec = {
routes: {
"/": {
page: {
root: "root",
elements: {
root: { type: "Card", props: {}, children: [] },
},
},
},
},
};
expect(catalog.validate(spec)).toMatchObject({ success: true, data: spec });
});
it("preserves metadata and React element features", () => {
const spec = {
metadata: { title: "Home", alternates: { canonical: "/" } },
routes: {
"/": {
page: {
root: "root",
state: { active: true },
elements: {
root: {
type: "Card",
props: {},
children: [],
slots: { header: [] },
visible: { $state: "/active" },
on: { press: { action: "save" } },
repeat: { statePath: "/items" },
watch: { "/active": { action: "track" } },
},
},
},
},
},
};
expect(catalog.validate(spec)).toMatchObject({ success: true, data: spec });
});
it("validates built-in Slot and Link elements in a real catalog", () => {
const builtInCatalog = schema.createCatalog({
components: startComponentDefinitions,
actions: {},
});
const result = builtInCatalog.validate({
routes: {
"/": {
page: {
root: "link",
elements: {
link: {
type: "Link",
props: { href: "/about" },
children: [],
},
},
},
},
},
layouts: {
main: {
root: "slot",
elements: {
slot: { type: "Slot", props: {}, children: [] },
},
},
},
});
expect(result.success).toBe(true);
});
it("requires children on every element", () => {
const result = catalog.validate({
routes: {
"/": {
page: {
root: "root",
elements: { root: { type: "Card", props: {} } },
},
},
},
});
expect(result.success).toBe(false);
});
it("infers optional top-level fields", () => {
type InferredSpec = StartSpec<Parameters<typeof schema.createCatalog>[0]>;
const spec: InferredSpec = {
routes: {
"/": {
page: {
root: "root",
elements: {
root: { type: "Card", props: {}, children: [] },
},
},
},
},
};
expect(spec.routes["/"]?.page.root).toBe("root");
});
});
+269
View File
@@ -0,0 +1,269 @@
import { defineSchema, type PromptContext } from "@json-render/core";
function startAppPromptTemplate(context: PromptContext): string {
const { catalog, options, formatZodType } = context;
const {
system = "You are a TanStack Start application generator.",
customRules = [],
} = options;
const lines: string[] = [system, ""];
lines.push("OUTPUT FORMAT:");
lines.push(
"Output JSONL (one JSON object per line) with RFC 6902 JSON Patch operations to build a TanStack Start application spec.",
);
lines.push(
"The spec defines routes, layouts, metadata, and state for a full TanStack Start app.",
);
lines.push("");
lines.push("Example output (each line is a separate JSON object):");
lines.push("");
lines.push(
`{"op":"add","path":"/metadata","value":{"title":{"default":"My App","template":"%s | My App"},"description":"A TanStack Start application"}}`,
);
lines.push(`{"op":"add","path":"/layouts","value":{}}`);
lines.push(
`{"op":"add","path":"/layouts/main","value":{"root":"shell","elements":{"shell":{"type":"AppShell","props":{},"children":["nav","slot"]},"nav":{"type":"NavBar","props":{},"children":[]},"slot":{"type":"Slot","props":{},"children":[]}}}}`,
);
lines.push(`{"op":"add","path":"/routes","value":{}}`);
lines.push(
`{"op":"add","path":"/routes/~1","value":{"layout":"main","metadata":{"title":"Home"},"page":{"root":"hero","elements":{"hero":{"type":"Card","props":{"title":"Welcome"},"children":[]}}}}}`,
);
lines.push("");
lines.push("SPEC STRUCTURE:");
lines.push("- metadata: Root SEO metadata and title templates");
lines.push(
"- layouts: Reusable element trees with a Slot element for page content",
);
lines.push("- routes: Route definitions keyed by URL pattern");
lines.push("- state: Global initial state shared by routes");
lines.push("");
lines.push("ROUTES:");
lines.push("Route keys use TanStack Router URL patterns:");
lines.push("- '/' - home page");
lines.push("- '/about' - static route");
lines.push("- '/blog/$slug' - dynamic segment");
lines.push("- '/docs/$' - splat segment matching the remaining path");
lines.push("");
lines.push(
"In JSON Patch paths, escape every forward slash in a route key as ~1.",
);
lines.push("- Route '/' becomes '/routes/~1'");
lines.push("- Route '/about' becomes '/routes/~1about'");
lines.push("- Route '/blog/$slug' becomes '/routes/~1blog~1$slug'");
lines.push("");
lines.push("Each route has:");
lines.push("- page: Element tree with root, elements, and optional state");
lines.push("- metadata: Route-specific SEO metadata");
lines.push("- layout: Key in the top-level layouts map");
lines.push("- loading, error, notFound: Optional fallback element trees");
lines.push("- loader: Optional server loader name");
lines.push("- staticParams: Optional params for prerendered dynamic routes");
lines.push("");
lines.push("PAGE ELEMENTS:");
lines.push("- props may contain $state, $item, $index, and $computed values");
lines.push("- on maps component events to one or more action bindings");
lines.push("- repeat renders children for values at a state path");
lines.push("- visible conditionally includes an element");
lines.push("- watch runs actions when state paths change");
lines.push(
"- slots maps named slots to element keys; use children for default",
);
lines.push("");
const catalogData = catalog as {
components?: Record<
string,
{
description?: string;
props?: unknown;
slots?: string[];
events?: string[];
}
>;
actions?: Record<string, { description?: string }>;
};
if (catalogData.components) {
lines.push(
`AVAILABLE COMPONENTS (${Object.keys(catalogData.components).length}):`,
);
lines.push("");
for (const [name, definition] of Object.entries(catalogData.components)) {
const props = definition.props
? formatZodType(definition.props as any)
: "{}";
const children = definition.slots?.length ? " [accepts children]" : "";
const events = definition.events?.length
? ` [events: ${definition.events.join(", ")}]`
: "";
const description = definition.description
? ` - ${definition.description}`
: "";
lines.push(`${name}: ${props}${description}${children}${events}`);
}
lines.push("");
}
lines.push("BUILT-IN COMPONENTS:");
lines.push("- Slot: Layout placeholder for page content");
lines.push("- Link: { href: string } client-side navigation link");
lines.push("");
if (catalogData.actions && Object.keys(catalogData.actions).length > 0) {
lines.push("AVAILABLE ACTIONS:");
for (const [name, definition] of Object.entries(catalogData.actions)) {
lines.push(
`${name}${definition.description ? `: ${definition.description}` : ""}`,
);
}
lines.push("");
}
lines.push("BUILT-IN ACTIONS:");
lines.push("- setState: { statePath, value }");
lines.push("- pushState: { statePath, value, clearStatePath? }");
lines.push("- removeState: { statePath, index }");
lines.push("- navigate: { href }");
lines.push("");
lines.push("RULES:");
const rules = [
"Output only JSONL patches, one JSON object per line",
"Add metadata, then layouts, then routes",
"Every layout must contain a Slot element",
"Only use available components plus Slot and Link",
"Every element must include type, props, and children",
"Every child and named-slot key must reference an existing element",
"Escape route-key slashes as ~1 in JSON Patch paths",
"Use Link for navigation between routes",
"Use repeat for lists and include realistic state data",
"Create a cohesive application with consistent layouts",
...customRules,
];
rules.forEach((rule, index) => lines.push(`${index + 1}. ${rule}`));
return lines.join("\n");
}
/** The AI generation schema for full TanStack Start applications. */
export const schema = defineSchema(
(s) => ({
spec: s.object({
metadata: {
...s.object({
title: { ...s.any(), ...s.optional() },
description: { ...s.string(), ...s.optional() },
keywords: { ...s.array(s.string()), ...s.optional() },
openGraph: { ...s.any(), ...s.optional() },
twitter: { ...s.any(), ...s.optional() },
robots: { ...s.any(), ...s.optional() },
alternates: { ...s.any(), ...s.optional() },
icons: { ...s.any(), ...s.optional() },
}),
...s.optional(),
},
routes: s.record(
s.object({
page: s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
slots: {
...s.record(s.array(s.string())),
...s.optional(),
},
visible: { ...s.any(), ...s.optional() },
on: { ...s.any(), ...s.optional() },
repeat: { ...s.any(), ...s.optional() },
watch: { ...s.any(), ...s.optional() },
}),
),
state: { ...s.any(), ...s.optional() },
}),
metadata: { ...s.any(), ...s.optional() },
layout: { ...s.string(), ...s.optional() },
loading: { ...s.any(), ...s.optional() },
error: { ...s.any(), ...s.optional() },
notFound: { ...s.any(), ...s.optional() },
loader: { ...s.string(), ...s.optional() },
staticParams: { ...s.any(), ...s.optional() },
}),
),
layouts: {
...s.record(
s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
slots: {
...s.record(s.array(s.string())),
...s.optional(),
},
visible: { ...s.any(), ...s.optional() },
on: { ...s.any(), ...s.optional() },
repeat: { ...s.any(), ...s.optional() },
watch: { ...s.any(), ...s.optional() },
}),
),
state: { ...s.any(), ...s.optional() },
}),
),
...s.optional(),
},
state: { ...s.any(), ...s.optional() },
}),
catalog: s.object({
components: s.map({
props: s.zod(),
slots: s.array(s.string()),
description: s.string(),
example: s.any(),
}),
actions: s.map({
params: s.zod(),
description: s.string(),
}),
}),
}),
{
promptTemplate: startAppPromptTemplate,
builtInActions: [
{
name: "setState",
description:
"Update state at a JSON Pointer. Params: { statePath, value }",
},
{
name: "pushState",
description:
"Append to an array. Params: { statePath, value, clearStatePath? }",
},
{
name: "removeState",
description: "Remove an array item. Params: { statePath, index }",
},
{
name: "navigate",
description: "Navigate within the app. Params: { href }",
},
],
},
);
export type StartSchema = typeof schema;
export type StartSpec<TCatalog> = typeof schema extends {
createCatalog: (catalog: TCatalog) => { _specType: infer S };
}
? S
: never;
+37
View File
@@ -0,0 +1,37 @@
/** Server-safe TanStack Start helpers with no React or router imports. */
export { createStartApp } from "./create-app";
export {
startComponentDefinitions,
type StartComponentDefinitions,
} from "./catalog";
export { schema, type StartSchema, type StartSpec } from "./schema";
export { collectStaticPaths, matchRoute, splatToPath } from "./router";
export {
metadataToHead,
resolveMetadata,
type ResolvedMetadata,
} from "./metadata";
export type {
CreateStartAppOptions,
HeadDescriptors,
LoaderFn,
MatchedRoute,
PageData,
StartAppExports,
StartAppSpec,
StartMetadata,
StartRouteSpec,
} from "./types";
export type {
ActionFn,
Actions,
BaseComponentProps,
ComponentContext,
ComponentFn,
Components,
EventHandle,
SetState,
StateModel,
} from "./catalog-types";
export type { Spec, StateStore } from "@json-render/core";
@@ -0,0 +1,52 @@
import React from "react";
import { createRootRoute, createRoute, notFound } from "@tanstack/react-router";
import { describe, expect, it } from "vitest";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
} from "./index";
import { createStartApp } from "./server";
import type { StartAppSpec } from "./types";
const spec: StartAppSpec = {
routes: {
"/$": {
page: {
root: "root",
elements: {
root: { type: "Card", props: {}, children: [] },
},
},
},
},
};
const app = createStartApp({ spec });
const rootRoute = createRootRoute();
const route = createRoute({
getParentRoute: () => rootRoute,
path: "$",
loader: async ({ location }) => {
const data = await app.getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => app.getHead({ pathname: match.pathname }),
component: RouteComponent,
pendingComponent: StartLoading,
errorComponent: StartErrorBoundary,
notFoundComponent: StartNotFound,
});
function RouteComponent() {
return <PageRenderer {...route.useLoaderData()} />;
}
describe("TanStack Router contract", () => {
it("accepts all json-render route hooks and components", () => {
expect(route.options.loader).toBeTypeOf("function");
expect(route.options.head).toBeTypeOf("function");
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { Spec } from "@json-render/core";
/**
* SEO metadata for TanStack Start pages.
*
* This framework-neutral shape resolves into TanStack Router `head`
* descriptors through `metadataToHead`.
*/
export interface StartMetadata {
/** Page title, or a title template configuration. */
title?:
| string
| {
/** Default title when no route overrides it. */
default: string;
/** Template containing `%s` for the route title. */
template?: string;
/** Absolute title that ignores the parent template. */
absolute?: string;
};
description?: string;
keywords?: string[];
openGraph?: {
title?: string;
description?: string;
images?: string | string[];
type?: string;
url?: string;
siteName?: string;
locale?: string;
};
twitter?: {
card?: "summary" | "summary_large_image" | "app" | "player";
title?: string;
description?: string;
images?: string | string[];
creator?: string;
site?: string;
};
robots?: string | { index?: boolean; follow?: boolean };
alternates?: { canonical?: string };
icons?: string | { icon?: string; apple?: string; shortcut?: string };
}
/** A route definition within a StartAppSpec. */
export interface StartRouteSpec {
/** Page content as a standard json-render element tree. */
page: Spec;
metadata?: StartMetadata;
/** Key of a reusable layout in `StartAppSpec.layouts`. */
layout?: string;
loading?: Spec;
error?: Spec;
notFound?: Spec;
/** Name of a loader supplied to `createStartApp`. */
loader?: string;
/** Parameter sets used to produce concrete prerender paths. */
staticParams?: Record<string, string>[];
}
/**
* A full json-render application for TanStack Start.
*
* Route keys use TanStack Router conventions: `/`, `/blog/$slug`, and
* `/docs/$` for a splat route.
*/
export interface StartAppSpec {
metadata?: StartMetadata;
routes: Record<string, StartRouteSpec>;
/** Layouts use a `Slot` element to mark where page content is inserted. */
layouts?: Record<string, Spec>;
state?: Record<string, unknown>;
}
/** The result of matching a pathname against the application spec. */
export interface MatchedRoute {
route: StartRouteSpec;
pattern: string;
/** Splat content is returned as a slash-delimited string under `_splat`. */
params: Record<string, string>;
}
export type LoaderFn = (
params: Record<string, string>,
) => Promise<Record<string, unknown>> | Record<string, unknown>;
export interface CreateStartAppOptions {
spec: StartAppSpec | (() => StartAppSpec | Promise<StartAppSpec>);
loaders?: Record<string, LoaderFn>;
}
/** Serializable data returned from a TanStack Start route loader. */
export interface PageData {
spec: Spec;
initialState?: Record<string, unknown>;
layoutSpec?: Spec | null;
}
/** Descriptors accepted by a TanStack Router route's `head` option. */
export interface HeadDescriptors {
meta: Record<string, string>[];
links: Record<string, string>[];
}
export interface StartAppExports {
/** Resolve serializable page data for a pathname, or null when unmatched. */
getPageData: (props: { pathname: string }) => Promise<PageData | null>;
/** Resolve metadata for a route's `head` option. */
getHead: (props: { pathname: string }) => Promise<HeadDescriptors>;
/** Get concrete pathnames for TanStack Start prerendering. */
getStaticPaths: () => Promise<string[]>;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from "tsup";
const sharedExternal = [
"react",
"react-dom",
"@tanstack/react-router",
"@json-render/core",
"@json-render/react",
"zod",
];
export default defineConfig([
{
entry: { index: "src/index.ts" },
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
splitting: false,
banner: ({ format }) => (format === "cjs" ? { js: '"use client";' } : {}),
external: sharedExternal,
},
{
entry: { server: "src/server.ts", catalog: "src/catalog.ts" },
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
splitting: false,
external: sharedExternal,
},
]);
+38 -5
View File
@@ -26,6 +26,7 @@ export const catalog = defineCatalog(schema, {
title: z.string(),
description: z.string().nullable(),
}),
slots: ["default", "header", "footer"],
description: "A card container",
},
Button: {
@@ -53,7 +54,7 @@ export const catalog = defineCatalog(schema, {
### 2. Define Component Implementations
Components are written using Vue's `h()` render function. `children` is a `VNode | VNode[]` — pass it directly to your container element.
Components are written using Vue's `h()` render function. The `slots` context uses Vue's native slot functions, so render a region with `slots.header?.()`. For convenience and React parity, `children` contains the already-rendered result of `slots.default?.()`.
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely.
@@ -64,11 +65,12 @@ import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
Card: ({ props, slots }) =>
h("div", { class: "card" }, [
h("h3", null, props.title),
h("header", null, slots.header?.() ?? h("h3", null, props.title)),
props.description ? h("p", null, props.description) : null,
children,
slots.default?.(),
h("footer", null, slots.footer?.()),
]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
@@ -131,6 +133,7 @@ interface UIElement {
type: string; // Component name from catalog
props: Record<string, unknown>; // Component props
children?: string[]; // Keys of child elements
slots?: Record<string, string[]>; // Named slots mapped to child keys
visible?: VisibilityCondition; // Visibility condition
}
```
@@ -163,6 +166,35 @@ Example spec:
}
```
### Named Slots
Use `children` for the default slot and the element's top-level `slots` object for other slot names declared by the catalog:
```typescript
Layout: ({ children, slots }) =>
h("div", null, [
h("header", null, slots.header?.()),
h("main", null, children),
h("footer", null, slots.footer?.()),
]),
```
The corresponding spec maps element keys to each region:
```json
{
"type": "Layout",
"props": {},
"children": ["main-content"],
"slots": {
"header": ["page-heading"],
"footer": ["page-actions"]
}
}
```
Registry components receive Vue-native slot functions and render them with `slots.header?.()`, `slots.footer?.()`, and so on. `slots.default?.()` renders the spec's `children`; `children` is an alias for that rendered result. In the JSON spec, keep default content in `children` rather than adding a `default` entry to `slots`.
## Providers
### StateProvider
@@ -366,11 +398,12 @@ The `setState`, `pushState`, `removeState`, and `validateForm` actions are built
When using `defineRegistry`, components receive these props via their render function:
```typescript
import type { VNode } from "vue";
import type { Slots, VNode } from "vue";
interface ComponentContext<P> {
props: P; // Typed props from the catalog (expressions resolved)
children?: VNode | VNode[]; // Rendered children (for container components)
slots: Slots; // Vue-native slot functions
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean; // Whether the parent is loading
+3 -1
View File
@@ -1,4 +1,4 @@
import type { VNode } from "vue";
import type { Slots, VNode } from "vue";
import type {
Catalog,
InferCatalogComponents,
@@ -59,6 +59,8 @@ export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
/** Rendered children (from the default slot) */
children?: VNode | VNode[];
/** Vue-native slot functions, including the default slot */
slots: Slots;
/** Simple event emitter (shorthand). Fires the event and returns void. */
emit: (event: string) => void;
/** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
+25
View File
@@ -128,6 +128,31 @@ describe("buildSpecFromParts", () => {
];
expect(buildSpecFromParts(parts)).toBeNull();
});
it("preserves named slots in nested spec parts", () => {
const spec = buildSpecFromParts([
{
type: SPEC_DATA_PART_TYPE,
data: {
type: "nested",
spec: {
type: "Layout",
props: {},
slots: {
header: [
{ type: "Heading", props: { text: "Header" }, children: [] },
],
},
},
},
},
]);
expect(spec).not.toBeNull();
const root = spec!.elements[spec!.root]!;
expect(root.slots?.header).toHaveLength(1);
expect(spec!.elements[root.slots!.header![0]!]!.type).toBe("Heading");
});
});
// ---------------------------------------------------------------------------
+234 -2
View File
@@ -1,12 +1,18 @@
import { describe, it, expect, vi } from "vitest";
import { afterEach, describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component } from "vue";
import { mount } from "@vue/test-utils";
import type { Spec } from "@json-render/core";
import { defineCatalog, type Spec } from "@json-render/core";
import { z } from "zod";
import { StateProvider } from "./composables/state";
import { VisibilityProvider } from "./composables/visibility";
import { ActionProvider } from "./composables/actions";
import { ValidationProvider } from "./composables/validation";
import { Renderer, defineRegistry, type ComponentRegistry } from "./renderer";
import { schema } from "./schema";
afterEach(() => {
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Minimal test catalog and registry
@@ -68,6 +74,23 @@ function mountRenderer(
});
}
function layoutSpec(slots: Record<string, string[]>): Spec {
return {
root: "layout",
elements: {
layout: {
type: "Layout",
props: {},
children: ["main"],
slots,
},
main: { type: "Text", props: { text: "Main" } },
header: { type: "Text", props: { text: "Header" } },
other: { type: "Text", props: { text: "Other" } },
},
};
}
// ---------------------------------------------------------------------------
// defineRegistry tests
// ---------------------------------------------------------------------------
@@ -111,6 +134,122 @@ describe("defineRegistry", () => {
expect(wrapper.find("[data-type='button']").exists()).toBe(true);
});
it("renders named slots as Vue slot functions through defineRegistry", () => {
const namedSlotsCatalog = defineCatalog(schema, {
components: {
Layout: {
props: z.object({}),
slots: ["default", "header", "footer"],
},
Text: {
props: z.object({ text: z.string() }),
slots: [],
},
},
actions: {},
});
const { registry: namedSlotsRegistry } = defineRegistry(namedSlotsCatalog, {
components: {
Layout: ({ slots }) =>
h("section", null, [
h("header", { "data-testid": "header-slot" }, slots.header?.()),
h("main", { "data-testid": "default-slot" }, slots.default?.()),
h("footer", { "data-testid": "footer-slot" }, slots.footer?.()),
]),
Text: ({ props }) => h("span", null, props.text),
},
});
const spec: Spec = {
root: "layout",
elements: {
layout: {
type: "Layout",
props: {},
children: ["main"],
slots: {
header: ["header"],
footer: ["footer"],
},
},
header: { type: "Text", props: { text: "Header" } },
main: { type: "Text", props: { text: "Main" } },
footer: { type: "Text", props: { text: "Footer" } },
},
};
const wrapper = mountRenderer(spec, namedSlotsRegistry);
expect(wrapper.find('[data-testid="header-slot"]').text()).toBe("Header");
expect(wrapper.find('[data-testid="default-slot"]').text()).toBe("Main");
expect(wrapper.find('[data-testid="footer-slot"]').text()).toBe("Footer");
});
it("advertises named slots in the Vue catalog prompt without default", () => {
const namedSlotsCatalog = defineCatalog(schema, {
components: {
Layout: { props: z.object({}), slots: ["default", "header"] },
},
actions: {},
});
const prompt = namedSlotsCatalog.prompt();
expect(prompt).toContain("slots: header");
expect(prompt).not.toContain("slots: default");
});
it("warns and ignores slots.default while preserving children", () => {
const namedSlotsCatalog = defineCatalog(schema, {
components: {
Layout: { props: z.object({}), slots: ["default"] },
Text: { props: z.object({ text: z.string() }), slots: [] },
},
actions: {},
});
const { registry: namedSlotsRegistry } = defineRegistry(namedSlotsCatalog, {
components: {
Layout: ({ children }) => h("main", null, children),
Text: ({ props }) => h("span", null, props.text),
},
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const wrapper = mountRenderer(
layoutSpec({ default: ["other"] }),
namedSlotsRegistry,
);
expect(wrapper.text()).toBe("Main");
expect(warn).toHaveBeenCalledWith(expect.stringContaining("slots.default"));
});
it("warns but renders an undeclared named slot", () => {
const namedSlotsCatalog = defineCatalog(schema, {
components: {
Layout: { props: z.object({}), slots: ["default"] },
Text: { props: z.object({ text: z.string() }), slots: [] },
},
actions: {},
});
const { registry: namedSlotsRegistry } = defineRegistry(namedSlotsCatalog, {
components: {
Layout: ({ slots }) => h("aside", null, slots.sidebar?.()),
Text: ({ props }) => h("span", null, props.text),
},
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const wrapper = mountRenderer(
layoutSpec({ sidebar: ["other"] }),
namedSlotsRegistry,
);
expect(wrapper.text()).toBe("Other");
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Unknown slot "sidebar"'),
);
});
it("emit('press') fires the corresponding on.press action", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const spec: Spec = {
@@ -134,6 +273,99 @@ describe("defineRegistry", () => {
// ---------------------------------------------------------------------------
describe("Renderer", () => {
it("delivers raw registry named slots lazily", () => {
let footerCalls = 0;
const Layout = defineComponent({
setup(_, { slots }) {
return () =>
h("section", null, [
h("header", null, slots.header?.()),
h("main", null, slots.default?.()),
slots.footer ? h("i", null, "has-footer") : null,
]);
},
});
const Text = defineComponent({
props: { element: { type: Object, required: true } },
setup(props) {
return () => h("span", null, (props.element as any).props.text);
},
});
const Footer = defineComponent({
setup() {
footerCalls += 1;
return () => h("span", null, "Footer");
},
});
const spec = layoutSpec({ header: ["header"], footer: ["footer"] });
spec.elements.footer = { type: "Footer", props: {} };
const wrapper = mountRenderer(spec, { Layout, Text, Footer });
expect(wrapper.text()).toContain("Header");
expect(wrapper.text()).toContain("Main");
expect(wrapper.text()).toContain("has-footer");
expect(wrapper.text()).not.toContain("Footer");
expect(footerCalls).toBe(0);
});
it("suppresses missing named slot warnings while loading", () => {
const Layout = defineComponent({
setup(_, { slots }) {
return () => h("header", null, slots.header?.());
},
});
const spec = layoutSpec({ header: ["missing"] });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const loadingWrapper = mountRenderer(spec, { Layout }, { loading: true });
expect(warn).not.toHaveBeenCalled();
loadingWrapper.unmount();
mountRenderer(spec, { Layout }, { loading: false });
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('in slot "header"'),
);
});
it("renders owner named slots once outside the owner repeat scope", () => {
const Layout = defineComponent({
setup(_, { slots }) {
return () => h("section", null, [slots.header?.(), slots.default?.()]);
},
});
const Text = defineComponent({
props: { element: { type: Object, required: true } },
setup(props) {
return () => h("span", null, (props.element as any).props.text);
},
});
const spec: Spec = {
root: "layout",
elements: {
layout: {
type: "Layout",
props: {},
repeat: { statePath: "/items" },
children: ["row"],
slots: { header: ["header"] },
},
row: { type: "Text", props: { text: { $item: "name" } } },
header: { type: "Text", props: { text: "Header" } },
},
};
const wrapper = mountRenderer(
spec,
{ Layout, Text },
{},
{},
{ items: [{ name: "A" }, { name: "B" }] },
);
expect(wrapper.text()).toBe("HeaderAB");
});
it("renders a single-element spec", () => {
const spec: Spec = {
root: "btn1",
+83 -24
View File
@@ -11,6 +11,7 @@ import {
type Component,
type ComputedRef,
type PropType,
type Slots,
type VNode,
} from "vue";
import type {
@@ -83,6 +84,11 @@ export interface ComponentRenderProps<P = Record<string, unknown>> {
*/
export type ComponentRegistry = Record<string, Component>;
const registryMetadata = new WeakMap<
ComponentRegistry,
Record<string, { slots?: string[] }>
>();
/**
* Props for the Renderer component
*/
@@ -395,6 +401,51 @@ const ElementRenderer = defineComponent({
return null;
}
const metadata = registryMetadata.get(props.registry)?.[
resolvedElement.type
];
if (resolvedElement.slots && metadata?.slots) {
const availableSlots = new Set(metadata.slots);
for (const slotName of Object.keys(resolvedElement.slots)) {
if (slotName === "default") {
console.warn(
`[json-render] Component "${resolvedElement.type}" uses slots.default. Use "children" for default slot content.`,
);
} else if (!availableSlots.has(slotName)) {
console.warn(
`[json-render] Unknown slot "${slotName}" on component "${resolvedElement.type}". Available slots: ${metadata.slots.join(", ")}`,
);
}
}
}
const renderChildKeys = (childKeys: string[], slotName?: string) =>
childKeys
.map((childKey) => {
const childElement = props.spec.elements[childKey];
if (!childElement) {
if (!props.loading) {
const location = slotName
? `in slot "${slotName}" of "${resolvedElement.type}"`
: `as child of "${resolvedElement.type}"`;
console.warn(
`[json-render] Missing element "${childKey}" referenced ${location}. This element will not render.`,
);
}
return null;
}
return h(ElementRenderer, {
key: childKey,
element: childElement,
elementKey: childKey,
spec: props.spec,
registry: props.registry,
loading: props.loading,
fallback: props.fallback,
});
})
.filter((node): node is VNode => node !== null);
// Render children
const childrenVNodes: VNode | VNode[] | undefined = resolvedElement.repeat
? h(RepeatChildren, {
@@ -404,28 +455,20 @@ const ElementRenderer = defineComponent({
loading: props.loading,
fallback: props.fallback,
})
: (resolvedElement.children
?.map((childKey) => {
const childElement = props.spec.elements[childKey];
if (!childElement) {
if (!props.loading) {
console.warn(
`[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
);
}
return null;
}
return h(ElementRenderer, {
key: childKey,
element: childElement,
elementKey: childKey,
spec: props.spec,
registry: props.registry,
loading: props.loading,
fallback: props.fallback,
});
})
.filter((n): n is VNode => n !== null) ?? undefined);
: resolvedElement.children
? renderChildKeys(resolvedElement.children)
: undefined;
const namedSlots = resolvedElement.slots
? Object.fromEntries(
Object.entries(resolvedElement.slots)
.filter(([slotName]) => slotName !== "default")
.map(([slotName, childKeys]) => [
slotName,
() => renderChildKeys(childKeys, slotName),
]),
)
: {};
const componentVNode = h(
Component,
@@ -436,7 +479,7 @@ const ElementRenderer = defineComponent({
bindings: elementBindings,
loading: props.loading,
},
{ default: () => childrenVNodes },
{ default: () => childrenVNodes, ...namedSlots },
);
// When devtools is mounted, wrap each element in a transparent span so
@@ -783,6 +826,7 @@ type DefineRegistryOptions<C extends Catalog> = {
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: VNode | VNode[];
slots: Slots;
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
@@ -815,7 +859,7 @@ type DefineRegistryActionFn = (
* ```
*/
export function defineRegistry<C extends Catalog>(
_catalog: C,
catalog: C,
options: DefineRegistryOptions<C>,
): DefineRegistryResult {
const registry: ComponentRegistry = {};
@@ -851,6 +895,7 @@ export function defineRegistry<C extends Catalog>(
(componentFn as DefineRegistryComponentFn)({
props: registryProps.element.props,
children: slots.default?.(),
slots,
emit: registryProps.emit,
on: registryProps.on,
bindings: registryProps.bindings,
@@ -860,6 +905,14 @@ export function defineRegistry<C extends Catalog>(
});
}
}
const catalogComponents = (
catalog as {
data?: { components?: Record<string, { slots?: string[] }> };
}
).data?.components;
if (catalogComponents) {
registryMetadata.set(registry, catalogComponents);
}
const actionMap = options.actions
? (Object.entries(options.actions) as Array<
@@ -957,6 +1010,12 @@ export function createRenderer<
): Component {
const registry: ComponentRegistry =
components as unknown as ComponentRegistry;
const catalogComponents = (
catalog.data as { components?: Record<string, { slots?: string[] }> }
).components;
if (catalogComponents) {
registryMetadata.set(registry, catalogComponents);
}
return defineComponent({
name: "CatalogRenderer",
+3
View File
@@ -22,6 +22,8 @@ export const schema = defineSchema(
props: s.propsOf("catalog.components"),
/** Child element keys (flat reference) */
children: s.array(s.string()),
/** Named slots mapped to child element keys */
slots: { ...s.record(s.array(s.string())), ...s.optional() },
/** Visibility condition */
visible: { ...s.any(), ...s.optional() },
/** Repeat children from a state array */
@@ -80,6 +82,7 @@ export const schema = defineSchema(
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
'NAMED SLOTS: Use "children" for the default slot. For other slots declared by the component, use a top-level "slots" object that maps each slot name to child element keys, for example {"slots":{"header":["heading"],"footer":["actions"]}}. Never use "slots.default". Every referenced key must exist.',
// Field placement
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
+109
View File
@@ -2568,6 +2568,37 @@ importers:
specifier: ^5.4.5
version: 5.9.3
packages/tanstack-start:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../core
'@json-render/react':
specifier: workspace:*
version: link:../react
react:
specifier: ^19.2.3
version: 19.2.4
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@tanstack/react-router':
specifier: 1.170.32
version: 1.170.32(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: 19.2.14
version: 19.2.14
tsup:
specifier: ^8.5.1
version: 8.5.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)
typescript:
specifier: ^5.4.5
version: 5.9.3
zod:
specifier: ^4.3.6
version: 4.3.6
packages/typescript-config: {}
packages/ui:
@@ -7324,6 +7355,30 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tanstack/history@1.162.1':
resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==}
engines: {node: '>=20.19'}
'@tanstack/react-router@1.170.32':
resolution: {integrity: sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw==}
engines: {node: '>=20.19'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
'@tanstack/react-store@0.9.3':
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/router-core@1.171.27':
resolution: {integrity: sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==}
engines: {node: '>=20.19'}
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -8880,6 +8935,9 @@ packages:
resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
cookie-es@3.1.1:
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -10898,6 +10956,10 @@ packages:
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
isbot@5.2.2:
resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==}
engines: {node: '>=18'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -13417,10 +13479,20 @@ packages:
peerDependencies:
seroval: ^1.0
seroval-plugins@1.6.4:
resolution: {integrity: sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==}
engines: {node: '>=10'}
peerDependencies:
seroval: ^1.0
seroval@1.5.1:
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
engines: {node: '>=10'}
seroval@1.6.4:
resolution: {integrity: sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==}
engines: {node: '>=10'}
serve-static@1.16.3:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
@@ -21250,6 +21322,33 @@ snapshots:
tailwindcss: 4.2.1
vite: 7.3.1(@types/node@22.19.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
'@tanstack/history@1.162.1': {}
'@tanstack/react-router@1.170.32(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/history': 1.162.1
'@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tanstack/router-core': 1.171.27
isbot: 5.2.2
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/store': 0.9.3
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
'@tanstack/router-core@1.171.27':
dependencies:
'@tanstack/history': 1.162.1
cookie-es: 3.1.1
seroval: 1.6.4
seroval-plugins: 1.6.4(seroval@1.6.4)
'@tanstack/store@0.9.3': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
@@ -23067,6 +23166,8 @@ snapshots:
convert-to-spaces@2.0.1: {}
cookie-es@3.1.1: {}
cookie-signature@1.2.2: {}
cookie@0.6.0: {}
@@ -25575,6 +25676,8 @@ snapshots:
isarray@2.0.5: {}
isbot@5.2.2: {}
isexe@2.0.0: {}
isexe@3.1.5: {}
@@ -29179,8 +29282,14 @@ snapshots:
dependencies:
seroval: 1.5.1
seroval-plugins@1.6.4(seroval@1.6.4):
dependencies:
seroval: 1.6.4
seroval@1.5.1: {}
seroval@1.6.4: {}
serve-static@1.16.3:
dependencies:
encodeurl: 2.0.0
+2
View File
@@ -77,6 +77,8 @@ const { result, newPatches } = compiler.push(chunk);
const finalSpec = compiler.getResult();
```
JSON Pointer paths containing `__proto__`, `constructor`, or `prototype` tokens are rejected by path utilities, state stores, and SpecStream. For `move` and `copy`, this applies to both `path` and `from`.
## Dynamic Prop Expressions
Any prop value can be a dynamic expression resolved at render time:
+199
View File
@@ -0,0 +1,199 @@
---
name: tanstack-start
description: Build JSON-defined TanStack Start applications with @json-render/tanstack-start. Use for Start route specs, splat routing, SSR loaders, head metadata, layouts, and client navigation. Do not use for generic TanStack Router apps that do not use json-render.
---
# @json-render/tanstack-start
Use this integration when a TanStack Start app needs complete pages or routes
described by json-render specs.
## Install
```bash
npm install @json-render/core @json-render/react @json-render/tanstack-start
```
## Application Spec
Include the server-safe built-in definitions in the generation catalog. The
renderer supplies their component implementations.
```typescript
import { defineCatalog } from "@json-render/core";
import {
schema,
startComponentDefinitions,
} from "@json-render/tanstack-start/server";
const catalog = defineCatalog(schema, {
components: {
...startComponentDefinitions,
Card: cardDefinition,
Shell: shellDefinition,
Navigation: navigationDefinition,
Home: homeDefinition,
Post: postDefinition,
},
actions: {},
});
```
Then use `StartAppSpec` and TanStack Router route patterns:
```typescript
import type { StartAppSpec } from "@json-render/tanstack-start";
export const spec: StartAppSpec = {
metadata: {
title: { default: "Site", template: "%s | Site" },
},
layouts: {
main: {
root: "shell",
elements: {
shell: { type: "Shell", props: {}, children: ["nav", "slot"] },
nav: { type: "Navigation", props: {}, children: [] },
slot: { type: "Slot", props: {}, children: [] },
},
},
},
routes: {
"/": {
layout: "main",
metadata: { title: "Home" },
page: {
root: "home",
elements: {
home: { type: "Home", props: {}, children: [] },
},
},
},
"/posts/$slug": {
layout: "main",
loader: "post",
staticParams: [{ slug: "hello" }],
page: {
root: "post",
elements: {
post: {
type: "Post",
props: { value: { $state: "/post" } },
children: [],
},
},
},
},
},
};
```
Routes use `/posts/$slug` for named parameters and `/docs/$` for a splat.
Splat loader parameters are slash-delimited strings under `_splat`. Escape
route-key slashes as `~1` when generating RFC 6902 patches.
Every layout needs a `Slot` element. Declare `Slot` and `Link` through
`startComponentDefinitions`; do not require consumers to register React
implementations for them.
## Server Helpers
```typescript
import { createStartApp } from "@json-render/tanstack-start/server";
export const { getPageData, getHead, getStaticPaths } = createStartApp({
spec,
loaders: {
post: async ({ slug }) => ({ post: await getPost(slug as string) }),
},
});
```
State merge precedence is application state, layout 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. Loader params are URL-decoded,
while values from `staticParams` are URL-encoded in generated paths.
Route matching treats trailing slashes as optional and accepts encoded or
decoded pathname representations so loader data and metadata resolve the same
static route.
## Route Wiring
```tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
} from "@json-render/tanstack-start";
import { getHead, getPageData } from "@/lib/json-app";
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => getHead({ pathname: match.pathname }),
component: () => <PageRenderer {...Route.useLoaderData()} />,
pendingComponent: StartLoading,
errorComponent: StartErrorBoundary,
notFoundComponent: StartNotFound,
});
```
TanStack Router loaders run on both the server and client. If a spec factory or
named loader uses credentials, database clients, or server-only imports, wrap
`getPageData` and `getHead` in a TanStack Start `createServerFn`; do not import
that server code directly into an isomorphic route loader.
## Root Provider
Wrap the root route's `Outlet` with `StartAppProvider`. Render `HeadContent` so
metadata from `getHead` reaches the document.
```tsx
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router";
import { StartAppProvider } from "@json-render/tanstack-start";
import { spec } from "@/lib/spec";
export const Route = createRootRoute({
component: () => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<StartAppProvider
registry={registry}
handlers={handlers}
spec={spec}
>
<Outlet />
</StartAppProvider>
<Scripts />
</body>
</html>
),
});
```
Use `StartLoading`, `StartErrorBoundary`, and `StartNotFound` for TanStack
Router's `pendingComponent`, `errorComponent`, and `notFoundComponent` options.
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`.
The default error boundary invalidates the router and reruns a failed loader
when the user selects **Try again**.
Import React components from `@json-render/tanstack-start`. Import `schema`,
`createStartApp`, `matchRoute`, `resolveMetadata`, and static path helpers from
`@json-render/tanstack-start/server`.
+30
View File
@@ -28,8 +28,14 @@ export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string(), description: z.string().nullable() }),
slots: ["default"],
description: "A card container",
},
Layout: {
props: z.object({}),
slots: ["default", "header", "footer"],
description: "Layout with named content regions",
},
Button: {
props: z.object({ label: z.string(), action: z.string() }),
description: "A clickable button",
@@ -54,12 +60,36 @@ export const { registry } = defineRegistry(catalog, {
props.description ? h("p", null, props.description) : null,
children,
]),
Layout: ({ slots }) =>
h("div", null, [
h("header", null, slots.header?.()),
h("main", null, slots.default?.()),
h("footer", null, slots.footer?.()),
]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
});
```
## Named Slots
Use `children` for the `"default"` slot. Use the element's top-level `slots` object for other slot names declared by the catalog:
```json
{
"type": "Layout",
"props": {},
"children": ["main"],
"slots": {
"header": ["heading"],
"footer": ["actions"]
}
}
```
Registry components receive Vue-native slot functions. Render them with `slots.header?.()`, `slots.footer?.()`, and so on. `slots.default?.()` renders the spec's `children`; the `children` context field is a convenience alias for that rendered result. In the JSON spec, keep default content in `children` rather than adding a `default` entry to `slots`.
### Render Specs
```vue