From 23b250ad40fec0c812c8c0c160bf83ff6e972b9f Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 22:54:56 +0300 Subject: [PATCH 1/9] feat(sources): add pinia persistStore adapter --- .changeset/pinia-source-adapter.md | 5 + README.md | 2 +- apps/docs/content/concepts/entry-points.mdx | 1 + apps/docs/content/guides/migrating.mdx | 34 ++----- bun.lock | 27 ++++++ docs/architecture.md | 3 +- package.json | 10 ++ src/adapters/sources/pinia.test.ts | 100 ++++++++++++++++++++ src/adapters/sources/pinia.ts | 43 +++++++++ tsdown.config.ts | 2 + typedoc.json | 1 + 11 files changed, 202 insertions(+), 26 deletions(-) create mode 100644 .changeset/pinia-source-adapter.md create mode 100644 src/adapters/sources/pinia.test.ts create mode 100644 src/adapters/sources/pinia.ts diff --git a/.changeset/pinia-source-adapter.md b/.changeset/pinia-source-adapter.md new file mode 100644 index 0000000..a57afeb --- /dev/null +++ b/.changeset/pinia-source-adapter.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": minor +--- + +Add first-party Pinia source adapter (`./sources/pinia`) with shape-named `persistStore` over `persistSource`. diff --git a/README.md b/README.md index ef8e985..1b65996 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Any store, any storage, one middleware — no flash.** -Hydration-aware persistence for any reactive store — no hydrate flash, no SSR mismatch. Store-agnostic via a structural `PersistableSource` (TanStack Store, zustand, jotai, valtio, mobx, or a hand-rolled atom); three composable seams (backend × codec × source) so you swap storage, serialization, or framework without rewriting. A first-class hydration signal gates UI on async backends; opt-in cross-tab sync, versioned migrations, encrypted/compressed backends, and retry-on-quota. Framework adapters for React, Solid, Vue, Svelte, Angular, and Preact. +Hydration-aware persistence for any reactive store — no hydrate flash, no SSR mismatch. Store-agnostic via a structural `PersistableSource` (TanStack Store, zustand, jotai, valtio, mobx, pinia, or a hand-rolled atom); three composable seams (backend × codec × source) so you swap storage, serialization, or framework without rewriting. A first-class hydration signal gates UI on async backends; opt-in cross-tab sync, versioned migrations, encrypted/compressed backends, and retry-on-quota. Framework adapters for React, Solid, Vue, Svelte, Angular, and Preact. [![core size](https://img.shields.io/size-limit/label/gzip/.size-limit.json/core/stainless-code/persist)](https://github.com/stainless-code/persist/blob/main/.size-limit.json) diff --git a/apps/docs/content/concepts/entry-points.mdx b/apps/docs/content/concepts/entry-points.mdx index 05c62b2..7c774cb 100644 --- a/apps/docs/content/concepts/entry-points.mdx +++ b/apps/docs/content/concepts/entry-points.mdx @@ -25,6 +25,7 @@ One subpath = one optional peer. No barrel — importing a subpath is the depend | `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` | | `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | | `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | +| `@stainless-code/persist/sources/pinia` | `persistStore` | `pinia` | | `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | | `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | | `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref`) | `vue` | diff --git a/apps/docs/content/guides/migrating.mdx b/apps/docs/content/guides/migrating.mdx index 16a2f08..ec37031 100644 --- a/apps/docs/content/guides/migrating.mdx +++ b/apps/docs/content/guides/migrating.mdx @@ -110,7 +110,7 @@ export const rootHydration = toHydrationSignal(persist); ## Migrating from pinia-persist -pinia-persist is a Pinia plugin; here persistence is a middleware call on any reactive source — same storage, explicit partialization, optional codec. Wrap the Pinia store's `$state` + `$subscribe` in a `PersistableSource`. +pinia-persist is a Pinia plugin; here persistence is a call-site middleware — same storage, explicit partialization, optional codec. Use first-party `persistStore` from `@stainless-code/persist/sources/pinia`. | pinia-persist | `@stainless-code/persist` | | -------------------------------- | ------------------------------------------------------------------------------------- | @@ -120,7 +120,7 @@ pinia-persist is a Pinia plugin; here persistence is a middleware call on any re | `serializer` | custom `StorageCodec` or default `jsonCodec` via `createStorage` | | `beforeHydrate` / `afterHydrate` | `onRehydrateStorage` | | `debug` | `onError` | -| `pinia.use(plugin)` | `persistSource(piniaSource, opts)` | +| `pinia.use(plugin)` | `persistStore(store, opts)` from `./sources/pinia` | | — | `toHydrationSignal` + framework adapter (no equivalent) | | — | `crossTab`, `migrate`, `maxAge`, `buster`, `throttleMs`, `retryWrite` (no equivalent) | @@ -136,33 +136,19 @@ export const usePrefsStore = defineStore("prefs", { persist: { key: "prefs", paths: ["theme"] }, }); -// @stainless-code/persist — wrap $state / $subscribe +// @stainless-code/persist — first-party Pinia source adapter import { defineStore } from "pinia"; -import { - createJSONStorage, - persistSource, - toHydrationSignal, -} from "@stainless-code/persist"; +import { createJSONStorage, toHydrationSignal } from "@stainless-code/persist"; +import { persistStore } from "@stainless-code/persist/sources/pinia"; export const usePrefsStore = defineStore("prefs", { state: () => ({ theme: "light", draft: "" }), }); const prefsStore = usePrefsStore(); -const persist = persistSource( - { - getState: () => prefsStore.$state, - setState: (updater) => { - prefsStore.$patch(updater(prefsStore.$state)); - }, - subscribe: (listener) => ({ - unsubscribe: prefsStore.$subscribe(() => listener()), - }), - }, - { - name: "prefs", - storage: createJSONStorage(() => localStorage), - partialize: (s) => ({ theme: s.theme }), - }, -); +const persist = persistStore(prefsStore, { + name: "prefs", + storage: createJSONStorage(() => localStorage), + partialize: (s) => ({ theme: s.theme }), +}); export const prefsHydration = toHydrationSignal(persist); ``` diff --git a/bun.lock b/bun.lock index cdcc8d4..be5111b 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "mobx": "6.16.1", "oxfmt": "0.57.0", "oxlint": "1.72.0", + "pinia": "3.0.4", "preact": "10.29.4", "publint": "0.3.21", "react": "19.2.7", @@ -59,6 +60,7 @@ "idb-keyval": ">=4.0.0", "jotai": ">=2.0.0", "mobx": ">=6.0.0", + "pinia": ">=2.0.0", "preact": ">=10.19.0", "react": "^18.0.0 || ^19.0.0", "react-native-mmkv": ">=4.0.0", @@ -78,6 +80,7 @@ "idb-keyval", "jotai", "mobx", + "pinia", "preact", "react", "react-native-mmkv", @@ -1198,6 +1201,12 @@ "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="], + "@vue/devtools-api": ["@vue/devtools-api@7.7.10", "", { "dependencies": { "@vue/devtools-kit": "^7.7.10" } }, "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA=="], + + "@vue/devtools-kit": ["@vue/devtools-kit@7.7.10", "", { "dependencies": { "@vue/devtools-shared": "^7.7.10", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw=="], + + "@vue/devtools-shared": ["@vue/devtools-shared@7.7.10", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ=="], + "@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="], "@vue/runtime-core": ["@vue/runtime-core@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw=="], @@ -1438,6 +1447,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], + "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -1958,6 +1969,8 @@ "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], + "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], + "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], @@ -2282,6 +2295,8 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], @@ -2438,6 +2453,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -2446,6 +2463,8 @@ "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "pinia": ["pinia@3.0.4", "", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], @@ -2712,6 +2731,8 @@ "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], + "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -2752,6 +2773,8 @@ "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], @@ -3156,6 +3179,10 @@ "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@vue/devtools-kit/birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], + + "@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "ast-kit/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], diff --git a/docs/architecture.md b/docs/architecture.md index d558d28..729e145 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,6 +34,7 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@stainless-code/persist/sources/jotai` | `adapters/sources/jotai` | `jotai` | | `@stainless-code/persist/sources/valtio` | `adapters/sources/valtio` | `valtio` | | `@stainless-code/persist/sources/mobx` | `adapters/sources/mobx` | `mobx` | +| `@stainless-code/persist/sources/pinia` | `adapters/sources/pinia` | `pinia` | | `@stainless-code/persist/frameworks/react` | `adapters/frameworks/react` | `react` | | `@stainless-code/persist/frameworks/solid` | `adapters/frameworks/solid` | `solid-js` | | `@stainless-code/persist/frameworks/vue` | `adapters/frameworks/vue` | `vue` | @@ -53,7 +54,7 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - `codecs/` — `StorageCodec` adapters (seroval, zod) - `backends/` — `StateStorage` adapters + wrappers (idb, async-storage, mmkv, secure-store, encrypted, compressed, node-fs) - `transport/` — `CrossTabEventTarget` adapters (crosstab — BroadcastChannel bridge) - - `sources/` — `PersistableSource` adapters (tanstack-store, zustand, jotai, valtio, mobx). Shape-named, not library-named — same persistable shape → same name → same merge semantics; the subpath carries the library. Alias when importing two same-shape adapters into one module. + - `sources/` — `PersistableSource` adapters (tanstack-store, zustand, jotai, valtio, mobx, pinia). Shape-named, not library-named — same persistable shape → same name → same merge semantics; the subpath carries the library. Alias when importing two same-shape adapters into one module. - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, svelte, svelte-store, angular, preact) A per-entry self-check test pins the invariant: every adapter's relative imports resolve into `core/` (no cross-adapter coupling). `dist/` mirrors `src/` (`dist//.mjs` via tsdown's record-form `entry` keyed by `/`) — src folder → tsdown key → dist path → subpath, all 1:1. diff --git a/package.json b/package.json index 2d2b9be..fbda483 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "mmkv", "mobx", "persistence", + "pinia", "preact", "react", "react-native", @@ -120,6 +121,10 @@ "types": "./dist/sources/mobx.d.mts", "import": "./dist/sources/mobx.mjs" }, + "./sources/pinia": { + "types": "./dist/sources/pinia.d.mts", + "import": "./dist/sources/pinia.mjs" + }, "./frameworks/react": { "types": "./dist/frameworks/react.d.mts", "import": "./dist/frameworks/react.mjs" @@ -216,6 +221,7 @@ "mobx": "6.16.1", "oxfmt": "0.57.0", "oxlint": "1.72.0", + "pinia": "3.0.4", "preact": "10.29.4", "publint": "0.3.21", "react": "19.2.7", @@ -244,6 +250,7 @@ "idb-keyval": ">=4.0.0", "jotai": ">=2.0.0", "mobx": ">=6.0.0", + "pinia": ">=2.0.0", "preact": ">=10.19.0", "react": "^18.0.0 || ^19.0.0", "react-native-mmkv": ">=4.0.0", @@ -306,6 +313,9 @@ }, "mobx": { "optional": true + }, + "pinia": { + "optional": true } }, "overrides": { diff --git a/src/adapters/sources/pinia.test.ts b/src/adapters/sources/pinia.test.ts new file mode 100644 index 0000000..ee931ba --- /dev/null +++ b/src/adapters/sources/pinia.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it } from "bun:test"; + +import { createPinia, defineStore, setActivePinia } from "pinia"; +import { ref } from "vue"; + +import { createJSONStorage } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; +import { MemoryStorage } from "../../testing/memory-storage"; +import { waitForHydration } from "../../testing/wait-for-hydration"; +import { persistStore } from "./pinia"; + +describe("persistStore", () => { + let memory: MemoryStorage; + + beforeEach(() => { + setActivePinia(createPinia()); + memory = new MemoryStorage(); + }); + + it("round-trips through persistSource (option store)", async () => { + const useCountStore = defineStore("count", { + state: () => ({ count: 0 }), + }); + const store = useCountStore(); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + await jsonStorage.setItem("count-store", { + state: { count: 7 }, + version: 0, + }); + + const persist = persistStore(store, { + name: "count-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(store.$state.count).toBe(7); + + store.$state = { ...store.$state, count: store.$state.count + 1 }; + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("count-store"); + expect(stored?.state.count).toBe(8); + + setActivePinia(createPinia()); + const freshStore = useCountStore(); + const rehydrate = persistStore(freshStore, { + name: "count-store", + storage: jsonStorage, + }); + await waitForHydration(rehydrate.hasHydrated); + expect(freshStore.$state.count).toBe(8); + }); + + it("round-trips through persistSource (setup store)", async () => { + const useSetupStore = defineStore("setup-count", () => { + const count = ref(0); + return { count }; + }); + const store = useSetupStore(); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + await jsonStorage.setItem("setup-count-store", { + state: { count: 3 }, + version: 0, + }); + + const persist = persistStore(store, { + name: "setup-count-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(store.count).toBe(3); + }); + + it("subscribe writes on state change after hydrate", async () => { + const useCountStore = defineStore("subscribe-count", { + state: () => ({ count: 0 }), + }); + const store = useCountStore(); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + + const persist = persistStore(store, { + name: "subscribe-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + + store.count = 42; + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("subscribe-store"); + expect(stored?.state.count).toBe(42); + }); +}); + +describe("pinia dependency isolation", () => { + itImportsOnlyFromCore(new URL("./pinia.ts", import.meta.url)); +}); diff --git a/src/adapters/sources/pinia.ts b/src/adapters/sources/pinia.ts new file mode 100644 index 0000000..02d92ad --- /dev/null +++ b/src/adapters/sources/pinia.ts @@ -0,0 +1,43 @@ +// pinia source adapter — peer `pinia` >=2.0.0. +import type { Store, StoreState } from "pinia"; + +import type { PersistApi, PersistOptions } from "../../core/persist-core"; +import { persistSource } from "../../core/persist-core"; + +/** + * Persist a Pinia store. `$state` / `$subscribe` map to `PersistableSource`. + * Hydrate assigns `$state` (shallow Object.assign via Pinia's setter), not + * object `$patch`. + * + * @example + * ```ts + * import { defineStore } from "pinia"; + * import { createJSONStorage } from "@stainless-code/persist"; + * import { persistStore } from "@stainless-code/persist/sources/pinia"; + * const useCountStore = defineStore("count", { state: () => ({ count: 0 }) }); + * const store = useCountStore(); + * const persist = persistStore(store, { name: "count", storage: createJSONStorage(() => localStorage) }); + * ``` + */ +export function persistStore< + TStore extends Store, + TPersistedState = StoreState, +>( + store: TStore, + options: PersistOptions, TPersistedState>, +): PersistApi, TPersistedState> { + return persistSource( + { + getState: () => store.$state as StoreState, + setState: (updater) => { + store.$state = updater( + store.$state as StoreState, + ) as TStore["$state"]; + }, + subscribe: (listener) => ({ + unsubscribe: store.$subscribe(() => listener(), { detached: true }), + }), + }, + options, + ); +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 99a19b2..3fdcafe 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ "sources/jotai": "src/adapters/sources/jotai.ts", "sources/valtio": "src/adapters/sources/valtio.ts", "sources/mobx": "src/adapters/sources/mobx.ts", + "sources/pinia": "src/adapters/sources/pinia.ts", "frameworks/react": "src/adapters/frameworks/react.ts", "frameworks/solid": "src/adapters/frameworks/solid.ts", "frameworks/vue": "src/adapters/frameworks/vue.ts", @@ -56,6 +57,7 @@ export default defineConfig({ "jotai", "valtio", "mobx", + "pinia", ], }, clean: true, diff --git a/typedoc.json b/typedoc.json index ff388c4..ed62f92 100644 --- a/typedoc.json +++ b/typedoc.json @@ -18,6 +18,7 @@ "src/adapters/sources/jotai.ts", "src/adapters/sources/valtio.ts", "src/adapters/sources/mobx.ts", + "src/adapters/sources/pinia.ts", "src/adapters/frameworks/react.ts", "src/adapters/frameworks/solid.ts", "src/adapters/frameworks/vue.ts", From a24ffe7291c21bfc391869c96d9b311df161f9a2 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 22:57:04 +0300 Subject: [PATCH 2/9] feat(sources): add redux persistStore + persistableReducer Ship ./sources/redux so classic and RTK stores can hydrate via a private set action; persistableReducer at the root avoids silent no-op hydrates. --- .changeset/redux-source-adapter.md | 5 + README.md | 2 +- apps/docs/content/concepts/entry-points.mdx | 1 + apps/docs/content/guides/migrating.mdx | 67 ++++----- bun.lock | 16 +++ docs/architecture.md | 3 +- package.json | 11 ++ src/adapters/sources/redux.test.ts | 149 ++++++++++++++++++++ src/adapters/sources/redux.ts | 83 +++++++++++ tsdown.config.ts | 2 + typedoc.json | 1 + 11 files changed, 298 insertions(+), 42 deletions(-) create mode 100644 .changeset/redux-source-adapter.md create mode 100644 src/adapters/sources/redux.test.ts create mode 100644 src/adapters/sources/redux.ts diff --git a/.changeset/redux-source-adapter.md b/.changeset/redux-source-adapter.md new file mode 100644 index 0000000..17b7769 --- /dev/null +++ b/.changeset/redux-source-adapter.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": minor +--- + +Add first-party Redux source adapter (`./sources/redux`) with `persistStore` and companion `persistableReducer` (classic + RTK). diff --git a/README.md b/README.md index 1b65996..5a74a15 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Any store, any storage, one middleware — no flash.** -Hydration-aware persistence for any reactive store — no hydrate flash, no SSR mismatch. Store-agnostic via a structural `PersistableSource` (TanStack Store, zustand, jotai, valtio, mobx, pinia, or a hand-rolled atom); three composable seams (backend × codec × source) so you swap storage, serialization, or framework without rewriting. A first-class hydration signal gates UI on async backends; opt-in cross-tab sync, versioned migrations, encrypted/compressed backends, and retry-on-quota. Framework adapters for React, Solid, Vue, Svelte, Angular, and Preact. +Hydration-aware persistence for any reactive store — no hydrate flash, no SSR mismatch. Store-agnostic via a structural `PersistableSource` (TanStack Store, zustand, jotai, valtio, mobx, pinia, redux, or a hand-rolled atom); three composable seams (backend × codec × source) so you swap storage, serialization, or framework without rewriting. A first-class hydration signal gates UI on async backends; opt-in cross-tab sync, versioned migrations, encrypted/compressed backends, and retry-on-quota. Framework adapters for React, Solid, Vue, Svelte, Angular, and Preact. [![core size](https://img.shields.io/size-limit/label/gzip/.size-limit.json/core/stainless-code/persist)](https://github.com/stainless-code/persist/blob/main/.size-limit.json) diff --git a/apps/docs/content/concepts/entry-points.mdx b/apps/docs/content/concepts/entry-points.mdx index 7c774cb..bd628bb 100644 --- a/apps/docs/content/concepts/entry-points.mdx +++ b/apps/docs/content/concepts/entry-points.mdx @@ -26,6 +26,7 @@ One subpath = one optional peer. No barrel — importing a subpath is the depend | `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` | | `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` | | `@stainless-code/persist/sources/pinia` | `persistStore` | `pinia` | +| `@stainless-code/persist/sources/redux` | `persistStore`, `persistableReducer` | `redux` | | `@stainless-code/persist/frameworks/react` | `useHydrated` React hook | `react` | | `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | | `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref`) | `vue` | diff --git a/apps/docs/content/guides/migrating.mdx b/apps/docs/content/guides/migrating.mdx index ec37031..6c1e02b 100644 --- a/apps/docs/content/guides/migrating.mdx +++ b/apps/docs/content/guides/migrating.mdx @@ -52,22 +52,22 @@ export const prefsHydration = toHydrationSignal(persist); ## Migrating from redux-persist -redux-persist reconciles whole reducer trees implicitly; here `merge` is explicit and a hydration signal gates UI until the snapshot lands. redux stores have `getState`/`subscribe`/`dispatch` (no `setState`), so wrap the store in a `PersistableSource` whose `setState` dispatches an action your reducer recognizes to replace state. - -| redux-persist | `@stainless-code/persist` | -| --------------------------------- | ------------------------------------------------------------------------ | -| `key` | `name` | -| `storage` | `storage` | -| `version` | `version` | -| `migrate` | `migrate` | -| `whitelist` / `blacklist` | `partialize` (project the slice) | -| `transforms` | `merge` (per-reducer in/out) | -| `serialize` / `deserialize` | custom `StorageCodec` via `createStorage` | -| `stateReconciler` | `merge` (default shallow-spread; customize) | -| `throttle` | `throttleMs` | -| `persistReducer` / `persistStore` | `persistSource(reduxSource, opts)` | -| — | `toHydrationSignal` + framework adapter (no redux-persist equivalent) | -| — | `crossTab`, `maxAge`, `buster`, `retryWrite`, schema validation (no eq.) | +redux-persist reconciles whole reducer trees implicitly; here `merge` is explicit and a hydration signal gates UI until the snapshot lands. Use first-party `persistStore` + `persistableReducer` from `@stainless-code/persist/sources/redux` — Redux has no `setState`, so the companion reducer handles the private hydrate/set action (RTK slices ignore foreign actions otherwise). Alias if you still import redux-persist's `persistStore`. + +| redux-persist | `@stainless-code/persist` | +| --------------------------------- | ------------------------------------------------------------------------------------- | +| `key` | `name` | +| `storage` | `storage` | +| `version` | `version` | +| `migrate` | `migrate` | +| `whitelist` / `blacklist` | `partialize` (project the slice) | +| `transforms` | `merge` (per-reducer in/out) | +| `serialize` / `deserialize` | custom `StorageCodec` via `createStorage` | +| `stateReconciler` | `merge` (default shallow-spread; customize) | +| `throttle` | `throttleMs` | +| `persistReducer` / `persistStore` | `persistableReducer` + `persistStore` from `./sources/redux` | +| — | `toHydrationSignal` + framework adapter (no redux-persist equivalent) | +| — | `crossTab`, `maxAge`, `buster`, `retryWrite`, schema validation (no eq.) | ```ts // redux-persist @@ -79,32 +79,19 @@ const persistedReducer = persistReducer({ key: "root", storage }, rootReducer); export const store = createStore(persistedReducer); persistStore(store); -// @stainless-code/persist — plain store + PersistableSource (dispatch ↔ setState) +// @stainless-code/persist import { createStore } from "redux"; +import { createJSONStorage, toHydrationSignal } from "@stainless-code/persist"; import { - createJSONStorage, - persistSource, - toHydrationSignal, -} from "@stainless-code/persist"; - -export const store = createStore(rootReducer); // rootReducer handles PERSIST_SET -const persist = persistSource( - { - getState: () => store.getState(), - setState: (updater) => - store.dispatch({ - type: "PERSIST_SET", - payload: updater(store.getState()), - }), - subscribe: (listener) => ({ - unsubscribe: store.subscribe(listener), - }), - }, - { - name: "root", - storage: createJSONStorage(() => localStorage), - }, -); + persistStore, + persistableReducer, +} from "@stainless-code/persist/sources/redux"; + +export const store = createStore(persistableReducer(rootReducer)); +const persist = persistStore(store, { + name: "root", + storage: createJSONStorage(() => localStorage), +}); export const rootHydration = toHydrationSignal(persist); ``` diff --git a/bun.lock b/bun.lock index be5111b..8268ce1 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@react-native-async-storage/async-storage": "3.1.1", + "@reduxjs/toolkit": "2.11.2", "@size-limit/preset-small-lib": "12.1.0", "@stainless-code/codemap": "0.11.1", "@tanstack/intent": "0.3.5", @@ -37,6 +38,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "react-native-mmkv": "4.3.2", + "redux": "5.0.1", "seroval": "1.5.4", "size-limit": "12.1.0", "solid-js": "1.9.14", @@ -64,6 +66,7 @@ "preact": ">=10.19.0", "react": "^18.0.0 || ^19.0.0", "react-native-mmkv": ">=4.0.0", + "redux": ">=5.0.0", "seroval": ">=1.0.0", "solid-js": ">=1.6.0", "svelte": ">=3.0.0", @@ -84,6 +87,7 @@ "preact", "react", "react-native-mmkv", + "redux", "seroval", "solid-js", "svelte", @@ -851,6 +855,8 @@ "@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.86.0", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.2.0", "react": "*", "react-native": "0.86.0" }, "optionalPeers": ["@types/react"] }, "sha512-4/ZLXdf/OSpPDVO0AsQ1SJdRIzt5t9BNQ46QwGgxvX7/cirYR5k8KXctNGGgW8lQo2gZChEfY2zFCZg9nM/jiw=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], @@ -941,6 +947,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], @@ -1913,6 +1921,8 @@ "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "immer": ["immer@11.1.15", "", {}, "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], "import-without-cache": ["import-without-cache@0.4.0", "", {}, "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ=="], @@ -2561,6 +2571,10 @@ "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], @@ -2603,6 +2617,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], diff --git a/docs/architecture.md b/docs/architecture.md index 729e145..e49c908 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,6 +35,7 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@stainless-code/persist/sources/valtio` | `adapters/sources/valtio` | `valtio` | | `@stainless-code/persist/sources/mobx` | `adapters/sources/mobx` | `mobx` | | `@stainless-code/persist/sources/pinia` | `adapters/sources/pinia` | `pinia` | +| `@stainless-code/persist/sources/redux` | `adapters/sources/redux` | `redux` | | `@stainless-code/persist/frameworks/react` | `adapters/frameworks/react` | `react` | | `@stainless-code/persist/frameworks/solid` | `adapters/frameworks/solid` | `solid-js` | | `@stainless-code/persist/frameworks/vue` | `adapters/frameworks/vue` | `vue` | @@ -54,7 +55,7 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - `codecs/` — `StorageCodec` adapters (seroval, zod) - `backends/` — `StateStorage` adapters + wrappers (idb, async-storage, mmkv, secure-store, encrypted, compressed, node-fs) - `transport/` — `CrossTabEventTarget` adapters (crosstab — BroadcastChannel bridge) - - `sources/` — `PersistableSource` adapters (tanstack-store, zustand, jotai, valtio, mobx, pinia). Shape-named, not library-named — same persistable shape → same name → same merge semantics; the subpath carries the library. Alias when importing two same-shape adapters into one module. + - `sources/` — `PersistableSource` adapters (tanstack-store, zustand, jotai, valtio, mobx, pinia, redux). Shape-named, not library-named — same persistable shape → same name → same merge semantics; the subpath carries the library. Alias when importing two same-shape adapters into one module. - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, svelte, svelte-store, angular, preact) A per-entry self-check test pins the invariant: every adapter's relative imports resolve into `core/` (no cross-adapter coupling). `dist/` mirrors `src/` (`dist//.mjs` via tsdown's record-form `entry` keyed by `/`) — src folder → tsdown key → dist path → subpath, all 1:1. diff --git a/package.json b/package.json index fbda483..c89e0b5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react", "react-native", "reactive", + "redux", "seroval", "solid", "state-management", @@ -125,6 +126,10 @@ "types": "./dist/sources/pinia.d.mts", "import": "./dist/sources/pinia.mjs" }, + "./sources/redux": { + "types": "./dist/sources/redux.d.mts", + "import": "./dist/sources/redux.mjs" + }, "./frameworks/react": { "types": "./dist/frameworks/react.d.mts", "import": "./dist/frameworks/react.mjs" @@ -200,6 +205,7 @@ "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@react-native-async-storage/async-storage": "3.1.1", + "@reduxjs/toolkit": "2.11.2", "@size-limit/preset-small-lib": "12.1.0", "@stainless-code/codemap": "0.11.1", "@tanstack/intent": "0.3.5", @@ -227,6 +233,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "react-native-mmkv": "4.3.2", + "redux": "5.0.1", "seroval": "1.5.4", "size-limit": "12.1.0", "solid-js": "1.9.14", @@ -254,6 +261,7 @@ "preact": ">=10.19.0", "react": "^18.0.0 || ^19.0.0", "react-native-mmkv": ">=4.0.0", + "redux": ">=5.0.0", "seroval": ">=1.0.0", "solid-js": ">=1.6.0", "svelte": ">=3.0.0", @@ -316,6 +324,9 @@ }, "pinia": { "optional": true + }, + "redux": { + "optional": true } }, "overrides": { diff --git a/src/adapters/sources/redux.test.ts b/src/adapters/sources/redux.test.ts new file mode 100644 index 0000000..503f46c --- /dev/null +++ b/src/adapters/sources/redux.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it } from "bun:test"; + +import { combineReducers, configureStore } from "@reduxjs/toolkit"; +import { createStore } from "redux"; +import type { Reducer } from "redux"; + +import { createJSONStorage } from "../../core/persist-core"; +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; +import { MemoryStorage } from "../../testing/memory-storage"; +import { waitForHydration } from "../../testing/wait-for-hydration"; +import { persistableReducer, persistStore } from "./redux"; + +interface CountState { + count: number; +} + +const increment = { type: "INCREMENT" as const }; + +const countReducer: Reducer = (state = { count: 0 }, action) => { + if (action.type === "INCREMENT") { + return { count: state.count + 1 }; + } + return state; +}; + +describe("persistStore", () => { + let memory: MemoryStorage; + + beforeEach(() => { + memory = new MemoryStorage(); + }); + + it("round-trips through persistSource (createStore)", async () => { + const jsonStorage = createJSONStorage(() => memory)!; + await jsonStorage.setItem("count-store", { + state: { count: 7 }, + version: 0, + }); + + const store = createStore(persistableReducer(countReducer)); + const persist = persistStore(store, { + name: "count-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(store.getState().count).toBe(7); + + store.dispatch(increment); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("count-store"); + expect(stored?.state.count).toBe(8); + + const freshStore = createStore(persistableReducer(countReducer)); + const rehydrate = persistStore(freshStore, { + name: "count-store", + storage: jsonStorage, + }); + await waitForHydration(rehydrate.hasHydrated); + expect(freshStore.getState().count).toBe(8); + }); + + it("round-trips through persistSource (RTK configureStore)", async () => { + const jsonStorage = createJSONStorage(() => memory)!; + await jsonStorage.setItem("rtk-store", { + state: { count: 3 }, + version: 0, + }); + + const store = configureStore({ + reducer: persistableReducer(countReducer), + }); + const persist = persistStore(store, { + name: "rtk-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(store.getState().count).toBe(3); + + store.dispatch(increment); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("rtk-store"); + expect(stored?.state.count).toBe(4); + }); + + it("round-trips with RTK combineReducers under persistableReducer", async () => { + interface Root { + count: CountState; + } + const rootReducer = combineReducers({ count: countReducer }); + const jsonStorage = createJSONStorage(() => memory)!; + await jsonStorage.setItem("combined-store", { + state: { count: { count: 9 } }, + version: 0, + }); + + const store = configureStore({ + reducer: persistableReducer(rootReducer), + }); + const persist = persistStore(store, { + name: "combined-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(store.getState().count.count).toBe(9); + }); + + it("subscribe writes on state change after hydrate", async () => { + const jsonStorage = createJSONStorage(() => memory)!; + const store = createStore(persistableReducer(countReducer)); + const persist = persistStore(store, { + name: "subscribe-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + + store.dispatch(increment); + await new Promise((resolve) => queueMicrotask(resolve)); + + const stored = await jsonStorage.getItem("subscribe-store"); + expect(stored?.state.count).toBe(1); + }); + + it("without persistableReducer, hydrate does not apply", async () => { + const jsonStorage = createJSONStorage(() => memory)!; + await jsonStorage.setItem("noop-store", { + state: { count: 7 }, + version: 0, + }); + + const store = createStore(countReducer); + const persist = persistStore(store, { + name: "noop-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(store.getState().count).toBe(0); + }); +}); + +describe("redux dependency isolation", () => { + itImportsOnlyFromCore(new URL("./redux.ts", import.meta.url)); +}); diff --git a/src/adapters/sources/redux.ts b/src/adapters/sources/redux.ts new file mode 100644 index 0000000..f9f4f02 --- /dev/null +++ b/src/adapters/sources/redux.ts @@ -0,0 +1,83 @@ +// redux source adapter — peer `redux` >=5.0.0 (covers classic + RTK stores). +import type { Action, Reducer, Store, UnknownAction } from "redux"; + +import type { PersistApi, PersistOptions } from "../../core/persist-core"; +import { persistSource } from "../../core/persist-core"; + +/** Private set/hydrate action — owned by this module; not for app dispatch. */ +const PERSIST_SET = "@stainless-code/persist/SET" as const; + +interface PersistSetAction { + type: typeof PERSIST_SET; + payload: TState; +} + +/** + * Wrap a root reducer so hydrate/`setState` can replace state via a private + * action. RTK slices ignore foreign actions — without this wrapper, hydrate + * is a silent no-op. Named `persistableReducer` (not `persistReducer`) to + * avoid clashing with redux-persist. + * + * @example + * ```ts + * import { createStore } from "redux"; + * import { persistableReducer } from "@stainless-code/persist/sources/redux"; + * const store = createStore(persistableReducer(rootReducer)); + * ``` + */ +export function persistableReducer< + TState, + TAction extends Action = UnknownAction, +>(baseReducer: Reducer): Reducer { + return (state, action) => { + if (action.type === PERSIST_SET) { + return (action as unknown as PersistSetAction).payload; + } + return baseReducer(state, action as TAction); + }; +} + +/** + * Persist a Redux store (classic `createStore` or RTK `configureStore`). + * Redux has no `setState` — hydrate/writes dispatch a private action handled + * by {@link persistableReducer}. Do not use `replaceReducer` for hydrate. + * + * If you also import redux-persist's `persistStore`, alias this one: + * `import { persistStore as persistWithStainless } from "@stainless-code/persist/sources/redux"`. + * + * @example + * ```ts + * import { createStore } from "redux"; + * import { createJSONStorage } from "@stainless-code/persist"; + * import { + * persistStore, + * persistableReducer, + * } from "@stainless-code/persist/sources/redux"; + * const store = createStore(persistableReducer(rootReducer)); + * const persist = persistStore(store, { + * name: "root", + * storage: createJSONStorage(() => localStorage), + * }); + * ``` + */ +export function persistStore( + store: Store, + options: PersistOptions, +): PersistApi { + return persistSource( + { + getState: () => store.getState(), + setState: (updater) => { + const action: PersistSetAction = { + type: PERSIST_SET, + payload: updater(store.getState()), + }; + store.dispatch(action as unknown as UnknownAction); + }, + subscribe: (listener) => ({ + unsubscribe: store.subscribe(listener), + }), + }, + options, + ); +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 3fdcafe..b8ca9d9 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ "sources/valtio": "src/adapters/sources/valtio.ts", "sources/mobx": "src/adapters/sources/mobx.ts", "sources/pinia": "src/adapters/sources/pinia.ts", + "sources/redux": "src/adapters/sources/redux.ts", "frameworks/react": "src/adapters/frameworks/react.ts", "frameworks/solid": "src/adapters/frameworks/solid.ts", "frameworks/vue": "src/adapters/frameworks/vue.ts", @@ -58,6 +59,7 @@ export default defineConfig({ "valtio", "mobx", "pinia", + "redux", ], }, clean: true, diff --git a/typedoc.json b/typedoc.json index ed62f92..2ae570f 100644 --- a/typedoc.json +++ b/typedoc.json @@ -19,6 +19,7 @@ "src/adapters/sources/valtio.ts", "src/adapters/sources/mobx.ts", "src/adapters/sources/pinia.ts", + "src/adapters/sources/redux.ts", "src/adapters/frameworks/react.ts", "src/adapters/frameworks/solid.ts", "src/adapters/frameworks/vue.ts", From f7fe8a7ac2247221a536158b8b16c07fac5685ed Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 22:58:25 +0300 Subject: [PATCH 3/9] docs: surface pinia/redux sources after adapter review Wire API nav + wrapping-stores recipe; drop shipped #7 from remaining-roi. --- apps/docs/content/recipes/index.mdx | 2 +- apps/docs/content/recipes/wrapping-stores.mdx | 39 ++++++++++++++++++- apps/docs/content/reference/api/index.mdx | 6 +++ apps/docs/content/reference/api/meta.ts | 2 + docs/plans/remaining-roi.md | 29 ++------------ docs/roadmap.md | 2 +- 6 files changed, 51 insertions(+), 29 deletions(-) diff --git a/apps/docs/content/recipes/index.mdx b/apps/docs/content/recipes/index.mdx index f6679fa..f1929d0 100644 --- a/apps/docs/content/recipes/index.mdx +++ b/apps/docs/content/recipes/index.mdx @@ -11,7 +11,7 @@ Composable patterns over the three seams. Start with [Composition](/recipes/comp | ------------------------------------------- | ------------------------------------------------------------------------------------------ | | [Composition](/recipes/composition) | Encrypt, compress, legacy IDB string payloads, namespaced stores | | [Options](/recipes/options) | Clear-all, partialize, merge, retryWrite, throttleMs, maxAge, buster, migrate, skipPersist | -| [Wrapping stores](/recipes/wrapping-stores) | zustand, jotai, valtio, mobx, custom `PersistableSource` | +| [Wrapping stores](/recipes/wrapping-stores) | zustand, jotai, valtio, mobx, pinia, redux, custom `PersistableSource` | | [Cross-tab](/recipes/crosstab) | localStorage sync and BroadcastChannel over IndexedDB | | [Zod](/recipes/zod) | Schema-gated persist with `createZodStorage` | | [React Native](/recipes/react-native) | AsyncStorage, MMKV, Expo Secure Store | diff --git a/apps/docs/content/recipes/wrapping-stores.mdx b/apps/docs/content/recipes/wrapping-stores.mdx index 78bb9e3..2debe3a 100644 --- a/apps/docs/content/recipes/wrapping-stores.mdx +++ b/apps/docs/content/recipes/wrapping-stores.mdx @@ -1,8 +1,8 @@ --- title: Wrapping stores -description: Persist zustand, jotai, valtio, mobx, or any custom PersistableSource with thin source adapters. +description: Persist zustand, jotai, valtio, mobx, pinia, redux, or any custom PersistableSource with thin source adapters. search: - tags: ["recipe", "zustand", "jotai", "valtio", "mobx"] + tags: ["recipe", "zustand", "jotai", "valtio", "mobx", "pinia", "redux"] --- Same `getState`/`setState`/`subscribe` shape → same adapter name → same merge semantics, regardless of library. Naming is shape-based (`persistStore` / `persistAtom` / `persistProxy` / `persistObservable`); the subpath carries the library. Importing two same-shape adapters into one module? Alias one: `import { persistStore as persistZustand } from "@stainless-code/persist/sources/zustand"`. Escape hatch: pass a custom `PersistableSource` to `persistSource` directly. @@ -72,6 +72,41 @@ const persist = persistObservable(prefs, { Or pass a custom `PersistableSource` to `persistSource` directly — the adapter is a thin wrapper over `getState`/`setState`/`subscribe`. +## pinia + +```ts +import { defineStore } from "pinia"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistStore } from "@stainless-code/persist/sources/pinia"; + +const usePrefs = defineStore("prefs", { + state: () => ({ theme: "light" as const }), +}); +const persist = persistStore(usePrefs(), { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + +## redux + +Wrap the root reducer with `persistableReducer` so hydrate can replace state (RTK slices ignore foreign actions otherwise). Alias if you also import redux-persist's `persistStore`. + +```ts +import { createStore } from "redux"; +import { createJSONStorage } from "@stainless-code/persist"; +import { + persistStore, + persistableReducer, +} from "@stainless-code/persist/sources/redux"; + +const store = createStore(persistableReducer(rootReducer)); +const persist = persistStore(store, { + name: "app:root:v1", + storage: createJSONStorage(() => localStorage), +}); +``` + ## Any other store ```ts diff --git a/apps/docs/content/reference/api/index.mdx b/apps/docs/content/reference/api/index.mdx index 38496ec..fb4fc53 100644 --- a/apps/docs/content/reference/api/index.mdx +++ b/apps/docs/content/reference/api/index.mdx @@ -107,6 +107,12 @@ One TypeDoc module per package entry. [Reference](/reference) `@stainless-code/persist/sources/mobx` + + `@stainless-code/persist/sources/pinia` + + + `@stainless-code/persist/sources/redux` + ## Frameworks diff --git a/apps/docs/content/reference/api/meta.ts b/apps/docs/content/reference/api/meta.ts index ff310d1..bb79b5e 100644 --- a/apps/docs/content/reference/api/meta.ts +++ b/apps/docs/content/reference/api/meta.ts @@ -20,6 +20,8 @@ export default defineMeta({ "adapters-sources-jotai", "adapters-sources-valtio", "adapters-sources-mobx", + "adapters-sources-pinia", + "adapters-sources-redux", "adapters-frameworks-react", "adapters-frameworks-solid", "adapters-frameworks-vue", diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index facbe77..e8da91e 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -70,26 +70,6 @@ Framework adapters mount `HydrationSignal` into each framework's external-store - **Deps:** item 2 (`examples/`) should land first so the playground has a source app (or seed from a docs recipe). - **Lands:** README + docs site link. Changeset: `minor` (dev/docs-only). -### 7. Redux + Pinia source adapters — Tier 2, M - -Migration guides already document hand-rolled `PersistableSource` wraps; first-party adapters close the gap with zustand/jotai/valtio/mobx. Research (2026-07-20) against local `rt2zz/redux-persist` + `prazdevs/pinia-plugin-persistedstate` and upstream `reduxjs/redux` / `vuejs/pinia` sources (OSS clones of the cores were unavailable in-agent — re-clone under `/Users/sutusebastian/Developer/OSS/{reduxjs/redux,vuejs/pinia}` before implementation). - -#### `./sources/redux` — `persistStore` + `persistableReducer` - -- **What:** thin `persistStore(store, opts)` over `persistSource`; companion `persistableReducer(baseReducer)` (not named `persistReducer`) that handles one internal set/hydrate action returning `action.payload`. Peer: `redux` (RTK stores are still Redux `Store`). -- **Why (fact-checked):** Redux has **no** `setState` — only `getState` / `dispatch` / `subscribe` → bare unsub fn (`createStore.ts`). `replaceReducer` dispatches private randomized `@@redux/REPLACE…` and re-runs the reducer with **previous** state — **not** payload hydrate. redux-persist owns writes inside `persistReducer` + lifecycle via `persistStore` → `dispatch` only (`persistStore.ts` / `persistReducer.ts`) — different ownership model from subscribe-writes. RTK slices ignore foreign actions unless `extraReducers` / root wrapper → silent no-op hydrate without `persistableReducer`. -- **Mapping:** `getState` → `store.getState()`; `setState(updater)` → `dispatch({ type: SET, payload: updater(getState()) })`; `subscribe` → `{ unsubscribe: store.subscribe(listener) }`. -- **Acceptance:** subpath + `persistableReducer` + co-located tests (plain `createStore` + RTK `configureStore`); migrating guide swaps hand-roll for adapter. Changeset: `minor`. -- **Lands:** `src/adapters/sources/redux.ts` (+ helper), entry-points table, [`guides/migrating`](../../apps/docs/content/guides/migrating.mdx). Alias if coexisting with redux-persist's `persistStore`. - -#### `./sources/pinia` — `persistStore` - -- **What:** thin `persistStore(store, opts)` over `persistSource`. Peer: `pinia` ≥ 2 (API floor; plugin ecosystem is often ≥ 3). -- **Why (fact-checked):** Pinia store is already near-`PersistableSource`. `$state` setter is `$patch(($state) => Object.assign($state, state))` — **shallow assign**, not root replace; object `$patch(partial)` is **deep** merge (`store.ts`). `$subscribe` returns a bare unsub and auto-detaches with the effect scope unless `{ detached: true }` — pinia-plugin-persistedstate uses `$patch` hydrate + `$subscribe(..., { detached: true })` (`runtime/core.ts`). Neither deletes absent keys on hydrate. Adapter is call-site (not a `pinia.use` plugin / `persist:` option). -- **Mapping:** `getState` → `store.$state`; `setState(updater)` → `store.$state = updater(store.$state)` (prefer over object `$patch`); `subscribe` → `{ unsubscribe: store.$subscribe(() => listener(), { detached: true }) }`. -- **Acceptance:** subpath + option-store + setup-store tests; migrating guide swaps hand-roll for adapter. Changeset: `minor`. -- **Lands:** `src/adapters/sources/pinia.ts`, entry-points table, migrating guide. - ### 8. Lit + Alpine framework adapters — Tier 2, M [Layers](https://stainless-code.com/layers/) ships framework mounts for Vanilla, React, Preact, Solid, Angular, Vue, Lit, Alpine, Svelte. Persist already covers React / Preact / Solid / Angular / Vue / Svelte (+ `svelte-store`). Gap vs Layers: **Lit** and **Alpine**. Vanilla is the core `HydrationSignal` / `toHydrationSignal` surface — no `./frameworks/vanilla` subpath (same as Layers' vanilla = core package). Research (2026-07-20) against local `stainless-code/layers` lit + alpine packages and OSS clones `lit` / `alpinejs`. @@ -122,11 +102,10 @@ From audit Appendix B.3. Each is a one-line composition over an existing seam; s ## Sequencing 1. **#1 (Query bridge)** — M, pure code, high adoption payoff, no deps. Best next pick. -2. **#7 (Redux + Pinia sources)** — M, closes migration-guide hand-rolls; Pinia is thin, Redux needs `persistableReducer`. -3. **#8 (Lit + Alpine frameworks)** — M, Layers parity; Lit is a thin controller, Alpine needs reactive bag + plugin. -4. **#3 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. -5. **#2 (examples/) → #6 (playground)** — demo arc; docs site already shipped. -6. **#4 (React ergonomics) + #5 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. +2. **#8 (Lit + Alpine frameworks)** — M, Layers parity; Lit is a thin controller, Alpine needs reactive bag + plugin. +3. **#3 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. +4. **#2 (examples/) → #6 (playground)** — demo arc; docs site already shipped. +5. **#4 (React ergonomics) + #5 (OPFS/SQLite/Cloudflare)** — strategic; decide ship-vs-recipe per item. ## Reference diff --git a/docs/roadmap.md b/docs/roadmap.md index 5f0d94f..cfd95a8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,7 +6,7 @@ Forward-looking plans only — **not** a mirror of `src/`. **Doc index:** [READM ## Next -- **Remaining ROI work** — actionable items not yet shipped: TanStack Query bridge, **Redux + Pinia source adapters**, **Lit + Alpine framework adapters** (Layers parity), `examples/` workspace, real-browser + SSR + framework-runtime test matrix, React ergonomics layer, OPFS/SQLite/Cloudflare adapters, playground. Plan: [`plans/remaining-roi.md`](./plans/remaining-roi.md). +- **Remaining ROI work** — actionable items not yet shipped: TanStack Query bridge, **Lit + Alpine framework adapters** (Layers parity), `examples/` workspace, real-browser + SSR + framework-runtime test matrix, React ergonomics layer, OPFS/SQLite/Cloudflare adapters, playground. Plan: [`plans/remaining-roi.md`](./plans/remaining-roi.md). - **Upstream TanStack Persist collaboration** — pitch the `persistSource` middleware model (structural `PersistableSource` + first-class hydration lifecycle) to the TanStack Persist maintainers as a merge target, after the stainless-code publish stabilises. Draft: [`plans/upstream-tanstack-pitch.md`](./plans/upstream-tanstack-pitch.md). --- From 7afb562bc6a76b2fdf65bfd696476cb59192b05d Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 22:59:26 +0300 Subject: [PATCH 4/9] docs: slim pinia/redux prose and refresh source order Tighten migrating leads + JSDoc gotchas; extend docs-voice source listing order; docs:api/validate/build green. --- .agents/skills/docs-voice/SKILL.md | 4 ++-- apps/docs/content/guides/migrating.mdx | 4 ++-- src/adapters/sources/pinia.ts | 6 +++--- src/adapters/sources/redux.ts | 7 +++---- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.agents/skills/docs-voice/SKILL.md b/.agents/skills/docs-voice/SKILL.md index ee4e912..8ae0222 100644 --- a/.agents/skills/docs-voice/SKILL.md +++ b/.agents/skills/docs-voice/SKILL.md @@ -33,8 +33,8 @@ was rejected) and the "when **not** to use it" anti-sell — keep both. Section `meta.ts` icons and tab icons stay. - **Adapter / listing order:** core → backends → codecs → sources → frameworks → transport. Within sources: tanstack-store → zustand → jotai → valtio → - mobx → custom. Within frameworks: react → preact → solid → angular → vue → - svelte (runes → store). + mobx → pinia → redux → custom. Within frameworks: react → preact → solid → + angular → vue → svelte (runes → store). ## Don't diff --git a/apps/docs/content/guides/migrating.mdx b/apps/docs/content/guides/migrating.mdx index 6c1e02b..e613b6a 100644 --- a/apps/docs/content/guides/migrating.mdx +++ b/apps/docs/content/guides/migrating.mdx @@ -52,7 +52,7 @@ export const prefsHydration = toHydrationSignal(persist); ## Migrating from redux-persist -redux-persist reconciles whole reducer trees implicitly; here `merge` is explicit and a hydration signal gates UI until the snapshot lands. Use first-party `persistStore` + `persistableReducer` from `@stainless-code/persist/sources/redux` — Redux has no `setState`, so the companion reducer handles the private hydrate/set action (RTK slices ignore foreign actions otherwise). Alias if you still import redux-persist's `persistStore`. +redux-persist owns writes inside `persistReducer`. Here wrap the root with `persistableReducer` and call `persistStore` — Redux has no `setState`, and RTK slices ignore foreign hydrate actions otherwise. Alias if you still import redux-persist's `persistStore`. | redux-persist | `@stainless-code/persist` | | --------------------------------- | ------------------------------------------------------------------------------------- | @@ -97,7 +97,7 @@ export const rootHydration = toHydrationSignal(persist); ## Migrating from pinia-persist -pinia-persist is a Pinia plugin; here persistence is a call-site middleware — same storage, explicit partialization, optional codec. Use first-party `persistStore` from `@stainless-code/persist/sources/pinia`. +pinia-persist is a Pinia plugin; here call `persistStore` on the store — same storage, `partialize`, optional codec. | pinia-persist | `@stainless-code/persist` | | -------------------------------- | ------------------------------------------------------------------------------------- | diff --git a/src/adapters/sources/pinia.ts b/src/adapters/sources/pinia.ts index 02d92ad..af392d4 100644 --- a/src/adapters/sources/pinia.ts +++ b/src/adapters/sources/pinia.ts @@ -5,9 +5,9 @@ import type { PersistApi, PersistOptions } from "../../core/persist-core"; import { persistSource } from "../../core/persist-core"; /** - * Persist a Pinia store. `$state` / `$subscribe` map to `PersistableSource`. - * Hydrate assigns `$state` (shallow Object.assign via Pinia's setter), not - * object `$patch`. + * Persist a Pinia store. Hydrate via `$state =` (shallow assign), not object + * `$patch`. Subscribe with `{ detached: true }` so the listener outlives the + * effect scope. * * @example * ```ts diff --git a/src/adapters/sources/redux.ts b/src/adapters/sources/redux.ts index f9f4f02..36e4605 100644 --- a/src/adapters/sources/redux.ts +++ b/src/adapters/sources/redux.ts @@ -13,10 +13,9 @@ interface PersistSetAction { } /** - * Wrap a root reducer so hydrate/`setState` can replace state via a private - * action. RTK slices ignore foreign actions — without this wrapper, hydrate - * is a silent no-op. Named `persistableReducer` (not `persistReducer`) to - * avoid clashing with redux-persist. + * Root-reducer wrapper so hydrate/`setState` can replace state. RTK slices + * ignore foreign actions — without this, hydrate is a silent no-op. Named to + * avoid clashing with redux-persist's `persistReducer`. * * @example * ```ts From 86e9d59a50925775a8c822c2f7ffb571d7e0765e Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:04:39 +0300 Subject: [PATCH 5/9] harden: Redux root-wrap footguns and Pinia test gaps DEV warn when setState/hydrate is ignored; document root-only persistableReducer; cover createSlice, bad per-slice wrap, setup round-trip, and detached $subscribe after effectScope.stop. --- apps/docs/content/guides/migrating.mdx | 2 +- apps/docs/content/recipes/wrapping-stores.mdx | 2 +- src/adapters/sources/pinia.test.ts | 41 +++++++++- src/adapters/sources/redux.test.ts | 81 ++++++++++++++++++- src/adapters/sources/redux.ts | 21 +++-- 5 files changed, 136 insertions(+), 11 deletions(-) diff --git a/apps/docs/content/guides/migrating.mdx b/apps/docs/content/guides/migrating.mdx index e613b6a..06dc344 100644 --- a/apps/docs/content/guides/migrating.mdx +++ b/apps/docs/content/guides/migrating.mdx @@ -52,7 +52,7 @@ export const prefsHydration = toHydrationSignal(persist); ## Migrating from redux-persist -redux-persist owns writes inside `persistReducer`. Here wrap the root with `persistableReducer` and call `persistStore` — Redux has no `setState`, and RTK slices ignore foreign hydrate actions otherwise. Alias if you still import redux-persist's `persistStore`. +redux-persist owns writes inside `persistReducer`. Here wrap the **root** reducer with `persistableReducer` (not each slice) and call `persistStore` — Redux has no `setState`, and RTK slices ignore foreign hydrate actions otherwise. Alias if you still import redux-persist's `persistStore`. | redux-persist | `@stainless-code/persist` | | --------------------------------- | ------------------------------------------------------------------------------------- | diff --git a/apps/docs/content/recipes/wrapping-stores.mdx b/apps/docs/content/recipes/wrapping-stores.mdx index 2debe3a..0901b2e 100644 --- a/apps/docs/content/recipes/wrapping-stores.mdx +++ b/apps/docs/content/recipes/wrapping-stores.mdx @@ -90,7 +90,7 @@ const persist = persistStore(usePrefs(), { ## redux -Wrap the root reducer with `persistableReducer` so hydrate can replace state (RTK slices ignore foreign actions otherwise). Alias if you also import redux-persist's `persistStore`. +Wrap the **root** reducer with `persistableReducer` (not each slice) so hydrate can replace state. Alias if you also import redux-persist's `persistStore`. ```ts import { createStore } from "redux"; diff --git a/src/adapters/sources/pinia.test.ts b/src/adapters/sources/pinia.test.ts index ee931ba..5c20229 100644 --- a/src/adapters/sources/pinia.test.ts +++ b/src/adapters/sources/pinia.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { createPinia, defineStore, setActivePinia } from "pinia"; -import { ref } from "vue"; +import { effectScope, ref } from "vue"; import { createJSONStorage } from "../../core/persist-core"; import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; @@ -71,6 +71,20 @@ describe("persistStore", () => { await waitForHydration(persist.hasHydrated); expect(store.count).toBe(3); + + store.count = 4; + await new Promise((resolve) => queueMicrotask(resolve)); + const stored = await jsonStorage.getItem("setup-count-store"); + expect(stored?.state.count).toBe(4); + + setActivePinia(createPinia()); + const freshStore = useSetupStore(); + const rehydrate = persistStore(freshStore, { + name: "setup-count-store", + storage: jsonStorage, + }); + await waitForHydration(rehydrate.hasHydrated); + expect(freshStore.count).toBe(4); }); it("subscribe writes on state change after hydrate", async () => { @@ -93,6 +107,31 @@ describe("persistStore", () => { const stored = await jsonStorage.getItem("subscribe-store"); expect(stored?.state.count).toBe(42); }); + + it("keeps writing after the Vue effectScope that called persistStore stops", async () => { + const useCountStore = defineStore("detached-count", { + state: () => ({ count: 0 }), + }); + const store = useCountStore(); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + const scope = effectScope(); + + let hasHydrated!: () => boolean; + scope.run(() => { + const persist = persistStore(store, { + name: "detached-store", + storage: jsonStorage, + }); + hasHydrated = persist.hasHydrated; + }); + await waitForHydration(hasHydrated); + scope.stop(); + + store.count = 11; + await new Promise((resolve) => queueMicrotask(resolve)); + const stored = await jsonStorage.getItem("detached-store"); + expect(stored?.state.count).toBe(11); + }); }); describe("pinia dependency isolation", () => { diff --git a/src/adapters/sources/redux.test.ts b/src/adapters/sources/redux.test.ts index 503f46c..833e751 100644 --- a/src/adapters/sources/redux.test.ts +++ b/src/adapters/sources/redux.test.ts @@ -1,6 +1,6 @@ -import { beforeEach, describe, expect, it } from "bun:test"; +import { beforeEach, describe, expect, it, spyOn } from "bun:test"; -import { combineReducers, configureStore } from "@reduxjs/toolkit"; +import { combineReducers, configureStore, createSlice } from "@reduxjs/toolkit"; import { createStore } from "redux"; import type { Reducer } from "redux"; @@ -107,6 +107,74 @@ describe("persistStore", () => { await waitForHydration(persist.hasHydrated); expect(store.getState().count.count).toBe(9); + + store.dispatch({ type: "INCREMENT" }); + await new Promise((resolve) => queueMicrotask(resolve)); + const stored = await jsonStorage.getItem("combined-store"); + expect(stored?.state.count.count).toBe(10); + }); + + it("round-trips with RTK createSlice under root persistableReducer", async () => { + const countSlice = createSlice({ + name: "count", + initialState: { count: 0 }, + reducers: { + increment(state) { + state.count += 1; + }, + }, + }); + const jsonStorage = createJSONStorage<{ count: number }>(() => memory)!; + await jsonStorage.setItem("slice-store", { + state: { count: 5 }, + version: 0, + }); + + const store = configureStore({ + reducer: persistableReducer(countSlice.reducer), + }); + const persist = persistStore(store, { + name: "slice-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + expect(store.getState().count).toBe(5); + + store.dispatch(countSlice.actions.increment()); + await new Promise((resolve) => queueMicrotask(resolve)); + const stored = await jsonStorage.getItem("slice-store"); + expect(stored?.state.count).toBe(6); + }); + + it("per-slice persistableReducer corrupts hydrate payload shape", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + interface Root { + count: CountState; + } + const jsonStorage = createJSONStorage(() => memory)!; + await jsonStorage.setItem("bad-wrap-store", { + state: { count: { count: 7 } }, + version: 0, + }); + + const store = configureStore({ + // Wrong: wrap a leaf, not the root — PERSIST_SET payload is the root. + reducer: combineReducers({ + count: persistableReducer(countReducer), + }), + }); + const persist = persistStore(store, { + name: "bad-wrap-store", + storage: jsonStorage, + }); + + await waitForHydration(persist.hasHydrated); + const nested = store.getState().count as unknown as { + count: { count: number }; + }; + expect(nested.count.count).toBe(7); + warn.mockRestore(); }); it("subscribe writes on state change after hydrate", async () => { @@ -126,7 +194,8 @@ describe("persistStore", () => { expect(stored?.state.count).toBe(1); }); - it("without persistableReducer, hydrate does not apply", async () => { + it("without persistableReducer, hydrate does not apply and warns in DEV", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); const jsonStorage = createJSONStorage(() => memory)!; await jsonStorage.setItem("noop-store", { state: { count: 7 }, @@ -141,6 +210,12 @@ describe("persistStore", () => { await waitForHydration(persist.hasHydrated); expect(store.getState().count).toBe(0); + expect( + warn.mock.calls.some((args) => + String(args[0]).includes("persistableReducer"), + ), + ).toBe(true); + warn.mockRestore(); }); }); diff --git a/src/adapters/sources/redux.ts b/src/adapters/sources/redux.ts index 36e4605..ffd01f4 100644 --- a/src/adapters/sources/redux.ts +++ b/src/adapters/sources/redux.ts @@ -13,9 +13,10 @@ interface PersistSetAction { } /** - * Root-reducer wrapper so hydrate/`setState` can replace state. RTK slices - * ignore foreign actions — without this, hydrate is a silent no-op. Named to - * avoid clashing with redux-persist's `persistReducer`. + * Wrap the **root** reducer so hydrate/`setState` can replace state. Do not + * wrap individual slices inside `combineReducers` — the payload is the full + * root. Without this wrapper RTK slices ignore the private action (silent + * no-op). Named to avoid clashing with redux-persist's `persistReducer`. * * @example * ```ts @@ -39,7 +40,8 @@ export function persistableReducer< /** * Persist a Redux store (classic `createStore` or RTK `configureStore`). * Redux has no `setState` — hydrate/writes dispatch a private action handled - * by {@link persistableReducer}. Do not use `replaceReducer` for hydrate. + * by {@link persistableReducer} on the root. Do not use `replaceReducer` for + * hydrate. * * If you also import redux-persist's `persistStore`, alias this one: * `import { persistStore as persistWithStainless } from "@stainless-code/persist/sources/redux"`. @@ -67,11 +69,20 @@ export function persistStore( { getState: () => store.getState(), setState: (updater) => { + const payload = updater(store.getState()); const action: PersistSetAction = { type: PERSIST_SET, - payload: updater(store.getState()), + payload, }; store.dispatch(action as unknown as UnknownAction); + if ( + process.env.NODE_ENV !== "production" && + store.getState() !== payload + ) { + console.warn( + "[@stainless-code/persist/sources/redux] setState/hydrate did not apply. Wrap the root reducer with persistableReducer (not each slice).", + ); + } }, subscribe: (listener) => ({ unsubscribe: store.subscribe(listener), From 6908b55195e61fed7ba311c3f6be0a011d1551b1 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:06:37 +0300 Subject: [PATCH 6/9] harden: avoid Redux DEV warn false positive on sync listeners Detect ignored hydrate via unchanged getState() reference, not payload inequality after dispatch. --- src/adapters/sources/redux.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/adapters/sources/redux.ts b/src/adapters/sources/redux.ts index ffd01f4..5b76672 100644 --- a/src/adapters/sources/redux.ts +++ b/src/adapters/sources/redux.ts @@ -69,15 +69,17 @@ export function persistStore( { getState: () => store.getState(), setState: (updater) => { - const payload = updater(store.getState()); + const prev = store.getState(); + const payload = updater(prev); const action: PersistSetAction = { type: PERSIST_SET, payload, }; store.dispatch(action as unknown as UnknownAction); + // Unchanged reference ⇒ reducer ignored PERSIST_SET (missing root wrap). if ( process.env.NODE_ENV !== "production" && - store.getState() !== payload + store.getState() === prev ) { console.warn( "[@stainless-code/persist/sources/redux] setState/hydrate did not apply. Wrap the root reducer with persistableReducer (not each slice).", From 5502344d145dc45b14cf6bdf9b7ad03ae42b9c94 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:13:56 +0300 Subject: [PATCH 7/9] docs: tighten pinia/redux prose per authoring + docs-voice Shorter migrating leads, drop first-party fluff, plain-root JSDoc wording, clarify hero source-pill subset vs full source order. --- apps/docs/content/guides/migrating.mdx | 4 ++-- apps/docs/content/recipes/wrapping-stores.mdx | 2 +- apps/docs/pages/_home/source-snippets.ts | 2 +- src/adapters/sources/redux.ts | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/docs/content/guides/migrating.mdx b/apps/docs/content/guides/migrating.mdx index 06dc344..5403049 100644 --- a/apps/docs/content/guides/migrating.mdx +++ b/apps/docs/content/guides/migrating.mdx @@ -52,7 +52,7 @@ export const prefsHydration = toHydrationSignal(persist); ## Migrating from redux-persist -redux-persist owns writes inside `persistReducer`. Here wrap the **root** reducer with `persistableReducer` (not each slice) and call `persistStore` — Redux has no `setState`, and RTK slices ignore foreign hydrate actions otherwise. Alias if you still import redux-persist's `persistStore`. +redux-persist owns writes inside `persistReducer`. Here wrap the root reducer with `persistableReducer` (not each slice) and call `persistStore`. Redux has no `setState`; RTK slices ignore foreign hydrate actions otherwise. Alias if you still import redux-persist's `persistStore`. | redux-persist | `@stainless-code/persist` | | --------------------------------- | ------------------------------------------------------------------------------------- | @@ -123,7 +123,7 @@ export const usePrefsStore = defineStore("prefs", { persist: { key: "prefs", paths: ["theme"] }, }); -// @stainless-code/persist — first-party Pinia source adapter +// @stainless-code/persist import { defineStore } from "pinia"; import { createJSONStorage, toHydrationSignal } from "@stainless-code/persist"; import { persistStore } from "@stainless-code/persist/sources/pinia"; diff --git a/apps/docs/content/recipes/wrapping-stores.mdx b/apps/docs/content/recipes/wrapping-stores.mdx index 0901b2e..3124a5f 100644 --- a/apps/docs/content/recipes/wrapping-stores.mdx +++ b/apps/docs/content/recipes/wrapping-stores.mdx @@ -90,7 +90,7 @@ const persist = persistStore(usePrefs(), { ## redux -Wrap the **root** reducer with `persistableReducer` (not each slice) so hydrate can replace state. Alias if you also import redux-persist's `persistStore`. +Wrap the root reducer with `persistableReducer` (not each slice) so hydrate can replace state. Alias if you also import redux-persist's `persistStore`. ```ts import { createStore } from "redux"; diff --git a/apps/docs/pages/_home/source-snippets.ts b/apps/docs/pages/_home/source-snippets.ts index fdad1fd..e51dd51 100644 --- a/apps/docs/pages/_home/source-snippets.ts +++ b/apps/docs/pages/_home/source-snippets.ts @@ -9,7 +9,7 @@ export interface SourceSnippet { export const defaultSourceId = "tanstack-store"; -/** Order: tanstack-store → zustand → jotai → valtio → mobx → custom */ +/** Hero subset (docs sources also list pinia → redux before custom). */ export const sourceSnippets: SourceSnippet[] = [ { id: "tanstack-store", diff --git a/src/adapters/sources/redux.ts b/src/adapters/sources/redux.ts index 5b76672..93bbf83 100644 --- a/src/adapters/sources/redux.ts +++ b/src/adapters/sources/redux.ts @@ -13,10 +13,10 @@ interface PersistSetAction { } /** - * Wrap the **root** reducer so hydrate/`setState` can replace state. Do not - * wrap individual slices inside `combineReducers` — the payload is the full - * root. Without this wrapper RTK slices ignore the private action (silent - * no-op). Named to avoid clashing with redux-persist's `persistReducer`. + * Wrap the root reducer so hydrate/`setState` can replace state. Do not wrap + * slices inside `combineReducers` — the payload is the full root. Without this, + * RTK slices ignore the private action (silent no-op). Named to avoid clashing + * with redux-persist's `persistReducer`. * * @example * ```ts From 642e9c5f09b09c6809325207f8514e21ec37368a Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:16:28 +0300 Subject: [PATCH 8/9] docs(home): add Pinia and Redux source pills Complete the hero source-swap roster with brand icons and minimal persistStore snippets, ordered before the custom escape hatch. --- apps/docs/pages/_home/source-snippets.ts | 39 +++++++++++++++++++++++- apps/docs/public/brands/pinia.svg | 1 + apps/docs/public/brands/redux.svg | 1 + 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 apps/docs/public/brands/pinia.svg create mode 100644 apps/docs/public/brands/redux.svg diff --git a/apps/docs/pages/_home/source-snippets.ts b/apps/docs/pages/_home/source-snippets.ts index e51dd51..be50092 100644 --- a/apps/docs/pages/_home/source-snippets.ts +++ b/apps/docs/pages/_home/source-snippets.ts @@ -9,7 +9,7 @@ export interface SourceSnippet { export const defaultSourceId = "tanstack-store"; -/** Hero subset (docs sources also list pinia → redux before custom). */ +/** Order: tanstack-store → zustand → jotai → valtio → mobx → pinia → redux → custom */ export const sourceSnippets: SourceSnippet[] = [ { id: "tanstack-store", @@ -90,6 +90,43 @@ const prefs = observable.object({ theme: "light" as const }); persistObservable(prefs, { name: "app:prefs:v1", storage: createJSONStorage(() => localStorage), +});`, + }, + { + id: "pinia", + label: "Pinia", + icon: "/brands/pinia.svg", + lang: "ts", + installCommand: "bun add @stainless-code/persist pinia", + code: `import { defineStore } from "pinia"; +import { createJSONStorage } from "@stainless-code/persist"; +import { persistStore } from "@stainless-code/persist/sources/pinia"; + +const usePrefs = defineStore("prefs", { + state: () => ({ theme: "light" as const }), +}); +persistStore(usePrefs(), { + name: "app:prefs:v1", + storage: createJSONStorage(() => localStorage), +});`, + }, + { + id: "redux", + label: "Redux", + icon: "/brands/redux.svg", + lang: "ts", + installCommand: "bun add @stainless-code/persist redux", + code: `import { createStore } from "redux"; +import { createJSONStorage } from "@stainless-code/persist"; +import { + persistStore, + persistableReducer, +} from "@stainless-code/persist/sources/redux"; + +const store = createStore(persistableReducer(rootReducer)); +persistStore(store, { + name: "app:root:v1", + storage: createJSONStorage(() => localStorage), });`, }, { diff --git a/apps/docs/public/brands/pinia.svg b/apps/docs/public/brands/pinia.svg new file mode 100644 index 0000000..3a20cba --- /dev/null +++ b/apps/docs/public/brands/pinia.svg @@ -0,0 +1 @@ + diff --git a/apps/docs/public/brands/redux.svg b/apps/docs/public/brands/redux.svg new file mode 100644 index 0000000..1b65ccc --- /dev/null +++ b/apps/docs/public/brands/redux.svg @@ -0,0 +1 @@ + \ No newline at end of file From 85f33f53f006eb8a049f76254510b6ef46ce9ad1 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:17:10 +0300 Subject: [PATCH 9/9] docs(home): align Seams sources with hero pills Use Any source (not custom source); include Pinia and Redux. --- apps/docs/pages/_home/Seams.astro | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/docs/pages/_home/Seams.astro b/apps/docs/pages/_home/Seams.astro index 7c5a9bd..a882925 100644 --- a/apps/docs/pages/_home/Seams.astro +++ b/apps/docs/pages/_home/Seams.astro @@ -20,7 +20,9 @@ const sources = [ "Jotai", "Valtio", "MobX", - "custom source", + "Pinia", + "Redux", + "Any source", ]; const frameworks = [