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..b02e5d5
--- /dev/null
+++ b/packages/store/src/query.test.ts
@@ -0,0 +1,252 @@
+// =============================================================================
+// @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("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();
+ 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..93e9c1c
--- /dev/null
+++ b/packages/store/src/query.ts
@@ -0,0 +1,354 @@
+// =============================================================================
+// @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;
+
+ // 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) {
+ 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"