Compare commits

...
Author SHA1 Message Date
Chris Tate 5e1cd38f3c fix dashboard build 2026-02-23 23:08:11 -06:00
Chris Tate c1317edcbc update lockfile for widened react peer deps 2026-02-23 23:04:58 -06:00
Chris Tate e34b979feb fixes 2026-02-23 22:47:14 -06:00
Chris Tate eb59228e7a fixes 2026-02-23 10:55:21 -06:00
Chris Tate 87e6d07e3d fixes 2026-02-23 10:35:42 -06:00
Chris Tate 25d092d729 fixes 2026-02-23 10:12:50 -06:00
Chris Tate b8172cc13f improvements 2026-02-23 10:00:04 -06:00
Chris Tate 6ea45666f8 e2e tests 2026-02-23 09:47:56 -06:00
Chris Tate 18aa92279d fixes 2026-02-23 09:25:10 -06:00
Chris Tate f324463e8b fixes 2026-02-23 09:08:18 -06:00
Chris Tate 6aea5d76b2 fixes 2026-02-23 08:49:58 -06:00
Chris Tate 5421622395 fixes 2026-02-23 08:34:07 -06:00
Chris Tate 6746edf707 add store adapters 2026-02-23 02:09:54 -06:00
Chris Tate 294545b1b8 fix CI 2026-02-23 01:44:36 -06:00
Chris Tate b9dbdd2b51 fixes 2026-02-23 01:38:48 -06:00
Chris Tate 2e0d3987d0 improvements 2026-02-23 01:13:01 -06:00
Chris Tate b9887b8529 update docs 2026-02-23 01:01:34 -06:00
Chris Tate 9cf6b81ea2 improvements 2026-02-23 00:54:42 -06:00
Chris Tate 7ae05911a2 external store adapter for state management
Introduces a `StateStore` interface that lets users plug in their own state management (Redux, Zustand, XState, etc.) instead of being locked into the internal `useState`-based store.

- Added `StateStore` interface and `createStateStore()` factory to `@json-render/core`
- `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop for controlled mode
- When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored)
- When `store` is omitted, everything works exactly as before (fully backward compatible)
- Applied across all platform packages: react, react-native, react-pdf
2026-02-23 00:42:48 -06:00
101 changed files with 3493 additions and 763 deletions
+16
View File
@@ -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)
+3
View File
@@ -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`:
+26 -1
View File
@@ -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 -1
View File
@@ -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 [
+2 -2
View File
@@ -53,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",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"extends": "@internal/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [
{
+1 -1
View File
@@ -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 [
+1 -1
View File
@@ -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",
+21 -17
View File
@@ -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 -1
View File
@@ -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 [
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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 -1
View File
@@ -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 [
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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,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 [
+1 -1
View File
@@ -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;
}
+1
View File
@@ -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",
+1 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/base.json",
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
+57
View File
@@ -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 |
+6 -1
View File
@@ -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"
},
+9
View File
@@ -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 };
+5
View File
@@ -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";
+213
View File
@@ -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);
});
});
+231
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
export {
immutableSetByPath,
flattenToPointers,
createStoreAdapter,
} from "./state-store";
export type { StoreAdapterConfig } from "./state-store";
+31 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/react-library.json",
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
+1 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@repo/eslint-config",
"name": "@internal/eslint-config",
"version": "0.0.0",
"type": "module",
"private": true,
+63
View File
@@ -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>`. |
+54
View File
@@ -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"
}
}
+147
View File
@@ -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");
});
});
+62
View File
@@ -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),
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+10
View File
@@ -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"],
});
+21
View File
@@ -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` |
+4 -3
View File
@@ -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"
}
+8 -158
View File
@@ -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";
+2 -1
View File
@@ -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 {
+29 -8
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/react-library.json",
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
+13
View File
@@ -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:
+3 -2
View File
@@ -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");
});
});
+8 -117
View File
@@ -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 -1
View File
@@ -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 {
+17 -4
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/react-library.json",
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
+41
View File
@@ -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"
}
}
+9
View File
@@ -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 };
+332
View File
@@ -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);
});
});
+220
View File
@@ -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];
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+10
View File
@@ -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"],
});
+16
View File
@@ -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"],
},
});
+31
View File
@@ -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 |
+3 -2
View File
@@ -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"
+14 -146
View File
@@ -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");
});
});
+8 -158
View File
@@ -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";
+2 -1
View File
@@ -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 {
+29 -8
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/react-library.json",
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
+61
View File
@@ -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.
+64
View File
@@ -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"
}
}
+230
View File
@@ -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);
});
});
+88
View File
@@ -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());
}
});
},
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+15
View File
@@ -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",
],
});
+1 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/react-library.json",
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
+1 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/react-library.json",
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "@repo/typescript-config",
"name": "@internal/typescript-config",
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/react-library.json",
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist"
},
+63
View File
@@ -0,0 +1,63 @@
# @json-render/zustand
Zustand adapter for json-render's `StateStore` interface. Wire a Zustand vanilla store as the state backend for json-render.
## Installation
```bash
npm install @json-render/zustand @json-render/core @json-render/react zustand
```
> **Note:** This adapter requires Zustand v5+. Zustand v4 is not supported due to
> breaking API changes in the vanilla store interface (`createStore`, `StoreApi`).
## Usage
```ts
import { createStore } from "zustand/vanilla";
import { zustandStateStore } from "@json-render/zustand";
import { StateProvider } from "@json-render/react";
// 1. Create a Zustand vanilla store
const bearStore = createStore(() => ({
count: 0,
name: "Bear",
}));
// 2. Create the json-render StateStore adapter
const store = zustandStateStore({ store: bearStore });
// 3. Use it
<StateProvider store={store}>
{/* json-render reads/writes go through Zustand */}
</StateProvider>
```
### With a nested slice
```ts
const appStore = createStore(() => ({
ui: { count: 0 },
auth: { token: null },
}));
const store = zustandStateStore({
store: appStore,
selector: (s) => s.ui,
updater: (next, s) => s.setState({ ui: next }),
});
```
## API
### `zustandStateStore(options)`
Creates a `StateStore` backed by a Zustand store.
#### Options
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `store` | `StoreApi<S>` | Yes | A Zustand vanilla store (from `createStore` in `zustand/vanilla`) |
| `selector` | `(state) => StateModel` | No | Select the json-render slice from the store state. Defaults to the entire state. |
| `updater` | `(nextState, store) => void` | No | Apply the next state back to the Zustand store. Defaults to a shallow merge so that keys outside the json-render model are preserved. Override for nested slices, or pass `(next, s) => s.setState(next, true)` for full replacement. |
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@json-render/zustand",
"version": "0.8.0",
"license": "Apache-2.0",
"description": "Zustand adapter for json-render StateStore",
"keywords": [
"json-render",
"zustand",
"state-management",
"adapter"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/zustand"
},
"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": {
"zustand": ">=5.0.0"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"zustand": "^5.0.11"
}
}
+198
View File
@@ -0,0 +1,198 @@
import { describe, it, expect, vi } from "vitest";
import { createStore } from "zustand/vanilla";
import { zustandStateStore } from "./index";
describe("zustandStateStore", () => {
it("get/set round-trip", () => {
const zStore = createStore<Record<string, unknown>>()(() => ({ count: 0 }));
const store = zustandStateStore({ store: zStore });
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 zStore = createStore<Record<string, unknown>>()(() => ({}));
const store = zustandStateStore({ store: zStore });
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 zStore = createStore<Record<string, unknown>>()(() => ({}));
const store = zustandStateStore({ store: zStore });
const listener = vi.fn();
store.subscribe(listener);
store.set("/x", 1);
expect(listener).toHaveBeenCalledTimes(1);
});
it("subscribe fires on update", () => {
const zStore = createStore<Record<string, unknown>>()(() => ({}));
const store = zustandStateStore({ store: zStore });
const listener = vi.fn();
store.subscribe(listener);
store.update({ "/a": 1, "/b": 2 });
expect(listener).toHaveBeenCalledTimes(1);
});
it("unsubscribe stops notifications", () => {
const zStore = createStore<Record<string, unknown>>()(() => ({}));
const store = zustandStateStore({ store: zStore });
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 zStore = createStore<Record<string, unknown>>()(() => ({
user: { name: "Alice", age: 30 },
}));
const store = zustandStateStore({ store: zStore });
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 zStore = createStore<Record<string, unknown>>()(() => ({
a: { x: 1 },
b: { y: 2 },
}));
const store = zustandStateStore({ store: zStore });
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 zStore = createStore<Record<string, unknown>>()(() => ({ x: 1 }));
const store = zustandStateStore({ store: zStore });
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 zStore = createStore<Record<string, unknown>>()(() => ({ x: 1 }));
const store = zustandStateStore({ store: zStore });
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 zStore = createStore<Record<string, unknown>>()(() => ({
a: 1,
b: 2,
}));
const store = zustandStateStore({ store: zStore });
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("subscribe does NOT fire when unrelated slice changes", () => {
interface AppState extends Record<string, unknown> {
ui: Record<string, unknown>;
other: { value: string };
}
const zStore = createStore<AppState>()(() => ({
ui: { count: 0 },
other: { value: "a" },
}));
const store = zustandStateStore({
store: zStore,
selector: (s) => s.ui,
updater: (next, s) => s.setState({ ui: next }),
});
const listener = vi.fn();
store.subscribe(listener);
zStore.setState({ other: { value: "b" } });
expect(listener).not.toHaveBeenCalled();
expect(store.get("/count")).toBe(0);
});
it("default updater preserves keys outside the json-render model", () => {
interface AppState extends Record<string, unknown> {
count: number;
theme: string;
}
const zStore = createStore<AppState>()(() => ({
count: 0,
theme: "dark",
}));
const store = zustandStateStore({ store: zStore });
store.set("/count", 5);
expect(store.get("/count")).toBe(5);
expect(zStore.getState().theme).toBe("dark");
});
it("works with custom selector and updater", () => {
interface AppState extends Record<string, unknown> {
ui: Record<string, unknown>;
}
const zStore = createStore<AppState>()(() => ({
ui: { count: 0 },
}));
const store = zustandStateStore({
store: zStore,
selector: (s) => s.ui,
updater: (next, s) => s.setState({ ui: next }),
});
store.set("/count", 5);
expect(store.get("/count")).toBe(5);
expect(zStore.getState().ui.count).toBe(5);
});
});
+83
View File
@@ -0,0 +1,83 @@
import type { StateModel, StateStore } from "@json-render/core";
import { createStoreAdapter } from "@json-render/core/store-utils";
import type { StoreApi } from "zustand";
export type { StateStore } from "@json-render/core";
/**
* Options for {@link zustandStateStore}.
*/
export interface ZustandStateStoreOptions<S extends StateModel = StateModel> {
/** A Zustand vanilla store (created with `createStore` from `zustand/vanilla`). */
store: StoreApi<S>;
/**
* Select the json-render state slice from the Zustand store state.
* Defaults to `(state) => state` (the entire store is the state model).
*/
selector?: (state: S) => StateModel;
/**
* Apply a state change back to the Zustand store.
* Defaults to a shallow merge (`store.setState(next)`) so that keys
* outside the json-render model are preserved. Override this if you use
* a selector and only want to update a nested slice, or pass
* `(next, s) => s.setState(next as S, true)` for full replacement.
*/
updater?: (nextState: StateModel, store: StoreApi<S>) => void;
}
/**
* Create a {@link StateStore} backed by a Zustand store.
*
* @example
* ```ts
* import { createStore } from "zustand/vanilla";
* import { zustandStateStore } from "@json-render/zustand";
*
* const bearStore = createStore(() => ({ count: 0, name: "Bear" }));
*
* const store = zustandStateStore({ store: bearStore });
*
* <StateProvider store={store}>...</StateProvider>
* ```
*
* @example Using a selector for a nested slice:
* ```ts
* const appStore = createStore(() => ({
* ui: { count: 0 },
* auth: { token: null },
* }));
*
* const store = zustandStateStore({
* store: appStore,
* selector: (s) => s.ui,
* updater: (next, s) => s.setState({ ui: next }),
* });
* ```
*/
export function zustandStateStore<S extends StateModel = StateModel>(
options: ZustandStateStoreOptions<S>,
): StateStore {
const {
store,
selector = (s: S) => s as StateModel,
updater = (next, s) => s.setState(next as Partial<S>),
} = options;
return createStoreAdapter({
getSnapshot: () => selector(store.getState()),
setSnapshot: (next) => updater(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());
}
});
},
});
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+10
View File
@@ -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", "zustand"],
});
+303 -93
View File
@@ -168,10 +168,10 @@ importers:
specifier: ^4.0.0
version: 4.3.5
devDependencies:
'@repo/eslint-config':
'@internal/eslint-config':
specifier: workspace:*
version: link:../../packages/eslint-config
'@repo/typescript-config':
'@internal/typescript-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@tailwindcss/postcss':
@@ -224,10 +224,10 @@ importers:
version: link:../../packages/shadcn
'@react-three/drei':
specifier: ^10.7.7
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0))(@types/react@19.2.3)(@types/three@0.182.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0)
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0))(@types/react@19.2.3)(@types/three@0.182.0)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0)
'@react-three/fiber':
specifier: ^9.5.0
version: 9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)
version: 9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)
'@streamdown/code':
specifier: ^1.0.2
version: 1.0.2(react@19.2.4)
@@ -277,7 +277,7 @@ importers:
specifier: 4.3.5
version: 4.3.5
devDependencies:
'@repo/eslint-config':
'@internal/eslint-config':
specifier: workspace:*
version: link:../../packages/eslint-config
'@tailwindcss/postcss':
@@ -386,7 +386,7 @@ importers:
specifier: ^4.0.0
version: 4.3.5
devDependencies:
'@repo/eslint-config':
'@internal/eslint-config':
specifier: workspace:*
version: link:../../packages/eslint-config
'@tailwindcss/postcss':
@@ -468,7 +468,7 @@ importers:
specifier: 4.3.5
version: 4.3.5
devDependencies:
'@repo/eslint-config':
'@internal/eslint-config':
specifier: workspace:*
version: link:../../packages/eslint-config
'@tailwindcss/postcss':
@@ -611,7 +611,7 @@ importers:
specifier: 4.3.5
version: 4.3.5
devDependencies:
'@repo/typescript-config':
'@internal/typescript-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
@@ -687,7 +687,7 @@ importers:
specifier: ^4.0.0
version: 4.3.5
devDependencies:
'@repo/eslint-config':
'@internal/eslint-config':
specifier: workspace:*
version: link:../../packages/eslint-config
'@tailwindcss/postcss':
@@ -758,7 +758,7 @@ importers:
specifier: ^4.0.0
version: 4.3.5
devDependencies:
'@repo/eslint-config':
'@internal/eslint-config':
specifier: workspace:*
version: link:../../../packages/eslint-config
'@stripe/ui-extension-tools':
@@ -786,7 +786,7 @@ importers:
specifier: ^4.0.0
version: 4.3.5
devDependencies:
'@repo/eslint-config':
'@internal/eslint-config':
specifier: workspace:*
version: link:../../../packages/eslint-config
'@stripe/ui-extension-tools':
@@ -802,7 +802,7 @@ importers:
specifier: workspace:*
version: link:../core
devDependencies:
'@repo/typescript-config':
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
tsup:
@@ -818,7 +818,7 @@ importers:
specifier: ^4.0.0
version: 4.3.5
devDependencies:
'@repo/typescript-config':
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
tsup:
@@ -864,8 +864,30 @@ importers:
specifier: ^8.50.0
version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2)
packages/jotai:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../core
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
jotai:
specifier: ^2.18.0
version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.3)(react@19.2.4)
tsup:
specifier: ^8.0.2
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
typescript:
specifier: ^5.4.5
version: 5.9.2
packages/react:
dependencies:
'@internal/react-state':
specifier: workspace:*
version: link:../react-state
'@json-render/core':
specifier: workspace:*
version: link:../core
@@ -873,7 +895,7 @@ importers:
specifier: ^19.2.3
version: 19.2.3
devDependencies:
'@repo/typescript-config':
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/react':
@@ -888,14 +910,17 @@ importers:
packages/react-native:
dependencies:
'@internal/react-state':
specifier: workspace:*
version: link:../react-state
'@json-render/core':
specifier: workspace:*
version: link:../core
react:
specifier: ^19.0.0
version: 19.2.3
specifier: ^18.0.0 || ^19.0.0
version: 19.2.4
devDependencies:
'@repo/typescript-config':
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/react':
@@ -903,7 +928,7 @@ importers:
version: 19.2.3
react-native:
specifier: 0.83.1
version: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.3)
version: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
tsup:
specifier: ^8.0.2
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
@@ -916,6 +941,9 @@ importers:
packages/react-pdf:
dependencies:
'@internal/react-state':
specifier: workspace:*
version: link:../react-state
'@json-render/core':
specifier: workspace:*
version: link:../core
@@ -923,10 +951,10 @@ importers:
specifier: ^4.3.2
version: 4.3.2(react@19.2.4)
react:
specifier: ^19.0.0
specifier: ^18.0.0 || ^19.0.0
version: 19.2.4
devDependencies:
'@repo/typescript-config':
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/react':
@@ -942,6 +970,56 @@ importers:
specifier: ^4.0.0
version: 4.3.5
packages/react-state:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../core
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: 19.2.3
version: 19.2.3
react:
specifier: ^19.0.0
version: 19.2.4
react-dom:
specifier: ^19.0.0
version: 19.2.4(react@19.2.4)
tsup:
specifier: ^8.0.2
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
typescript:
specifier: ^5.4.5
version: 5.9.2
packages/redux:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../core
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@reduxjs/toolkit':
specifier: ^2.11.2
version: 2.11.2(react@19.2.4)
redux:
specifier: ^5.0.1
version: 5.0.1
tsup:
specifier: ^8.0.2
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
typescript:
specifier: ^5.4.5
version: 5.9.2
packages/remotion:
dependencies:
'@json-render/core':
@@ -951,7 +1029,7 @@ importers:
specifier: ^19.2.3
version: 19.2.3
devDependencies:
'@repo/typescript-config':
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/react':
@@ -1009,7 +1087,7 @@ importers:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
devDependencies:
'@repo/typescript-config':
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/react':
@@ -1036,10 +1114,10 @@ importers:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
devDependencies:
'@repo/eslint-config':
'@internal/eslint-config':
specifier: workspace:*
version: link:../eslint-config
'@repo/typescript-config':
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/node':
@@ -1058,6 +1136,67 @@ importers:
specifier: 5.9.2
version: 5.9.2
packages/zustand:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../core
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
tsup:
specifier: ^8.0.2
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
typescript:
specifier: ^5.4.5
version: 5.9.2
zustand:
specifier: ^5.0.11
version: 5.0.11(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
tests/e2e:
devDependencies:
'@ai-sdk/gateway':
specifier: ^3.0.53
version: 3.0.53(zod@4.3.5)
'@json-render/core':
specifier: workspace:*
version: link:../../packages/core
'@json-render/jotai':
specifier: workspace:*
version: link:../../packages/jotai
'@json-render/react':
specifier: workspace:*
version: link:../../packages/react
'@json-render/redux':
specifier: workspace:*
version: link:../../packages/redux
'@json-render/zustand':
specifier: workspace:*
version: link:../../packages/zustand
'@reduxjs/toolkit':
specifier: ^2.11.2
version: 2.11.2(react@19.2.4)
ai:
specifier: ^6.0.97
version: 6.0.97(zod@4.3.5)
jotai:
specifier: ^2.18.0
version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.3)(react@19.2.4)
redux:
specifier: ^5.0.1
version: 5.0.1
vitest:
specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.2))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
zod:
specifier: ^4.3.5
version: 4.3.5
zustand:
specifier: ^5.0.11
version: 5.0.11(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
packages:
'@0no-co/graphql.web@1.2.0':
@@ -1107,6 +1246,12 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.53':
resolution: {integrity: sha512-QT3FEoNARMRlk8JJVR7L98exiK9C8AGfrEJVbRxBT1yIXKs/N19o/+PsjTRVsARgDJNcy9JbJp1FspKucEat0Q==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.13':
resolution: {integrity: sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og==}
engines: {node: '>=18'}
@@ -4175,6 +4320,17 @@ packages:
react-native:
optional: true
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@remote-ui/async-subscription@2.1.18':
resolution: {integrity: sha512-O+76qOiTGN6iJZBFncELNIBnhCbHAIYrsrG0n3SjmwXE9Gy6nl4xZoa5FqIcw5VPz7EAdwKBkFsRE5uyQB7FOw==}
@@ -4484,6 +4640,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@streamdown/code@1.0.2':
resolution: {integrity: sha512-QKLS3sC8no5y0YvhGLA+ZjtNhznWU09IvFcjRKgSA35ulckMLw3b5T1ha+o1DaW8BS8l0zceLPFZa3/X9+agWQ==}
peerDependencies:
@@ -4616,6 +4775,21 @@ packages:
'@types/react-dom':
optional: true
'@testing-library/react@16.3.2':
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
engines: {node: '>=18'}
peerDependencies:
'@testing-library/dom': ^10.0.0
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
@@ -5195,6 +5369,12 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ai@6.0.97:
resolution: {integrity: sha512-eZIAcBymwGhBwncRH/v9pillZNMeRCDkc4BwcvwXerXd7sxjVxRis3ZNCNCpP02pVH4NLs81ljm4cElC4vbNcQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -7278,6 +7458,9 @@ packages:
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -7811,6 +7994,24 @@ packages:
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
jotai@2.18.0:
resolution: {integrity: sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@babel/core': '>=7.0.0'
'@babel/template': '>=7.0.0'
'@types/react': '>=17.0.0'
react: '>=17.0.0'
peerDependenciesMeta:
'@babel/core':
optional: true
'@babel/template':
optional: true
'@types/react':
optional: true
react:
optional: true
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
@@ -9370,6 +9571,14 @@ packages:
recma-stringify@1.0.0:
resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -9462,6 +9671,9 @@ packages:
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-cwd@3.0.0:
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
engines: {node: '>=8'}
@@ -11058,6 +11270,13 @@ snapshots:
'@vercel/oidc': 3.1.0
zod: 4.3.5
'@ai-sdk/gateway@3.0.53(zod@4.3.5)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
'@vercel/oidc': 3.1.0
zod: 4.3.5
'@ai-sdk/provider-utils@4.0.13(zod@4.3.5)':
dependencies:
'@ai-sdk/provider': 3.0.7
@@ -15374,15 +15593,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
'@react-native/virtualized-lists@0.83.1(@types/react@19.2.3)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.3))(react@19.2.3)':
dependencies:
invariant: 2.2.4
nullthrows: 1.1.1
react: 19.2.3
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.3
'@react-native/virtualized-lists@0.83.1(@types/react@19.2.3)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)':
dependencies:
invariant: 2.2.4
@@ -15391,7 +15601,6 @@ snapshots:
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.3
optional: true
'@react-navigation/bottom-tabs@7.12.0(@react-navigation/native@7.1.28(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.4.0(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.11.1(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
@@ -15621,12 +15830,12 @@ snapshots:
'@react-pdf/primitives': 4.1.1
'@react-pdf/stylesheet': 6.1.2
'@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0))(@types/react@19.2.3)(@types/three@0.182.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0)':
'@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0))(@types/react@19.2.3)(@types/three@0.182.0)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0)':
dependencies:
'@babel/runtime': 7.28.6
'@mediapipe/tasks-vision': 0.10.17
'@monogrid/gainmap-js': 3.4.0(three@0.182.0)
'@react-three/fiber': 9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)
'@react-three/fiber': 9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)
'@use-gesture/react': 10.3.1(react@19.2.4)
camera-controls: 3.1.2(three@0.182.0)
cross-env: 7.0.3
@@ -15643,10 +15852,10 @@ snapshots:
three-mesh-bvh: 0.8.3(three@0.182.0)
three-stdlib: 2.36.1(three@0.182.0)
troika-three-text: 0.52.4(three@0.182.0)
tunnel-rat: 0.1.2(@types/react@19.2.3)(react@19.2.4)
tunnel-rat: 0.1.2(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
utility-types: 3.11.0
zustand: 5.0.11(@types/react@19.2.3)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
zustand: 5.0.11(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
optionalDependencies:
react-dom: 19.2.4(react@19.2.4)
transitivePeerDependencies:
@@ -15654,7 +15863,7 @@ snapshots:
- '@types/three'
- immer
'@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)':
'@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)':
dependencies:
'@babel/runtime': 7.28.6
'@types/webxr': 0.5.24
@@ -15667,7 +15876,7 @@ snapshots:
suspend-react: 0.1.3(react@19.2.4)
three: 0.182.0
use-sync-external-store: 1.6.0(react@19.2.4)
zustand: 5.0.11(@types/react@19.2.3)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
zustand: 5.0.11(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
optionalDependencies:
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
expo-asset: 12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
@@ -15678,6 +15887,17 @@ snapshots:
- '@types/react'
- immer
'@reduxjs/toolkit@2.11.2(react@19.2.4)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.4
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.4
'@remote-ui/async-subscription@2.1.18':
dependencies:
'@remote-ui/rpc': 1.4.7
@@ -15980,6 +16200,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@streamdown/code@1.0.2(react@19.2.4)':
dependencies:
react: 19.2.4
@@ -16126,6 +16348,16 @@ snapshots:
'@types/react': 19.2.3
'@types/react-dom': 19.2.3(@types/react@19.2.3)
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@testing-library/dom': 10.4.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.3
'@types/react-dom': 19.2.3(@types/react@19.2.3)
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
@@ -16790,6 +17022,14 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 4.3.5
ai@6.0.97(zod@4.3.5):
dependencies:
'@ai-sdk/gateway': 3.0.53(zod@4.3.5)
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
'@opentelemetry/api': 1.9.0
zod: 4.3.5
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -19360,6 +19600,8 @@ snapshots:
immediate@3.0.6: {}
immer@11.1.4: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -20135,6 +20377,13 @@ snapshots:
jose@6.1.3: {}
jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.3)(react@19.2.4):
optionalDependencies:
'@babel/core': 7.29.0
'@babel/template': 7.28.6
'@types/react': 19.2.3
react: 19.2.4
joycon@3.1.1: {}
js-tokens@4.0.0: {}
@@ -22205,54 +22454,6 @@ snapshots:
- supports-color
- utf-8-validate
react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.3):
dependencies:
'@jest/create-cache-key-function': 29.7.0
'@react-native/assets-registry': 0.83.1
'@react-native/codegen': 0.83.1(@babel/core@7.29.0)
'@react-native/community-cli-plugin': 0.83.1
'@react-native/gradle-plugin': 0.83.1
'@react-native/js-polyfills': 0.83.1
'@react-native/normalize-colors': 0.83.1
'@react-native/virtualized-lists': 0.83.1(@types/react@19.2.3)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.3))(react@19.2.3)
abort-controller: 3.0.0
anser: 1.4.10
ansi-regex: 5.0.1
babel-jest: 29.7.0(@babel/core@7.29.0)
babel-plugin-syntax-hermes-parser: 0.32.0
base64-js: 1.5.1
commander: 12.1.0
flow-enums-runtime: 0.0.6
glob: 7.2.3
hermes-compiler: 0.14.0
invariant: 2.2.4
jest-environment-node: 29.7.0
memoize-one: 5.2.1
metro-runtime: 0.83.3
metro-source-map: 0.83.3
nullthrows: 1.1.1
pretty-format: 29.7.0
promise: 8.3.0
react: 19.2.3
react-devtools-core: 6.1.5
react-refresh: 0.14.2
regenerator-runtime: 0.13.11
scheduler: 0.27.0
semver: 7.7.3
stacktrace-parser: 0.1.11
whatwg-fetch: 3.6.20
ws: 7.5.10
yargs: 17.7.2
optionalDependencies:
'@types/react': 19.2.3
transitivePeerDependencies:
- '@babel/core'
- '@react-native-community/cli'
- '@react-native/metro-config'
- bufferutil
- supports-color
- utf-8-validate
react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4):
dependencies:
'@jest/create-cache-key-function': 29.7.0
@@ -22300,7 +22501,6 @@ snapshots:
- bufferutil
- supports-color
- utf-8-validate
optional: true
react-reconciler@0.29.0(react@18.3.1):
dependencies:
@@ -22536,6 +22736,12 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -22679,6 +22885,8 @@ snapshots:
requires-port@1.0.0: {}
reselect@5.1.1: {}
resolve-cwd@3.0.0:
dependencies:
resolve-from: 5.0.0
@@ -23703,9 +23911,9 @@ snapshots:
safe-buffer: 5.2.1
optional: true
tunnel-rat@0.1.2(@types/react@19.2.3)(react@19.2.4):
tunnel-rat@0.1.2(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4):
dependencies:
zustand: 4.5.7(@types/react@19.2.3)(react@19.2.4)
zustand: 4.5.7(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
- immer
@@ -24456,16 +24664,18 @@ snapshots:
zod@4.3.5: {}
zustand@4.5.7(@types/react@19.2.3)(react@19.2.4):
zustand@4.5.7(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.3
immer: 11.1.4
react: 19.2.4
zustand@5.0.11(@types/react@19.2.3)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
zustand@5.0.11(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
optionalDependencies:
'@types/react': 19.2.3
immer: 11.1.4
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
+1
View File
@@ -3,3 +3,4 @@ packages:
- "examples/*"
- "examples/stripe-app/*"
- "packages/*"
- "tests/*"
+22
View File
@@ -171,12 +171,33 @@ const schema = defineSchema(builder, {
These appear in prompts as `[built-in]` and don't require handlers in `defineRegistry`.
## StateStore
The `StateStore` interface allows external state management libraries (Redux, Zustand, XState, etc.) to be plugged into json-render renderers. The `createStateStore` factory creates a simple in-memory implementation:
```typescript
import { createStateStore, type StateStore } from "@json-render/core";
const store = createStateStore({ count: 0 });
store.get("/count"); // 0
store.set("/count", 1); // updates and notifies subscribers
store.update({ "/a": 1, "/b": 2 }); // batch update
store.subscribe(() => {
console.log(store.getSnapshot()); // { count: 1 }
});
```
The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`, `getSnapshot()`, `subscribe(listener)`.
## Key Exports
| Export | Purpose |
|--------|---------|
| `defineSchema` | Create a new schema |
| `defineCatalog` | Create a catalog from schema |
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
| `resolvePropValue` | Resolve a single prop expression against data |
| `resolveElementProps` | Resolve all prop expressions in an element |
| `buildUserPrompt` | Build user prompts with refinement and state context |
@@ -186,5 +207,6 @@ These appear in prompts as `[built-in]` and don't require handlers in `defineReg
| `createJsonRenderTransform` | TransformStream separating text from JSONL in mixed streams |
| `parseSpecStreamLine` | Parse single JSONL line |
| `applySpecStreamPatch` | Apply patch to object |
| `StateStore` | Interface for plugging in external state management |
| `BuiltInAction` | Type for built-in action definitions (`name` + `description`) |
| `ActionBinding` | Action binding type (includes `preventDefault` field) |
+19 -1
View File
@@ -143,11 +143,27 @@ The `setState` action is handled automatically by `ActionProvider` and updates t
| Provider | Purpose |
|----------|---------|
| `StateProvider` | Share state across components (JSON Pointer paths) |
| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. |
| `ActionProvider` | Handle actions dispatched from components |
| `VisibilityProvider` | Enable conditional rendering based on state |
| `ValidationProvider` | Form field validation |
### External Store (Controlled Mode)
Pass a `StateStore` to `StateProvider` (or `JSONUIProvider` / `createRenderer`) to use external state management:
```tsx
import { createStateStore, type StateStore } from "@json-render/react-native";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>{children}</StateProvider>
store.set("/count", 1); // React re-renders automatically
```
When `store` is provided, `initialState` and `onStateChange` are ignored.
## Key Exports
| Export | Purpose |
@@ -166,3 +182,5 @@ The `setState` action is handled automatically by `ActionProvider` and updates t
| `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` |
| `StateStore` | Interface for plugging in external state management |
+20 -1
View File
@@ -88,11 +88,28 @@ Use `visible` on elements to show/hide based on state. New syntax: `{ "$state":
| Provider | Purpose |
|----------|---------|
| `StateProvider` | Share state across components (JSON Pointer paths) |
| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. |
| `ActionProvider` | Handle actions dispatched via the event system |
| `VisibilityProvider` | Enable conditional rendering based on state |
| `ValidationProvider` | Form field validation |
### External Store (Controlled Mode)
Pass a `StateStore` to `StateProvider` (or `JSONUIProvider` / `createRenderer`) to use external state management (Redux, Zustand, XState, etc.):
```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);
```
When `store` is provided, `initialState` and `onStateChange` are ignored.
## Dynamic Prop Expressions
Any prop value can be a data-driven expression resolved by the renderer before components receive props:
@@ -212,6 +229,8 @@ const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
| `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` |
| `StateStore` | Interface for plugging in external state management |
| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries |
| `EventHandle` | Event handle type (`emit`, `shouldPreventDefault`, `bound`) |
| `ComponentContext` | Typed component context (catalog-aware) |
+22
View File
@@ -0,0 +1,22 @@
{
"name": "e2e-tests",
"private": true,
"scripts": {
"test": "vitest run --config vitest.config.ts"
},
"devDependencies": {
"@ai-sdk/gateway": "^3.0.53",
"@json-render/core": "workspace:*",
"@json-render/jotai": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/redux": "workspace:*",
"@json-render/zustand": "workspace:*",
"@reduxjs/toolkit": "^2.11.2",
"ai": "^6.0.97",
"jotai": "^2.18.0",
"redux": "^5.0.1",
"vitest": "^4.0.17",
"zod": "^4.3.5",
"zustand": "^5.0.11"
}
}
+221
View File
@@ -0,0 +1,221 @@
import { describe, it, expect } from "vitest";
import { streamText } from "ai";
import { z } from "zod";
import {
defineCatalog,
buildUserPrompt,
createStateStore,
createSpecStreamCompiler,
type Spec,
type StateStore,
} from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { reduxStateStore } from "@json-render/redux";
import { zustandStateStore } from "@json-render/zustand";
import { jotaiStateStore } from "@json-render/jotai";
import { configureStore, createSlice } from "@reduxjs/toolkit";
import { createStore as createZustandStore } from "zustand/vanilla";
import { atom } from "jotai";
import { createStore as createJotaiStore } from "jotai/vanilla";
const HAS_API_KEY = !!process.env.AI_GATEWAY_API_KEY;
const catalog = defineCatalog(schema, {
components: {
Text: {
props: z.object({ content: z.string() }),
description: "Text content",
},
Input: {
props: z.object({
label: z.string().nullable(),
value: z.string().nullable(),
placeholder: z.string().nullable(),
}),
description:
"Text input. Use value with $bindState for two-way state binding.",
example: {
label: "Name",
value: { $bindState: "/form/name" },
placeholder: "Enter name",
},
},
Button: {
props: z.object({
label: z.string(),
action: z.string().nullable(),
}),
description: "Clickable button",
},
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).nullable(),
}),
slots: ["default"],
description: "Layout container",
},
},
actions: {},
});
async function generateSpec(): Promise<Spec> {
const prompt = buildUserPrompt({
prompt:
"Create a simple form with two text inputs bound to state: one for /form/name and one for /form/email. Include initial state with name set to 'Alice' and email set to 'alice@example.com'. Use a vertical Stack as the container.",
});
const result = streamText({
model: "anthropic/claude-haiku-4.5",
system: catalog.prompt(),
prompt,
temperature: 0,
});
const compiler = createSpecStreamCompiler<Spec>();
for await (const chunk of result.textStream) {
compiler.push(chunk);
}
return compiler.getResult();
}
function extractStatePaths(spec: Spec): string[] {
const paths: string[] = [];
function walk(obj: unknown) {
if (obj === null || obj === undefined) return;
if (typeof obj !== "object") return;
if (Array.isArray(obj)) {
for (const item of obj) walk(item);
return;
}
const record = obj as Record<string, unknown>;
if ("$bindState" in record && typeof record.$bindState === "string") {
paths.push(record.$bindState);
}
if ("$state" in record && typeof record.$state === "string") {
paths.push(record.$state);
}
for (const value of Object.values(record)) {
walk(value);
}
}
if (spec.elements) walk(spec.elements);
return [...new Set(paths)];
}
function flattenState(
obj: Record<string, unknown>,
prefix = "",
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const pointer = `${prefix}/${key}`;
if (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
) {
Object.assign(
result,
flattenState(value as Record<string, unknown>, pointer),
);
} else {
result[pointer] = value;
}
}
return result;
}
function verifyStoreRoundTrip(store: StateStore, spec: Spec) {
if (spec.state) {
const flat = flattenState(spec.state);
store.update(flat);
}
const paths = extractStatePaths(spec);
expect(paths.length).toBeGreaterThan(0);
for (const path of paths) {
const original = store.get(path);
const testValue = `test-${Date.now()}`;
store.set(path, testValue);
expect(store.get(path)).toBe(testValue);
store.set(path, original);
expect(store.get(path)).toBe(original);
}
const batchUpdate: Record<string, unknown> = {};
for (const path of paths) {
batchUpdate[path] = `batch-${Date.now()}`;
}
store.update(batchUpdate);
for (const path of paths) {
expect(store.get(path)).toBe(batchUpdate[path]);
}
}
describe.skipIf(!HAS_API_KEY)(
"e2e: Claude Haiku 4.5 + StateStore adapters",
() => {
let spec: Spec;
it("generates a valid spec with state bindings from Claude Haiku 4.5", async () => {
spec = await generateSpec();
expect(spec.root).toBeTruthy();
expect(spec.elements).toBeTruthy();
expect(Object.keys(spec.elements).length).toBeGreaterThan(0);
const paths = extractStatePaths(spec);
expect(paths.length).toBeGreaterThan(0);
});
it("works with createStateStore (built-in)", () => {
const store = createStateStore(spec.state ?? {});
verifyStoreRoundTrip(store, spec);
});
it("works with reduxStateStore (Redux Toolkit)", () => {
const uiSlice = createSlice({
name: "ui",
initialState: (spec.state ?? {}) 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)),
});
verifyStoreRoundTrip(store, spec);
});
it("works with zustandStateStore (Zustand)", () => {
const zStore = createZustandStore<Record<string, unknown>>()(
() => spec.state ?? {},
);
const store = zustandStateStore({ store: zStore });
verifyStoreRoundTrip(store, spec);
});
it("works with jotaiStateStore (Jotai)", () => {
const stateAtom = atom<Record<string, unknown>>(spec.state ?? {});
const jStore = createJotaiStore();
const store = jotaiStateStore({ atom: stateAtom, store: jStore });
verifyStoreRoundTrip(store, spec);
});
},
);

Some files were not shown because too many files have changed in this diff Show More