瀏覽代碼

feat: Add `@xstate/store` (atom) support (#157)

* feat: add @xstate/store integration using atoms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add README for @json-render/xstate-store

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: rename xstateStoreStateStore to xstateStore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* README updates

* Keep naming convention

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
David Khourshid 4 月之前
父節點
當前提交
6bcaaad57d

+ 43 - 0
packages/xstate-store/README.md

@@ -0,0 +1,43 @@
+# @json-render/xstate-store
+
+[XState Store](https://stately.ai/docs/xstate-store) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for json-render.
+
+## Installation
+
+```bash
+npm install @json-render/xstate-store @json-render/core @json-render/react @xstate/store
+```
+
+> [!NOTE]
+> This adapter requires `@xstate/store` v3+.
+
+## Usage
+
+```ts
+import { createAtom } from "@xstate/store";
+import { xstateStoreStateStore } from "@json-render/xstate-store";
+import { StateProvider } from "@json-render/react";
+
+// 1. Create an atom
+const uiAtom = createAtom({ count: 0 });
+
+// 2. Create the json-render StateStore adapter
+const store = xstateStoreStateStore({ atom: uiAtom });
+
+// 3. Use it
+<StateProvider store={store}>
+  {/* json-render reads/writes go through @xstate/store */}
+</StateProvider>
+```
+
+## API
+
+### `xstateStoreStateStore(options)`
+
+Creates a `StateStore` backed by an `@xstate/store` atom.
+
+#### Options
+
+| Option | Type | Required | Description |
+|--------|------|----------|-------------|
+| `atom` | `Atom<StateModel>` | Yes | An `@xstate/store` atom (from `createAtom`) holding the json-render state model |

+ 55 - 0
packages/xstate-store/package.json

@@ -0,0 +1,55 @@
+{
+  "name": "@json-render/xstate-store",
+  "version": "0.0.0",
+  "license": "Apache-2.0",
+  "description": "XState Store adapter for json-render StateStore",
+  "keywords": [
+    "json-render",
+    "xstate",
+    "xstate-store",
+    "state-management",
+    "adapter"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/xstate-store"
+  },
+  "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": {
+    "@xstate/store": ">=3.0.0"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5",
+    "@xstate/store": "^3.0.0"
+  }
+}

+ 136 - 0
packages/xstate-store/src/index.test.ts

@@ -0,0 +1,136 @@
+import { describe, it, expect, vi } from "vitest";
+import { createAtom } from "@xstate/store";
+import { xstateStoreStateStore } from "./index";
+
+function createTestStore(initial: Record<string, unknown> = {}) {
+  const atom = createAtom<Record<string, unknown>>(initial);
+  const store = xstateStoreStateStore({ atom });
+  return { atom, store };
+}
+
+describe("xstateStoreStateStore", () => {
+  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 atom", () => {
+    const atom = createAtom<Record<string, unknown>>({ count: 0 });
+    const store = xstateStoreStateStore({ atom });
+
+    atom.set({ count: 99 });
+
+    expect(store.get("/count")).toBe(99);
+    expect(store.getSnapshot().count).toBe(99);
+  });
+});

+ 45 - 0
packages/xstate-store/src/index.ts

@@ -0,0 +1,45 @@
+import type { StateModel, StateStore } from "@json-render/core";
+import { createStoreAdapter } from "@json-render/core/store-utils";
+import type { Atom } from "@xstate/store";
+
+export type { StateStore } from "@json-render/core";
+
+/**
+ * Options for {@link xstateStoreStateStore}.
+ */
+export interface XstateStoreStateStoreOptions {
+  /** An `@xstate/store` atom (created with `createAtom`). */
+  atom: Atom<StateModel>;
+}
+
+/**
+ * Create a {@link StateStore} backed by an `@xstate/store` atom.
+ *
+ * @example
+ * ```ts
+ * import { createAtom } from "@xstate/store";
+ * import { xstateStoreStateStore } from "@json-render/xstate-store";
+ *
+ * const uiAtom = createAtom<Record<string, unknown>>({ count: 0 });
+ *
+ * const store = xstateStoreStateStore({ atom: uiAtom });
+ *
+ * <StateProvider store={store}>...</StateProvider>
+ * ```
+ */
+export function xstateStoreStateStore(
+  options: XstateStoreStateStoreOptions,
+): StateStore {
+  const { atom } = options;
+
+  return createStoreAdapter({
+    getSnapshot: () => atom.get(),
+    setSnapshot: (next) => atom.set(next),
+    subscribe(listener) {
+      const sub = atom.subscribe(() => {
+        listener();
+      });
+      return () => sub.unsubscribe();
+    },
+  });
+}

+ 9 - 0
packages/xstate-store/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@internal/typescript-config/base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 14 - 0
packages/xstate-store/tsup.config.ts

@@ -0,0 +1,14 @@
+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",
+    "@xstate/store",
+  ],
+});

File diff suppressed because it is too large
+ 169 - 150
pnpm-lock.yaml


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