diff --git a/.agents/skills/docs-voice/SKILL.md b/.agents/skills/docs-voice/SKILL.md index 8ae0222..185de9b 100644 --- a/.agents/skills/docs-voice/SKILL.md +++ b/.agents/skills/docs-voice/SKILL.md @@ -34,7 +34,7 @@ was rejected) and the "when **not** to use it" anti-sell — keep both. - **Adapter / listing order:** core → backends → codecs → sources → frameworks → transport. Within sources: tanstack-store → zustand → jotai → valtio → mobx → pinia → redux → custom. Within frameworks: react → preact → solid → - angular → vue → svelte (runes → store). + angular → vue → lit → alpine → svelte (runes → store). ## Don't diff --git a/.changeset/alpine-framework-adapter.md b/.changeset/alpine-framework-adapter.md new file mode 100644 index 0000000..ef75c80 --- /dev/null +++ b/.changeset/alpine-framework-adapter.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": minor +--- + +Add Alpine framework adapter (`./frameworks/alpine`) — `useHydrated` + `$hydrated` plugin over `HydrationSignal`. diff --git a/.changeset/lit-framework-adapter.md b/.changeset/lit-framework-adapter.md new file mode 100644 index 0000000..540142a --- /dev/null +++ b/.changeset/lit-framework-adapter.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": minor +--- + +Add Lit `HydrationController` framework adapter (`./frameworks/lit`). diff --git a/README.md b/README.md index 5a74a15..bd8bc23 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, 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. +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, Preact, Solid, Angular, Vue, Lit, Alpine, and Svelte. [![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/adapters/framework-adapter.mdx b/apps/docs/content/adapters/framework-adapter.mdx index 680ea81..f00c713 100644 --- a/apps/docs/content/adapters/framework-adapter.mdx +++ b/apps/docs/content/adapters/framework-adapter.mdx @@ -1,11 +1,13 @@ --- title: Writing a framework adapter -description: Bridge HydrationSignal into your UI framework — the same shape as the shipped React, Solid, Vue, and Svelte adapters. +description: Bridge HydrationSignal into your UI framework — same shape as the shipped React, Preact, Solid, Angular, Vue, Lit, Alpine, and Svelte adapters. search: tags: ["adapter", "framework"] --- -The React hook (`@stainless-code/persist/frameworks/react`) is ~20 lines over `HydrationSignal` — every adapter is the same shape. Solid (`@stainless-code/persist/frameworks/solid`, `Accessor` via `from`), Vue (`@stainless-code/persist/frameworks/vue`, `Ref` via `shallowRef` + `onScopeDispose`), and Svelte (`@stainless-code/persist/frameworks/svelte` runes `hydratedRune`; `@stainless-code/persist/frameworks/svelte-store` `hydratedStore` for Svelte 4 + Svelte 5 store users) ship the same way. The contract (full version on `HydrationSignal`'s JSDoc): subscribe returns an idempotent unsubscribe; each subscribe call is an independent subscription; **no initial notification and no payload** — pull `isHydrated()` after attach and on every notification; transitions while detached aren't replayed (the snapshot re-read recovers); **render `hydrated: true` on the server** (no storage server-side); `null` signal = no persistence = hydrated. +The React hook is ~20 lines over `HydrationSignal` — every adapter is that shape. Preact, Solid, Angular, Vue, Lit, Alpine, and Svelte ship the same contract (full text on `HydrationSignal`'s JSDoc). + +Subscribe returns an idempotent unsubscribe; each call is an independent subscription. **No initial notification and no payload** — pull `isHydrated()` after attach and on every notification. Transitions while detached aren't replayed (re-read recovers). **Render `hydrated: true` on the server**; `null` signal = no persistence = hydrated. ```ts import type { HydrationSignal } from "@stainless-code/persist"; diff --git a/apps/docs/content/concepts/entry-points.mdx b/apps/docs/content/concepts/entry-points.mdx index bd628bb..39195fc 100644 --- a/apps/docs/content/concepts/entry-points.mdx +++ b/apps/docs/content/concepts/entry-points.mdx @@ -28,11 +28,13 @@ One subpath = one optional peer. No barrel — importing a subpath is the depend | `@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/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | | `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | +| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal`) | `@angular/core` (>=17) | | `@stainless-code/persist/frameworks/vue` | `useHydrated` (Vue `Ref`) | `vue` | +| `@stainless-code/persist/frameworks/lit` | `HydrationController` (Lit `ReactiveController`) | `lit` (>=3) | +| `@stainless-code/persist/frameworks/alpine` | `useHydrated` + `$hydrated` plugin (Alpine reactive bag) | `alpinejs` (>=3) | | `@stainless-code/persist/frameworks/svelte` | `hydratedRune` (Svelte 5 runes `current`) | `svelte` (>=5.7) | | `@stainless-code/persist/frameworks/svelte-store` | `hydratedStore` (Svelte `Readable`) | `svelte` (>=3) | -| `@stainless-code/persist/frameworks/angular` | `useHydrated` (Angular `Signal`) | `@angular/core` (>=17) | -| `@stainless-code/persist/frameworks/preact` | `useHydrated` (Preact `{ hydrated: boolean }`) | `preact` (>=10.19) | Generated signatures: [API reference](/reference/api). diff --git a/apps/docs/content/guides/hydration.mdx b/apps/docs/content/guides/hydration.mdx index d764944..af416c1 100644 --- a/apps/docs/content/guides/hydration.mdx +++ b/apps/docs/content/guides/hydration.mdx @@ -16,18 +16,45 @@ const { hydrated } = useHydrated(prefsHydration); if (!hydrated) return ; ``` +```ts +// Preact — { hydrated: boolean } +import { useHydrated } from "@stainless-code/persist/frameworks/preact"; +const { hydrated } = useHydrated(prefsHydration); +``` + ```ts // Solid — Accessor import { useHydrated } from "@stainless-code/persist/frameworks/solid"; const hydrated = useHydrated(prefsHydration); ``` +```ts +// Angular — Signal +import { useHydrated } from "@stainless-code/persist/frameworks/angular"; +const hydrated = useHydrated(prefsHydration); +``` + ```ts // Vue — Ref import { useHydrated } from "@stainless-code/persist/frameworks/vue"; const hydrated = useHydrated(prefsHydration); ``` +```ts +// Lit — ReactiveController +import { HydrationController } from "@stainless-code/persist/frameworks/lit"; +// in LitElement: this.#hydration = new HydrationController(this, prefsHydration); +// gate: this.#hydration.hydrated +``` + +```ts +// Alpine — reactive bag + plugin +import persist, { useHydrated } from "@stainless-code/persist/frameworks/alpine"; +Alpine.plugin(persist); +const { hydrated } = useHydrated(prefsHydration); +// template: x-show="hydrated" / $hydrated(prefsHydration).hydrated +``` + ```ts // Svelte 5 runes import { hydratedRune } from "@stainless-code/persist/frameworks/svelte"; @@ -41,16 +68,4 @@ import { hydratedStore } from "@stainless-code/persist/frameworks/svelte-store"; const hydrated = hydratedStore(prefsHydration); ``` -```ts -// Angular — Signal -import { useHydrated } from "@stainless-code/persist/frameworks/angular"; -const hydrated = useHydrated(prefsHydration); -``` - -```ts -// Preact — { hydrated: boolean } -import { useHydrated } from "@stainless-code/persist/frameworks/preact"; -const { hydrated } = useHydrated(prefsHydration); -``` - See [IndexedDB + React](/guides/idb-react) for the full async path, and [Writing a framework adapter](/adapters/framework-adapter) to author a new one. diff --git a/apps/docs/content/reference/api/index.mdx b/apps/docs/content/reference/api/index.mdx index fb4fc53..b8f41e6 100644 --- a/apps/docs/content/reference/api/index.mdx +++ b/apps/docs/content/reference/api/index.mdx @@ -121,12 +121,24 @@ One TypeDoc module per package entry. [Reference](/reference) `@stainless-code/persist/frameworks/react` + + `@stainless-code/persist/frameworks/preact` + `@stainless-code/persist/frameworks/solid` + + `@stainless-code/persist/frameworks/angular` + `@stainless-code/persist/frameworks/vue` + + `@stainless-code/persist/frameworks/lit` + + + `@stainless-code/persist/frameworks/alpine` + `@stainless-code/persist/frameworks/svelte` @@ -137,10 +149,4 @@ One TypeDoc module per package entry. [Reference](/reference) > `@stainless-code/persist/frameworks/svelte-store` - - `@stainless-code/persist/frameworks/angular` - - - `@stainless-code/persist/frameworks/preact` - diff --git a/apps/docs/content/reference/api/meta.ts b/apps/docs/content/reference/api/meta.ts index bb79b5e..6e2755e 100644 --- a/apps/docs/content/reference/api/meta.ts +++ b/apps/docs/content/reference/api/meta.ts @@ -23,11 +23,13 @@ export default defineMeta({ "adapters-sources-pinia", "adapters-sources-redux", "adapters-frameworks-react", + "adapters-frameworks-preact", "adapters-frameworks-solid", + "adapters-frameworks-angular", "adapters-frameworks-vue", + "adapters-frameworks-lit", + "adapters-frameworks-alpine", "adapters-frameworks-svelte", "adapters-frameworks-svelte-store", - "adapters-frameworks-angular", - "adapters-frameworks-preact", ], }); diff --git a/apps/docs/pages/_home/Batteries.astro b/apps/docs/pages/_home/Batteries.astro index e808f2b..cd61796 100644 --- a/apps/docs/pages/_home/Batteries.astro +++ b/apps/docs/pages/_home/Batteries.astro @@ -97,7 +97,7 @@ import { contentHref } from "blume/components/content/base-href.ts"; title="No framework in the core" > Zero-dep engine — adapters mount HydrationSignal into - React, Solid, Vue, Svelte, Angular, Preact. + React, Preact, Solid, Angular, Vue, Lit, Alpine, Svelte. diff --git a/apps/docs/pages/_home/Seams.astro b/apps/docs/pages/_home/Seams.astro index a882925..494e0a5 100644 --- a/apps/docs/pages/_home/Seams.astro +++ b/apps/docs/pages/_home/Seams.astro @@ -31,6 +31,8 @@ const frameworks = [ "Solid", "Angular", "Vue", + "Lit", + "Alpine", "Svelte (runes)", "Svelte (store)", ]; diff --git a/apps/docs/pages/_home/UseCases.astro b/apps/docs/pages/_home/UseCases.astro index 7706e1a..932947e 100644 --- a/apps/docs/pages/_home/UseCases.astro +++ b/apps/docs/pages/_home/UseCases.astro @@ -43,7 +43,7 @@ const reachFor = [ fit: "Nice-to-have", }, { - useCase: "Gate UI the same way in React / Solid / Vue / Svelte / Angular / Preact", + useCase: "Gate UI the same way in React / Preact / Solid / Angular / Vue / Lit / Alpine / Svelte", involves: "`./frameworks/*` hydration adapters", fit: "Ideal", }, diff --git a/bun.lock b/bun.lock index 8268ce1..719662d 100644 --- a/bun.lock +++ b/bun.lock @@ -17,11 +17,13 @@ "@tanstack/store": "0.11.0", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", + "@types/alpinejs": "3.13.11", "@types/bun": "1.3.14", "@types/node": "26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260705.1", + "alpinejs": "3.15.12", "expo-secure-store": "57.0.0", "husky": "9.1.7", "idb-keyval": "6.2.6", @@ -29,6 +31,7 @@ "jsdom": "29.1.1", "knip": "6.24.0", "lint-staged": "17.0.8", + "lit": "3.3.3", "mobx": "6.16.1", "oxfmt": "0.57.0", "oxlint": "1.72.0", @@ -58,9 +61,11 @@ "@angular/core": ">=17.0.0", "@react-native-async-storage/async-storage": ">=1.0.0", "@tanstack/store": ">=0.10.0", + "alpinejs": ">=3.0.0", "expo-secure-store": ">=12.0.0", "idb-keyval": ">=4.0.0", "jotai": ">=2.0.0", + "lit": ">=3.0.0", "mobx": ">=6.0.0", "pinia": ">=2.0.0", "preact": ">=10.19.0", @@ -79,9 +84,11 @@ "@angular/core", "@react-native-async-storage/async-storage", "@tanstack/store", + "alpinejs", "expo-secure-store", "idb-keyval", "jotai", + "lit", "mobx", "pinia", "preact", @@ -623,6 +630,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.6.0", "", {}, "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ=="], + + "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], + "@loaderkit/resolve": ["@loaderkit/resolve@1.0.6", "", { "dependencies": { "@braidai/lang": "^1.0.0" } }, "sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg=="], "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], @@ -1015,6 +1026,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@types/alpinejs": ["@types/alpinejs@3.13.11", "", {}, "sha512-3KhGkDixCPiLdL3Z/ok1GxHwLxEWqQOKJccgaQL01wc0EVM2tCTaqlC3NIedmxAXkVzt/V6VTM8qPgnOHKJ1MA=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -1215,7 +1228,7 @@ "@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/reactivity": ["@vue/reactivity@3.1.5", "", { "dependencies": { "@vue/shared": "3.1.5" } }, "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg=="], "@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=="], @@ -1253,6 +1266,8 @@ "ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="], + "alpinejs": ["alpinejs@3.15.12", "", { "dependencies": { "@vue/reactivity": "~3.1.1" } }, "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q=="], + "am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="], "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], @@ -2085,6 +2100,12 @@ "listr2": ["listr2@10.2.2", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw=="], + "lit": ["lit@3.3.3", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw=="], + + "lit-element": ["lit-element@4.2.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w=="], + + "lit-html": ["lit-html@3.3.3", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -3199,6 +3220,12 @@ "@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + "@vue/reactivity/@vue/shared": ["@vue/shared@3.1.5", "", {}, "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="], + + "@vue/runtime-core/@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="], + + "@vue/runtime-dom/@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="], + "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 e49c908..0e1ad4a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,12 +37,14 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@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/preact` | `adapters/frameworks/preact` | `preact` (>=10.19) | | `@stainless-code/persist/frameworks/solid` | `adapters/frameworks/solid` | `solid-js` | +| `@stainless-code/persist/frameworks/angular` | `adapters/frameworks/angular` | `@angular/core` (>=17) | | `@stainless-code/persist/frameworks/vue` | `adapters/frameworks/vue` | `vue` | +| `@stainless-code/persist/frameworks/lit` | `adapters/frameworks/lit` | `lit` (>=3) | +| `@stainless-code/persist/frameworks/alpine` | `adapters/frameworks/alpine` | `alpinejs` (>=3) | | `@stainless-code/persist/frameworks/svelte` | `adapters/frameworks/svelte` | `svelte` (>=5.7 runes) | | `@stainless-code/persist/frameworks/svelte-store` | `adapters/frameworks/svelte-store` | `svelte` (>=3 store) | -| `@stainless-code/persist/frameworks/angular` | `adapters/frameworks/angular` | `@angular/core` (>=17) | -| `@stainless-code/persist/frameworks/preact` | `adapters/frameworks/preact` | `preact` (>=10.19) | No barrel — importing a subpath is the dependency opt-in. Each subpath entry owns its optional peer dep when the adapter needs one — the no-peer entries are the core, encrypted, compressed, node-fs, and crosstab subpaths — and the peer stays external in the build (`tsdown.config.ts` `neverBundle`) so consumers tree-shake cleanly. @@ -56,13 +58,13 @@ No barrel — importing a subpath is the dependency opt-in. Each subpath entry o - `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, 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) + - `frameworks/` — `HydrationSignal` framework adapters (react, preact, solid, angular, vue, lit, alpine, svelte, svelte-store) 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. ## Hydration lifecycle -`persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down via `destroy()`. The hydration signal (`HydrationSignal` from `hydration`) is observed from outside the store — framework adapters mount it into their external-store mechanism (React `useSyncExternalStore` via `./frameworks/react`, Solid `from` via `./frameworks/solid`, Vue `shallowRef` + `onScopeDispose` via `./frameworks/vue`, Svelte runes `createSubscriber` via `./frameworks/svelte` / stores `readable` via `./frameworks/svelte-store`, Angular `signal` + `effect` via `./frameworks/angular`, Preact `useSyncExternalStore` via `preact/compat` via `./frameworks/preact`) without coupling to the store's read path. SSR policy: render `hydrated = true` on the server; `null` signal = no persistence = hydrated. +`persistSource` hydrates on create (skip with `skipHydration`; `rehydrate()` is awaitable), subscribe-writes on every `setState` (gated until hydrated; optional trailing `throttleMs`), and tears down via `destroy()`. The hydration signal (`HydrationSignal` from `hydration`) is observed from outside the store — framework adapters mount it into their external-store mechanism (React / Preact `useSyncExternalStore`, Solid `from`, Angular `signal` + `effect`, Vue `shallowRef` + `onScopeDispose`, Lit `ReactiveController`, Alpine reactive bag + `$hydrated`, Svelte runes / stores) without coupling to the store's read path. SSR policy: render `hydrated = true` on the server; `null` signal = no persistence = hydrated. ## Sync vs async diff --git a/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index e8da91e..40255f7 100644 --- a/docs/plans/remaining-roi.md +++ b/docs/plans/remaining-roi.md @@ -10,7 +10,7 @@ Actionable items not yet shipped from the docs-adapters ROI work. When an item s - **Codec** — `StorageCodec`: pure `encode` / `decode` between the `StorageValue` envelope and the backend's wire type. Sync by design — async transforms (encryption, compression) are backend **wrappers**, not codecs. - **Source** — `PersistableSource`: `getState` / `setState` / `subscribe`. Structural, store-agnostic. -Framework adapters mount `HydrationSignal` into each framework's external-store mechanism (React `useSyncExternalStore`, Solid `from`, Vue `shallowRef` + `onScopeDispose`, Svelte runes `createSubscriber` / stores `readable`, Angular `signal` + `effect`, Preact `useSyncExternalStore` via `preact/compat`). +Framework adapters mount `HydrationSignal` into each framework's external-store mechanism (React / Preact `useSyncExternalStore`, Solid `from`, Angular `signal` + `effect`, Vue `shallowRef` + `onScopeDispose`, Lit `ReactiveController`, Alpine reactive bag + `$hydrated`, Svelte runes `createSubscriber` / stores `readable`). **Layout:** `src/core/` (zero-dep engine) + `src/adapters//` (`codecs/`, `backends/`, `transport/`, `sources/`, `frameworks/`). One subpath per optional peer, mirroring `src/` → `dist/` → `.//` 1:1. No barrel — importing a subpath is the dependency opt-in. Each adapter imports only from `core/` (enforced by a per-entry self-check test). Full seam model + entry-point table + test matrix: [`docs/architecture.md`](../architecture.md). Consumer docs: [https://stainless-code.com/persist](https://stainless-code.com/persist) (`apps/docs`); npm landing: root [`README.md`](../../README.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). -### 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`. - -#### `./frameworks/lit` — `HydrationController` (or `useHydrated` controller) - -- **What:** Lit `ReactiveController` that mounts a `HydrationSignal` and exposes `hydrated` (SSR snapshot `true` when no signal / on server). Peer: `lit`. -- **Why (fact-checked):** Layers Lit adapter drives hosts via `ReactiveController` + `host.requestUpdate()` on subscribe (`packages/lit/src/index.ts` `subscribeStackSnapshot` / `StackController`). Persist's contract is the same seam as React's `useHydrated`: `subscribeHydrated` + `isHydrated` only — gate flash, don't own store reads. Lit has no hooks; controller hostAdd / hostDisconnected is the lifecycle pair. -- **Mapping:** `hostConnected` → subscribe; on notify → `host.requestUpdate()`; `hostDisconnected` → unsubscribe; getter `hydrated` → `signal?.isHydrated() ?? true`. -- **Acceptance:** subpath + controller tests (mock `ReactiveControllerHost`); adapters index + docs guide. Changeset: `minor`. -- **Lands:** `src/adapters/frameworks/lit.ts`, entry-points table, [`adapters`](../../apps/docs/content/) / getting-started framework list. - -#### `./frameworks/alpine` — plugin + `$hydrated` / `useHydrated` - -- **What:** Alpine plugin that mounts `HydrationSignal` into Alpine reactivity (`Alpine.reactive` bag + subscribe). Peer: `alpinejs`. Optional CDN entry if Layers-style auto-plugin is wanted later. -- **Why (fact-checked):** Layers Alpine keeps a reactive bag and `stack.subscribe` → mutate bag so Alpine tracks (`packages/alpine/src/index.ts`); warns if `useStack` runs before `Alpine.plugin`. Persist needs the same: bare `subscribeHydrated` alone won't re-render `x-show` / `x-text` without a reactive property Alpine already tracks. Prefer thin magic/data (`$hydrated(signal)` or `Alpine.data`) over a heavy directive — hydration is a boolean gate, not a stack outlet. -- **Mapping:** `getSnapshot` → `reactive({ hydrated })`; `subscribeHydrated` → set `bag.hydrated = signal.isHydrated()`; cleanup on Alpine destroy / effect teardown; null signal → `hydrated: true`. -- **Acceptance:** subpath + plugin tests (mock Alpine runtime or happy-dom + alpine); adapters index + docs. Changeset: `minor`. -- **Lands:** `src/adapters/frameworks/alpine.ts`, entry-points table, docs framework list (order after Vue, before Svelte — match Layers: … Vue, Lit, Alpine, Svelte). - ## Backlog (lower-priority, brainstormed — not ROI-tiered) From audit Appendix B.3. Each is a one-line composition over an existing seam; ship only if demand surfaces. @@ -102,10 +82,9 @@ 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. **#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. +2. **#3 (real-browser + SSR matrix)** — M, de-risks the hydration-critical paths before more surface lands. +3. **#2 (examples/) → #6 (playground)** — demo arc; docs site already shipped. +4. **#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 cfd95a8..c117cc2 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, **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, `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). --- diff --git a/package.json b/package.json index c89e0b5..e787241 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.2.1", "description": "Hydration-aware persistence for any reactive store — zero-dep persistSource core; codecs, backends, cross-tab transport, source + framework hydration adapters ship as opt-in recipes", "keywords": [ + "alpine", "angular", "broadcastchannel", "codec", @@ -12,6 +13,7 @@ "hydration", "indexeddb", "jotai", + "lit", "localstorage", "middleware", "migration", @@ -134,14 +136,30 @@ "types": "./dist/frameworks/react.d.mts", "import": "./dist/frameworks/react.mjs" }, + "./frameworks/preact": { + "types": "./dist/frameworks/preact.d.mts", + "import": "./dist/frameworks/preact.mjs" + }, "./frameworks/solid": { "types": "./dist/frameworks/solid.d.mts", "import": "./dist/frameworks/solid.mjs" }, + "./frameworks/angular": { + "types": "./dist/frameworks/angular.d.mts", + "import": "./dist/frameworks/angular.mjs" + }, "./frameworks/vue": { "types": "./dist/frameworks/vue.d.mts", "import": "./dist/frameworks/vue.mjs" }, + "./frameworks/lit": { + "types": "./dist/frameworks/lit.d.mts", + "import": "./dist/frameworks/lit.mjs" + }, + "./frameworks/alpine": { + "types": "./dist/frameworks/alpine.d.mts", + "import": "./dist/frameworks/alpine.mjs" + }, "./frameworks/svelte": { "types": "./dist/frameworks/svelte.d.mts", "import": "./dist/frameworks/svelte.mjs" @@ -149,14 +167,6 @@ "./frameworks/svelte-store": { "types": "./dist/frameworks/svelte-store.d.mts", "import": "./dist/frameworks/svelte-store.mjs" - }, - "./frameworks/angular": { - "types": "./dist/frameworks/angular.d.mts", - "import": "./dist/frameworks/angular.mjs" - }, - "./frameworks/preact": { - "types": "./dist/frameworks/preact.d.mts", - "import": "./dist/frameworks/preact.mjs" } }, "publishConfig": { @@ -212,11 +222,13 @@ "@tanstack/store": "0.11.0", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", + "@types/alpinejs": "3.13.11", "@types/bun": "1.3.14", "@types/node": "26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260705.1", + "alpinejs": "3.15.12", "expo-secure-store": "57.0.0", "husky": "9.1.7", "idb-keyval": "6.2.6", @@ -224,6 +236,7 @@ "jsdom": "29.1.1", "knip": "6.24.0", "lint-staged": "17.0.8", + "lit": "3.3.3", "mobx": "6.16.1", "oxfmt": "0.57.0", "oxlint": "1.72.0", @@ -253,9 +266,11 @@ "@angular/core": ">=17.0.0", "@react-native-async-storage/async-storage": ">=1.0.0", "@tanstack/store": ">=0.10.0", + "alpinejs": ">=3.0.0", "expo-secure-store": ">=12.0.0", "idb-keyval": ">=4.0.0", "jotai": ">=2.0.0", + "lit": ">=3.0.0", "mobx": ">=6.0.0", "pinia": ">=2.0.0", "preact": ">=10.19.0", @@ -298,6 +313,12 @@ "vue": { "optional": true }, + "lit": { + "optional": true + }, + "alpinejs": { + "optional": true + }, "zod": { "optional": true }, diff --git a/src/adapters/frameworks/alpine.test.ts b/src/adapters/frameworks/alpine.test.ts new file mode 100644 index 0000000..4158f1c --- /dev/null +++ b/src/adapters/frameworks/alpine.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; + +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; +import type { AlpineLike, HydratedBag } from "./alpine"; +import persist, { useHydrated } from "./alpine"; + +function createFakeSignal() { + let hydrated = false; + const listeners = new Set<() => void>(); + return { + subscribeHydrated: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + isHydrated: () => hydrated, + set: (value: boolean) => { + hydrated = value; + listeners.forEach((l) => l()); + }, + listenerCount: () => listeners.size, + }; +} + +type MagicFn = AlpineLike["magic"] extends ( + name: string, + callback: infer C, +) => void + ? C + : never; + +function createMockAlpine(): AlpineLike & { magics: Map } { + const magics = new Map(); + return { + magics, + reactive: (o) => o, + magic(name, fn) { + magics.set(name, fn); + }, + }; +} + +describe("useHydrated / persist (alpine)", () => { + itImportsOnlyFromCore(new URL("./alpine.ts", import.meta.url)); + + afterEach(() => { + // Keep a mock runtime installed so later tests don't trip the missing-plugin warn. + persist(createMockAlpine()); + }); + + it("null signal → hydrated true (no subscribe)", () => { + persist(createMockAlpine()); + const bag = useHydrated(null); + expect(bag.hydrated).toBe(true); + bag.destroy(); + }); + + it("undefined signal → hydrated true", () => { + persist(createMockAlpine()); + expect(useHydrated(undefined).hydrated).toBe(true); + }); + + it("signal flip updates bag.hydrated and calls Alpine.reactive", () => { + const reactiveCalls: object[] = []; + const alpine = createMockAlpine(); + const { magics } = alpine; + const tracked: AlpineLike & { magics: typeof magics } = { + magics, + reactive: (o) => { + reactiveCalls.push(o as object); + return o; + }, + magic: alpine.magic.bind(alpine), + }; + persist(tracked); + const signal = createFakeSignal(); + const bag = useHydrated(signal); + expect(reactiveCalls).toHaveLength(1); + expect(bag.hydrated).toBe(false); + expect(signal.listenerCount()).toBe(1); + + signal.set(true); + expect(bag.hydrated).toBe(true); + + signal.set(false); + expect(bag.hydrated).toBe(false); + + bag.destroy(); + expect(signal.listenerCount()).toBe(0); + signal.set(true); + expect(bag.hydrated).toBe(false); + }); + + it("plugin registers $hydrated magic", () => { + const alpine = createMockAlpine(); + persist(alpine); + + expect(alpine.magics.has("hydrated")).toBe(true); + const magic = alpine.magics.get("hydrated")!; + const el = {} as Element; + const cleanups: Array<() => void> = []; + const fn = magic( + el as never, + { + Alpine: alpine, + cleanup: (cb: () => void) => cleanups.push(cb), + } as unknown as Parameters[1], + ) as (signal: ReturnType | null) => HydratedBag; + + const signal = createFakeSignal(); + const bag = fn(signal); + expect(bag.hydrated).toBe(false); + expect(fn(signal)).toBe(bag); + + signal.set(true); + expect(bag.hydrated).toBe(true); + + for (const c of cleanups) c(); + expect(signal.listenerCount()).toBe(0); + }); +}); + +describe("useHydrated before plugin (isolated)", () => { + it("warns once when useHydrated runs before Alpine.plugin(persist)", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const mod = await import(`./alpine.ts?pre-plugin=${Date.now()}`); + expect(mod.useHydrated(null).hydrated).toBe(true); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("Alpine.plugin(persist)"); + + mod.useHydrated(null); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); +}); diff --git a/src/adapters/frameworks/alpine.ts b/src/adapters/frameworks/alpine.ts new file mode 100644 index 0000000..e27fb68 --- /dev/null +++ b/src/adapters/frameworks/alpine.ts @@ -0,0 +1,110 @@ +// Alpine hydration adapter — peer `alpinejs` >=3.0.0 (`@types/alpinejs`; package ships none). +import type { HydrationSignal } from "../../core/hydration"; + +export type AlpineLike = Pick; + +/** Alpine-reactive bag returned by {@link useHydrated}. */ +export interface HydratedBag { + hydrated: boolean; + /** Unsubscribe from the hydration signal. No-op when there is no signal. */ + destroy(): void; +} + +let alpineRuntime: AlpineLike | undefined; +let warnedMissingRuntime = false; + +/** Reactive holder when the plugin has run; plain object fallback for unit tests. */ +function alpineReactive(seed: T): T { + if (alpineRuntime) return alpineRuntime.reactive(seed); + if (process.env.NODE_ENV !== "production" && !warnedMissingRuntime) { + warnedMissingRuntime = true; + console.warn( + "[@stainless-code/persist/frameworks/alpine] useHydrated called before Alpine.plugin(persist); updates won't be reactive.", + ); + } + return seed; +} + +/** + * Mount a `HydrationSignal` into Alpine reactivity — reactive `{ hydrated }` + * for `x-show` / `x-text`. Call after `Alpine.plugin(persist)`. Null signal → + * `{ hydrated: true }`. Tear down with `bag.destroy()` from `Alpine.data`. + * Template `$hydrated(signal)` caches per element and cleans up on remove. + * + * @example + * ```ts + * import persist, { useHydrated } from "@stainless-code/persist/frameworks/alpine"; + * Alpine.plugin(persist); + * Alpine.data("prefs", () => { + * const hydration = useHydrated(prefsHydration); + * return { + * get hydrated() { return hydration.hydrated; }, + * destroy() { hydration.destroy(); }, + * }; + * }); + * ``` + */ +export function useHydrated( + signal: HydrationSignal | null | undefined, +): HydratedBag { + let unsubscribe: (() => void) | undefined; + const bag = alpineReactive({ + hydrated: signal?.isHydrated() ?? true, + destroy() { + unsubscribe?.(); + unsubscribe = undefined; + }, + }); + + if (signal) { + unsubscribe = signal.subscribeHydrated(() => { + bag.hydrated = signal.isHydrated(); + }); + } + + return bag; +} + +/** Per-element cache so `$hydrated(signal)` re-evals don't stack subscriptions. */ +const magicBags = new WeakMap< + Element, + Map +>(); + +/** + * Alpine plugin — stores the runtime for `Alpine.reactive` and registers + * `$hydrated` (`(signal) => useHydrated(signal)`). + * + * @example + * ```ts + * import persist from "@stainless-code/persist/frameworks/alpine"; + * Alpine.plugin(persist); + * ``` + */ +export default function persist(Alpine: AlpineLike): void { + alpineRuntime = Alpine; + warnedMissingRuntime = false; + + Alpine.magic("hydrated", (el, { cleanup }) => { + return (signal: HydrationSignal | null | undefined) => { + let map = magicBags.get(el); + if (!map) { + map = new Map(); + magicBags.set(el, map); + cleanup(() => { + for (const bag of map!.values()) bag.destroy(); + magicBags.delete(el); + }); + } + const key = signal ?? null; + let bag = map.get(key); + if (!bag) { + bag = useHydrated(signal); + map.set(key, bag); + } + return bag; + }; + }); +} + +export { persist }; diff --git a/src/adapters/frameworks/lit.test.ts b/src/adapters/frameworks/lit.test.ts new file mode 100644 index 0000000..2b702cc --- /dev/null +++ b/src/adapters/frameworks/lit.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, mock } from "bun:test"; + +import type { ReactiveControllerHost } from "lit"; + +import { itImportsOnlyFromCore } from "../../testing/assert-core-only-imports"; +import { HydrationController } from "./lit"; + +function createFakeSignal() { + let hydrated = false; + const listeners = new Set<() => void>(); + return { + subscribeHydrated: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + isHydrated: () => hydrated, + set: (value: boolean) => { + hydrated = value; + listeners.forEach((l) => l()); + }, + listenerCount: () => listeners.size, + }; +} + +function createFakeHost(): ReactiveControllerHost & { + requestUpdate: ReturnType; + controllers: unknown[]; +} { + const controllers: unknown[] = []; + return { + controllers, + addController(controller) { + controllers.push(controller); + }, + removeController() {}, + requestUpdate: mock(() => {}), + updateComplete: Promise.resolve(true), + }; +} + +describe("HydrationController (lit)", () => { + itImportsOnlyFromCore(new URL("./lit.ts", import.meta.url)); + + it("registers with the host and stays hydrated for a null signal", () => { + const host = createFakeHost(); + const controller = new HydrationController(host, null); + expect(host.controllers).toEqual([controller]); + expect(controller.hydrated).toBe(true); + controller.hostConnected(); + expect(host.requestUpdate).not.toHaveBeenCalled(); + }); + + it("does not subscribe for undefined signal (hydrated stays true)", () => { + const host = createFakeHost(); + const controller = new HydrationController(host, undefined); + expect(controller.hydrated).toBe(true); + controller.hostConnected(); + expect(host.requestUpdate).not.toHaveBeenCalled(); + }); + + it("requestUpdate on connect and on signal flip; hydrated tracks isHydrated()", () => { + const signal = createFakeSignal(); + const host = createFakeHost(); + const controller = new HydrationController(host, signal); + expect(controller.hydrated).toBe(false); + controller.hostConnected(); + expect(signal.listenerCount()).toBe(1); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + + signal.set(true); + expect(host.requestUpdate).toHaveBeenCalledTimes(2); + expect(controller.hydrated).toBe(true); + + signal.set(false); + expect(host.requestUpdate).toHaveBeenCalledTimes(3); + expect(controller.hydrated).toBe(false); + }); + + it("requestUpdate on reconnect after rehydrate flips to false while detached", () => { + const signal = createFakeSignal(); + signal.set(true); + const host = createFakeHost(); + const controller = new HydrationController(host, signal); + controller.hostConnected(); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + controller.hostDisconnected(); + + signal.set(false); + controller.hostConnected(); + expect(controller.hydrated).toBe(false); + expect(host.requestUpdate).toHaveBeenCalledTimes(2); + }); + + it("hostDisconnected unsubscribes so further flips do not requestUpdate", () => { + const signal = createFakeSignal(); + const host = createFakeHost(); + const controller = new HydrationController(host, signal); + controller.hostConnected(); + expect(signal.listenerCount()).toBe(1); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + + controller.hostDisconnected(); + expect(signal.listenerCount()).toBe(0); + + signal.set(true); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + expect(controller.hydrated).toBe(true); + }); +}); diff --git a/src/adapters/frameworks/lit.ts b/src/adapters/frameworks/lit.ts new file mode 100644 index 0000000..06db442 --- /dev/null +++ b/src/adapters/frameworks/lit.ts @@ -0,0 +1,58 @@ +// Lit hydration adapter — peer `lit` >=3.0.0. +import type { ReactiveController, ReactiveControllerHost } from "lit"; + +import type { HydrationSignal } from "../../core/hydration"; + +/** + * Lit `ReactiveController` over `HydrationSignal` — gate with `hydrated`. + * Construct on the host (`addController`); subscribe on connect, tear down on + * disconnect. Null signal → `hydrated` stays `true`. + * + * @example + * ```ts + * import { LitElement, html } from "lit"; + * import { HydrationController } from "@stainless-code/persist/frameworks/lit"; + * + * class PrefsEl extends LitElement { + * #hydration = new HydrationController(this, prefsHydration); + * render() { + * return this.#hydration.hydrated + * ? html`` + * : html``; + * } + * } + * ``` + */ +export class HydrationController implements ReactiveController { + readonly #host: ReactiveControllerHost; + readonly #signal: HydrationSignal | null | undefined; + #unsubscribe: (() => void) | undefined; + + constructor( + host: ReactiveControllerHost, + signal: HydrationSignal | null | undefined, + ) { + this.#host = host; + this.#signal = signal; + host.addController(this); + } + + get hydrated(): boolean { + return this.#signal?.isHydrated() ?? true; + } + + hostConnected(): void { + if (!this.#signal) return; + this.#unsubscribe = this.#signal.subscribeHydrated(() => { + this.#host.requestUpdate(); + }); + // Pull-model attach: re-read snapshot (covers hydrate-before-connect and + // reconnect during rehydrate's false window). + this.#host.requestUpdate(); + } + + hostDisconnected(): void { + this.#unsubscribe?.(); + this.#unsubscribe = undefined; + } +} diff --git a/tsdown.config.ts b/tsdown.config.ts index b8ca9d9..317980b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -28,12 +28,14 @@ export default defineConfig({ "sources/pinia": "src/adapters/sources/pinia.ts", "sources/redux": "src/adapters/sources/redux.ts", "frameworks/react": "src/adapters/frameworks/react.ts", + "frameworks/preact": "src/adapters/frameworks/preact.ts", "frameworks/solid": "src/adapters/frameworks/solid.ts", + "frameworks/angular": "src/adapters/frameworks/angular.ts", "frameworks/vue": "src/adapters/frameworks/vue.ts", + "frameworks/lit": "src/adapters/frameworks/lit.ts", + "frameworks/alpine": "src/adapters/frameworks/alpine.ts", "frameworks/svelte": "src/adapters/frameworks/svelte.ts", "frameworks/svelte-store": "src/adapters/frameworks/svelte-store.ts", - "frameworks/angular": "src/adapters/frameworks/angular.ts", - "frameworks/preact": "src/adapters/frameworks/preact.ts", }, outDir, format: "esm", @@ -51,6 +53,8 @@ export default defineConfig({ "@angular/core", "preact", "vue", + "lit", + "alpinejs", "@react-native-async-storage/async-storage", "react-native-mmkv", "expo-secure-store", diff --git a/typedoc.json b/typedoc.json index 2ae570f..012dc4b 100644 --- a/typedoc.json +++ b/typedoc.json @@ -21,12 +21,14 @@ "src/adapters/sources/pinia.ts", "src/adapters/sources/redux.ts", "src/adapters/frameworks/react.ts", + "src/adapters/frameworks/preact.ts", "src/adapters/frameworks/solid.ts", + "src/adapters/frameworks/angular.ts", "src/adapters/frameworks/vue.ts", + "src/adapters/frameworks/lit.ts", + "src/adapters/frameworks/alpine.ts", "src/adapters/frameworks/svelte.ts", - "src/adapters/frameworks/svelte-store.ts", - "src/adapters/frameworks/angular.ts", - "src/adapters/frameworks/preact.ts" + "src/adapters/frameworks/svelte-store.ts" ], "cleanOutputDir": false, "outputs": [