From fc40f8ce7bd244850c1970a60e12274513465f9b Mon Sep 17 00:00:00 2001 From: Ryuz Date: Fri, 17 Jul 2026 21:40:38 +0200 Subject: [PATCH 1/2] feat(store): add url query persist --- apps/website/package.json | 2 +- .../pages/(content)/guide/libraries/store.mdx | 52 +++ bun.lock | 5 +- packages/store/README.md | 29 ++ packages/store/happydom.ts | 2 +- packages/store/package.json | 11 +- packages/store/src/query.test.ts | 234 ++++++++++++ packages/store/src/query.ts | 346 ++++++++++++++++++ packages/store/tsconfig.json | 1 + packages/store/tsdown.config.ts | 4 +- templates/elysia/package.json | 2 +- templates/hono/package.json | 2 +- templates/nitro/package.json | 2 +- templates/vite/package.json | 2 +- 14 files changed, 684 insertions(+), 10 deletions(-) create mode 100644 packages/store/src/query.test.ts create mode 100644 packages/store/src/query.ts diff --git a/apps/website/package.json b/apps/website/package.json index df1c24f..362505a 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -12,7 +12,7 @@ "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist-mono": "^5.2.8", "@ilha/router": "^0.8.6", - "@ilha/store": "^0.7.2", + "@ilha/store": "^0.7.3", "areia": "^0.1.36", "dedent": "^1.7.2", "ilha": "^0.9.2", diff --git a/apps/website/src/pages/(content)/guide/libraries/store.mdx b/apps/website/src/pages/(content)/guide/libraries/store.mdx index f946a68..1754517 100644 --- a/apps/website/src/pages/(content)/guide/libraries/store.mdx +++ b/apps/website/src/pages/(content)/guide/libraries/store.mdx @@ -363,6 +363,58 @@ persist(cartStore, "cart"); Call the returned unsubscribe before `store.dispose()` for per-island stores. +## URL persistence — `persistQuery(store, options?)` + +`persist`'s sibling for the query string, from the `@ilha/store/query` entry point. Instead of one serialized blob, each state key gets **its own search param** (`?q=boots&page=2`) — shareable, reload-safe, back/forward-friendly. Prior art: [nuqs](https://nuqs.dev) for React. + +The canonical use case is a filter bar. UI state that drives data loading (search text, sort, page) belongs in the URL, because `@ilha/router` loaders re-run on every navigation and read `ctx.url.searchParams`. The cycle is **one-way**: store → URL → loader → render. The store is the _write_ path for the controls; the loader stays the _read_ path for data — on the server and the client. + +```ts +import { z } from "zod"; +import { store } from "@ilha/store"; +import { persistQuery } from "@ilha/store/query"; + +export const filters = store( + z.object({ + q: z.string().default(""), + page: z.coerce.number().int().min(1).default(1), + sort: z.string().default(""), + }), +).build(); + +persistQuery(filters, { debounce: 250 }); +``` + +```tsx +// The search input binds to the store; persistQuery debounces the URL write. + +``` + +```ts +// The loader is the read path — it re-runs automatically on every query write. +export const load = ({ url }) => + searchProducts(url.searchParams.get("q") ?? ""); +``` + +What it does: + +1. **URL is the source of truth on init**: owned params are parsed from `location.search` and written through `setState`, so schema stores coerce and validate (`?page=3` → `3`); invalid values (`?page=banana`) degrade to the key's default — never throw. +2. **Writes through the router**: store commits navigate via `@ilha/router`'s `navigate()` (auto-detected; **not** raw `history.replaceState`), so loaders re-run and route signals stay live. Params it doesn't own are preserved — several stores can share one URL. +3. **Syncs back**: back/forward and link navigations that change owned params write into the store, without echoing another navigation. +4. **Clean URLs**: with `omitDefaults` (default on), a param is removed when its value equals the store default — no `?page=1&q=` noise. + +Returns an unsubscribe (flushes any pending debounced write). No-op on the server. + +| Option | Default | Description | +| -------------- | -------------- | -------------------------------------------------------------------------------------------------------- | +| `params` | all state keys | Keys to persist: `{ q: "search" }` renames, `{ tags: { serialize, deserialize } }` adds a codec | +| `history` | `"replace"` | `"replace"`, `"push"`, or `(changedKeys) => "push" \| "replace"` (e.g. push for `page`, replace for `q`) | +| `debounce` | `0` | Coalesce URL writes (ms) — for per-keystroke bound inputs. A push flushes a pending replace first | +| `omitDefaults` | `true` | Drop params whose value equals the store default (`""` counts as equal to a `""` default) | +| `navigate` | auto-detect | Injected URL writer; without `@ilha/router` installed, falls back to the History API with a dev warning | + +Values serialize with `String()` by default; deserialization hands the raw string to `setState`, so schema stores coerce it (`z.coerce.number()`). For arrays, dates, etc., give the key a `{ serialize, deserialize }` codec in `params`. + ## SSR — `dehydrate()` / `hydrate()` Stores are module-level singletons, so on a concurrent SSR server they must **not** be written during a render — request A's data would leak into request B. Instead, state travels the same way ilha island state does: serialized into the HTML, then seeded on the client. diff --git a/bun.lock b/bun.lock index 3351b4b..81b5893 100644 --- a/bun.lock +++ b/bun.lock @@ -63,17 +63,20 @@ }, "packages/store": { "name": "@ilha/store", - "version": "0.7.2", + "version": "0.7.3", "devDependencies": { + "@ilha/router": "0.8.6", "alien-signals": "3.2.1", "ilha": "0.9.2", "zod": "4.4.3", }, "peerDependencies": { + "@ilha/router": ">=0.8.6", "alien-signals": "^3.2.1", "ilha": ">=0.9.2", }, "optionalPeers": [ + "@ilha/router", "ilha", ], }, diff --git a/packages/store/README.md b/packages/store/README.md index 5cc93ff..5ad0a2a 100644 --- a/packages/store/README.md +++ b/packages/store/README.md @@ -390,6 +390,35 @@ persist(cartStore, "cart"); Call the returned unsubscribe before `store.dispose()` for per-island stores. +## URL persistence — `persistQuery(store, options?)` + +`persist`'s sibling for the query string ([nuqs](https://nuqs.dev)-style), from the `@ilha/store/query` entry point. Each state key maps to **its own search param** (`?q=boots&page=2`) — shareable, reload-safe, back/forward-friendly. Writes go through `@ilha/router`'s `navigate()` (auto-detected, or injected via `options.navigate`) so loaders re-run; the URL seeds the store on init (schema-coerced/validated, invalid params degrade to defaults); back/forward syncs back into the store without echo loops. With `omitDefaults` (default), default-valued params are dropped from the URL. + +```ts +import { z } from "zod"; +import { store } from "@ilha/store"; +import { persistQuery } from "@ilha/store/query"; + +const filters = store( + z.object({ + q: z.string().default(""), + page: z.coerce.number().int().min(1).default(1), + }), +).build(); + +persistQuery(filters, { debounce: 250 }); +``` + +| Option | Default | Description | +| -------------- | -------------- | ------------------------------------------------------------------------------------------------------- | +| `params` | all state keys | Keys to persist: `{ q: "search" }` renames, `{ tags: { serialize, deserialize } }` adds a codec | +| `history` | `"replace"` | `"replace"`, `"push"`, or `(changedKeys) => "push" \| "replace"` | +| `debounce` | `0` | Coalesce URL writes (ms); a push flushes a pending debounced replace first | +| `omitDefaults` | `true` | Drop params whose value equals the store default | +| `navigate` | auto-detect | Injected URL writer; without `@ilha/router` installed, falls back to the History API with a dev warning | + +Returns an unsubscribe (flushes any pending debounced write). No-op on the server — loaders reading `ctx.url.searchParams` remain the way to consume query state there. See the [store guide](https://ilhajs.dev/guide/libraries/store) for the full filter-bar walkthrough. + ## SSR — `dehydrate()` / `hydrate()` Stores are module-level singletons, so on a concurrent SSR server they must **not** be written during a render — request A's data would leak into request B. Instead, state travels the same way ilha island state does: serialized into the HTML, then seeded on the client. diff --git a/packages/store/happydom.ts b/packages/store/happydom.ts index 7f712d0..a97b004 100644 --- a/packages/store/happydom.ts +++ b/packages/store/happydom.ts @@ -1,3 +1,3 @@ import { GlobalRegistrator } from "@happy-dom/global-registrator"; -GlobalRegistrator.register(); +GlobalRegistrator.register({ url: "http://localhost/" }); diff --git a/packages/store/package.json b/packages/store/package.json index 1297301..e10d61e 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -1,6 +1,6 @@ { "name": "@ilha/store", - "version": "0.7.2", + "version": "0.7.3", "description": "Shared reactive store for Ilha islands.", "keywords": [ "forms", @@ -41,6 +41,10 @@ "./form": { "types": "./dist/form.d.ts", "import": "./dist/form.js" + }, + "./query": { + "types": "./dist/query.d.ts", + "import": "./dist/query.js" } }, "publishConfig": { @@ -52,15 +56,20 @@ "cleanup": "rimraf dist node_modules" }, "devDependencies": { + "@ilha/router": "0.8.6", "alien-signals": "3.2.1", "ilha": "0.9.2", "zod": "4.4.3" }, "peerDependencies": { + "@ilha/router": ">=0.8.6", "alien-signals": "^3.2.1", "ilha": ">=0.9.2" }, "peerDependenciesMeta": { + "@ilha/router": { + "optional": true + }, "ilha": { "optional": true } diff --git a/packages/store/src/query.test.ts b/packages/store/src/query.test.ts new file mode 100644 index 0000000..9ccc573 --- /dev/null +++ b/packages/store/src/query.test.ts @@ -0,0 +1,234 @@ +// ============================================================================= +// @ilha/store/query — persistQuery() test suite +// Run with: bun test (happy-dom provides window/location/history) +// ============================================================================= + +import { describe, it, expect, mock, beforeEach } from "bun:test"; + +import { z } from "zod"; + +import { store } from "./index"; +import { persistQuery, type NavigateFn } from "./query"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Injected navigate that mirrors the router's contract onto happy-dom history. */ +function makeNavigate() { + return mock((to, opts) => { + history[opts?.replace ? "replaceState" : "pushState"](null, "", to); + }); +} + +function filtersStore() { + return store( + z.object({ + q: z.string().default(""), + page: z.coerce.number().int().min(1).default(1), + sort: z.string().default(""), + }), + ) + .onError(() => {}) // silence expected rejections (invalid URL params) + .build(); +} + +beforeEach(() => { + history.replaceState(null, "", "/list"); +}); + +describe("persistQuery()", () => { + it("store write → URL via navigate (replace by default), unrelated params untouched", () => { + history.replaceState(null, "", "/list?tab=all"); + const s = filtersStore(); + const navigate = makeNavigate(); + const stop = persistQuery(s, { navigate }); + + s.setState({ q: "shoes", page: 2 }); + + expect(navigate).toHaveBeenCalledTimes(1); + const [to, opts] = navigate.mock.calls[0]!; + expect(opts?.replace).toBe(true); + const url = new URL(to, location.origin); + expect(url.pathname).toBe("/list"); + expect(url.searchParams.get("tab")).toBe("all"); + expect(url.searchParams.get("q")).toBe("shoes"); + expect(url.searchParams.get("page")).toBe("2"); + stop(); + }); + + it('history: "push" pushes; back (popstate) restores the previous store value', () => { + const s = filtersStore(); + const navigate = makeNavigate(); + const stop = persistQuery(s, { navigate, history: "push" }); + + s.setState({ page: 2 }); + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate.mock.calls[0]![1]?.replace).toBe(false); + expect(location.search).toBe("?page=2"); + + // Simulate the back button: URL returns to the previous entry, popstate fires. + history.replaceState(null, "", "/list"); + window.dispatchEvent(new Event("popstate")); + + expect(s.getState().page).toBe(1); + // Router → store sync must not echo a new navigation. + expect(navigate).toHaveBeenCalledTimes(1); + stop(); + }); + + it("seeds from the URL on init: coerced + validated, invalid values degrade to defaults", () => { + history.replaceState(null, "", "/list?page=3&q=boots"); + const s = filtersStore(); + const stop = persistQuery(s, { navigate: makeNavigate() }); + expect(s.getState()).toEqual({ q: "boots", page: 3, sort: "" }); + stop(); + + history.replaceState(null, "", "/list?page=banana&q=hats"); + const s2 = filtersStore(); + const stop2 = persistQuery(s2, { navigate: makeNavigate() }); + expect(s2.getState()).toEqual({ q: "hats", page: 1, sort: "" }); + stop2(); + }); + + it("omitDefaults: returning a key to its default removes the param", () => { + const s = filtersStore(); + const navigate = makeNavigate(); + const stop = persistQuery(s, { navigate }); + + s.setState({ page: 2 }); + expect(location.search).toBe("?page=2"); + s.setState({ page: 1 }); + expect(location.search).toBe(""); + // Empty string counts as default-equal when the default is "". + s.setState({ q: "x" }); + s.setState({ q: "" }); + expect(location.search).toBe(""); + stop(); + }); + + it("no write-echo loop: one store write → exactly one navigation", () => { + const s = filtersStore(); + const navigate = makeNavigate(); + const stop = persistQuery(s, { navigate }); + + s.setState({ q: "boots" }); + // Even if the router reports our own navigation back (afterNavigate), + // applying the same URL must not re-navigate. + window.dispatchEvent(new Event("popstate")); + expect(navigate).toHaveBeenCalledTimes(1); + stop(); + }); + + it("debounce: rapid writes coalesce into one navigation with the final state", async () => { + const s = filtersStore(); + const navigate = makeNavigate(); + const stop = persistQuery(s, { navigate, debounce: 20 }); + + s.setState({ q: "a" }); + s.setState({ q: "ab" }); + s.setState({ q: "abc" }); + expect(navigate).toHaveBeenCalledTimes(0); + await sleep(60); + expect(navigate).toHaveBeenCalledTimes(1); + expect(new URL(navigate.mock.calls[0]![0], location.origin).searchParams.get("q")).toBe("abc"); + stop(); + }); + + it("a push write flushes the pending debounced replace first", async () => { + const s = filtersStore(); + const navigate = makeNavigate(); + const stop = persistQuery(s, { + navigate, + debounce: 1000, + history: (keys) => (keys.includes("page") ? "push" : "replace"), + }); + + s.setState({ q: "boots" }); // debounced replace, still pending + s.setState({ page: 2 }); // push — must flush the replace first + expect(navigate).toHaveBeenCalledTimes(2); + const first = new URL(navigate.mock.calls[0]![0], location.origin); + expect(navigate.mock.calls[0]![1]?.replace).toBe(true); + expect(first.searchParams.get("q")).toBe("boots"); + expect(first.searchParams.get("page")).toBeNull(); + const second = new URL(navigate.mock.calls[1]![0], location.origin); + expect(navigate.mock.calls[1]![1]?.replace).toBe(false); + expect(second.searchParams.get("q")).toBe("boots"); + expect(second.searchParams.get("page")).toBe("2"); + stop(); + }); + + it("two stores persisting different keys don't clobber each other", () => { + const a = store({ q: "" }).build(); + const b = store({ page: "1" }).build(); + const navigate = makeNavigate(); + const stopA = persistQuery(a, { navigate }); + const stopB = persistQuery(b, { navigate }); + + a.setState({ q: "boots" }); + b.setState({ page: "4" }); + const sp = new URL(location.href).searchParams; + expect(sp.get("q")).toBe("boots"); + expect(sp.get("page")).toBe("4"); + + a.setState({ q: "" }); + expect(new URL(location.href).searchParams.get("page")).toBe("4"); + stopA(); + stopB(); + }); + + it("params mapping + per-key codec: custom names and serialization round-trip", () => { + history.replaceState(null, "", "/list?search=hats&tags=a,b"); + const s = store({ q: "", tags: [] as string[], internal: 0 }).build(); + const navigate = makeNavigate(); + const stop = persistQuery(s, { + navigate, + params: { + q: "search", + tags: { + serialize: (tags) => tags.join(","), + deserialize: (raw) => (raw === "" ? [] : raw.split(",")), + }, + }, + }); + + expect(s.getState().q).toBe("hats"); + expect(s.getState().tags).toEqual(["a", "b"]); + + s.setState({ tags: ["x"], internal: 9 }); // internal is not persisted + const sp = new URL(location.href).searchParams; + expect(sp.get("tags")).toBe("x"); + expect(sp.get("internal")).toBeNull(); + stop(); + }); + + it("teardown flushes a pending debounced write and stops syncing", async () => { + const s = filtersStore(); + const navigate = makeNavigate(); + const stop = persistQuery(s, { navigate, debounce: 1000 }); + + s.setState({ q: "boots" }); + expect(navigate).toHaveBeenCalledTimes(0); + stop(); + expect(navigate).toHaveBeenCalledTimes(1); + s.setState({ q: "later" }); + await sleep(10); + expect(navigate).toHaveBeenCalledTimes(1); + stop(); // idempotent + }); + + it("SSR: no-op without window — never touches history/location", () => { + const g = globalThis as { window?: unknown }; + const win = g.window; + // Simulate a server environment. + delete g.window; + try { + const s = filtersStore(); + const navigate = makeNavigate(); + const stop = persistQuery(s, { navigate }); + s.setState({ q: "boots" }); + expect(navigate).toHaveBeenCalledTimes(0); + stop(); + } finally { + g.window = win; + } + }); +}); diff --git a/packages/store/src/query.ts b/packages/store/src/query.ts new file mode 100644 index 0000000..cd424a0 --- /dev/null +++ b/packages/store/src/query.ts @@ -0,0 +1,346 @@ +// ============================================================================= +// @ilha/store/query — persistQuery(): URL query-string persistence +// +// Keeps store keys in sync with individual search params (?q=abc&page=2), +// nuqs-style. URL writes go through @ilha/router's navigate() (auto-detected, +// or injected via options) so loaders re-run and route signals stay correct. +// ============================================================================= + +import type { StoreBuiltins, Unsub } from "./index"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** The slice of a built store persistQuery needs. */ +export type PersistQueryStore = Pick< + StoreBuiltins, + "getState" | "getInitialState" | "setState" | "subscribe" +>; + +/** Signature-compatible with `@ilha/router`'s `navigate`. */ +export type NavigateFn = (to: string, opts?: { replace?: boolean; scroll?: boolean }) => void; + +/** Per-key mapping: a param name, or a codec with optional custom (de)serialization. */ +export type QueryParamCodec = { + /** Search param name. Default: the state key itself. */ + param?: string; + /** Value → param string. Default: `String(value)`. */ + serialize?: (value: T) => string; + /** + * Param string → value. Default: identity (the raw string) — schema stores + * coerce/validate it on write (e.g. `z.coerce.number()`). Throwing falls + * back to the key's default. + */ + deserialize?: (raw: string) => T; +}; + +export type PersistQueryParams = { + [K in keyof TState & string]?: string | QueryParamCodec; +}; + +export type HistoryMode = "push" | "replace"; + +export interface PersistQueryOptions { + /** + * Which keys to persist and how they map to search params. Omitted → every + * state key, each under its own name. + */ + params?: PersistQueryParams; + /** + * History strategy per write: `"replace"` (default) or `"push"`, or a + * function of the changed keys. + */ + history?: HistoryMode | ((changedKeys: string[]) => HistoryMode); + /** Debounce URL writes (ms) — for per-keystroke bound inputs. Default: `0`. */ + debounce?: number; + /** + * Drop a param from the URL when its value equals the store's initial + * default (clean URLs). Default: `true`. + */ + omitDefaults?: boolean; + /** + * URL writer. Default: auto-detect `@ilha/router`'s `navigate` (dynamic + * import); when the router isn't installed, falls back to the raw History + * API with a dev warning (loaders won't re-run on writes). + */ + navigate?: NavigateFn; +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +/** The subset of `@ilha/router`'s module surface persistQuery uses. */ +interface RouterModule { + navigate: NavigateFn; + afterNavigate(fn: (nav: { from: string; to: string; type: string }) => void): () => void; + routePath(): string; + routeSearch(): string; + routeHash(): string; +} + +interface Entry { + key: string; + param: string; + serialize: (value: unknown) => string; + deserialize?: (raw: string) => unknown; + /** Serialized form of the key's initial default — the omit sentinel. */ + defaultStr: string; +} + +function buildEntries( + initial: TState, + params: PersistQueryParams | undefined, +): Entry[] { + const rec = initial as Record; + const keys = params ? Object.keys(params) : Object.keys(rec); + return keys.map((key) => { + const spec = params?.[key as keyof TState & string]; + const codec = typeof spec === "object" && spec !== null ? spec : {}; + const param = typeof spec === "string" ? spec : (codec.param ?? key); + const serialize = (codec.serialize ?? String) as (value: unknown) => string; + return { + key, + param, + serialize, + deserialize: codec.deserialize as Entry["deserialize"], + defaultStr: serialize(rec[key]), + }; + }); +} + +/** + * Keep a store in sync with the URL query string, one search param per key: + * + * 1. On call (client), seeds the store from `location.search` — owned params + * are parsed and written through `setState`, so schema stores coerce and + * validate them; invalid values degrade to the key's default, never throw. + * 2. Subscribes to the store and mirrors owned keys into the URL via the + * router's `navigate()` (replace by default), preserving unrelated params. + * 3. Subscribes to committed navigations (back/forward, links) and writes + * owned-param changes back into the store — without re-triggering a + * navigation. + * + * No-op on the server (returns an inert unsubscribe) — loaders reading + * `ctx.url.searchParams` remain the way to consume query state server-side. + * Call the returned function to stop syncing (flushes any pending debounced + * write). + * + * ```ts + * const filters = store(schema).build(); + * persistQuery(filters, { debounce: 250 }); + * ``` + */ +export function persistQuery( + store: PersistQueryStore, + options: PersistQueryOptions = {}, +): Unsub { + if (typeof window === "undefined") return () => {}; + + const omitDefaults = options.omitDefaults !== false; + const debounceMs = options.debounce ?? 0; + const initial = store.getInitialState() as Record; + const entries = buildEntries(store.getInitialState(), options.params); + + let stopped = false; + // True while a URL → store write is committing, so the store subscriber + // doesn't echo it back into a navigation. + let applying = false; + + // --- navigate resolution -------------------------------------------------- + let router: RouterModule | null = null; + let nav: NavigateFn | null = options.navigate ?? null; + let unsubIncoming: Unsub = () => {}; + // Writes that arrive before auto-detection resolves, replayed in order. + let deferred: Array<() => void> | null = null; + + const onPopstate = () => { + applyFromUrl(new URL(location.href)); + }; + + const listenPopstate = () => { + window.addEventListener("popstate", onPopstate); + unsubIncoming = () => window.removeEventListener("popstate", onPopstate); + }; + + if (nav) { + listenPopstate(); + } else { + deferred = []; + import("@ilha/router").then( + (mod: RouterModule) => { + if (stopped) return; + router = mod; + nav = mod.navigate; + unsubIncoming = mod.afterNavigate((n) => { + applyFromUrl(new URL(n.to, location.origin)); + }); + drainDeferred(); + }, + () => { + if (stopped) return; + if (process.env.NODE_ENV !== "production") { + console.warn( + "[@ilha/store] persistQuery: @ilha/router not found — falling back to the " + + "History API. Route loaders will NOT re-run on query writes; pass " + + "options.navigate to integrate with your router.", + ); + } + nav = (to, opts) => { + history[opts?.replace ? "replaceState" : "pushState"](history.state, "", to); + }; + listenPopstate(); + drainDeferred(); + }, + ); + } + + const drainDeferred = () => { + const queue = deferred!; + deferred = null; + for (const run of queue) run(); + }; + + // --- URL helpers ---------------------------------------------------------- + + /** Current logical URL — router signals when mounted, `location` otherwise. */ + const currentUrl = (): URL => { + if (router && router.routePath()) { + return new URL( + router.routePath() + router.routeSearch() + router.routeHash(), + location.origin, + ); + } + return new URL(location.href); + }; + + /** Param string a key should occupy in the URL, or `null` for "absent". */ + const ownedValue = (state: Record, e: Entry): string | null => { + const str = e.serialize(state[e.key]); + return omitDefaults && str === e.defaultStr ? null : str; + }; + + // --- URL → store ---------------------------------------------------------- + + function applyFromUrl(url: URL): void { + if (stopped) return; + const sp = url.searchParams; + const state = store.getState() as Record; + + // Echo guard: if every owned param already reflects the store, no-op. + // (Our own navigations always match here, by construction.) + const differs = entries.some((e) => { + const raw = sp.get(e.param); + if (raw == null) return e.serialize(state[e.key]) !== e.defaultStr; + return raw !== e.serialize(state[e.key]); + }); + if (!differs) return; + + applying = true; + try { + for (const e of entries) { + const raw = sp.get(e.param); + let next = initial[e.key]; + if (raw != null) { + try { + next = e.deserialize ? e.deserialize(raw) : raw; + } catch { + // Bad param → key's default. + } + } + // Per-key commits so one invalid param can't reject the others. + // Schema stores validate/coerce; a rejected patch leaves state as-is. + store.setState({ [e.key]: next } as Partial); + if (raw != null) { + const cur = (store.getState() as Record)[e.key]; + const accepted = + !Object.is(cur, state[e.key]) || Object.is(cur, next) || e.serialize(cur) === raw; + // Schema rejected the raw value and the previous value doesn't + // match the URL either — degrade to the default. + if (!accepted) store.setState({ [e.key]: initial[e.key] } as Partial); + } + } + } finally { + applying = false; + } + } + + // --- store → URL ---------------------------------------------------------- + + const performWrite = (state: Record, mode: HistoryMode) => { + const run = () => { + if (stopped && !nav) return; + const url = currentUrl(); + const from = url.pathname + url.search + url.hash; + const sp = url.searchParams; + for (const e of entries) { + const v = ownedValue(state, e); + if (v == null) sp.delete(e.param); + else sp.set(e.param, v); + } + const search = sp.toString(); + const to = url.pathname + (search ? `?${search}` : "") + url.hash; + if (to === from) return; + nav!(to, mode === "replace" ? { replace: true, scroll: false } : { replace: false }); + }; + if (deferred) deferred.push(run); + else run(); + }; + + // --- debounce ------------------------------------------------------------- + let pendingState: Record | null = null; + let timer: ReturnType | null = null; + + const flush = () => { + if (timer != null) { + clearTimeout(timer); + timer = null; + } + if (pendingState == null) return; + const state = pendingState; + pendingState = null; + performWrite(state, "replace"); + }; + + const resolveMode = (changedKeys: string[]): HistoryMode => { + const h = options.history; + if (typeof h === "function") return h(changedKeys); + return h ?? "replace"; + }; + + const unsubStore = store.subscribe((state, prevState) => { + if (applying) return; + const s = state as Record; + const p = prevState as Record; + const changed = entries.filter((e) => !Object.is(s[e.key], p[e.key])).map((e) => e.key); + if (changed.length === 0) return; + + if (resolveMode(changed) === "push") { + // Flush any pending debounced replace first (it holds the pre-push + // state) so the pushed history entry's predecessor is coherent. + flush(); + performWrite(s, "push"); + return; + } + + pendingState = s; + if (debounceMs > 0) { + if (timer != null) clearTimeout(timer); + timer = setTimeout(flush, debounceMs); + } else { + flush(); + } + }); + + // --- init: URL is the source of truth ------------------------------------- + applyFromUrl(new URL(location.href)); + + return () => { + if (stopped) return; + stopped = true; + flush(); + unsubStore(); + unsubIncoming(); + }; +} diff --git a/packages/store/tsconfig.json b/packages/store/tsconfig.json index d005045..c57b9ef 100644 --- a/packages/store/tsconfig.json +++ b/packages/store/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "paths": { "@ilha/store": ["./src/index.ts"], + "@ilha/router": ["../router/src/index.ts"], "ilha": ["../ilha/src/index.ts"], "ilha/jsx-runtime": ["../ilha/src/jsx-runtime.ts"], "ilha/jsx-dev-runtime": ["../ilha/src/jsx-dev-runtime.ts"] diff --git a/packages/store/tsdown.config.ts b/packages/store/tsdown.config.ts index e9ed1b3..04eb540 100644 --- a/packages/store/tsdown.config.ts +++ b/packages/store/tsdown.config.ts @@ -1,9 +1,9 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/form.ts"], + entry: ["src/index.ts", "src/form.ts", "src/query.ts"], platform: "browser", dts: true, minify: false, - external: ["alien-signals", "ilha"], + external: ["alien-signals", "ilha", "@ilha/router"], }); diff --git a/templates/elysia/package.json b/templates/elysia/package.json index d0e7904..ca43427 100644 --- a/templates/elysia/package.json +++ b/templates/elysia/package.json @@ -11,7 +11,7 @@ "dependencies": { "@elysiajs/static": "^1.4.10", "@ilha/router": "^0.8.6", - "@ilha/store": "^0.7.2", + "@ilha/store": "^0.7.3", "areia": "^0.1.36", "elysia": "^1.4.29", "ilha": "^0.9.2", diff --git a/templates/hono/package.json b/templates/hono/package.json index 748c56d..d314fe9 100644 --- a/templates/hono/package.json +++ b/templates/hono/package.json @@ -9,7 +9,7 @@ "dependencies": { "@hono/node-server": "^2.0.8", "@ilha/router": "^0.8.6", - "@ilha/store": "^0.7.2", + "@ilha/store": "^0.7.3", "areia": "^0.1.36", "hono": "^4.12.28", "ilha": "^0.9.2", diff --git a/templates/nitro/package.json b/templates/nitro/package.json index 35318b1..90828c7 100644 --- a/templates/nitro/package.json +++ b/templates/nitro/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@ilha/router": "^0.8.6", - "@ilha/store": "^0.7.2", + "@ilha/store": "^0.7.3", "areia": "^0.1.36", "ilha": "^0.9.2", "nitro": "^3.0.260610-beta", diff --git a/templates/vite/package.json b/templates/vite/package.json index e4e96a4..db1864f 100644 --- a/templates/vite/package.json +++ b/templates/vite/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@ilha/router": "^0.8.6", - "@ilha/store": "^0.7.2", + "@ilha/store": "^0.7.3", "areia": "^0.1.36", "ilha": "^0.9.2", "quando": "^0.1.6" From a987ae4962bdaca368fbb3a18ee2fc8aaa48e00b Mon Sep 17 00:00:00 2001 From: Ryuz Date: Fri, 17 Jul 2026 21:49:03 +0200 Subject: [PATCH 2/2] fix(pr): improvements --- packages/store/src/query.test.ts | 18 ++++++++++++++++++ packages/store/src/query.ts | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/store/src/query.test.ts b/packages/store/src/query.test.ts index 9ccc573..b02e5d5 100644 --- a/packages/store/src/query.test.ts +++ b/packages/store/src/query.test.ts @@ -200,6 +200,24 @@ describe("persistQuery()", () => { stop(); }); + it("an external navigation cancels a pending debounced write", async () => { + const s = filtersStore(); + const navigate = makeNavigate(); + const stop = persistQuery(s, { navigate, debounce: 20 }); + + s.setState({ q: "stale" }); // debounced, still pending + // Back/forward lands on a different owned-param state before the flush. + history.replaceState(null, "", "/list?q=fresh"); + window.dispatchEvent(new Event("popstate")); + + expect(s.getState().q).toBe("fresh"); + await sleep(60); + // The stale pending write must not have navigated over the external URL. + expect(navigate).toHaveBeenCalledTimes(0); + expect(location.search).toBe("?q=fresh"); + stop(); + }); + it("teardown flushes a pending debounced write and stops syncing", async () => { const s = filtersStore(); const navigate = makeNavigate(); diff --git a/packages/store/src/query.ts b/packages/store/src/query.ts index cd424a0..93e9c1c 100644 --- a/packages/store/src/query.ts +++ b/packages/store/src/query.ts @@ -237,6 +237,14 @@ export function persistQuery( }); if (!differs) return; + // The external URL supersedes any pending debounced write — cancel it so + // a later flush can't clobber the URL with pre-navigation state. + if (timer != null) { + clearTimeout(timer); + timer = null; + } + pendingState = null; + applying = true; try { for (const e of entries) {