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