From 71138e21b00e92a67523dbb4f1b98192413d14e6 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:21:15 +0300 Subject: [PATCH 1/8] feat(frameworks): add Lit HydrationController --- .agents/skills/docs-voice/SKILL.md | 2 +- .changeset/lit-framework-adapter.md | 5 + README.md | 2 +- .../content/adapters/framework-adapter.mdx | 4 +- apps/docs/content/concepts/entry-points.mdx | 1 + apps/docs/content/guides/hydration.mdx | 7 ++ apps/docs/content/reference/api/index.mdx | 3 + apps/docs/content/reference/api/meta.ts | 1 + apps/docs/pages/_home/Seams.astro | 1 + bun.lock | 11 +++ docs/architecture.md | 5 +- package.json | 10 ++ src/adapters/frameworks/lit.test.ts | 93 +++++++++++++++++++ src/adapters/frameworks/lit.ts | 58 ++++++++++++ tsdown.config.ts | 2 + typedoc.json | 1 + 16 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 .changeset/lit-framework-adapter.md create mode 100644 src/adapters/frameworks/lit.test.ts create mode 100644 src/adapters/frameworks/lit.ts 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/lit-framework-adapter.md b/.changeset/lit-framework-adapter.md new file mode 100644 index 0000000..60953ba --- /dev/null +++ b/.changeset/lit-framework-adapter.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": minor +--- + +Add Lit `HydrationController` framework adapter (`./frameworks/lit`) over the `HydrationSignal` seam. diff --git a/README.md b/README.md index 5a74a15..fc4edda 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, Solid, Vue, Lit, 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/adapters/framework-adapter.mdx b/apps/docs/content/adapters/framework-adapter.mdx index 680ea81..db21fff 100644 --- a/apps/docs/content/adapters/framework-adapter.mdx +++ b/apps/docs/content/adapters/framework-adapter.mdx @@ -1,11 +1,11 @@ --- 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 — the same shape as the shipped React, Solid, Vue, Lit, 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 (`@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`), Lit (`@stainless-code/persist/frameworks/lit`, `HydrationController` via `ReactiveController`), 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. ```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..c531043 100644 --- a/apps/docs/content/concepts/entry-points.mdx +++ b/apps/docs/content/concepts/entry-points.mdx @@ -30,6 +30,7 @@ One subpath = one optional peer. No barrel — importing a subpath is the depend | `@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` | +| `@stainless-code/persist/frameworks/lit` | `HydrationController` (Lit `ReactiveController`) | `lit` (>=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) | diff --git a/apps/docs/content/guides/hydration.mdx b/apps/docs/content/guides/hydration.mdx index d764944..da4d9ae 100644 --- a/apps/docs/content/guides/hydration.mdx +++ b/apps/docs/content/guides/hydration.mdx @@ -28,6 +28,13 @@ 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 // Svelte 5 runes import { hydratedRune } from "@stainless-code/persist/frameworks/svelte"; diff --git a/apps/docs/content/reference/api/index.mdx b/apps/docs/content/reference/api/index.mdx index fb4fc53..96d0072 100644 --- a/apps/docs/content/reference/api/index.mdx +++ b/apps/docs/content/reference/api/index.mdx @@ -127,6 +127,9 @@ One TypeDoc module per package entry. [Reference](/reference) `@stainless-code/persist/frameworks/vue` + + `@stainless-code/persist/frameworks/lit` + `@stainless-code/persist/frameworks/svelte` diff --git a/apps/docs/content/reference/api/meta.ts b/apps/docs/content/reference/api/meta.ts index bb79b5e..da87e68 100644 --- a/apps/docs/content/reference/api/meta.ts +++ b/apps/docs/content/reference/api/meta.ts @@ -25,6 +25,7 @@ export default defineMeta({ "adapters-frameworks-react", "adapters-frameworks-solid", "adapters-frameworks-vue", + "adapters-frameworks-lit", "adapters-frameworks-svelte", "adapters-frameworks-svelte-store", "adapters-frameworks-angular", diff --git a/apps/docs/pages/_home/Seams.astro b/apps/docs/pages/_home/Seams.astro index a882925..0a0cca2 100644 --- a/apps/docs/pages/_home/Seams.astro +++ b/apps/docs/pages/_home/Seams.astro @@ -31,6 +31,7 @@ const frameworks = [ "Solid", "Angular", "Vue", + "Lit", "Svelte (runes)", "Svelte (store)", ]; diff --git a/bun.lock b/bun.lock index 8268ce1..f1a8e8b 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "jsdom": "29.1.1", "knip": "6.24.0", "lint-staged": "17.0.8", + "lit": "^3.0.0", "mobx": "6.16.1", "oxfmt": "0.57.0", "oxlint": "1.72.0", @@ -623,6 +624,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=="], @@ -2085,6 +2090,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=="], diff --git a/docs/architecture.md b/docs/architecture.md index e49c908..00b3303 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,7 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@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` | +| `@stainless-code/persist/frameworks/lit` | `adapters/frameworks/lit` | `lit` (>=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) | @@ -56,13 +57,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, solid, vue, lit, 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. ## 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 `useSyncExternalStore` via `./frameworks/react`, Solid `from` via `./frameworks/solid`, Vue `shallowRef` + `onScopeDispose` via `./frameworks/vue`, Lit `ReactiveController` via `./frameworks/lit`, 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. ## Sync vs async diff --git a/package.json b/package.json index c89e0b5..a91b6c5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "hydration", "indexeddb", "jotai", + "lit", "localstorage", "middleware", "migration", @@ -142,6 +143,10 @@ "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/svelte": { "types": "./dist/frameworks/svelte.d.mts", "import": "./dist/frameworks/svelte.mjs" @@ -224,6 +229,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", @@ -256,6 +262,7 @@ "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 +305,9 @@ "vue": { "optional": true }, + "lit": { + "optional": true + }, "zod": { "optional": true }, diff --git a/src/adapters/frameworks/lit.test.ts b/src/adapters/frameworks/lit.test.ts new file mode 100644 index 0000000..8488430 --- /dev/null +++ b/src/adapters/frameworks/lit.test.ts @@ -0,0 +1,93 @@ +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 signal flip and 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).not.toHaveBeenCalled(); + + signal.set(true); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + expect(controller.hydrated).toBe(true); + + signal.set(false); + expect(host.requestUpdate).toHaveBeenCalledTimes(2); + expect(controller.hydrated).toBe(false); + }); + + 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); + + controller.hostDisconnected(); + expect(signal.listenerCount()).toBe(0); + + signal.set(true); + expect(host.requestUpdate).not.toHaveBeenCalled(); + 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..8044708 --- /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` that mounts a `HydrationSignal` and exposes + * `hydrated` for template gating. Construct on the host (calls + * `host.addController(this)`); subscribe on `hostConnected`, tear down on + * `hostDisconnected`. Null/undefined signal → `hydrated` stays `true` (no + * subscribe). Same SSR policy as React: treat as hydrated when there is no + * signal / nothing to gate server-side. + * + * @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(); + }); + } + + hostDisconnected(): void { + this.#unsubscribe?.(); + this.#unsubscribe = undefined; + } +} diff --git a/tsdown.config.ts b/tsdown.config.ts index b8ca9d9..a71fec2 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ "frameworks/react": "src/adapters/frameworks/react.ts", "frameworks/solid": "src/adapters/frameworks/solid.ts", "frameworks/vue": "src/adapters/frameworks/vue.ts", + "frameworks/lit": "src/adapters/frameworks/lit.ts", "frameworks/svelte": "src/adapters/frameworks/svelte.ts", "frameworks/svelte-store": "src/adapters/frameworks/svelte-store.ts", "frameworks/angular": "src/adapters/frameworks/angular.ts", @@ -51,6 +52,7 @@ export default defineConfig({ "@angular/core", "preact", "vue", + "lit", "@react-native-async-storage/async-storage", "react-native-mmkv", "expo-secure-store", diff --git a/typedoc.json b/typedoc.json index 2ae570f..51cc95a 100644 --- a/typedoc.json +++ b/typedoc.json @@ -23,6 +23,7 @@ "src/adapters/frameworks/react.ts", "src/adapters/frameworks/solid.ts", "src/adapters/frameworks/vue.ts", + "src/adapters/frameworks/lit.ts", "src/adapters/frameworks/svelte.ts", "src/adapters/frameworks/svelte-store.ts", "src/adapters/frameworks/angular.ts", From b08a038a8bde988735ae0b33d10cd81037c223fc Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:24:04 +0300 Subject: [PATCH 2/8] feat(frameworks): add Alpine hydration plugin --- .changeset/alpine-framework-adapter.md | 5 + README.md | 2 +- .../content/adapters/framework-adapter.mdx | 4 +- apps/docs/content/concepts/entry-points.mdx | 1 + apps/docs/content/guides/hydration.mdx | 8 ++ apps/docs/content/reference/api/index.mdx | 3 + apps/docs/content/reference/api/meta.ts | 1 + apps/docs/pages/_home/Batteries.astro | 2 +- apps/docs/pages/_home/Seams.astro | 1 + apps/docs/pages/_home/UseCases.astro | 2 +- bun.lock | 17 ++- docs/architecture.md | 5 +- package.json | 10 ++ src/adapters/frameworks/alpine.test.ts | 117 ++++++++++++++++ src/adapters/frameworks/alpine.ts | 127 ++++++++++++++++++ tsdown.config.ts | 2 + typedoc.json | 1 + 17 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 .changeset/alpine-framework-adapter.md create mode 100644 src/adapters/frameworks/alpine.test.ts create mode 100644 src/adapters/frameworks/alpine.ts diff --git a/.changeset/alpine-framework-adapter.md b/.changeset/alpine-framework-adapter.md new file mode 100644 index 0000000..a09ee74 --- /dev/null +++ b/.changeset/alpine-framework-adapter.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/persist": minor +--- + +Add Alpine hydration plugin + `useHydrated` / `$hydrated` framework adapter (`./frameworks/alpine`) over the `HydrationSignal` seam. diff --git a/README.md b/README.md index fc4edda..ac3651c 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, Lit, 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, Lit, Alpine, 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/adapters/framework-adapter.mdx b/apps/docs/content/adapters/framework-adapter.mdx index db21fff..9af3ea6 100644 --- a/apps/docs/content/adapters/framework-adapter.mdx +++ b/apps/docs/content/adapters/framework-adapter.mdx @@ -1,11 +1,11 @@ --- title: Writing a framework adapter -description: Bridge HydrationSignal into your UI framework — the same shape as the shipped React, Solid, Vue, Lit, and Svelte adapters. +description: Bridge HydrationSignal into your UI framework — the same shape as the shipped React, Solid, 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`), Lit (`@stainless-code/persist/frameworks/lit`, `HydrationController` via `ReactiveController`), 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 (`@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`), Lit (`@stainless-code/persist/frameworks/lit`, `HydrationController` via `ReactiveController`), Alpine (`@stainless-code/persist/frameworks/alpine`, reactive `{ hydrated }` bag + `$hydrated` plugin), 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. ```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 c531043..f67d048 100644 --- a/apps/docs/content/concepts/entry-points.mdx +++ b/apps/docs/content/concepts/entry-points.mdx @@ -31,6 +31,7 @@ One subpath = one optional peer. No barrel — importing a subpath is the depend | `@stainless-code/persist/frameworks/solid` | `useHydrated` (Solid `Accessor`) | `solid-js` | | `@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) | diff --git a/apps/docs/content/guides/hydration.mdx b/apps/docs/content/guides/hydration.mdx index da4d9ae..efc8ed8 100644 --- a/apps/docs/content/guides/hydration.mdx +++ b/apps/docs/content/guides/hydration.mdx @@ -35,6 +35,14 @@ import { HydrationController } from "@stainless-code/persist/frameworks/lit"; // 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"; diff --git a/apps/docs/content/reference/api/index.mdx b/apps/docs/content/reference/api/index.mdx index 96d0072..849df43 100644 --- a/apps/docs/content/reference/api/index.mdx +++ b/apps/docs/content/reference/api/index.mdx @@ -130,6 +130,9 @@ One TypeDoc module per package entry. [Reference](/reference) `@stainless-code/persist/frameworks/lit` + + `@stainless-code/persist/frameworks/alpine` + `@stainless-code/persist/frameworks/svelte` diff --git a/apps/docs/content/reference/api/meta.ts b/apps/docs/content/reference/api/meta.ts index da87e68..7d3ddb3 100644 --- a/apps/docs/content/reference/api/meta.ts +++ b/apps/docs/content/reference/api/meta.ts @@ -26,6 +26,7 @@ export default defineMeta({ "adapters-frameworks-solid", "adapters-frameworks-vue", "adapters-frameworks-lit", + "adapters-frameworks-alpine", "adapters-frameworks-svelte", "adapters-frameworks-svelte-store", "adapters-frameworks-angular", diff --git a/apps/docs/pages/_home/Batteries.astro b/apps/docs/pages/_home/Batteries.astro index e808f2b..767ac32 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, Solid, Vue, Lit, Alpine, Svelte, Angular, Preact. diff --git a/apps/docs/pages/_home/Seams.astro b/apps/docs/pages/_home/Seams.astro index 0a0cca2..494e0a5 100644 --- a/apps/docs/pages/_home/Seams.astro +++ b/apps/docs/pages/_home/Seams.astro @@ -32,6 +32,7 @@ const frameworks = [ "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..9702894 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 / Solid / Vue / Lit / Alpine / Svelte / Angular / Preact", involves: "`./frameworks/*` hydration adapters", fit: "Ideal", }, diff --git a/bun.lock b/bun.lock index f1a8e8b..56f2d4e 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@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,7 +30,7 @@ "jsdom": "29.1.1", "knip": "6.24.0", "lint-staged": "17.0.8", - "lit": "^3.0.0", + "lit": "3.3.3", "mobx": "6.16.1", "oxfmt": "0.57.0", "oxlint": "1.72.0", @@ -59,9 +60,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", @@ -80,9 +83,11 @@ "@angular/core", "@react-native-async-storage/async-storage", "@tanstack/store", + "alpinejs", "expo-secure-store", "idb-keyval", "jotai", + "lit", "mobx", "pinia", "preact", @@ -1220,7 +1225,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=="], @@ -1258,6 +1263,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=="], @@ -3210,6 +3217,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 00b3303..e9cbebf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,6 +40,7 @@ Persistence is bound to a structural `PersistableSource` (`getState` / `setState | `@stainless-code/persist/frameworks/solid` | `adapters/frameworks/solid` | `solid-js` | | `@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) | @@ -57,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, lit, svelte, svelte-store, angular, preact) + - `frameworks/` — `HydrationSignal` framework adapters (react, solid, vue, lit, alpine, 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. ## 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`, Lit `ReactiveController` via `./frameworks/lit`, 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 `useSyncExternalStore` via `./frameworks/react`, Solid `from` via `./frameworks/solid`, Vue `shallowRef` + `onScopeDispose` via `./frameworks/vue`, Lit `ReactiveController` via `./frameworks/lit`, Alpine `Alpine.reactive` bag + `$hydrated` via `./frameworks/alpine`, 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. ## Sync vs async diff --git a/package.json b/package.json index a91b6c5..5a4be44 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", @@ -147,6 +148,10 @@ "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" @@ -222,6 +227,7 @@ "@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", @@ -259,6 +265,7 @@ "@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", @@ -308,6 +315,9 @@ "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..e86ffbc --- /dev/null +++ b/src/adapters/frameworks/alpine.test.ts @@ -0,0 +1,117 @@ +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 = ( + el: Element, + utils: { Alpine: AlpineLike; cleanup?: (fn: () => void) => void }, +) => unknown; + +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 via mock Alpine.reactive", () => { + persist(createMockAlpine()); + const signal = createFakeSignal(); + const bag = useHydrated(signal); + 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, { + Alpine: alpine, + cleanup: (cb) => cleanups.push(cb), + }) 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..8be963e --- /dev/null +++ b/src/adapters/frameworks/alpine.ts @@ -0,0 +1,127 @@ +// Alpine hydration adapter — peer `alpinejs` >=3.0.0. +import type { HydrationSignal } from "../../core/hydration"; + +/** Minimal Alpine surface used by the adapter (no `@types/alpinejs` required). */ +export interface AlpineLike { + reactive(obj: T): T; + magic( + name: string, + fn: ( + el: Element, + utils: { Alpine: AlpineLike; cleanup?: (fn: () => void) => void }, + ) => unknown, + ): void; +} + +/** 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( + "[persist/alpine] useHydrated called before Alpine.plugin(persist); updates won't be reactive.", + ); + } + return seed; +} + +/** + * Mount a `HydrationSignal` into Alpine reactivity. Returns a reactive bag + * `{ hydrated }` so `x-show` / `x-text` track updates. Call after + * `Alpine.plugin(persist)`, or inside `Alpine.data` and tear down with + * `bag.destroy()` (or the component `destroy()` hook). Null/undefined signal + * → `{ hydrated: true }` (no subscribe). Renders `true` on the server. + * + * Prefer `useHydrated` inside `Alpine.data` with explicit teardown when you + * need a long-lived subscription; `$hydrated(signal)` in templates caches per + * element and cleans up when the element is removed. + * + * @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(); }, + * }; + * }); + * // template: x-show="hydrated" / $hydrated(prefsHydration).hydrated + * ``` + */ +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/tsdown.config.ts b/tsdown.config.ts index a71fec2..6bd061e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ "frameworks/solid": "src/adapters/frameworks/solid.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", @@ -53,6 +54,7 @@ export default defineConfig({ "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 51cc95a..7e9e496 100644 --- a/typedoc.json +++ b/typedoc.json @@ -24,6 +24,7 @@ "src/adapters/frameworks/solid.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", From a051ab5969fbe54207c416c5cc06e8f1dfd4dcdd Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:25:07 +0300 Subject: [PATCH 3/8] fix(frameworks): Lit connect race + docs order / ROI strike RequestUpdate when already hydrated on connect; align homepage and hydration guide with docs-voice framework order; retire ROI #8. --- apps/docs/content/guides/hydration.mdx | 24 +++++++++++------------ apps/docs/pages/_home/Batteries.astro | 2 +- apps/docs/pages/_home/UseCases.astro | 2 +- docs/plans/remaining-roi.md | 27 +++----------------------- docs/roadmap.md | 2 +- src/adapters/frameworks/alpine.ts | 15 ++++---------- src/adapters/frameworks/lit.test.ts | 10 ++++++++++ src/adapters/frameworks/lit.ts | 2 ++ 8 files changed, 34 insertions(+), 50 deletions(-) diff --git a/apps/docs/content/guides/hydration.mdx b/apps/docs/content/guides/hydration.mdx index efc8ed8..af416c1 100644 --- a/apps/docs/content/guides/hydration.mdx +++ b/apps/docs/content/guides/hydration.mdx @@ -16,12 +16,24 @@ 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"; @@ -56,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/pages/_home/Batteries.astro b/apps/docs/pages/_home/Batteries.astro index 767ac32..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, Lit, Alpine, Svelte, Angular, Preact. + React, Preact, Solid, Angular, Vue, Lit, Alpine, Svelte. diff --git a/apps/docs/pages/_home/UseCases.astro b/apps/docs/pages/_home/UseCases.astro index 9702894..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 / Lit / Alpine / 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/docs/plans/remaining-roi.md b/docs/plans/remaining-roi.md index e8da91e..da39eae 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). -### 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/src/adapters/frameworks/alpine.ts b/src/adapters/frameworks/alpine.ts index 8be963e..56a4626 100644 --- a/src/adapters/frameworks/alpine.ts +++ b/src/adapters/frameworks/alpine.ts @@ -36,21 +36,15 @@ function alpineReactive(seed: T): T { } /** - * Mount a `HydrationSignal` into Alpine reactivity. Returns a reactive bag - * `{ hydrated }` so `x-show` / `x-text` track updates. Call after - * `Alpine.plugin(persist)`, or inside `Alpine.data` and tear down with - * `bag.destroy()` (or the component `destroy()` hook). Null/undefined signal - * → `{ hydrated: true }` (no subscribe). Renders `true` on the server. - * - * Prefer `useHydrated` inside `Alpine.data` with explicit teardown when you - * need a long-lived subscription; `$hydrated(signal)` in templates caches per - * element and cleans up when the element is removed. + * 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 { @@ -58,7 +52,6 @@ function alpineReactive(seed: T): T { * destroy() { hydration.destroy(); }, * }; * }); - * // template: x-show="hydrated" / $hydrated(prefsHydration).hydrated * ``` */ export function useHydrated( diff --git a/src/adapters/frameworks/lit.test.ts b/src/adapters/frameworks/lit.test.ts index 8488430..6d9aae8 100644 --- a/src/adapters/frameworks/lit.test.ts +++ b/src/adapters/frameworks/lit.test.ts @@ -76,6 +76,16 @@ describe("HydrationController (lit)", () => { expect(controller.hydrated).toBe(false); }); + it("requestUpdate on connect when already hydrated", () => { + const signal = createFakeSignal(); + signal.set(true); + const host = createFakeHost(); + const controller = new HydrationController(host, signal); + controller.hostConnected(); + expect(controller.hydrated).toBe(true); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); + }); + it("hostDisconnected unsubscribes so further flips do not requestUpdate", () => { const signal = createFakeSignal(); const host = createFakeHost(); diff --git a/src/adapters/frameworks/lit.ts b/src/adapters/frameworks/lit.ts index 8044708..767cc67 100644 --- a/src/adapters/frameworks/lit.ts +++ b/src/adapters/frameworks/lit.ts @@ -49,6 +49,8 @@ export class HydrationController implements ReactiveController { this.#unsubscribe = this.#signal.subscribeHydrated(() => { this.#host.requestUpdate(); }); + // Hydration may have completed between construct and connect. + if (this.#signal.isHydrated()) this.#host.requestUpdate(); } hostDisconnected(): void { From 8fadb100f85e384db6d44aa2068420435b35c626 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:25:31 +0300 Subject: [PATCH 4/8] docs: order framework API nav per docs-voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react → preact → solid → angular → vue → lit → alpine → svelte. --- apps/docs/content/reference/api/index.mdx | 12 ++++++------ apps/docs/content/reference/api/meta.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/docs/content/reference/api/index.mdx b/apps/docs/content/reference/api/index.mdx index 849df43..b8f41e6 100644 --- a/apps/docs/content/reference/api/index.mdx +++ b/apps/docs/content/reference/api/index.mdx @@ -121,9 +121,15 @@ 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` @@ -143,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 7d3ddb3..6e2755e 100644 --- a/apps/docs/content/reference/api/meta.ts +++ b/apps/docs/content/reference/api/meta.ts @@ -23,13 +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", ], }); From 8461612d4ce4f48faa844b870554b1500460108e Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:28:34 +0300 Subject: [PATCH 5/8] harden: Lit attach snapshot + docs-voice framework order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always requestUpdate on hostConnected; reorder listings/exports to react→preact→solid→angular→vue→lit→alpine→svelte; pin Alpine.reactive. --- README.md | 2 +- .../content/adapters/framework-adapter.mdx | 4 ++-- apps/docs/content/concepts/entry-points.mdx | 4 ++-- docs/architecture.md | 8 ++++---- docs/plans/remaining-roi.md | 2 +- package.json | 16 +++++++-------- src/adapters/frameworks/alpine.test.ts | 11 ++++++++-- src/adapters/frameworks/lit.test.ts | 20 ++++++++++++------- src/adapters/frameworks/lit.ts | 5 +++-- tsdown.config.ts | 4 ++-- typedoc.json | 6 +++--- 11 files changed, 48 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index ac3651c..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, Lit, Alpine, 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 9af3ea6..681c375 100644 --- a/apps/docs/content/adapters/framework-adapter.mdx +++ b/apps/docs/content/adapters/framework-adapter.mdx @@ -1,11 +1,11 @@ --- title: Writing a framework adapter -description: Bridge HydrationSignal into your UI framework — the same shape as the shipped React, Solid, Vue, Lit, Alpine, 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`), Lit (`@stainless-code/persist/frameworks/lit`, `HydrationController` via `ReactiveController`), Alpine (`@stainless-code/persist/frameworks/alpine`, reactive `{ hydrated }` bag + `$hydrated` plugin), 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 (`@stainless-code/persist/frameworks/react`) is ~20 lines over `HydrationSignal` — every adapter is the same shape. Preact, Solid, Angular, Vue, Lit (`HydrationController`), Alpine (reactive bag + `$hydrated` plugin), and Svelte (runes / store) ship the same 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. ```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 f67d048..39195fc 100644 --- a/apps/docs/content/concepts/entry-points.mdx +++ b/apps/docs/content/concepts/entry-points.mdx @@ -28,13 +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/docs/architecture.md b/docs/architecture.md index e9cbebf..0e1ad4a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,14 +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. @@ -58,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, lit, alpine, 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`, Lit `ReactiveController` via `./frameworks/lit`, Alpine `Alpine.reactive` bag + `$hydrated` via `./frameworks/alpine`, 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 da39eae..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). diff --git a/package.json b/package.json index 5a4be44..b636a71 100644 --- a/package.json +++ b/package.json @@ -136,10 +136,18 @@ "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" @@ -159,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": { diff --git a/src/adapters/frameworks/alpine.test.ts b/src/adapters/frameworks/alpine.test.ts index e86ffbc..6ed7a35 100644 --- a/src/adapters/frameworks/alpine.test.ts +++ b/src/adapters/frameworks/alpine.test.ts @@ -57,10 +57,17 @@ describe("useHydrated / persist (alpine)", () => { expect(useHydrated(undefined).hydrated).toBe(true); }); - it("signal flip updates bag.hydrated via mock Alpine.reactive", () => { - persist(createMockAlpine()); + it("signal flip updates bag.hydrated and calls Alpine.reactive", () => { + const alpine = createMockAlpine(); + const reactiveCalls: object[] = []; + alpine.reactive = (o) => { + reactiveCalls.push(o); + return o; + }; + persist(alpine); const signal = createFakeSignal(); const bag = useHydrated(signal); + expect(reactiveCalls).toHaveLength(1); expect(bag.hydrated).toBe(false); expect(signal.listenerCount()).toBe(1); diff --git a/src/adapters/frameworks/lit.test.ts b/src/adapters/frameworks/lit.test.ts index 6d9aae8..2b702cc 100644 --- a/src/adapters/frameworks/lit.test.ts +++ b/src/adapters/frameworks/lit.test.ts @@ -58,32 +58,37 @@ describe("HydrationController (lit)", () => { expect(host.requestUpdate).not.toHaveBeenCalled(); }); - it("requestUpdate on signal flip and hydrated tracks isHydrated()", () => { + 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).not.toHaveBeenCalled(); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); signal.set(true); - expect(host.requestUpdate).toHaveBeenCalledTimes(1); + expect(host.requestUpdate).toHaveBeenCalledTimes(2); expect(controller.hydrated).toBe(true); signal.set(false); - expect(host.requestUpdate).toHaveBeenCalledTimes(2); + expect(host.requestUpdate).toHaveBeenCalledTimes(3); expect(controller.hydrated).toBe(false); }); - it("requestUpdate on connect when already hydrated", () => { + 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(controller.hydrated).toBe(true); 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", () => { @@ -92,12 +97,13 @@ describe("HydrationController (lit)", () => { 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).not.toHaveBeenCalled(); + expect(host.requestUpdate).toHaveBeenCalledTimes(1); expect(controller.hydrated).toBe(true); }); }); diff --git a/src/adapters/frameworks/lit.ts b/src/adapters/frameworks/lit.ts index 767cc67..82eec8c 100644 --- a/src/adapters/frameworks/lit.ts +++ b/src/adapters/frameworks/lit.ts @@ -49,8 +49,9 @@ export class HydrationController implements ReactiveController { this.#unsubscribe = this.#signal.subscribeHydrated(() => { this.#host.requestUpdate(); }); - // Hydration may have completed between construct and connect. - if (this.#signal.isHydrated()) 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 { diff --git a/tsdown.config.ts b/tsdown.config.ts index 6bd061e..317980b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -28,14 +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", diff --git a/typedoc.json b/typedoc.json index 7e9e496..012dc4b 100644 --- a/typedoc.json +++ b/typedoc.json @@ -21,14 +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": [ From d3d8fef2753f5d9ba838e3f469556df3f6b6f8c9 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:30:21 +0300 Subject: [PATCH 6/8] docs: tighten Lit/Alpine prose per authoring + docs-voice Slim JSDoc and framework-adapter lead; align Alpine warn prefix with other adapters; shorten changesets. --- .changeset/alpine-framework-adapter.md | 2 +- .changeset/lit-framework-adapter.md | 2 +- apps/docs/content/adapters/framework-adapter.mdx | 4 +++- src/adapters/frameworks/alpine.ts | 2 +- src/adapters/frameworks/lit.ts | 9 +++------ 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.changeset/alpine-framework-adapter.md b/.changeset/alpine-framework-adapter.md index a09ee74..ef75c80 100644 --- a/.changeset/alpine-framework-adapter.md +++ b/.changeset/alpine-framework-adapter.md @@ -2,4 +2,4 @@ "@stainless-code/persist": minor --- -Add Alpine hydration plugin + `useHydrated` / `$hydrated` framework adapter (`./frameworks/alpine`) over the `HydrationSignal` seam. +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 index 60953ba..540142a 100644 --- a/.changeset/lit-framework-adapter.md +++ b/.changeset/lit-framework-adapter.md @@ -2,4 +2,4 @@ "@stainless-code/persist": minor --- -Add Lit `HydrationController` framework adapter (`./frameworks/lit`) over the `HydrationSignal` seam. +Add Lit `HydrationController` framework adapter (`./frameworks/lit`). diff --git a/apps/docs/content/adapters/framework-adapter.mdx b/apps/docs/content/adapters/framework-adapter.mdx index 681c375..f00c713 100644 --- a/apps/docs/content/adapters/framework-adapter.mdx +++ b/apps/docs/content/adapters/framework-adapter.mdx @@ -5,7 +5,9 @@ search: tags: ["adapter", "framework"] --- -The React hook (`@stainless-code/persist/frameworks/react`) is ~20 lines over `HydrationSignal` — every adapter is the same shape. Preact, Solid, Angular, Vue, Lit (`HydrationController`), Alpine (reactive bag + `$hydrated` plugin), and Svelte (runes / store) ship the same 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/src/adapters/frameworks/alpine.ts b/src/adapters/frameworks/alpine.ts index 56a4626..0f10450 100644 --- a/src/adapters/frameworks/alpine.ts +++ b/src/adapters/frameworks/alpine.ts @@ -29,7 +29,7 @@ function alpineReactive(seed: T): T { if (process.env.NODE_ENV !== "production" && !warnedMissingRuntime) { warnedMissingRuntime = true; console.warn( - "[persist/alpine] useHydrated called before Alpine.plugin(persist); updates won't be reactive.", + "[@stainless-code/persist/frameworks/alpine] useHydrated called before Alpine.plugin(persist); updates won't be reactive.", ); } return seed; diff --git a/src/adapters/frameworks/lit.ts b/src/adapters/frameworks/lit.ts index 82eec8c..06db442 100644 --- a/src/adapters/frameworks/lit.ts +++ b/src/adapters/frameworks/lit.ts @@ -4,12 +4,9 @@ import type { ReactiveController, ReactiveControllerHost } from "lit"; import type { HydrationSignal } from "../../core/hydration"; /** - * Lit `ReactiveController` that mounts a `HydrationSignal` and exposes - * `hydrated` for template gating. Construct on the host (calls - * `host.addController(this)`); subscribe on `hostConnected`, tear down on - * `hostDisconnected`. Null/undefined signal → `hydrated` stays `true` (no - * subscribe). Same SSR policy as React: treat as hydrated when there is no - * signal / nothing to gate server-side. + * 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 From b71b485bb10c5e14a29ae484936336534aef19c2 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:32:58 +0300 Subject: [PATCH 7/8] fix(ci): ignore alpinejs in knip unused-deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapter uses a structural AlpineLike — alpinejs ships no types, so the peer never appears as an import. --- knip.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knip.json b/knip.json index dcb5934..2cdd40c 100644 --- a/knip.json +++ b/knip.json @@ -8,6 +8,6 @@ "lint-staged.config.js" ], "ignore": [".codemap/**"], - "ignoreDependencies": ["@stainless-code/codemap"], + "ignoreDependencies": ["@stainless-code/codemap", "alpinejs"], "ignoreWorkspaces": ["apps/*"] } From 5c9b5fa922ea41d59731e2f63e29681e1b22340a Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Mon, 20 Jul 2026 23:36:36 +0300 Subject: [PATCH 8/8] fix(frameworks): type Alpine adapter via @types/alpinejs Replace the hand-rolled AlpineLike with Pick from alpinejs typings so knip sees the peer, and drop the alpinejs ignoreDependency workaround. --- bun.lock | 3 +++ knip.json | 2 +- package.json | 1 + src/adapters/frameworks/alpine.test.ts | 36 ++++++++++++++++---------- src/adapters/frameworks/alpine.ts | 16 +++--------- 5 files changed, 31 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index 56f2d4e..719662d 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "@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", @@ -1025,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=="], diff --git a/knip.json b/knip.json index 2cdd40c..dcb5934 100644 --- a/knip.json +++ b/knip.json @@ -8,6 +8,6 @@ "lint-staged.config.js" ], "ignore": [".codemap/**"], - "ignoreDependencies": ["@stainless-code/codemap", "alpinejs"], + "ignoreDependencies": ["@stainless-code/codemap"], "ignoreWorkspaces": ["apps/*"] } diff --git a/package.json b/package.json index b636a71..e787241 100644 --- a/package.json +++ b/package.json @@ -222,6 +222,7 @@ "@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", diff --git a/src/adapters/frameworks/alpine.test.ts b/src/adapters/frameworks/alpine.test.ts index 6ed7a35..4158f1c 100644 --- a/src/adapters/frameworks/alpine.test.ts +++ b/src/adapters/frameworks/alpine.test.ts @@ -21,10 +21,12 @@ function createFakeSignal() { }; } -type MagicFn = ( - el: Element, - utils: { Alpine: AlpineLike; cleanup?: (fn: () => void) => void }, -) => unknown; +type MagicFn = AlpineLike["magic"] extends ( + name: string, + callback: infer C, +) => void + ? C + : never; function createMockAlpine(): AlpineLike & { magics: Map } { const magics = new Map(); @@ -58,13 +60,18 @@ describe("useHydrated / persist (alpine)", () => { }); it("signal flip updates bag.hydrated and calls Alpine.reactive", () => { - const alpine = createMockAlpine(); const reactiveCalls: object[] = []; - alpine.reactive = (o) => { - reactiveCalls.push(o); - return o; + 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(alpine); + persist(tracked); const signal = createFakeSignal(); const bag = useHydrated(signal); expect(reactiveCalls).toHaveLength(1); @@ -91,10 +98,13 @@ describe("useHydrated / persist (alpine)", () => { const magic = alpine.magics.get("hydrated")!; const el = {} as Element; const cleanups: Array<() => void> = []; - const fn = magic(el, { - Alpine: alpine, - cleanup: (cb) => cleanups.push(cb), - }) as (signal: ReturnType | null) => HydratedBag; + 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); diff --git a/src/adapters/frameworks/alpine.ts b/src/adapters/frameworks/alpine.ts index 0f10450..e27fb68 100644 --- a/src/adapters/frameworks/alpine.ts +++ b/src/adapters/frameworks/alpine.ts @@ -1,17 +1,7 @@ -// Alpine hydration adapter — peer `alpinejs` >=3.0.0. +// Alpine hydration adapter — peer `alpinejs` >=3.0.0 (`@types/alpinejs`; package ships none). import type { HydrationSignal } from "../../core/hydration"; -/** Minimal Alpine surface used by the adapter (no `@types/alpinejs` required). */ -export interface AlpineLike { - reactive(obj: T): T; - magic( - name: string, - fn: ( - el: Element, - utils: { Alpine: AlpineLike; cleanup?: (fn: () => void) => void }, - ) => unknown, - ): void; -} +export type AlpineLike = Pick; /** Alpine-reactive bag returned by {@link useHydrated}. */ export interface HydratedBag { @@ -101,7 +91,7 @@ export default function persist(Alpine: AlpineLike): void { if (!map) { map = new Map(); magicBags.set(el, map); - cleanup?.(() => { + cleanup(() => { for (const bag of map!.values()) bag.destroy(); magicBags.delete(el); });