mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3201854481 | ||
|
|
64c889221e | ||
|
|
49838fa353 | ||
|
|
fa47b08869 | ||
|
|
5ccb109c08 | ||
|
|
62932f6516 |
+2
-9
@@ -6,11 +6,8 @@ node_modules
|
||||
.pnp.js
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
@@ -44,10 +41,6 @@ yarn-error.log*
|
||||
# opensrc - source code for packages
|
||||
opensrc/
|
||||
|
||||
# json-studio (separate repo)
|
||||
json-studio/
|
||||
.env*.local
|
||||
|
||||
# Stripe apps (generated from template + build artifacts)
|
||||
examples/stripe-app/*/stripe-app.json
|
||||
examples/stripe-app/*/.build
|
||||
|
||||
@@ -27,6 +27,21 @@ This ensures we don't install outdated versions that may have incompatible types
|
||||
- Do not use emojis in code or UI
|
||||
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
|
||||
|
||||
## AI SDK / AI Gateway
|
||||
|
||||
When using the Vercel AI SDK (`ai` package) with AI Gateway, pass the model as a plain string identifier -- do not import a provider constructor:
|
||||
|
||||
```ts
|
||||
import { streamText } from "ai";
|
||||
|
||||
const result = streamText({
|
||||
model: "anthropic/claude-haiku-4.5",
|
||||
prompt: "...",
|
||||
});
|
||||
```
|
||||
|
||||
This requires `AI_GATEWAY_API_KEY` to be set in the environment. See `tests/e2e/` for examples.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
All apps and examples with dev servers use [portless](https://github.com/vercel-labs/portless) to avoid hardcoded ports. Portless assigns random ports and exposes each app via `.localhost` URLs.
|
||||
@@ -53,6 +68,7 @@ Do **not** add `--port` flags -- portless handles port assignment automatically.
|
||||
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
|
||||
- Package `README.md` files in `packages/*/README.md`
|
||||
- Root `README.md` (if packages table, install commands, or examples are affected)
|
||||
- Web app docs in `apps/web/` (if guides, API references, or examples need updating)
|
||||
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
|
||||
- `AGENTS.md` (if workflow or conventions change)
|
||||
|
||||
|
||||
@@ -114,6 +114,9 @@ function Dashboard({ spec }) {
|
||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
||||
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
||||
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||
|
||||
## Renderers
|
||||
|
||||
|
||||
@@ -63,11 +63,36 @@ React Native renderer with standard components, providers, and hooks.
|
||||
### StateProvider
|
||||
|
||||
```tsx
|
||||
<StateProvider initialState={object}>
|
||||
<StateProvider initialState={object} onStateChange={fn}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
|
||||
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
|
||||
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
|
||||
|
||||
#### External Store (Controlled Mode)
|
||||
|
||||
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react-native";
|
||||
|
||||
const store = createStateStore({ count: 0 });
|
||||
|
||||
<StateProvider store={store}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
|
||||
// Mutate from anywhere — components re-render automatically:
|
||||
store.set("/count", 1);
|
||||
```
|
||||
|
||||
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
|
||||
|
||||
### ActionProvider
|
||||
|
||||
```tsx
|
||||
|
||||
@@ -108,14 +108,17 @@ const PDFRenderer = createRenderer(catalog, components);
|
||||
```typescript
|
||||
interface CreateRendererProps {
|
||||
spec: Spec | null;
|
||||
store?: StateStore;
|
||||
state?: Record<string, unknown>;
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
}
|
||||
```
|
||||
|
||||
When `store` is provided, `state` and `onStateChange` are ignored (controlled mode).
|
||||
|
||||
## Renderer
|
||||
|
||||
The main component that renders a spec to `@react-pdf/renderer` elements.
|
||||
@@ -344,6 +347,19 @@ Renders current page number and total pages. Format uses `{pageNumber}` and `{to
|
||||
}
|
||||
```
|
||||
|
||||
## External Store (Controlled Mode)
|
||||
|
||||
Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer` for full control over state:
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react-pdf";
|
||||
|
||||
const store = createStateStore({ invoice: { total: 100 } });
|
||||
store.set("/invoice/total", 200);
|
||||
```
|
||||
|
||||
When `store` is provided, `initialState` / `state` and `onStateChange` are ignored.
|
||||
|
||||
## Server-Safe Import
|
||||
|
||||
Import schema and catalog definitions without pulling in React or `@react-pdf/renderer`:
|
||||
|
||||
@@ -10,11 +10,36 @@ React components, providers, and hooks.
|
||||
### StateProvider
|
||||
|
||||
```tsx
|
||||
<StateProvider initialState={object}>
|
||||
<StateProvider initialState={object} onStateChange={fn}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
|
||||
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
|
||||
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
|
||||
|
||||
#### External Store (Controlled Mode)
|
||||
|
||||
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react";
|
||||
|
||||
const store = createStateStore({ count: 0 });
|
||||
|
||||
<StateProvider store={store}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
|
||||
// Mutate from anywhere — React re-renders automatically:
|
||||
store.set("/count", 1);
|
||||
```
|
||||
|
||||
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
|
||||
|
||||
### ActionProvider
|
||||
|
||||
```tsx
|
||||
|
||||
@@ -9,6 +9,26 @@ Notable changes and updates to json-render.
|
||||
|
||||
February 2026
|
||||
|
||||
### New: External State Store
|
||||
|
||||
The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode.
|
||||
|
||||
New adapter packages: `@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`.
|
||||
|
||||
### Changed: `onStateChange` signature updated (breaking)
|
||||
|
||||
The `onStateChange` callback now receives a single array of changed entries instead of being called once per path. This makes batch updates via `update()` easier to handle:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
onStateChange?: (path: string, value: unknown) => void
|
||||
|
||||
// After
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void
|
||||
```
|
||||
|
||||
The callback is only called when a `set()` or `update()` call actually changes the state. A `set()` call produces a single-element array; an `update()` call produces one array with all changed paths.
|
||||
|
||||
### New: `@json-render/react-pdf`
|
||||
|
||||
PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
|
||||
|
||||
@@ -259,6 +259,25 @@ The condition uses the same [visibility](/docs/visibility) expression format.
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## External Store (Controlled Mode)
|
||||
|
||||
For advanced use cases, you can pass a `StateStore` to `StateProvider` to use your own state management (Redux, Zustand, XState, etc.) instead of the built-in internal store:
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react";
|
||||
|
||||
const store = createStateStore({ user: { name: "Alice" } });
|
||||
|
||||
<StateProvider store={store}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
|
||||
// Mutate from anywhere — React re-renders automatically:
|
||||
store.set("/user/name", "Bob");
|
||||
```
|
||||
|
||||
When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. See the [React API reference](/docs/api/react#external-store-controlled-mode) for the full `StateStore` interface.
|
||||
|
||||
## Next
|
||||
|
||||
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { GeistPixelSquare } from "geist/font/pixel";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { DocsChat } from "@/components/docs-chat";
|
||||
@@ -91,7 +92,9 @@ export default async function RootLayout({
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable}`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||
|
||||
@@ -5,19 +5,20 @@ import { join } from "node:path";
|
||||
export { getPageTitle } from "@/lib/page-titles";
|
||||
|
||||
// Cache font data in memory after first load
|
||||
let fontCache: { geistRegular: Buffer } | null = null;
|
||||
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null = null;
|
||||
|
||||
async function loadFonts() {
|
||||
if (fontCache) return fontCache;
|
||||
const geistRegular = await readFile(
|
||||
join(process.cwd(), "public/Geist-Regular.ttf"),
|
||||
);
|
||||
fontCache = { geistRegular };
|
||||
const [geistRegular, geistPixelSquare] = await Promise.all([
|
||||
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
|
||||
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
|
||||
]);
|
||||
fontCache = { geistRegular, geistPixelSquare };
|
||||
return fontCache;
|
||||
}
|
||||
|
||||
export async function renderOgImage(title: string) {
|
||||
const { geistRegular } = await loadFonts();
|
||||
const { geistRegular, geistPixelSquare } = await loadFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
@@ -53,8 +54,8 @@ export async function renderOgImage(title: string) {
|
||||
<span
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontFamily: "Geist",
|
||||
fontWeight: 400,
|
||||
fontFamily: "Geist Pixel Square",
|
||||
fontWeight: 500,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
@@ -99,6 +100,12 @@ export async function renderOgImage(title: string) {
|
||||
style: "normal",
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
name: "Geist Pixel Square",
|
||||
data: geistPixelSquare.buffer as ArrayBuffer,
|
||||
style: "normal",
|
||||
weight: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Playground } from "@/components/playground";
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||
|
||||
export const metadata = {
|
||||
title: PAGE_TITLES["playground"],
|
||||
};
|
||||
export const metadata = pageMetadata("playground");
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
return <Playground />;
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Header() {
|
||||
</svg>
|
||||
</span>
|
||||
<Link href="/">
|
||||
<span className="font-medium tracking-tight text-lg">
|
||||
<span className="font-medium tracking-tight text-lg font-(family-name:--font-geist-pixel-square)">
|
||||
json-render
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -16,17 +16,20 @@ import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { Header } from "./header";
|
||||
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
||||
import { JsonEditor } from "@visual-json/react";
|
||||
import type { JsonValue } from "@visual-json/react";
|
||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||
|
||||
type Tab = "json" | "nested" | "stream" | "catalog";
|
||||
type Tab = "json" | "nested" | "stream" | "catalog" | "visual";
|
||||
type RenderView = "preview" | "code";
|
||||
type MobileView =
|
||||
| "json"
|
||||
| "nested"
|
||||
| "stream"
|
||||
| "catalog"
|
||||
| "visual"
|
||||
| "preview"
|
||||
| "generated-code";
|
||||
|
||||
@@ -233,6 +236,20 @@ export function Playground() {
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
const handleVisualChange = useCallback(
|
||||
(value: JsonValue) => {
|
||||
if (!selectedVersionId || isStreaming) return;
|
||||
setVersions((prev) =>
|
||||
prev.map((v) =>
|
||||
v.id === selectedVersionId
|
||||
? { ...v, tree: value as unknown as Spec }
|
||||
: v,
|
||||
),
|
||||
);
|
||||
},
|
||||
[selectedVersionId, isStreaming],
|
||||
);
|
||||
|
||||
const jsonCode = currentTree
|
||||
? JSON.stringify(currentTree, null, 2)
|
||||
: "// waiting...";
|
||||
@@ -479,31 +496,69 @@ ${jsx}
|
||||
? jsonCode
|
||||
: activeTab === "nested"
|
||||
? nestedCode
|
||||
: "";
|
||||
: activeTab === "visual"
|
||||
? jsonCode
|
||||
: "";
|
||||
|
||||
const codePane = (
|
||||
<div className="h-full flex flex-col border-t border-border">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
|
||||
(tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{activeTab !== "catalog" && (
|
||||
{activeTab !== "catalog" && activeTab !== "visual" && (
|
||||
<CopyButton text={copyText} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === "catalog" ? (
|
||||
{activeTab === "visual" ? (
|
||||
currentTree ? (
|
||||
<JsonEditor
|
||||
value={currentTree as unknown as JsonValue}
|
||||
onChange={handleVisualChange}
|
||||
readOnly={isStreaming}
|
||||
sidebarOpen={false}
|
||||
height="100%"
|
||||
className="h-full"
|
||||
style={
|
||||
{
|
||||
"--vj-bg": "var(--background)",
|
||||
"--vj-bg-panel": "var(--background)",
|
||||
"--vj-bg-hover": "var(--muted)",
|
||||
"--vj-bg-selected": "var(--primary)",
|
||||
"--vj-bg-selected-muted": "var(--muted)",
|
||||
"--vj-text": "var(--foreground)",
|
||||
"--vj-text-selected": "var(--primary-foreground)",
|
||||
"--vj-text-muted": "var(--muted-foreground)",
|
||||
"--vj-text-dim": "var(--muted-foreground)",
|
||||
"--vj-border": "var(--border)",
|
||||
"--vj-border-subtle": "var(--border)",
|
||||
"--vj-accent": "var(--primary)",
|
||||
"--vj-accent-muted": "var(--muted)",
|
||||
"--vj-input-bg": "var(--secondary)",
|
||||
"--vj-input-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||
{"// generate a spec to edit visually"}
|
||||
</div>
|
||||
)
|
||||
) : activeTab === "catalog" ? (
|
||||
<div className="h-full flex flex-col text-sm">
|
||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||
{(
|
||||
@@ -735,19 +790,21 @@ ${jsx}
|
||||
: 0}
|
||||
</button>
|
||||
{/* Code tabs */}
|
||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setMobileView(tab)}
|
||||
className={`text-xs font-mono transition-colors shrink-0 ${
|
||||
mobileView === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
|
||||
(tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setMobileView(tab)}
|
||||
className={`text-xs font-mono transition-colors shrink-0 ${
|
||||
mobileView === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{/* Preview / code toggle */}
|
||||
{[
|
||||
@@ -770,7 +827,41 @@ ${jsx}
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{mobileView === "catalog" ? (
|
||||
{mobileView === "visual" ? (
|
||||
currentTree ? (
|
||||
<JsonEditor
|
||||
value={currentTree as unknown as JsonValue}
|
||||
onChange={handleVisualChange}
|
||||
readOnly={isStreaming}
|
||||
sidebarOpen={false}
|
||||
height="100%"
|
||||
className="h-full"
|
||||
style={
|
||||
{
|
||||
"--vj-bg": "var(--background)",
|
||||
"--vj-bg-panel": "var(--background)",
|
||||
"--vj-bg-hover": "var(--muted)",
|
||||
"--vj-bg-selected": "var(--primary)",
|
||||
"--vj-bg-selected-muted": "var(--muted)",
|
||||
"--vj-text": "var(--foreground)",
|
||||
"--vj-text-selected": "var(--primary-foreground)",
|
||||
"--vj-text-muted": "var(--muted-foreground)",
|
||||
"--vj-text-dim": "var(--muted-foreground)",
|
||||
"--vj-border": "var(--border)",
|
||||
"--vj-border-subtle": "var(--border)",
|
||||
"--vj-accent": "var(--primary)",
|
||||
"--vj-accent-muted": "var(--muted)",
|
||||
"--vj-input-bg": "var(--secondary)",
|
||||
"--vj-input-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||
{"// generate a spec to edit visually"}
|
||||
</div>
|
||||
)
|
||||
) : mobileView === "catalog" ? (
|
||||
<div className="h-full flex flex-col text-sm">
|
||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||
{(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
|
||||
@@ -28,11 +28,13 @@
|
||||
"@upstash/redis": "^1.36.1",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"@visual-json/react": "0.1.1",
|
||||
"ai": "^6.0.33",
|
||||
"bash-tool": "1.3.14",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"geist": "1.7.0",
|
||||
"just-bash": "2.9.6",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
@@ -51,8 +53,8 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^22.15.3",
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/nextjs.json",
|
||||
"extends": "@internal/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"zod": "4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
|
||||
@@ -196,24 +196,28 @@ export function Widget({
|
||||
onDeleted?.();
|
||||
}, [widgetId, onDeleted]);
|
||||
|
||||
const handleStateChange = useCallback((path: string, value: unknown) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev };
|
||||
// Convert path like "customerForm/name" to nested object
|
||||
const parts = path.split("/");
|
||||
let current: Record<string, unknown> = next;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i]!;
|
||||
if (!(part in current) || typeof current[part] !== "object") {
|
||||
current[part] = {};
|
||||
const handleStateChange = useCallback(
|
||||
(changes: Array<{ path: string; value: unknown }>) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const { path, value } of changes) {
|
||||
const parts = path.split("/");
|
||||
let current: Record<string, unknown> = next;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i]!;
|
||||
if (!(part in current) || typeof current[part] !== "object") {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
const lastPart = parts[parts.length - 1]!;
|
||||
current[lastPart] = value;
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
const lastPart = parts[parts.length - 1]!;
|
||||
current[lastPart] = value;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Use spec from stream, or initial spec for saved widgets
|
||||
const currentSpec = spec || initialSpec;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
|
||||
@@ -24,7 +24,7 @@ interface DashboardRendererProps {
|
||||
spec: Spec | null;
|
||||
state?: Record<string, unknown>;
|
||||
setState?: SetState;
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"zod": "4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"zod": "4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { config as reactConfig } from "@repo/eslint-config/react-internal";
|
||||
import { config as reactConfig } from "@internal/eslint-config/react-internal";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@stripe/ui-extension-tools": "^0.0.1",
|
||||
"eslint": "^9.39.0"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface StripeRendererProps {
|
||||
/** Function to update data */
|
||||
setData?: SetState;
|
||||
/** Callback when data changes */
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { config as reactConfig } from "@repo/eslint-config/react-internal";
|
||||
import { config as reactConfig } from "@internal/eslint-config/react-internal";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@stripe/ui-extension-tools": "^0.0.1",
|
||||
"eslint": "^9.39.0"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface StripeRendererProps {
|
||||
/** Function to update data */
|
||||
setData?: SetState;
|
||||
/** Callback when data changes */
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "pnpm --filter e2e-tests test",
|
||||
"prepare": "husky",
|
||||
"changeset": "changeset",
|
||||
"ci:version": "changeset version && pnpm install --no-frozen-lockfile",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"extends": "@internal/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -221,6 +221,63 @@ Schema options:
|
||||
|
||||
The transform splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
|
||||
|
||||
### State Store
|
||||
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `createStateStore(initialState?)` | Create a framework-agnostic in-memory `StateStore` |
|
||||
| `StateStore` | Interface for plugging in external state management (Redux, Zustand, XState, etc.) |
|
||||
| `StateModel` | State model type (`Record<string, unknown>`) |
|
||||
|
||||
The `StateStore` interface allows renderers to use external state management instead of the built-in internal store:
|
||||
|
||||
```typescript
|
||||
import { createStateStore, type StateStore } from "@json-render/core";
|
||||
|
||||
// Simple in-memory store
|
||||
const store = createStateStore({ count: 0 });
|
||||
|
||||
store.get("/count"); // 0
|
||||
store.set("/count", 1); // updates and notifies subscribers
|
||||
store.getSnapshot(); // { count: 1 }
|
||||
|
||||
// Subscribe to changes (compatible with React's useSyncExternalStore)
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
console.log("state changed:", store.getSnapshot());
|
||||
});
|
||||
```
|
||||
|
||||
Pass the store to `StateProvider` in any renderer package (`@json-render/react`, `@json-render/react-native`, `@json-render/react-pdf`) for controlled mode.
|
||||
|
||||
### Store Utilities (for adapter authors)
|
||||
|
||||
Available via `@json-render/core/store-utils`:
|
||||
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `createStoreAdapter(config)` | Build a full `StateStore` from a minimal `{ getSnapshot, setSnapshot, subscribe }` config |
|
||||
| `immutableSetByPath(root, path, value)` | Immutably set a value at a JSON Pointer path with structural sharing |
|
||||
| `flattenToPointers(obj)` | Flatten a nested object into JSON Pointer keyed entries |
|
||||
| `StoreAdapterConfig` | Config type for `createStoreAdapter` |
|
||||
|
||||
```typescript
|
||||
import { createStoreAdapter, immutableSetByPath, flattenToPointers } from "@json-render/core/store-utils";
|
||||
```
|
||||
|
||||
`createStoreAdapter` handles `get`, `set` (with no-op detection), batched `update`, `getSnapshot`, `getServerSnapshot`, and `subscribe` -- adapter authors only need to supply the snapshot source, write API, and subscribe mechanism:
|
||||
|
||||
```typescript
|
||||
import { createStoreAdapter } from "@json-render/core/store-utils";
|
||||
|
||||
const store = createStoreAdapter({
|
||||
getSnapshot: () => myLib.getState(),
|
||||
setSnapshot: (next) => myLib.setState(next),
|
||||
subscribe: (listener) => myLib.subscribe(listener),
|
||||
});
|
||||
```
|
||||
|
||||
The official adapter packages (`@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`) are all built on top of `createStoreAdapter`.
|
||||
|
||||
### Types
|
||||
|
||||
| Export | Purpose |
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./store-utils": {
|
||||
"types": "./dist/store-utils.d.ts",
|
||||
"import": "./dist/store-utils.mjs",
|
||||
"require": "./dist/store-utils.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -48,7 +53,7 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
// Minimal process.env typing for dev-only warnings.
|
||||
// Uses a namespaced interface so it merges cleanly with @types/node if present.
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
readonly NODE_ENV?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const process: { readonly env: NodeJS.ProcessEnv };
|
||||
@@ -15,6 +15,7 @@ export type {
|
||||
AndCondition,
|
||||
OrCondition,
|
||||
StateModel,
|
||||
StateStore,
|
||||
ComponentSchema,
|
||||
ValidationMode,
|
||||
PatchOp,
|
||||
@@ -57,6 +58,10 @@ export {
|
||||
SPEC_DATA_PART_TYPE,
|
||||
} from "./types";
|
||||
|
||||
// State Store
|
||||
export type { StoreAdapterConfig } from "./state-store";
|
||||
export { createStateStore } from "./state-store";
|
||||
|
||||
// Visibility
|
||||
export type { VisibilityContext } from "./visibility";
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createStateStore, flattenToPointers } from "./state-store";
|
||||
|
||||
describe("createStateStore", () => {
|
||||
it("creates a store with initial state", () => {
|
||||
const store = createStateStore({ name: "test" });
|
||||
expect(store.getSnapshot()).toEqual({ name: "test" });
|
||||
expect(store.get("/name")).toBe("test");
|
||||
});
|
||||
|
||||
it("set notifies subscribers", () => {
|
||||
const store = createStateStore({});
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.set("/x", 1);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(store.get("/x")).toBe(1);
|
||||
});
|
||||
|
||||
it("set skips notification when value is unchanged", () => {
|
||||
const store = createStateStore({ x: 1 });
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.set("/x", 1);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(store.getSnapshot()).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
it("update notifies subscribers once", () => {
|
||||
const store = createStateStore({});
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.update({ "/a": 1, "/b": 2 });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(store.get("/a")).toBe(1);
|
||||
expect(store.get("/b")).toBe(2);
|
||||
});
|
||||
|
||||
it("update skips notification when no values changed", () => {
|
||||
const store = createStateStore({ a: 1, b: 2 });
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.update({ "/a": 1, "/b": 2 });
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unsubscribe stops notifications", () => {
|
||||
const store = createStateStore({});
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = store.subscribe(listener);
|
||||
|
||||
store.set("/x", 1);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
store.set("/x", 2);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("getSnapshot returns a new reference after mutation", () => {
|
||||
const store = createStateStore({ x: 1 });
|
||||
const snap1 = store.getSnapshot();
|
||||
|
||||
store.set("/x", 2);
|
||||
const snap2 = store.getSnapshot();
|
||||
|
||||
expect(snap1).not.toBe(snap2);
|
||||
expect(snap2.x).toBe(2);
|
||||
});
|
||||
|
||||
it("getSnapshot returns same reference when set is a no-op", () => {
|
||||
const store = createStateStore({ x: 1 });
|
||||
const snap1 = store.getSnapshot();
|
||||
|
||||
store.set("/x", 1);
|
||||
const snap2 = store.getSnapshot();
|
||||
|
||||
expect(snap1).toBe(snap2);
|
||||
});
|
||||
|
||||
it("set on nested path does not mutate previous snapshot", () => {
|
||||
const store = createStateStore({ user: { name: "Alice", age: 30 } });
|
||||
const snap1 = store.getSnapshot();
|
||||
|
||||
store.set("/user/name", "Bob");
|
||||
const snap2 = store.getSnapshot();
|
||||
|
||||
expect(snap1.user).toEqual({ name: "Alice", age: 30 });
|
||||
expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
|
||||
expect(snap1.user).not.toBe(snap2.user);
|
||||
});
|
||||
|
||||
it("update on nested paths does not mutate previous snapshot", () => {
|
||||
const store = createStateStore({
|
||||
user: { name: "Alice" },
|
||||
meta: { version: 1 },
|
||||
});
|
||||
const snap1 = store.getSnapshot();
|
||||
|
||||
store.update({ "/user/name": "Bob", "/meta/version": 2 });
|
||||
const snap2 = store.getSnapshot();
|
||||
|
||||
expect((snap1.user as Record<string, unknown>).name).toBe("Alice");
|
||||
expect((snap1.meta as Record<string, unknown>).version).toBe(1);
|
||||
expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
|
||||
expect((snap2.meta as Record<string, unknown>).version).toBe(2);
|
||||
});
|
||||
|
||||
it("set preserves structural sharing for untouched branches", () => {
|
||||
const store = createStateStore({
|
||||
a: { x: 1 },
|
||||
b: { y: 2 },
|
||||
});
|
||||
const snap1 = store.getSnapshot();
|
||||
|
||||
store.set("/a/x", 99);
|
||||
const snap2 = store.getSnapshot();
|
||||
|
||||
expect(snap2.b).toBe(snap1.b);
|
||||
expect(snap2.a).not.toBe(snap1.a);
|
||||
});
|
||||
|
||||
it("getServerSnapshot returns the same state as getSnapshot", () => {
|
||||
const store = createStateStore({ x: 1 });
|
||||
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||
|
||||
store.set("/x", 2);
|
||||
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||
});
|
||||
});
|
||||
|
||||
describe("flattenToPointers", () => {
|
||||
it("flattens top-level keys", () => {
|
||||
expect(flattenToPointers({ a: 1, b: "hello" })).toEqual({
|
||||
"/a": 1,
|
||||
"/b": "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens nested plain objects", () => {
|
||||
expect(flattenToPointers({ user: { name: "Alice", age: 30 } })).toEqual({
|
||||
"/user/name": "Alice",
|
||||
"/user/age": 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves arrays as leaf values", () => {
|
||||
expect(flattenToPointers({ items: [1, 2, 3] })).toEqual({
|
||||
"/items": [1, 2, 3],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves null as a leaf value", () => {
|
||||
expect(flattenToPointers({ x: null })).toEqual({ "/x": null });
|
||||
});
|
||||
|
||||
it("handles deeply nested objects", () => {
|
||||
expect(flattenToPointers({ a: { b: { c: 42 } } })).toEqual({
|
||||
"/a/b/c": 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty object for empty input", () => {
|
||||
expect(flattenToPointers({})).toEqual({});
|
||||
});
|
||||
|
||||
it("handles mixed nesting", () => {
|
||||
expect(
|
||||
flattenToPointers({
|
||||
count: 1,
|
||||
user: { name: "Alice" },
|
||||
tags: ["a", "b"],
|
||||
}),
|
||||
).toEqual({
|
||||
"/count": 1,
|
||||
"/user/name": "Alice",
|
||||
"/tags": ["a", "b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("stops recursion on circular references via seen set", () => {
|
||||
const obj: Record<string, unknown> = { name: "root" };
|
||||
obj.self = obj;
|
||||
|
||||
const result = flattenToPointers(obj);
|
||||
|
||||
expect(result["/name"]).toBe("root");
|
||||
expect(result["/self/name"]).toBe("root");
|
||||
expect(result["/self/self"]).toBe(obj);
|
||||
});
|
||||
|
||||
it("caps recursion at depth limit", () => {
|
||||
let current: Record<string, unknown> = { leaf: true };
|
||||
for (let i = 0; i < 25; i++) {
|
||||
current = { nested: current };
|
||||
}
|
||||
|
||||
const result = flattenToPointers(current);
|
||||
|
||||
const keys = Object.keys(result);
|
||||
expect(keys.length).toBe(1);
|
||||
const key = keys[0]!;
|
||||
expect(key.split("/").length).toBeLessThanOrEqual(22);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
getByPath,
|
||||
parseJsonPointer,
|
||||
type StateModel,
|
||||
type StateStore,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Immutably set a value at a JSON Pointer path using structural sharing.
|
||||
* Only objects along the path are shallow-cloned; untouched branches keep
|
||||
* their original references.
|
||||
*/
|
||||
export function immutableSetByPath(
|
||||
root: StateModel,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): StateModel {
|
||||
const segments = parseJsonPointer(path);
|
||||
if (segments.length === 0) return root;
|
||||
|
||||
const result = { ...root };
|
||||
let current: Record<string, unknown> = result;
|
||||
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i]!;
|
||||
const child = current[seg];
|
||||
if (Array.isArray(child)) {
|
||||
current[seg] = [...child];
|
||||
} else if (child !== null && typeof child === "object") {
|
||||
current[seg] = { ...(child as Record<string, unknown>) };
|
||||
} else {
|
||||
const nextSeg = segments[i + 1];
|
||||
current[seg] = nextSeg !== undefined && /^\d+$/.test(nextSeg) ? [] : {};
|
||||
}
|
||||
current = current[seg] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const lastSeg = segments[segments.length - 1]!;
|
||||
if (Array.isArray(current)) {
|
||||
if (lastSeg === "-") {
|
||||
(current as unknown[]).push(value);
|
||||
} else {
|
||||
(current as unknown[])[parseInt(lastSeg, 10)] = value;
|
||||
}
|
||||
} else {
|
||||
current[lastSeg] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple in-memory {@link StateStore}.
|
||||
*
|
||||
* This is the default store used by `StateProvider` when no external store is
|
||||
* provided. It mirrors the previous `useState`-based behaviour but is
|
||||
* framework-agnostic so it can also be used in tests or non-React contexts.
|
||||
*/
|
||||
export function createStateStore(initialState: StateModel = {}): StateStore {
|
||||
let state: StateModel = { ...initialState };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get(path: string): unknown {
|
||||
return getByPath(state, path);
|
||||
},
|
||||
|
||||
set(path: string, value: unknown): void {
|
||||
if (getByPath(state, path) === value) return;
|
||||
state = immutableSetByPath(state, path, value);
|
||||
notify();
|
||||
},
|
||||
|
||||
update(updates: Record<string, unknown>): void {
|
||||
let changed = false;
|
||||
let next = state;
|
||||
for (const [path, value] of Object.entries(updates)) {
|
||||
if (getByPath(next, path) !== value) {
|
||||
next = immutableSetByPath(next, path, value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
state = next;
|
||||
notify();
|
||||
},
|
||||
|
||||
getSnapshot(): StateModel {
|
||||
return state;
|
||||
},
|
||||
|
||||
getServerSnapshot(): StateModel {
|
||||
return state;
|
||||
},
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for {@link createStoreAdapter}. Adapter authors supply these
|
||||
* three callbacks; everything else (get, set, update, no-op detection,
|
||||
* getServerSnapshot) is handled by the returned {@link StateStore}.
|
||||
*/
|
||||
export interface StoreAdapterConfig {
|
||||
/** Return the current state snapshot from the underlying store. */
|
||||
getSnapshot: () => StateModel;
|
||||
/** Write a new state snapshot to the underlying store. */
|
||||
setSnapshot: (next: StateModel) => void;
|
||||
/** Subscribe to changes in the underlying store. Return an unsubscribe fn. */
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full {@link StateStore} from a minimal adapter config.
|
||||
*
|
||||
* Handles `get`, `set` (with no-op detection), `update` (batched, with no-op
|
||||
* detection), `getSnapshot`, `getServerSnapshot`, and `subscribe` -- so each
|
||||
* adapter only needs to wire its snapshot source, write API, and subscribe
|
||||
* mechanism.
|
||||
*/
|
||||
export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
|
||||
return {
|
||||
get(path: string): unknown {
|
||||
return getByPath(config.getSnapshot(), path);
|
||||
},
|
||||
|
||||
set(path: string, value: unknown): void {
|
||||
const current = config.getSnapshot();
|
||||
if (getByPath(current, path) === value) return;
|
||||
config.setSnapshot(immutableSetByPath(current, path, value));
|
||||
},
|
||||
|
||||
update(updates: Record<string, unknown>): void {
|
||||
let next = config.getSnapshot();
|
||||
let changed = false;
|
||||
for (const [path, value] of Object.entries(updates)) {
|
||||
if (getByPath(next, path) !== value) {
|
||||
next = immutableSetByPath(next, path, value);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
config.setSnapshot(next);
|
||||
},
|
||||
|
||||
getSnapshot: config.getSnapshot,
|
||||
|
||||
getServerSnapshot: config.getSnapshot,
|
||||
|
||||
subscribe: config.subscribe,
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_FLATTEN_DEPTH = 20;
|
||||
|
||||
/**
|
||||
* Recursively flatten a plain object into a `Record<string, unknown>` keyed by
|
||||
* JSON Pointer paths. Only leaf values (non-plain-object) appear in the output.
|
||||
*
|
||||
* Includes circular reference protection and a depth cap to prevent stack
|
||||
* overflow on pathological inputs.
|
||||
*
|
||||
* ```ts
|
||||
* flattenToPointers({ user: { name: "Alice" }, count: 1 })
|
||||
* // => { "/user/name": "Alice", "/count": 1 }
|
||||
* ```
|
||||
*/
|
||||
export function flattenToPointers(
|
||||
obj: Record<string, unknown>,
|
||||
prefix = "",
|
||||
_depth = 0,
|
||||
_seen?: Set<object>,
|
||||
_warned?: { current: boolean },
|
||||
): Record<string, unknown> {
|
||||
const seen = _seen ?? new Set<object>();
|
||||
const warned = _warned ?? { current: false };
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const pointer = `${prefix}/${key}`;
|
||||
if (
|
||||
_depth < MAX_FLATTEN_DEPTH &&
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype &&
|
||||
!seen.has(value)
|
||||
) {
|
||||
seen.add(value);
|
||||
Object.assign(
|
||||
result,
|
||||
flattenToPointers(
|
||||
value as Record<string, unknown>,
|
||||
pointer,
|
||||
_depth + 1,
|
||||
seen,
|
||||
warned,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
!warned.current &&
|
||||
_depth >= MAX_FLATTEN_DEPTH &&
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype &&
|
||||
!seen.has(value as object)
|
||||
) {
|
||||
warned.current = true;
|
||||
console.warn(
|
||||
`flattenToPointers: depth limit (${MAX_FLATTEN_DEPTH}) reached. Nested state beyond this depth will be treated as a leaf value.`,
|
||||
);
|
||||
}
|
||||
result[pointer] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
immutableSetByPath,
|
||||
flattenToPointers,
|
||||
createStoreAdapter,
|
||||
} from "./state-store";
|
||||
export type { StoreAdapterConfig } from "./state-store";
|
||||
@@ -178,6 +178,36 @@ export interface Spec {
|
||||
*/
|
||||
export type StateModel = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* An abstract store that owns state and notifies subscribers on change.
|
||||
*
|
||||
* Consumers can supply their own implementation (backed by Redux, Zustand,
|
||||
* XState, etc.) or use the built-in {@link createStateStore} for a simple
|
||||
* in-memory store.
|
||||
*/
|
||||
export interface StateStore {
|
||||
/** Read a value by JSON Pointer path. */
|
||||
get: (path: string) => unknown;
|
||||
/**
|
||||
* Write a value by JSON Pointer path and notify subscribers.
|
||||
* Equality is checked by reference (`===`), not deep comparison.
|
||||
* Callers must pass a new object/array reference for changes to be detected.
|
||||
*/
|
||||
set: (path: string, value: unknown) => void;
|
||||
/**
|
||||
* Write multiple values at once and notify subscribers (single notification).
|
||||
* Each value is compared by reference (`===`); only paths whose value
|
||||
* actually changed are applied.
|
||||
*/
|
||||
update: (updates: Record<string, unknown>) => void;
|
||||
/** Return the full state object (used by `useSyncExternalStore`). */
|
||||
getSnapshot: () => StateModel;
|
||||
/** Optional server snapshot for SSR (passed to `useSyncExternalStore`). Falls back to `getSnapshot` when omitted. */
|
||||
getServerSnapshot?: () => StateModel;
|
||||
/** Register a listener that is called on every state change. Returns an unsubscribe function. */
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component schema definition using Zod
|
||||
*/
|
||||
@@ -236,7 +266,7 @@ function unescapeJsonPointer(token: string): string {
|
||||
/**
|
||||
* Parse a JSON Pointer path into unescaped segments.
|
||||
*/
|
||||
function parseJsonPointer(path: string): string[] {
|
||||
export function parseJsonPointer(path: string): string[] {
|
||||
const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
|
||||
return raw.map(unescapeJsonPointer);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
entry: ["src/index.ts", "src/store-utils.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@repo/eslint-config",
|
||||
"name": "@internal/eslint-config",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# @json-render/jotai
|
||||
|
||||
Jotai adapter for json-render's `StateStore` interface. Wire a Jotai atom as the state backend for json-render.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/jotai @json-render/core @json-render/react jotai
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { atom } from "jotai";
|
||||
import { jotaiStateStore } from "@json-render/jotai";
|
||||
import { StateProvider } from "@json-render/react";
|
||||
|
||||
// 1. Create an atom that holds the json-render state
|
||||
const uiAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||
|
||||
// 2. Create the json-render StateStore adapter
|
||||
const store = jotaiStateStore({ atom: uiAtom });
|
||||
|
||||
// 3. Use it
|
||||
<StateProvider store={store}>
|
||||
{/* json-render reads/writes go through Jotai */}
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
### With a shared Jotai store
|
||||
|
||||
If your app already uses a Jotai `<Provider>` with a custom store, pass it so both json-render and your components share the same state:
|
||||
|
||||
```ts
|
||||
import { atom, createStore } from "jotai";
|
||||
import { Provider as JotaiProvider } from "jotai/react";
|
||||
import { jotaiStateStore } from "@json-render/jotai";
|
||||
import { StateProvider } from "@json-render/react";
|
||||
|
||||
const jStore = createStore();
|
||||
const uiAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||
|
||||
const store = jotaiStateStore({ atom: uiAtom, store: jStore });
|
||||
|
||||
<JotaiProvider store={jStore}>
|
||||
<StateProvider store={store}>
|
||||
{/* Both json-render and useAtom() see the same state */}
|
||||
</StateProvider>
|
||||
</JotaiProvider>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `jotaiStateStore(options)`
|
||||
|
||||
Creates a `StateStore` backed by a Jotai atom.
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Type | Required | Description |
|
||||
|--------|------|----------|-------------|
|
||||
| `atom` | `WritableAtom<StateModel, [StateModel], void>` | Yes | A writable atom holding the state model |
|
||||
| `store` | Jotai `Store` | No | The Jotai store instance. Defaults to a new store created internally. Pass your own to share state with `<Provider>`. |
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@json-render/jotai",
|
||||
"version": "0.8.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Jotai adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
"json-render",
|
||||
"jotai",
|
||||
"state-management",
|
||||
"adapter"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/jotai"
|
||||
},
|
||||
"homepage": "https://github.com/vercel-labs/json-render#readme",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jotai": ">=2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"jotai": "^2.18.0",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { atom } from "jotai";
|
||||
import { createStore } from "jotai/vanilla";
|
||||
import { jotaiStateStore } from "./index";
|
||||
|
||||
function createTestStore(initial: Record<string, unknown> = {}) {
|
||||
const stateAtom = atom<Record<string, unknown>>(initial);
|
||||
const jStore = createStore();
|
||||
const store = jotaiStateStore({ atom: stateAtom, store: jStore });
|
||||
return { stateAtom, jStore, store };
|
||||
}
|
||||
|
||||
describe("jotaiStateStore", () => {
|
||||
it("get/set round-trip", () => {
|
||||
const { store } = createTestStore({ count: 0 });
|
||||
|
||||
expect(store.get("/count")).toBe(0);
|
||||
|
||||
store.set("/count", 42);
|
||||
|
||||
expect(store.get("/count")).toBe(42);
|
||||
expect(store.getSnapshot().count).toBe(42);
|
||||
});
|
||||
|
||||
it("update round-trip with multiple values", () => {
|
||||
const { store } = createTestStore({});
|
||||
|
||||
store.update({ "/a": 1, "/b": "hello" });
|
||||
|
||||
expect(store.get("/a")).toBe(1);
|
||||
expect(store.get("/b")).toBe("hello");
|
||||
expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" });
|
||||
});
|
||||
|
||||
it("subscribe fires on set", () => {
|
||||
const { store } = createTestStore({});
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.set("/x", 1);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("subscribe fires on update", () => {
|
||||
const { store } = createTestStore({});
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.update({ "/a": 1, "/b": 2 });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("unsubscribe stops notifications", () => {
|
||||
const { store } = createTestStore({});
|
||||
const listener = vi.fn();
|
||||
const unsub = store.subscribe(listener);
|
||||
|
||||
store.set("/x", 1);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsub();
|
||||
store.set("/x", 2);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("getSnapshot immutability -- previous snapshot is not mutated", () => {
|
||||
const { store } = createTestStore({ user: { name: "Alice", age: 30 } });
|
||||
const snap1 = store.getSnapshot();
|
||||
|
||||
store.set("/user/name", "Bob");
|
||||
const snap2 = store.getSnapshot();
|
||||
|
||||
expect(snap1.user).toEqual({ name: "Alice", age: 30 });
|
||||
expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
|
||||
expect(snap1.user).not.toBe(snap2.user);
|
||||
});
|
||||
|
||||
it("structural sharing -- untouched branches keep references", () => {
|
||||
const { store } = createTestStore({
|
||||
a: { x: 1 },
|
||||
b: { y: 2 },
|
||||
});
|
||||
const snap1 = store.getSnapshot();
|
||||
|
||||
store.set("/a/x", 99);
|
||||
const snap2 = store.getSnapshot();
|
||||
|
||||
expect(snap2.b).toBe(snap1.b);
|
||||
expect(snap2.a).not.toBe(snap1.a);
|
||||
});
|
||||
|
||||
it("getServerSnapshot returns same as getSnapshot", () => {
|
||||
const { store } = createTestStore({ x: 1 });
|
||||
|
||||
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||
|
||||
store.set("/x", 2);
|
||||
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||
});
|
||||
|
||||
it("set skips update when value is unchanged", () => {
|
||||
const { store } = createTestStore({ x: 1 });
|
||||
const snap1 = store.getSnapshot();
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.set("/x", 1);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(store.getSnapshot()).toBe(snap1);
|
||||
});
|
||||
|
||||
it("update skips update when no values changed", () => {
|
||||
const { store } = createTestStore({ a: 1, b: 2 });
|
||||
const snap1 = store.getSnapshot();
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.update({ "/a": 1, "/b": 2 });
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(store.getSnapshot()).toBe(snap1);
|
||||
});
|
||||
|
||||
it("reads from the shared Jotai store", () => {
|
||||
const stateAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||
const jStore = createStore();
|
||||
const store = jotaiStateStore({ atom: stateAtom, store: jStore });
|
||||
|
||||
jStore.set(stateAtom, { count: 99 });
|
||||
|
||||
expect(store.get("/count")).toBe(99);
|
||||
expect(store.getSnapshot().count).toBe(99);
|
||||
});
|
||||
|
||||
it("creates an internal store when none is provided", () => {
|
||||
const stateAtom = atom<Record<string, unknown>>({ value: "hello" });
|
||||
const store = jotaiStateStore({ atom: stateAtom });
|
||||
|
||||
expect(store.get("/value")).toBe("hello");
|
||||
|
||||
store.set("/value", "world");
|
||||
expect(store.get("/value")).toBe("world");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { StateModel, StateStore } from "@json-render/core";
|
||||
import { createStoreAdapter } from "@json-render/core/store-utils";
|
||||
import type { WritableAtom } from "jotai";
|
||||
import { createStore as createJotaiStore } from "jotai/vanilla";
|
||||
|
||||
export type { StateStore } from "@json-render/core";
|
||||
|
||||
type JotaiStore = ReturnType<typeof createJotaiStore>;
|
||||
|
||||
/**
|
||||
* Options for {@link jotaiStateStore}.
|
||||
*/
|
||||
export interface JotaiStateStoreOptions {
|
||||
/** A writable atom that holds the json-render state model. */
|
||||
atom: WritableAtom<StateModel, [StateModel], void>;
|
||||
/**
|
||||
* The Jotai store instance. Defaults to `createStore()` from `jotai/vanilla`.
|
||||
* Pass your own if you use a `<Provider store={...}>` in your React tree.
|
||||
*/
|
||||
store?: JotaiStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link StateStore} backed by a Jotai atom.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { atom } from "jotai";
|
||||
* import { jotaiStateStore } from "@json-render/jotai";
|
||||
*
|
||||
* const uiAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||
*
|
||||
* const store = jotaiStateStore({ atom: uiAtom });
|
||||
*
|
||||
* <StateProvider store={store}>...</StateProvider>
|
||||
* ```
|
||||
*
|
||||
* @example With a shared Jotai store:
|
||||
* ```ts
|
||||
* import { atom, createStore } from "jotai";
|
||||
*
|
||||
* const jStore = createStore();
|
||||
* const uiAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||
*
|
||||
* const store = jotaiStateStore({ atom: uiAtom, store: jStore });
|
||||
*
|
||||
* // In React:
|
||||
* <JotaiProvider store={jStore}>
|
||||
* <StateProvider store={store}>...</StateProvider>
|
||||
* </JotaiProvider>
|
||||
* ```
|
||||
*/
|
||||
export function jotaiStateStore(options: JotaiStateStoreOptions): StateStore {
|
||||
const stateAtom = options.atom;
|
||||
const jStore = options.store ?? createJotaiStore();
|
||||
|
||||
return createStoreAdapter({
|
||||
getSnapshot: () => jStore.get(stateAtom),
|
||||
setSnapshot: (next) => jStore.set(stateAtom, next),
|
||||
subscribe: (listener) => jStore.sub(stateAtom, listener),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ["@json-render/core", "@json-render/core/store-utils", "jotai"],
|
||||
});
|
||||
@@ -214,6 +214,26 @@ const systemPrompt = catalog.prompt({
|
||||
});
|
||||
```
|
||||
|
||||
## External Store (Controlled Mode)
|
||||
|
||||
For full control over state, pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Redux, Zustand, XState, etc.):
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react-native";
|
||||
|
||||
// Use the built-in store outside of React
|
||||
const store = createStateStore({ count: 0 });
|
||||
|
||||
<StateProvider store={store}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
|
||||
// Mutate from anywhere — React will re-render automatically:
|
||||
store.set("/count", 1);
|
||||
```
|
||||
|
||||
When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. The same `store` prop is available on `createRenderer`, `JSONUIProvider`, and `StateProvider`.
|
||||
|
||||
## Hooks
|
||||
|
||||
| Hook | Purpose |
|
||||
@@ -227,3 +247,4 @@ const systemPrompt = catalog.prompt({
|
||||
| `useAction(name)` | Get a single action dispatch function |
|
||||
| `useUIStream(options)` | Stream specs from an API endpoint |
|
||||
| `createStandardActionHandlers(options)` | Create handlers for standard actions |
|
||||
| `createStateStore(initialState)` | Create a framework-agnostic in-memory `StateStore` |
|
||||
|
||||
@@ -56,10 +56,11 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*"
|
||||
"@json-render/core": "workspace:*",
|
||||
"@internal/react-state": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"react-native": "0.83.1",
|
||||
"tsup": "^8.0.2",
|
||||
@@ -67,7 +68,7 @@
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-native": ">=0.71.0",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
|
||||
@@ -1,158 +1,8 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getByPath, setByPath, type StateModel } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* State context value
|
||||
*/
|
||||
export interface StateContextValue {
|
||||
/** The current state model */
|
||||
state: StateModel;
|
||||
/** Get a value by path */
|
||||
get: (path: string) => unknown;
|
||||
/** Set a value by path */
|
||||
set: (path: string, value: unknown) => void;
|
||||
/** Update multiple values at once */
|
||||
update: (updates: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const StateContext = createContext<StateContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Props for StateProvider
|
||||
*/
|
||||
export interface StateProviderProps {
|
||||
/** Initial state model */
|
||||
initialState?: StateModel;
|
||||
/** Callback when state changes */
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for state model context
|
||||
*/
|
||||
export function StateProvider({
|
||||
initialState = {},
|
||||
onStateChange,
|
||||
children,
|
||||
}: StateProviderProps) {
|
||||
const [state, setStateInternal] = useState<StateModel>(initialState);
|
||||
|
||||
// Keep a ref to the latest state so `get` doesn't change on every update.
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
// Track the serialized initialState to detect actual value changes (not just reference changes)
|
||||
const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
|
||||
|
||||
// Sync external state changes with internal state - only when values actually change
|
||||
useEffect(() => {
|
||||
const newJson = JSON.stringify(initialState);
|
||||
if (newJson !== initialStateJsonRef.current) {
|
||||
initialStateJsonRef.current = newJson;
|
||||
if (initialState && Object.keys(initialState).length > 0) {
|
||||
setStateInternal((prev) => ({ ...prev, ...initialState }));
|
||||
}
|
||||
}
|
||||
}, [initialState]);
|
||||
|
||||
// `get` uses a ref so it never changes identity — consumers that only
|
||||
// need `get` won't re-render on every state change.
|
||||
const get = useCallback(
|
||||
(path: string) => getByPath(stateRef.current, path),
|
||||
[],
|
||||
);
|
||||
|
||||
const set = useCallback(
|
||||
(path: string, value: unknown) => {
|
||||
setStateInternal((prev) => {
|
||||
const next = { ...prev };
|
||||
setByPath(next, path, value);
|
||||
return next;
|
||||
});
|
||||
// Side effect after the state update
|
||||
onStateChange?.(path, value);
|
||||
},
|
||||
[onStateChange],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Record<string, unknown>) => {
|
||||
const entries = Object.entries(updates);
|
||||
setStateInternal((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [path, value] of entries) {
|
||||
setByPath(next, path, value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Side effects after the state update
|
||||
for (const [path, value] of entries) {
|
||||
onStateChange?.(path, value);
|
||||
}
|
||||
},
|
||||
[onStateChange],
|
||||
);
|
||||
|
||||
const value = useMemo<StateContextValue>(
|
||||
() => ({
|
||||
state,
|
||||
get,
|
||||
set,
|
||||
update,
|
||||
}),
|
||||
[state, get, set, update],
|
||||
);
|
||||
|
||||
return (
|
||||
<StateContext.Provider value={value}>{children}</StateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the state context
|
||||
*/
|
||||
export function useStateStore(): StateContextValue {
|
||||
const ctx = useContext(StateContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useStateStore must be used within a StateProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a value from the state model
|
||||
*/
|
||||
export function useStateValue<T>(path: string): T | undefined {
|
||||
const { state } = useStateStore();
|
||||
return getByPath(state, path) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get and set a value from the state model (like useState).
|
||||
*
|
||||
* @deprecated Use {@link useBoundProp} with `$bindState` expressions instead.
|
||||
* `useStateBinding` takes a raw state path string, while `useBoundProp` works
|
||||
* with the renderer's `bindings` map and supports both `$bindState` and
|
||||
* `$bindItem` expressions.
|
||||
*/
|
||||
export function useStateBinding<T>(
|
||||
path: string,
|
||||
): [T | undefined, (value: T) => void] {
|
||||
const { state, set } = useStateStore();
|
||||
const value = getByPath(state, path) as T | undefined;
|
||||
const setValue = useCallback(
|
||||
(newValue: T) => set(path, newValue),
|
||||
[path, set],
|
||||
);
|
||||
return [value, setValue];
|
||||
}
|
||||
export {
|
||||
StateProvider,
|
||||
useStateStore,
|
||||
useStateValue,
|
||||
useStateBinding,
|
||||
type StateContextValue,
|
||||
type StateProviderProps,
|
||||
} from "@internal/react-state";
|
||||
|
||||
@@ -54,7 +54,8 @@ export {
|
||||
} from "./schema";
|
||||
|
||||
// Core types (re-exported for convenience)
|
||||
export type { Spec } from "@json-render/core";
|
||||
export type { Spec, StateStore } from "@json-render/core";
|
||||
export { createStateStore } from "@json-render/core";
|
||||
|
||||
// Catalog-aware types for React Native
|
||||
export type {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ActionBinding,
|
||||
Catalog,
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
@@ -414,7 +415,12 @@ export interface JSONUIProviderProps {
|
||||
* Custom components are merged with (and override) standard components.
|
||||
*/
|
||||
registry?: ComponentRegistry;
|
||||
/** Initial state model */
|
||||
/**
|
||||
* External store (controlled mode). When provided, `initialState` and
|
||||
* `onStateChange` are ignored.
|
||||
*/
|
||||
store?: StateStore;
|
||||
/** Initial state model (uncontrolled mode) */
|
||||
initialState?: Record<string, unknown>;
|
||||
/** Action handlers */
|
||||
handlers?: Record<
|
||||
@@ -428,8 +434,8 @@ export interface JSONUIProviderProps {
|
||||
string,
|
||||
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||
>;
|
||||
/** Callback when state changes */
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
/** Callback when state changes (uncontrolled mode) */
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -438,6 +444,7 @@ export interface JSONUIProviderProps {
|
||||
*/
|
||||
export function JSONUIProvider({
|
||||
registry,
|
||||
store,
|
||||
initialState,
|
||||
handlers,
|
||||
navigate,
|
||||
@@ -446,7 +453,11 @@ export function JSONUIProvider({
|
||||
children,
|
||||
}: JSONUIProviderProps) {
|
||||
return (
|
||||
<StateProvider initialState={initialState} onStateChange={onStateChange}>
|
||||
<StateProvider
|
||||
store={store}
|
||||
initialState={initialState}
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||
<ValidationProvider customFunctions={validationFunctions}>
|
||||
@@ -636,12 +647,17 @@ type DefineRegistryActionFn = (
|
||||
export interface CreateRendererProps {
|
||||
/** The spec to render (AI-generated JSON) */
|
||||
spec: Spec | null;
|
||||
/** State context for dynamic values */
|
||||
/**
|
||||
* External store (controlled mode). When provided, `state` and
|
||||
* `onStateChange` are ignored.
|
||||
*/
|
||||
store?: StateStore;
|
||||
/** State context for dynamic values (uncontrolled mode) */
|
||||
state?: Record<string, unknown>;
|
||||
/** Action handler */
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
/** Callback when state changes (e.g., from form inputs) */
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
/** Callback when state changes (uncontrolled mode) */
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
/** Fallback component for unknown types */
|
||||
@@ -691,6 +707,7 @@ export function createRenderer<
|
||||
// Return the renderer component
|
||||
return function CatalogRenderer({
|
||||
spec,
|
||||
store,
|
||||
state,
|
||||
onAction,
|
||||
onStateChange,
|
||||
@@ -715,7 +732,11 @@ export function createRenderer<
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={state} onStateChange={onStateChange}>
|
||||
<StateProvider
|
||||
store={store}
|
||||
initialState={state}
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<ValidationProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -154,6 +154,19 @@ All render functions accept an optional second argument with:
|
||||
- `state` - Initial state for `$state` / `$cond` dynamic prop resolution
|
||||
- `handlers` - Action handlers
|
||||
|
||||
## External Store (Controlled Mode)
|
||||
|
||||
For full control over state, pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer`. When `store` is provided, `initialState` and `onStateChange` are ignored and the store is the single source of truth:
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react-pdf";
|
||||
|
||||
const store = createStateStore({ invoice: { total: 100 } });
|
||||
|
||||
// Mutate from anywhere — components re-render automatically:
|
||||
store.set("/invoice/total", 200);
|
||||
```
|
||||
|
||||
## Server-Safe Import
|
||||
|
||||
Import schema and catalog definitions without pulling in React:
|
||||
|
||||
@@ -61,17 +61,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@internal/react-state": "workspace:*",
|
||||
"@react-pdf/renderer": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import {
|
||||
StateProvider,
|
||||
useStateStore,
|
||||
useStateValue,
|
||||
useStateBinding,
|
||||
} from "./state";
|
||||
|
||||
describe("state re-exports (smoke test)", () => {
|
||||
it("StateProvider + useStateStore round-trip", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.get("/count")).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.set("/count", 42);
|
||||
});
|
||||
|
||||
expect(result.current.state.count).toBe(42);
|
||||
});
|
||||
|
||||
it("useStateValue reads from state", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ name: "Alice" }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateValue("/name"), { wrapper });
|
||||
|
||||
expect(result.current).toBe("Alice");
|
||||
});
|
||||
|
||||
it("useStateBinding returns value and setter", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ x: 1 }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateBinding("/x"), { wrapper });
|
||||
|
||||
const [value, setValue] = result.current;
|
||||
expect(value).toBe(1);
|
||||
expect(typeof setValue).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -1,117 +1,8 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getByPath, setByPath, type StateModel } from "@json-render/core";
|
||||
|
||||
export interface StateContextValue {
|
||||
state: StateModel;
|
||||
get: (path: string) => unknown;
|
||||
set: (path: string, value: unknown) => void;
|
||||
update: (updates: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const StateContext = createContext<StateContextValue | null>(null);
|
||||
|
||||
export interface StateProviderProps {
|
||||
initialState?: StateModel;
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function StateProvider({
|
||||
initialState = {},
|
||||
onStateChange,
|
||||
children,
|
||||
}: StateProviderProps) {
|
||||
const [state, setStateInternal] = useState<StateModel>(initialState);
|
||||
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
|
||||
|
||||
useEffect(() => {
|
||||
const newJson = JSON.stringify(initialState);
|
||||
if (newJson !== initialStateJsonRef.current) {
|
||||
initialStateJsonRef.current = newJson;
|
||||
if (initialState && Object.keys(initialState).length > 0) {
|
||||
setStateInternal((prev) => ({ ...prev, ...initialState }));
|
||||
}
|
||||
}
|
||||
}, [initialState]);
|
||||
|
||||
const get = useCallback(
|
||||
(path: string) => getByPath(stateRef.current, path),
|
||||
[],
|
||||
);
|
||||
|
||||
const set = useCallback(
|
||||
(path: string, value: unknown) => {
|
||||
setStateInternal((prev) => {
|
||||
const next = { ...prev };
|
||||
setByPath(next, path, value);
|
||||
return next;
|
||||
});
|
||||
onStateChange?.(path, value);
|
||||
},
|
||||
[onStateChange],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Record<string, unknown>) => {
|
||||
const entries = Object.entries(updates);
|
||||
setStateInternal((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [path, value] of entries) {
|
||||
setByPath(next, path, value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
for (const [path, value] of entries) {
|
||||
onStateChange?.(path, value);
|
||||
}
|
||||
},
|
||||
[onStateChange],
|
||||
);
|
||||
|
||||
const value = useMemo<StateContextValue>(
|
||||
() => ({ state, get, set, update }),
|
||||
[state, get, set, update],
|
||||
);
|
||||
|
||||
return (
|
||||
<StateContext.Provider value={value}>{children}</StateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useStateStore(): StateContextValue {
|
||||
const ctx = useContext(StateContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useStateStore must be used within a StateProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useStateValue<T>(path: string): T | undefined {
|
||||
const { state } = useStateStore();
|
||||
return getByPath(state, path) as T | undefined;
|
||||
}
|
||||
|
||||
export function useStateBinding<T>(
|
||||
path: string,
|
||||
): [T | undefined, (value: T) => void] {
|
||||
const { state, set } = useStateStore();
|
||||
const value = getByPath(state, path) as T | undefined;
|
||||
const setValue = useCallback(
|
||||
(newValue: T) => set(path, newValue),
|
||||
[path, set],
|
||||
);
|
||||
return [value, setValue];
|
||||
}
|
||||
export {
|
||||
StateProvider,
|
||||
useStateStore,
|
||||
useStateValue,
|
||||
useStateBinding,
|
||||
type StateContextValue,
|
||||
type StateProviderProps,
|
||||
} from "@internal/react-state";
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
export { schema, type ReactPdfSchema, type ReactPdfSpec } from "./schema";
|
||||
|
||||
// Core types (re-exported for convenience)
|
||||
export type { Spec } from "@json-render/core";
|
||||
export type { Spec, StateStore } from "@json-render/core";
|
||||
export { createStateStore } from "@json-render/core";
|
||||
|
||||
// Catalog-aware types
|
||||
export type {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ActionBinding,
|
||||
Catalog,
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
@@ -343,6 +344,7 @@ export function Renderer({
|
||||
|
||||
export interface JSONUIProviderProps {
|
||||
registry?: ComponentRegistry;
|
||||
store?: StateStore;
|
||||
initialState?: Record<string, unknown>;
|
||||
handlers?: Record<
|
||||
string,
|
||||
@@ -353,11 +355,12 @@ export interface JSONUIProviderProps {
|
||||
string,
|
||||
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||
>;
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function JSONUIProvider({
|
||||
store,
|
||||
initialState,
|
||||
handlers,
|
||||
navigate,
|
||||
@@ -366,7 +369,11 @@ export function JSONUIProvider({
|
||||
children,
|
||||
}: JSONUIProviderProps) {
|
||||
return (
|
||||
<StateProvider initialState={initialState} onStateChange={onStateChange}>
|
||||
<StateProvider
|
||||
store={store}
|
||||
initialState={initialState}
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||
<ValidationProvider customFunctions={validationFunctions}>
|
||||
@@ -430,9 +437,10 @@ export function defineRegistry<C extends Catalog>(
|
||||
|
||||
export interface CreateRendererProps {
|
||||
spec: Spec | null;
|
||||
store?: StateStore;
|
||||
state?: Record<string, unknown>;
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
}
|
||||
@@ -461,6 +469,7 @@ export function createRenderer<
|
||||
|
||||
return function CatalogRenderer({
|
||||
spec,
|
||||
store,
|
||||
state,
|
||||
onAction,
|
||||
onStateChange,
|
||||
@@ -484,7 +493,11 @@ export function createRenderer<
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={state} onStateChange={onStateChange}>
|
||||
<StateProvider
|
||||
store={store}
|
||||
initialState={state}
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<ValidationProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@internal/react-state",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"description": "Shared React state context for json-render renderer packages",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "19.2.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
// Minimal process.env typing for dev-only warnings.
|
||||
// Uses a namespaced interface so it merges cleanly with @types/node if present.
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
readonly NODE_ENV?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare const process: { readonly env: NodeJS.ProcessEnv };
|
||||
@@ -0,0 +1,332 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { createStateStore } from "@json-render/core";
|
||||
import {
|
||||
StateProvider,
|
||||
useStateStore,
|
||||
useStateValue,
|
||||
useStateBinding,
|
||||
} from "./index";
|
||||
|
||||
// ============================================================================
|
||||
// Uncontrolled mode (default)
|
||||
// ============================================================================
|
||||
|
||||
describe("StateProvider (uncontrolled)", () => {
|
||||
it("provides initial state to children", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ user: { name: "John" } }}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.state).toEqual({ user: { name: "John" } });
|
||||
});
|
||||
|
||||
it("provides empty object when no initial state", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.state).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStateStore (uncontrolled)", () => {
|
||||
it("provides get function to retrieve values", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ user: { name: "John" } }}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.get("/user/name")).toBe("John");
|
||||
});
|
||||
|
||||
it("allows setting state at path with set function", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{}}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.set("/user/name", "Alice");
|
||||
});
|
||||
|
||||
expect((result.current.state.user as Record<string, unknown>).name).toBe(
|
||||
"Alice",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onStateChange callback with changes array on set", () => {
|
||||
const onStateChange = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{}} onStateChange={onStateChange}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.set("/count", 42);
|
||||
});
|
||||
|
||||
expect(onStateChange).toHaveBeenCalledTimes(1);
|
||||
expect(onStateChange).toHaveBeenCalledWith([{ path: "/count", value: 42 }]);
|
||||
});
|
||||
|
||||
it("calls onStateChange callback once with all changes on update", () => {
|
||||
const onStateChange = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{}} onStateChange={onStateChange}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.update({ "/name": "John", "/age": 30 });
|
||||
});
|
||||
|
||||
expect(onStateChange).toHaveBeenCalledTimes(1);
|
||||
expect(onStateChange).toHaveBeenCalledWith([
|
||||
{ path: "/name", value: "John" },
|
||||
{ path: "/age", value: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows updating multiple values with update function", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{}}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.update({
|
||||
"/name": "John",
|
||||
"/age": 30,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.state.name).toBe("John");
|
||||
expect(result.current.state.age).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStateValue", () => {
|
||||
it("returns value at specified path", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ user: { name: "John", age: 30 } }}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateValue("/user/name"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe("John");
|
||||
});
|
||||
|
||||
it("returns undefined for missing path", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{}}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateValue("/missing"), { wrapper });
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates when state changes", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
() => ({
|
||||
store: useStateStore(),
|
||||
value: useStateValue<number>("/count"),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.value).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.store.set("/count", 5);
|
||||
});
|
||||
|
||||
rerender();
|
||||
expect(result.current.value).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStateBinding", () => {
|
||||
it("returns tuple with value and setter for path", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateBinding("/name"), { wrapper });
|
||||
|
||||
const [value, setValue] = result.current;
|
||||
expect(value).toBe("John");
|
||||
expect(typeof setValue).toBe("function");
|
||||
});
|
||||
|
||||
it("setter updates the value", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(() => useStateBinding("/name"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const [, setValue] = result.current;
|
||||
setValue("Alice");
|
||||
});
|
||||
|
||||
rerender();
|
||||
const [value] = result.current;
|
||||
expect(value).toBe("Alice");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Controlled mode (external store)
|
||||
// ============================================================================
|
||||
|
||||
describe("StateProvider (controlled mode)", () => {
|
||||
it("reads initial state from the external store", () => {
|
||||
const store = createStateStore({ count: 7 });
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider store={store}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.state).toEqual({ count: 7 });
|
||||
expect(result.current.get("/count")).toBe(7);
|
||||
});
|
||||
|
||||
it("re-renders when the external store updates", () => {
|
||||
const store = createStateStore({ count: 0 });
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider store={store}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateValue<number>("/count"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe(0);
|
||||
|
||||
act(() => {
|
||||
store.set("/count", 42);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(42);
|
||||
});
|
||||
|
||||
it("set() writes through to the external store", () => {
|
||||
const store = createStateStore({});
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider store={store}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.set("/name", "Alice");
|
||||
});
|
||||
|
||||
expect(store.getSnapshot().name).toBe("Alice");
|
||||
expect(result.current.state.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("update() writes multiple values through to the external store", () => {
|
||||
const store = createStateStore({});
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider store={store}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.update({ "/a": 1, "/b": 2 });
|
||||
});
|
||||
|
||||
expect(store.getSnapshot().a).toBe(1);
|
||||
expect(store.getSnapshot().b).toBe(2);
|
||||
});
|
||||
|
||||
it("does NOT call onStateChange when using an external store", () => {
|
||||
const store = createStateStore({});
|
||||
const onStateChange = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider store={store} onStateChange={onStateChange}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.set("/x", 99);
|
||||
});
|
||||
|
||||
expect(onStateChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores initialState when an external store is provided", () => {
|
||||
const store = createStateStore({ fromStore: true });
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider store={store} initialState={{ fromProp: true }}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.state).toEqual({ fromStore: true });
|
||||
expect(result.current.get("/fromProp")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// initialState sync fast-path
|
||||
// ============================================================================
|
||||
|
||||
describe("StateProvider initialState sync", () => {
|
||||
it("does not re-flatten when initialState reference is unchanged", () => {
|
||||
const initialState = { count: 0 };
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={initialState}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.state).toEqual({ count: 0 });
|
||||
|
||||
act(() => {
|
||||
result.current.set("/count", 5);
|
||||
});
|
||||
|
||||
rerender();
|
||||
expect(result.current.state.count).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
getByPath,
|
||||
createStateStore,
|
||||
type StateModel,
|
||||
type StateStore,
|
||||
} from "@json-render/core";
|
||||
import { flattenToPointers } from "@json-render/core/store-utils";
|
||||
|
||||
/**
|
||||
* State context value
|
||||
*/
|
||||
export interface StateContextValue {
|
||||
/** The current state model */
|
||||
state: StateModel;
|
||||
/** Get a value by path */
|
||||
get: (path: string) => unknown;
|
||||
/** Set a value by path */
|
||||
set: (path: string, value: unknown) => void;
|
||||
/** Update multiple values at once */
|
||||
update: (updates: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const StateContext = createContext<StateContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Props for StateProvider
|
||||
*/
|
||||
export interface StateProviderProps {
|
||||
/**
|
||||
* External store that owns the state. When provided, the provider operates
|
||||
* in **controlled mode** — `initialState` and `onStateChange` are ignored
|
||||
* and the store is the single source of truth.
|
||||
*/
|
||||
store?: StateStore;
|
||||
/** Initial state model (used only in uncontrolled mode) */
|
||||
initialState?: StateModel;
|
||||
/**
|
||||
* Callback when state changes (used only in uncontrolled mode).
|
||||
* Called once per `set` or `update` with all changed entries.
|
||||
*/
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function computeInitialFlat(
|
||||
isControlled: boolean,
|
||||
initialState: StateModel,
|
||||
): Record<string, unknown> | null {
|
||||
if (isControlled) return null;
|
||||
if (Object.keys(initialState).length === 0) return {};
|
||||
return flattenToPointers(initialState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for state model context.
|
||||
*
|
||||
* Supports two modes:
|
||||
* - **Controlled**: pass a `store` prop (e.g. backed by Redux / Zustand).
|
||||
* - **Uncontrolled** (default): omit `store` and optionally pass
|
||||
* `initialState` / `onStateChange`.
|
||||
*/
|
||||
export function StateProvider({
|
||||
store: externalStore,
|
||||
initialState = {},
|
||||
onStateChange,
|
||||
children,
|
||||
}: StateProviderProps) {
|
||||
const internalStoreRef = useRef<StateStore | undefined>(undefined);
|
||||
if (!externalStore && !internalStoreRef.current) {
|
||||
internalStoreRef.current = createStateStore(initialState);
|
||||
}
|
||||
|
||||
const store = externalStore ?? internalStoreRef.current!;
|
||||
|
||||
// Refs for stable callback identity — callbacks never change regardless of
|
||||
// whether the consumer passes a new store / onStateChange reference.
|
||||
const storeRef = useRef(store);
|
||||
storeRef.current = store;
|
||||
|
||||
const isControlledRef = useRef(!!externalStore);
|
||||
isControlledRef.current = !!externalStore;
|
||||
|
||||
const initialModeRef = useRef(externalStore ? "controlled" : "uncontrolled");
|
||||
const modeWarnedRef = useRef(false);
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const currentMode = externalStore ? "controlled" : "uncontrolled";
|
||||
if (currentMode !== initialModeRef.current && !modeWarnedRef.current) {
|
||||
modeWarnedRef.current = true;
|
||||
console.warn(
|
||||
`StateProvider: switching from ${initialModeRef.current} to ${currentMode} mode is not supported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const prevInitialStateRef = useRef(initialState);
|
||||
const prevFlatRef = useRef<Record<string, unknown> | null>(
|
||||
computeInitialFlat(!!externalStore, initialState),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (externalStore) return;
|
||||
if (initialState === prevInitialStateRef.current) return;
|
||||
prevInitialStateRef.current = initialState;
|
||||
const nextFlat =
|
||||
initialState && Object.keys(initialState).length > 0
|
||||
? flattenToPointers(initialState)
|
||||
: {};
|
||||
const prevFlat = prevFlatRef.current ?? {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(prevFlat),
|
||||
...Object.keys(nextFlat),
|
||||
]);
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const key of allKeys) {
|
||||
if (prevFlat[key] !== nextFlat[key]) {
|
||||
updates[key] = key in nextFlat ? nextFlat[key] : undefined;
|
||||
}
|
||||
}
|
||||
prevFlatRef.current = nextFlat;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
store.update(updates);
|
||||
}
|
||||
}, [externalStore, initialState, store]);
|
||||
|
||||
const state = useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getServerSnapshot ?? store.getSnapshot,
|
||||
);
|
||||
|
||||
const onStateChangeRef = useRef(onStateChange);
|
||||
onStateChangeRef.current = onStateChange;
|
||||
|
||||
const set = useCallback((path: string, value: unknown) => {
|
||||
const s = storeRef.current;
|
||||
const prev = s.getSnapshot();
|
||||
s.set(path, value);
|
||||
if (!isControlledRef.current && s.getSnapshot() !== prev) {
|
||||
onStateChangeRef.current?.([{ path, value }]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const update = useCallback((updates: Record<string, unknown>) => {
|
||||
const s = storeRef.current;
|
||||
const prev = s.getSnapshot();
|
||||
s.update(updates);
|
||||
if (!isControlledRef.current && s.getSnapshot() !== prev) {
|
||||
const changes: Array<{ path: string; value: unknown }> = [];
|
||||
for (const [path, value] of Object.entries(updates)) {
|
||||
if (getByPath(prev, path) !== value) {
|
||||
changes.push({ path, value });
|
||||
}
|
||||
}
|
||||
if (changes.length > 0) {
|
||||
onStateChangeRef.current?.(changes);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const get = useCallback((path: string) => storeRef.current.get(path), []);
|
||||
|
||||
const value = useMemo<StateContextValue>(
|
||||
() => ({ state, get, set, update }),
|
||||
[state, get, set, update],
|
||||
);
|
||||
|
||||
return (
|
||||
<StateContext.Provider value={value}>{children}</StateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the state context
|
||||
*/
|
||||
export function useStateStore(): StateContextValue {
|
||||
const ctx = useContext(StateContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useStateStore must be used within a StateProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a value from the state model
|
||||
*/
|
||||
export function useStateValue<T>(path: string): T | undefined {
|
||||
const { state } = useStateStore();
|
||||
return getByPath(state, path) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get and set a value from the state model (like useState).
|
||||
*
|
||||
* @deprecated Use {@link useBoundProp} with `$bindState` expressions instead.
|
||||
* `useStateBinding` takes a raw state path string, while `useBoundProp` works
|
||||
* with the renderer's `bindings` map and supports both `$bindState` and
|
||||
* `$bindItem` expressions.
|
||||
*/
|
||||
export function useStateBinding<T>(
|
||||
path: string,
|
||||
): [T | undefined, (value: T) => void] {
|
||||
const { state, set } = useStateStore();
|
||||
const value = getByPath(state, path) as T | undefined;
|
||||
const setValue = useCallback(
|
||||
(newValue: T) => set(path, newValue),
|
||||
[path, set],
|
||||
);
|
||||
return [value, setValue];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.tsx"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ["@json-render/core", "@json-render/core/store-utils", "react"],
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
react: path.resolve(__dirname, "../../node_modules/react"),
|
||||
"react-dom": path.resolve(__dirname, "../../node_modules/react-dom"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.tsx"],
|
||||
},
|
||||
});
|
||||
@@ -171,6 +171,35 @@ const name = get("/user/name"); // "John"
|
||||
set("/user/age", 25);
|
||||
```
|
||||
|
||||
#### External Store (Controlled Mode)
|
||||
|
||||
For full control over state, pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Redux, Zustand, XState, etc.):
|
||||
|
||||
```tsx
|
||||
import { createStateStore, type StateStore } from "@json-render/react";
|
||||
|
||||
// Option 1: Use the built-in store outside of React
|
||||
const store = createStateStore({ count: 0 });
|
||||
|
||||
<StateProvider store={store}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
|
||||
// Mutate from anywhere — React will re-render automatically:
|
||||
store.set("/count", 1);
|
||||
|
||||
// Option 2: Implement the StateStore interface with your own backend
|
||||
const zustandStore: StateStore = {
|
||||
get: (path) => getByPath(useStore.getState(), path),
|
||||
set: (path, value) => useStore.setState(prev => { /* ... */ }),
|
||||
update: (updates) => useStore.setState(prev => { /* ... */ }),
|
||||
getSnapshot: () => useStore.getState(),
|
||||
subscribe: (listener) => useStore.subscribe(listener),
|
||||
};
|
||||
```
|
||||
|
||||
When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. The same `store` prop is available on `createRenderer`, `JSONUIProvider`, and `StateProvider`.
|
||||
|
||||
### ActionProvider
|
||||
|
||||
Handle actions from components:
|
||||
@@ -428,6 +457,7 @@ function App() {
|
||||
| `useActions` | Access actions context |
|
||||
| `useAction` | Get a single action dispatch function |
|
||||
| `useUIStream` | Stream specs from an API endpoint |
|
||||
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
|
||||
|
||||
### Types
|
||||
|
||||
@@ -439,3 +469,4 @@ function App() {
|
||||
| `ComponentFn` | Component render function type |
|
||||
| `SetState` | State setter type |
|
||||
| `StateModel` | State model type |
|
||||
| `StateStore` | Interface for plugging in external state management |
|
||||
|
||||
@@ -50,10 +50,11 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*"
|
||||
"@json-render/core": "workspace:*",
|
||||
"@internal/react-state": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import {
|
||||
@@ -8,174 +8,42 @@ import {
|
||||
useStateBinding,
|
||||
} from "./state";
|
||||
|
||||
describe("StateProvider", () => {
|
||||
it("provides initial state to children", () => {
|
||||
describe("state re-exports (smoke test)", () => {
|
||||
it("StateProvider + useStateStore round-trip", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ user: { name: "John" } }}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
<StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.state).toEqual({ user: { name: "John" } });
|
||||
});
|
||||
|
||||
it("provides empty object when no initial state", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.state).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStateStore", () => {
|
||||
it("provides get function to retrieve values", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ user: { name: "John" } }}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
expect(result.current.get("/user/name")).toBe("John");
|
||||
});
|
||||
|
||||
it("allows setting state at path with set function", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{}}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.set("/user/name", "Alice");
|
||||
});
|
||||
|
||||
expect((result.current.state.user as Record<string, unknown>).name).toBe(
|
||||
"Alice",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onStateChange callback when state changes", () => {
|
||||
const onStateChange = vi.fn();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{}} onStateChange={onStateChange}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
expect(result.current.get("/count")).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.set("/count", 42);
|
||||
});
|
||||
|
||||
expect(onStateChange).toHaveBeenCalledWith("/count", 42);
|
||||
expect(result.current.state.count).toBe(42);
|
||||
});
|
||||
|
||||
it("allows updating multiple values with update function", () => {
|
||||
it("useStateValue reads from state", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{}}>{children}</StateProvider>
|
||||
<StateProvider initialState={{ name: "Alice" }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||
const { result } = renderHook(() => useStateValue("/name"), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.update({
|
||||
"/name": "John",
|
||||
"/age": 30,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.state.name).toBe("John");
|
||||
expect(result.current.state.age).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStateValue", () => {
|
||||
it("returns value at specified path", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ user: { name: "John", age: 30 } }}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateValue("/user/name"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe("John");
|
||||
expect(result.current).toBe("Alice");
|
||||
});
|
||||
|
||||
it("returns undefined for missing path", () => {
|
||||
it("useStateBinding returns value and setter", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{}}>{children}</StateProvider>
|
||||
<StateProvider initialState={{ x: 1 }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateValue("/missing"), { wrapper });
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates when state changes", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
// Use a single hook that returns both values
|
||||
const { result, rerender } = renderHook(
|
||||
() => ({
|
||||
store: useStateStore(),
|
||||
value: useStateValue<number>("/count"),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.value).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.store.set("/count", 5);
|
||||
});
|
||||
|
||||
rerender();
|
||||
expect(result.current.value).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStateBinding", () => {
|
||||
it("returns tuple with value and setter for path", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStateBinding("/name"), { wrapper });
|
||||
const { result } = renderHook(() => useStateBinding("/x"), { wrapper });
|
||||
|
||||
const [value, setValue] = result.current;
|
||||
expect(value).toBe("John");
|
||||
expect(value).toBe(1);
|
||||
expect(typeof setValue).toBe("function");
|
||||
});
|
||||
|
||||
it("setter updates the value", () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(() => useStateBinding("/name"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const [, setValue] = result.current;
|
||||
setValue("Alice");
|
||||
});
|
||||
|
||||
rerender();
|
||||
const [value] = result.current;
|
||||
expect(value).toBe("Alice");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,160 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { getByPath, setByPath, type StateModel } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* State context value
|
||||
*/
|
||||
export interface StateContextValue {
|
||||
/** The current state model */
|
||||
state: StateModel;
|
||||
/** Get a value by path */
|
||||
get: (path: string) => unknown;
|
||||
/** Set a value by path */
|
||||
set: (path: string, value: unknown) => void;
|
||||
/** Update multiple values at once */
|
||||
update: (updates: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const StateContext = createContext<StateContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Props for StateProvider
|
||||
*/
|
||||
export interface StateProviderProps {
|
||||
/** Initial state model */
|
||||
initialState?: StateModel;
|
||||
/** Callback when state changes */
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for state model context
|
||||
*/
|
||||
export function StateProvider({
|
||||
initialState = {},
|
||||
onStateChange,
|
||||
children,
|
||||
}: StateProviderProps) {
|
||||
const [state, setStateInternal] = useState<StateModel>(initialState);
|
||||
|
||||
// Keep a ref to the latest state so `get` doesn't change on every update.
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
// Track the serialized initialState to detect actual value changes (not just reference changes)
|
||||
const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
|
||||
|
||||
// Sync external state changes with internal state - only when values actually change
|
||||
useEffect(() => {
|
||||
const newJson = JSON.stringify(initialState);
|
||||
if (newJson !== initialStateJsonRef.current) {
|
||||
initialStateJsonRef.current = newJson;
|
||||
if (initialState && Object.keys(initialState).length > 0) {
|
||||
setStateInternal((prev) => ({ ...prev, ...initialState }));
|
||||
}
|
||||
}
|
||||
}, [initialState]);
|
||||
|
||||
// `get` uses a ref so it never changes identity — consumers that only
|
||||
// need `get` won't re-render on every state change.
|
||||
const get = useCallback(
|
||||
(path: string) => getByPath(stateRef.current, path),
|
||||
[],
|
||||
);
|
||||
|
||||
const set = useCallback(
|
||||
(path: string, value: unknown) => {
|
||||
setStateInternal((prev) => {
|
||||
const next = { ...prev };
|
||||
setByPath(next, path, value);
|
||||
return next;
|
||||
});
|
||||
// Side effect after the state update
|
||||
onStateChange?.(path, value);
|
||||
},
|
||||
[onStateChange],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Record<string, unknown>) => {
|
||||
const entries = Object.entries(updates);
|
||||
setStateInternal((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [path, value] of entries) {
|
||||
setByPath(next, path, value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Side effects after the state update
|
||||
for (const [path, value] of entries) {
|
||||
onStateChange?.(path, value);
|
||||
}
|
||||
},
|
||||
[onStateChange],
|
||||
);
|
||||
|
||||
const value = useMemo<StateContextValue>(
|
||||
() => ({
|
||||
state,
|
||||
get,
|
||||
set,
|
||||
update,
|
||||
}),
|
||||
[state, get, set, update],
|
||||
);
|
||||
|
||||
return (
|
||||
<StateContext.Provider value={value}>{children}</StateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the state context
|
||||
*/
|
||||
export function useStateStore(): StateContextValue {
|
||||
const ctx = useContext(StateContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useStateStore must be used within a StateProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a value from the state model
|
||||
*/
|
||||
export function useStateValue<T>(path: string): T | undefined {
|
||||
const { state } = useStateStore();
|
||||
return getByPath(state, path) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get and set a value from the state model (like useState).
|
||||
*
|
||||
* @deprecated Use {@link useBoundProp} with `$bindState` expressions instead.
|
||||
* `useStateBinding` takes a raw state path string, while `useBoundProp` works
|
||||
* with the renderer's `bindings` map and supports both `$bindState` and
|
||||
* `$bindItem` expressions.
|
||||
*/
|
||||
export function useStateBinding<T>(
|
||||
path: string,
|
||||
): [T | undefined, (value: T) => void] {
|
||||
const { state, set } = useStateStore();
|
||||
const value = getByPath(state, path) as T | undefined;
|
||||
const setValue = useCallback(
|
||||
(newValue: T) => set(path, newValue),
|
||||
[path, set],
|
||||
);
|
||||
return [value, setValue];
|
||||
}
|
||||
export {
|
||||
StateProvider,
|
||||
useStateStore,
|
||||
useStateValue,
|
||||
useStateBinding,
|
||||
type StateContextValue,
|
||||
type StateProviderProps,
|
||||
} from "@internal/react-state";
|
||||
|
||||
@@ -54,7 +54,8 @@ export {
|
||||
} from "./schema";
|
||||
|
||||
// Core types (re-exported for convenience)
|
||||
export type { Spec } from "@json-render/core";
|
||||
export type { Spec, StateStore } from "@json-render/core";
|
||||
export { createStateStore } from "@json-render/core";
|
||||
|
||||
// Catalog-aware types for React
|
||||
export type {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ActionBinding,
|
||||
Catalog,
|
||||
SchemaDefinition,
|
||||
StateStore,
|
||||
} from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
@@ -394,7 +395,12 @@ export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
|
||||
export interface JSONUIProviderProps {
|
||||
/** Component registry */
|
||||
registry: ComponentRegistry;
|
||||
/** Initial state model */
|
||||
/**
|
||||
* External store (controlled mode). When provided, `initialState` and
|
||||
* `onStateChange` are ignored.
|
||||
*/
|
||||
store?: StateStore;
|
||||
/** Initial state model (uncontrolled mode) */
|
||||
initialState?: Record<string, unknown>;
|
||||
/** Action handlers */
|
||||
handlers?: Record<
|
||||
@@ -408,8 +414,8 @@ export interface JSONUIProviderProps {
|
||||
string,
|
||||
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||
>;
|
||||
/** Callback when state changes */
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
/** Callback when state changes (uncontrolled mode) */
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -418,6 +424,7 @@ export interface JSONUIProviderProps {
|
||||
*/
|
||||
export function JSONUIProvider({
|
||||
registry,
|
||||
store,
|
||||
initialState,
|
||||
handlers,
|
||||
navigate,
|
||||
@@ -426,7 +433,11 @@ export function JSONUIProvider({
|
||||
children,
|
||||
}: JSONUIProviderProps) {
|
||||
return (
|
||||
<StateProvider initialState={initialState} onStateChange={onStateChange}>
|
||||
<StateProvider
|
||||
store={store}
|
||||
initialState={initialState}
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||
<ValidationProvider customFunctions={validationFunctions}>
|
||||
@@ -623,12 +634,17 @@ type DefineRegistryActionFn = (
|
||||
export interface CreateRendererProps {
|
||||
/** The spec to render (AI-generated JSON) */
|
||||
spec: Spec | null;
|
||||
/** State context for dynamic values */
|
||||
/**
|
||||
* External store (controlled mode). When provided, `state` and
|
||||
* `onStateChange` are ignored.
|
||||
*/
|
||||
store?: StateStore;
|
||||
/** State context for dynamic values (uncontrolled mode) */
|
||||
state?: Record<string, unknown>;
|
||||
/** Action handler */
|
||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||
/** Callback when state changes (e.g., from form inputs) */
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
/** Callback when state changes (uncontrolled mode) */
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
/** Fallback component for unknown types */
|
||||
@@ -678,6 +694,7 @@ export function createRenderer<
|
||||
// Return the renderer component
|
||||
return function CatalogRenderer({
|
||||
spec,
|
||||
store,
|
||||
state,
|
||||
onAction,
|
||||
onStateChange,
|
||||
@@ -702,7 +719,11 @@ export function createRenderer<
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={state} onStateChange={onStateChange}>
|
||||
<StateProvider
|
||||
store={store}
|
||||
initialState={state}
|
||||
onStateChange={onStateChange}
|
||||
>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<ValidationProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# @json-render/redux
|
||||
|
||||
Redux adapter for json-render's `StateStore` interface. Wire a Redux store (or Redux Toolkit slice) as the state backend for json-render.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/redux @json-render/core @json-render/react redux
|
||||
# or with Redux Toolkit (recommended):
|
||||
npm install @json-render/redux @json-render/core @json-render/react @reduxjs/toolkit
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { configureStore, createSlice } from "@reduxjs/toolkit";
|
||||
import { reduxStateStore } from "@json-render/redux";
|
||||
import { StateProvider } from "@json-render/react";
|
||||
|
||||
// 1. Define a slice for json-render state
|
||||
const uiSlice = createSlice({
|
||||
name: "ui",
|
||||
initialState: { count: 0 } as Record<string, unknown>,
|
||||
reducers: {
|
||||
replaceUiState: (_state, action) => action.payload,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Create the Redux store
|
||||
const reduxStore = configureStore({
|
||||
reducer: { ui: uiSlice.reducer },
|
||||
});
|
||||
|
||||
// 3. Create the json-render StateStore adapter
|
||||
const store = reduxStateStore({
|
||||
store: reduxStore,
|
||||
selector: (state) => state.ui,
|
||||
dispatch: (next, s) => s.dispatch(uiSlice.actions.replaceUiState(next)),
|
||||
});
|
||||
|
||||
// 4. Use it
|
||||
<StateProvider store={store}>
|
||||
{/* json-render reads/writes go through Redux */}
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `reduxStateStore(options)`
|
||||
|
||||
Creates a `StateStore` backed by a Redux store.
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Type | Required | Description |
|
||||
|--------|------|----------|-------------|
|
||||
| `store` | `Store` | Yes | The Redux store instance |
|
||||
| `selector` | `(state) => StateModel` | Yes | Select the json-render slice from the Redux state tree. For a simple store where the entire state is the model, use `(s) => s`. |
|
||||
| `dispatch` | `(nextState, store) => void` | Yes | Dispatch an action that replaces the selected slice with the next state |
|
||||
|
||||
The `dispatch` callback receives the full next state model and the Redux store, so you can dispatch any action shape your reducers expect.
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@json-render/redux",
|
||||
"version": "0.8.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Redux adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
"json-render",
|
||||
"redux",
|
||||
"state-management",
|
||||
"adapter"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/redux"
|
||||
},
|
||||
"homepage": "https://github.com/vercel-labs/json-render#readme",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@reduxjs/toolkit": ">=2.0.0",
|
||||
"redux": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@reduxjs/toolkit": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"redux": "^5.0.1",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { configureStore, createSlice } from "@reduxjs/toolkit";
|
||||
import { reduxStateStore } from "./index";
|
||||
|
||||
function createTestStore(initial: Record<string, unknown> = {}) {
|
||||
const uiSlice = createSlice({
|
||||
name: "ui",
|
||||
initialState: initial as Record<string, unknown>,
|
||||
reducers: {
|
||||
replace: (_state, action) => action.payload,
|
||||
},
|
||||
});
|
||||
|
||||
const reduxStore = configureStore({
|
||||
reducer: { ui: uiSlice.reducer },
|
||||
});
|
||||
|
||||
const store = reduxStateStore({
|
||||
store: reduxStore,
|
||||
selector: (state) => state.ui as Record<string, unknown>,
|
||||
dispatch: (next, s) => s.dispatch(uiSlice.actions.replace(next)),
|
||||
});
|
||||
|
||||
return { reduxStore, store, uiSlice };
|
||||
}
|
||||
|
||||
describe("reduxStateStore", () => {
|
||||
it("get/set round-trip", () => {
|
||||
const { store } = createTestStore({ count: 0 });
|
||||
|
||||
expect(store.get("/count")).toBe(0);
|
||||
|
||||
store.set("/count", 42);
|
||||
|
||||
expect(store.get("/count")).toBe(42);
|
||||
expect(store.getSnapshot().count).toBe(42);
|
||||
});
|
||||
|
||||
it("update round-trip with multiple values", () => {
|
||||
const { store } = createTestStore({});
|
||||
|
||||
store.update({ "/a": 1, "/b": "hello" });
|
||||
|
||||
expect(store.get("/a")).toBe(1);
|
||||
expect(store.get("/b")).toBe("hello");
|
||||
expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" });
|
||||
});
|
||||
|
||||
it("subscribe fires on set", () => {
|
||||
const { store } = createTestStore({});
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.set("/x", 1);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("subscribe fires on update", () => {
|
||||
const { store } = createTestStore({});
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.update({ "/a": 1, "/b": 2 });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("unsubscribe stops notifications", () => {
|
||||
const { store } = createTestStore({});
|
||||
const listener = vi.fn();
|
||||
const unsub = store.subscribe(listener);
|
||||
|
||||
store.set("/x", 1);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsub();
|
||||
store.set("/x", 2);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("getSnapshot immutability -- previous snapshot is not mutated", () => {
|
||||
const { store } = createTestStore({ user: { name: "Alice", age: 30 } });
|
||||
const snap1 = store.getSnapshot();
|
||||
|
||||
store.set("/user/name", "Bob");
|
||||
const snap2 = store.getSnapshot();
|
||||
|
||||
expect(snap1.user).toEqual({ name: "Alice", age: 30 });
|
||||
expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
|
||||
expect(snap1.user).not.toBe(snap2.user);
|
||||
});
|
||||
|
||||
it("structural sharing -- untouched branches keep references", () => {
|
||||
const { store } = createTestStore({
|
||||
a: { x: 1 },
|
||||
b: { y: 2 },
|
||||
});
|
||||
const snap1 = store.getSnapshot();
|
||||
|
||||
store.set("/a/x", 99);
|
||||
const snap2 = store.getSnapshot();
|
||||
|
||||
expect(snap2.b).toBe(snap1.b);
|
||||
expect(snap2.a).not.toBe(snap1.a);
|
||||
});
|
||||
|
||||
it("getServerSnapshot returns same as getSnapshot", () => {
|
||||
const { store } = createTestStore({ x: 1 });
|
||||
|
||||
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||
|
||||
store.set("/x", 2);
|
||||
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||
});
|
||||
|
||||
it("subscribe does NOT fire when unrelated slice changes", () => {
|
||||
const uiSlice = createSlice({
|
||||
name: "ui",
|
||||
initialState: { count: 0 } as Record<string, unknown>,
|
||||
reducers: {
|
||||
replace: (_state, action) => action.payload,
|
||||
},
|
||||
});
|
||||
|
||||
const otherSlice = createSlice({
|
||||
name: "other",
|
||||
initialState: { value: "a" },
|
||||
reducers: {
|
||||
update: (state, action) => {
|
||||
state.value = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const reduxStore = configureStore({
|
||||
reducer: {
|
||||
ui: uiSlice.reducer,
|
||||
other: otherSlice.reducer,
|
||||
},
|
||||
});
|
||||
|
||||
const store = reduxStateStore({
|
||||
store: reduxStore,
|
||||
selector: (state) => state.ui as Record<string, unknown>,
|
||||
dispatch: (next, s) => s.dispatch(uiSlice.actions.replace(next)),
|
||||
});
|
||||
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
reduxStore.dispatch(otherSlice.actions.update("b"));
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(store.get("/count")).toBe(0);
|
||||
});
|
||||
|
||||
it("set skips dispatch when value is unchanged", () => {
|
||||
const { store } = createTestStore({ x: 1 });
|
||||
const snap1 = store.getSnapshot();
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.set("/x", 1);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(store.getSnapshot()).toBe(snap1);
|
||||
});
|
||||
|
||||
it("update skips dispatch when no values changed", () => {
|
||||
const { store } = createTestStore({ a: 1, b: 2 });
|
||||
const snap1 = store.getSnapshot();
|
||||
const listener = vi.fn();
|
||||
store.subscribe(listener);
|
||||
|
||||
store.update({ "/a": 1, "/b": 2 });
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(store.getSnapshot()).toBe(snap1);
|
||||
});
|
||||
|
||||
it("works with identity selector (entire state is the model)", () => {
|
||||
const slice = createSlice({
|
||||
name: "root",
|
||||
initialState: { x: 1 } as Record<string, unknown>,
|
||||
reducers: {
|
||||
replace: (_state, action) => action.payload,
|
||||
},
|
||||
});
|
||||
|
||||
const reduxStore = configureStore({
|
||||
reducer: slice.reducer,
|
||||
});
|
||||
|
||||
const store = reduxStateStore({
|
||||
store: reduxStore,
|
||||
selector: (s) => s as Record<string, unknown>,
|
||||
dispatch: (next, s) => s.dispatch(slice.actions.replace(next)),
|
||||
});
|
||||
|
||||
expect(store.get("/x")).toBe(1);
|
||||
|
||||
store.set("/x", 2);
|
||||
expect(store.get("/x")).toBe(2);
|
||||
});
|
||||
|
||||
it("defaults selector to identity when omitted", () => {
|
||||
const slice = createSlice({
|
||||
name: "root",
|
||||
initialState: { x: 1 } as Record<string, unknown>,
|
||||
reducers: {
|
||||
replace: (_state, action) => action.payload,
|
||||
},
|
||||
});
|
||||
|
||||
const reduxStore = configureStore({
|
||||
reducer: slice.reducer,
|
||||
});
|
||||
|
||||
const store = reduxStateStore({
|
||||
store: reduxStore,
|
||||
dispatch: (next, s) => s.dispatch(slice.actions.replace(next)),
|
||||
});
|
||||
|
||||
expect(store.get("/x")).toBe(1);
|
||||
|
||||
store.set("/x", 2);
|
||||
expect(store.get("/x")).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { StateModel, StateStore } from "@json-render/core";
|
||||
import { createStoreAdapter } from "@json-render/core/store-utils";
|
||||
import type { Store, Action, UnknownAction } from "redux";
|
||||
|
||||
export type { StateStore } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* Options for {@link reduxStateStore}.
|
||||
*/
|
||||
export interface ReduxStateStoreOptions<
|
||||
S extends StateModel = StateModel,
|
||||
A extends Action = UnknownAction,
|
||||
> {
|
||||
/** The Redux store instance. */
|
||||
store: Store<S, A>;
|
||||
/**
|
||||
* Select the json-render state slice from the Redux state tree.
|
||||
* Defaults to `(state) => state` (the entire store is the state model).
|
||||
*/
|
||||
selector?: (state: S) => StateModel;
|
||||
/**
|
||||
* Dispatch a state change back to the Redux store.
|
||||
*
|
||||
* Called for every `set` / `update` with the full next state model.
|
||||
* You must dispatch an action that replaces the selected slice.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* dispatch: (nextState, reduxStore) =>
|
||||
* reduxStore.dispatch(replaceState(nextState))
|
||||
* ```
|
||||
*/
|
||||
dispatch: (nextState: StateModel, store: Store<S, A>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link StateStore} backed by a Redux store.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { configureStore, createSlice } from "@reduxjs/toolkit";
|
||||
* import { reduxStateStore } from "@json-render/redux";
|
||||
*
|
||||
* const uiSlice = createSlice({
|
||||
* name: "ui",
|
||||
* initialState: { count: 0 } as Record<string, unknown>,
|
||||
* reducers: {
|
||||
* replaceUiState: (_state, action) => action.payload,
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* const reduxStore = configureStore({
|
||||
* reducer: { ui: uiSlice.reducer },
|
||||
* });
|
||||
*
|
||||
* const store = reduxStateStore({
|
||||
* store: reduxStore,
|
||||
* selector: (state) => state.ui,
|
||||
* dispatch: (next, s) => s.dispatch(uiSlice.actions.replaceUiState(next)),
|
||||
* });
|
||||
*
|
||||
* <StateProvider store={store}>...</StateProvider>
|
||||
* ```
|
||||
*/
|
||||
export function reduxStateStore<
|
||||
S extends StateModel = StateModel,
|
||||
A extends Action = UnknownAction,
|
||||
>(options: ReduxStateStoreOptions<S, A>): StateStore {
|
||||
const { store, selector = (s: S) => s as StateModel, dispatch } = options;
|
||||
|
||||
return createStoreAdapter({
|
||||
getSnapshot: () => selector(store.getState()),
|
||||
setSnapshot: (next) => dispatch(next, store),
|
||||
subscribe(listener) {
|
||||
let prev = selector(store.getState());
|
||||
return store.subscribe(() => {
|
||||
const current = selector(store.getState());
|
||||
if (current !== prev) {
|
||||
prev = current;
|
||||
listener();
|
||||
// Re-read after listener in case it triggered a synchronous dispatch;
|
||||
// absorb that change so it doesn't fire a duplicate notification.
|
||||
prev = selector(store.getState());
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: [
|
||||
"@json-render/core",
|
||||
"@json-render/core/store-utils",
|
||||
"redux",
|
||||
"@reduxjs/toolkit",
|
||||
],
|
||||
});
|
||||
@@ -54,7 +54,7 @@
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"remotion": "4.0.418",
|
||||
"tsup": "^8.0.2",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@repo/typescript-config",
|
||||
"name": "@internal/typescript-config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { config } from "@repo/eslint-config/react-internal";
|
||||
import { config } from "@internal/eslint-config/react-internal";
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default config;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@repo/ui",
|
||||
"name": "@internal/ui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -12,8 +12,8 @@
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user