Browser
Adapter
History API synchronization with push, replace, and popstate.
- Package
- @queryweave/browser
- Runtime
- Browser
- Depends on
- @queryweave/core
- Framework required
- No
The browser adapter reads the query from location.search and writes it with
history.pushState or history.replaceState, keeping the path and the hash. It listens for
popstate and reports when the query changes. Decoding, validation, defaults, and the canonical form
stay in the model, so the adapter only moves strings. It is tested in Chromium, Firefox, and WebKit.
Install
Section titled “Install”pnpm add @queryweave/core @queryweave/browserExample
Section titled “Example”import { createBrowserAdapter } from "@queryweave/browser";import { createQueryRuntime, defineQueryModel, param } from "@queryweave/core";
const products = defineQueryModel({ search: param.text().optional(), page: param.integer({ min: 1 }).default(1),});
const adapter = createBrowserAdapter();const runtime = createQueryRuntime({ model: products, adapter });
runtime.subscribe((snapshot) => { render(snapshot.values);});
await runtime.update({ search: "vue" }); // ?search=vueawait runtime.update({ page: 2 }, { navigation: "replace" });// ?search=vue&page=2 — the current entry was replacedHistory1 / 1
- history.pushState
- history.replaceState
- popstate
The adapter writes through the History API and re-reads on popstate.
Type a query, press Enter.
Typed state
{
"page": 1,
"sort": "created_at",
"status": "all"
}Canonical URL
Valid/productsNothing happens at import time
Section titled “Nothing happens at import time”The adapter resolves its target when you create it, not when the module is evaluated, and it
attaches the popstate listener only when something subscribes. Importing
@queryweave/browser in a server bundle is therefore harmless; calling createBrowserAdapter()
there is what fails, and it fails with a clear message rather than a ReferenceError.
// Server rendering: create the adapter on the client only.if (typeof window !== "undefined") { adapter = createBrowserAdapter();}An explicit target
Section titled “An explicit target”const iframe = document.querySelector<HTMLIFrameElement>("#product-preview");const target = iframe?.contentWindow;if (target === null || target === undefined) throw new Error("Product preview is missing.");
// This runtime reads and writes the preview frame, not the parent page.const adapter = createBrowserAdapter({ target });const runtime = createQueryRuntime({ model: products, adapter });
await runtime.update({ search: "books", page: 2 });// iframe URL: /preview?search=books&page=2Useful for tests, for multi-frame applications, and for any case where the ambient window is not the one you mean.
Cleanup
Section titled “Cleanup”adapter.dispose();Disposal removes the popstate listener, clears subscribers, and makes later navigation throw. In
a single-page application with one long-lived runtime you rarely need it; in a component that
creates its own adapter, tie it to teardown.
Edge cases
Section titled “Edge cases”- Path and hash are preserved. The adapter rebuilds the URL from
location.hrefwith only the query replaced, so even a pathname that starts with//survives. - An empty query drops the
?. Writing an empty output produces/products, not/products?. history.stateis kept on replace and cleared on push. Replacing keeps the entry’s state object; a pushed entry starts withnull, so a router’s state is not copied onto an entry it did not create.- A hash-only or path-only
popstateis ignored. Subscribers hear only about the query. - Writes by other code are not observed.
pushStatefires no event, so a second adapter or a router writing the same history is invisible to this one. Share one adapter per window, and use@queryweave/vue-routerwhen a router owns the history. - Safari rate-limits history writes — around a hundred in ten seconds — and throws a
SecurityErrorpast that. The write then rejects the transition; give the runtime athrottlewindow for fast-changing inputs. - After
dispose,push,replace, andsubscribethrow. Reading does not.