Skip to content

feat(adapter): add QueryBuilderAdapter for one-shot hydration and two-way binding#86

Merged
cgarciagarcia merged 38 commits into
mainfrom
feature/adapter-hydration
May 21, 2026
Merged

feat(adapter): add QueryBuilderAdapter for one-shot hydration and two-way binding#86
cgarciagarcia merged 38 commits into
mainfrom
feature/adapter-hydration

Conversation

@cgarciagarcia

@cgarciagarcia cgarciagarcia commented May 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds QueryBuilderAdapter (read() + optional write(state)) on BaseConfig.adapter — bridges the builder to any external source (URL, localStorage, hash router, in-memory, …) with lazy one-shot read on creation and an opt-in per-mutation write for two-way binding.
  • Adds the built-in createSearchParamsAdapter for the URL case (configurable keys for filter / sort / include / fields, explicit allowedParams allowlist, injectable source for SSR/tests).
  • Exports the pure parseSearchParams helper for ad-hoc use without the adapter wrapper.
  • Fixes a latent bug in useQueryBuilder where useRef(new Builder(config)) re-evaluated new Builder() on every render (only the first ref value was kept). The new lazy-ref pattern guarantees adapter.read() runs exactly once.
  • Docs: new "Hydrating from URL" section in README.md, "Adapters" section in llms.txt, and two new live examples in docs/playground.html (URL Adapter and localStorage Adapter).
  • Playground: adds an inline localStorage inspector that appears only when the localStorage Adapter tab is active — a textarea wired to the rqb-playground-demo key with Sample and Save & Run buttons, inline JSON validation, and auto-refresh after every Run (preserving focus so user edits aren't clobbered). Lets people drive the demo end-to-end without devtools.

API at a glance

// URL hydration with custom keys + extra params allowed
useQueryBuilder({
  adapter: createSearchParamsAdapter({
    keys: { filter: "filt", sort: "srt" },
    allowedParams: ["locale"],
  }),
})

// Custom adapter (e.g. localStorage two-way binding)
useQueryBuilder({
  adapter: {
    read:  () => JSON.parse(localStorage.getItem("qb") ?? "{}"),
    write: (state) => localStorage.setItem("qb", JSON.stringify(state)),
  },
})

Precedence on seed: defaults < adapter.read() < explicit BaseConfig fields — explicit config always wins.

Patterns used

  • Adapter / Strategy for the read/write boundary — any source plugs in by implementing the interface.
  • Lazy Initialization in the hook, matching useState(() => …) semantics.
  • Dependency Inversion — the library no longer needs to import window; window.location.search lives inside the opt-in factory's default source.

Test plan

  • pnpm test — 188/188 passing (3 new hook tests + dedicated suites for parser and adapter)
  • pnpm lint — clean
  • pnpm build:types — clean
  • Manually open docs/playground.html and click URL Adapter → confirm the hydrated state appears in the right panel
  • Manually open docs/playground.html, click localStorage Adapter, then SampleSave & Run → confirm the builder hydrates from the textarea JSON
  • Run code that mutates the builder and confirm the textarea auto-refreshes (unless focused) to reflect what write() persisted
  • Smoke test in a consumer app: navigate to ?filt[status]=active&srt=-name&locale=es with the renamed keys and confirm builder.build() produces the canonical ?filter[status]=active&sort=-name&locale=es

…-way binding

Introduces an `adapter` option on `BaseConfig` accepting any
`{ read(), write?() }` object. `read()` runs exactly once on builder
creation (lazy seed, like `useState(() => ...)`); the optional `write(state)`
is wired as an internal subscriber, firing on every mutation for two-way
sync. Precedence: defaults < adapter.read() < explicit config.

Built-in `createSearchParamsAdapter` hydrates from `window.location.search`
with configurable URL keys (`filter`, `sort`, `include`, `fields`) and an
explicit `allowedParams` allowlist so nothing from the environment leaks
into state unauthorised. The pure parser `parseSearchParams` is also
exported for ad-hoc use.

Also fixes a latent bug in `useQueryBuilder`: `useRef(new Builder(config))`
re-evaluated the constructor on every render (only the first ref value was
kept). Now uses the standard lazy-ref pattern so adapter.read() is
guaranteed to run exactly once.

Docs: README "Hydrating from URL" section, llms.txt "Adapters" section,
and two new playground examples (URL adapter + localStorage adapter).
@codacy-production

codacy-production Bot commented May 11, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 71 complexity · 0 duplication

Metric Results
Complexity 71
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

…mple

Adds a collapsible "localStorage" section in the right panel of the
playground, visible only when the "localStorage Adapter" example tab is
active. It exposes a textarea wired to the `rqb-playground-demo` key with
Sample / Clear / Reload / Save & Run buttons, plus inline JSON validation
feedback. The textarea auto-refreshes after every Run (unless it has focus,
so the user's edits aren't clobbered), so people can watch the persisted
state evolve as the builder mutates and edit it back to see hydration in
action — no devtools required.
…e & Run

Drops the Reload and Clear buttons from the localStorage inspector — Reload
was a niche "discard unsaved edits" shortcut, and Clear was just a one-click
alias for "empty the textarea + Save" (saveStorageKey already removes the
key when the value is empty). Sample and Save & Run cover the demo flow
end-to-end without the extra surface area.
@cgarciagarcia cgarciagarcia self-assigned this May 11, 2026
- parseSearchParams: drop the defensive `search ?? ""` since the parameter
  is typed as string; add tests for empty filter values
  (`?filter[status]=`) and operator-with-empty-value (`?filter[score]=>=`).
- searchParamsAdapter: add a no-source SSR/node test (window undefined) and
  a happy-dom suite that exercises the `window.location.search` default,
  including re-reading it across multiple read() calls.

Also fixes a stale `initialState` reference in the createSearchParamsAdapter
JSDoc — should be `adapter`.

Coverage on the new modules now reads 100% statements / 100% branches /
100% functions / 100% lines. Total tests: 193 (was 188).
….each

- parseSearchParams: 12 tests → 4 (two `it.each` tables cover the empty
  inputs and the input-to-state mappings; "honours custom URL keys" and
  "allowlist" stay as their own tests because they exercise option flags).
- searchParamsAdapter (node): merged "uses the injected source" into the
  broader "forwards source, keys, and allowedParams" — the merged
  assertion already exercises source injection plus option propagation.
- searchParamsAdapter (browser): collapsed two tests into one that asserts
  both the default-source fallback AND the per-call re-read by chaining
  history.replaceState calls.

Coverage stays at 100% statements / 100% branches / 100% functions /
100% lines on every new module. Total tests: 190 (down from 193) with
fewer lines of test scaffolding to maintain.
The prefix list duplicated the literal operator strings already declared in
`FilterOperator`. Now derived: `Object.values(FilterOperator)` filtered to
drop `Equals` (the implicit operator carries no prefix on the wire) and
sorted by length descending so multi-char operators (`<=`, `>=`, `<>`) win
the prefix race against their single-char prefixes (`<`, `>`). Single
source of truth — adding a new operator to FilterOperator is enough.
The code is self-evident enough: filter+sort on a known enum. The comment
restated mechanics rather than encoding a non-obvious invariant.
Closes the symmetry of the URL adapter so consumers can drive filters /
sort / include / fields entirely via URL with round-tripping and
reasonable defaults against injection.

Writer

- New pure serializeSearchParams(state, options?): inverse of
  parseSearchParams. Same configurable keys, same allowedParams contract
  (only allowlisted entries from state.params reach the URL).
- SearchParamsAdapterOptions.sync opt-in: true|'replace' uses
  history.replaceState, 'push' uses history.pushState, a function gets
  the serialised search string (for router integration or debouncing).
  Adapter only exposes write when sync is set.
- Default writer preserves any query params not managed by the adapter
  (utm_source, gclid, theme, ...) so third-party params stay intact.

Aliases (option A)

- Both ends now respect aliases: writer applies forward (state userName
  to URL name), reader applies the reverse (URL name to state userName).
  State stays in frontend space, URL stays in wire / backend space (same
  as .build()).
- QueryBuilderAdapter.read gains an AdapterReadContext arg. The Builder
  passes its aliases automatically so consumers do not declare them twice.
- First-wins on alias collisions; the unused mappings stay as-is.

excludeKeys (defense in depth)

- New SearchParamsAdapterOptions.excludeKeys denylist. Drops matching
  attribute names on read across filter / sort / include / fields, and
  overrides allowedParams for params. Matched on the raw URL name before
  any reverse alias, since the threat is what reaches the wire.
- Mitigates crafted links like ?filter[is_admin]=true flowing into state
  and out to the backend without explicit frontend consent.

Tests, docs, playground

- 220 tests green; 100% coverage on every new module.
- README adds Aliases, excludeKeys and sync sections; llms.txt updated.
- Playground adds a Two-way URL example demoing all three (sync stubbed
  via console.log so the URL bar is not touched). Runtime mirror updated
  and serializeSearchParams exposed to the editor.
Replaces the flat allowedParams + excludeKeys: string[] with a per-bucket
shape so the developer's intent is explicit at the declaration site.

API change

- allowedParams: string[]
  -> allowed: { filters?, sorts?, includes?, fields?, params? }
  Per-bucket allowlist. Omit a bucket and the default applies:
  filters/sorts/includes/fields = allow-all, params = deny-all.
  Applies symmetrically on read AND write.

- excludeKeys: string[]
  -> excludeKeys: { filters?, sorts?, includes?, fields?, params? }
  Per-bucket denylist. Per-bucket because a name like 'password' is
  dangerous as a filter but legitimate as a fields selection; one flat
  list would silently break legitimate use. Wins over allowed when both
  are set on the same bucket. Also applied symmetrically on read AND
  write.

For fields, both the short prop ('password') and the entity.prop
('user.password') form match — the caller picks the precision.

Shared policy helper

- New src/utils/searchParamsPolicy.ts exports compilePolicy(options),
  returning a pass(bucket, ...candidates) gate that encapsulates the
  deny-then-allow chain. parseSearchParams and serializeSearchParams now
  use the same gate, eliminating the repeated
    if (excluded.X.has(...)) continue;
    if (allowed.X && !allowed.X.has(...)) continue;
  pattern from every bucket.

- Net effect on the two files: parseSearchParams.ts -28% LOC,
  serializeSearchParams.ts -35% LOC. The asymmetric default
  (params is deny-by-default, the rest allow-by-default) now lives in
  one place inside the helper.

Other touches

- isManagedKey in the adapter renamed its local allowedParams -> managedParams
  to avoid suggesting the public option still exists.
- makeWriter uses defaultSource() instead of duplicating the SSR check
  inline.

Tests, playground

- 234 tests pass; 100% coverage on every new/changed module
  (searchParams adapter, parser, serializer, policy helper).
- Playground runtime mirror gets the same _compilePolicy helper and the
  Two-way URL example was updated to showcase the bucket-scoped allowed
  (password legitimate as a field, blocked as a filter) and excludeKeys.
- TYPE_DEFS in the playground updated for Monaco autocomplete.
Reorders the URL adapter section around use cases instead of API surface.

- Opens with a concrete scenario (share a filtered list link) instead of
  an interface dump.
- Adds a 30-second example up front that copy/pastes and works.
- New "Read-only hydration" sub-section for the no-sync case so it does
  not get buried inside the writer discussion.
- "Customising the writer" lists the three forms of sync side by side.
- Aliases section reads as a workflow (frontend names in code, backend
  names on the wire) rather than a feature spec.
- New "Renaming the URL keys" shows the before/after URL and lists the
  why (shorter / shareable / brand / collision-avoidance), since the
  benefit was implicit before.
- Locking down section split into allowed vs excludeKeys with a
  "which one should I use?" table; clarifies that excludeKeys for a
  bucket where allowed is also defined is redundant (and why you might
  still want it).
- Custom adapter example now uses localStorage (more concrete than the
  generic memory store).
- llms.txt synced to the new shape, including the bucket-scoped
  allowed/excludeKeys.
The serializer and the writer used to call decodeURIComponent on the full
URLSearchParams.toString() output. That made brackets readable
(filter%5Bname%5D -> filter[name]) but as a side effect also decoded any
%XX inside values, producing two problems:

1. Double-decode of literal % in values. A value like "50%25discount"
   ended up as "50%discount" after one round-trip — silent state
   corruption.
2. The resulting search could include a bare % (e.g. "50% off") that
   violates RFC 3986 even though browsers tolerate it.

Replaces the blanket decodeURIComponent with prettifyBrackets, which
only decodes the percent-escapes for the characters that are part of
the library's own URL protocol:

  [ ] ,  <  >  =

Everything else (%, &, +, spaces, etc.) stays URL-escaped and survives
the round-trip through URLSearchParams intact.

Applied in three places: serializeSearchParams, the merge path in the
adapter's writer, and the playground runtime mirror. Adds three tests
covering: round-trip with a literal %, round-trip with =/&/# and spaces,
and the readability assertion for brackets.

Note: src/actions/build.ts has the same pre-existing pattern. Not
touched here since it is out of this PR's scope; flagged for a separate
fix.
The URL adapter uses a CSV-over-querystring protocol (the same shape
spatie/laravel-query-builder expects). Two character classes inside
filter values cannot survive a round-trip and silently corrupt:

- ',' is the multi-value separator: filter[tag]=a,b parses back as two
  entries.
- Leading '<', '>', '<=', '>=', '<>' are operator prefixes:
  filter[age]=>=18 parses back as operator '>=' + value '18'.

These limits are structural to the protocol — not a bug we can patch
without changing the wire format. Documents the constraint where the
developer will see it:

- JSDoc at the top of SearchParamsAdapterOptions, so the IDE tooltip
  shows it on hover / autocomplete.
- New "Known limitations" subsection at the end of "Hydrating from URL"
  in README.md with a small table mapping each character to why it
  breaks, plus a note clarifying that %, &, + and spaces are safe.
- Mirror note in llms.txt for parity.
createSearchParamsAdapter used to let parseSearchParams /
serializeSearchParams compile the allow/deny policy from options on
every read AND every write. Since options is immutable for the
adapter's lifetime, this was wasted work — especially on the writer,
which fires on every builder mutation (typing into a search box,
flipping filters quickly, etc.) and builds 10 Sets per call for no
benefit.

Adds an optional 3rd argument policy?: PolicyGate to both pure
functions. When supplied, it is used as-is; when omitted, the function
falls back to compiling internally (so existing callers see no
behaviour change). The adapter now compiles the policy once in the
factory and threads it into both read and write paths.

Also exports PolicyGate and compilePolicy publicly so consumers writing
their own adapters can apply the same optimisation, and to keep the
contract of the new 3rd argument importable.

Two tests added (one in each pure-function suite) confirming the 3rd
argument path produces the same output as the implicit compilation.
Two issues raised by the code review, fixed together because they share
the same mechanism:

#3 — DX footgun
  When `aliases` are set, a developer naturally writes the policy with
  the frontend names they use everywhere else in their code:

    aliases: { userName: "name" }
    allowed: { filters: ["userName"] }    // PREVIOUSLY: silently dropped
                                          //  every ?filter[name]=...

  The check ran against the raw URL name (backend) only, so the
  intuitive form was a no-op and the dev's filters disappeared with no
  warning.

#3 — Security bypass
  Symmetric problem on the deny side:

    aliases: { adminFlag: "is_admin" }
    excludeKeys: { filters: ["is_admin"] }

  Blocked ?filter[is_admin]=true. But an attacker who knew the alias
  key could send ?filter[adminFlag]=true — the raw URL name doesn't
  match the denylist, the entry hydrates as { attribute: "adminFlag" },
  and the next .build() emits ?filter[is_admin]=true to the backend
  anyway. The denylist was a paper wall.

Fix
  pass() is already variadic. parseSearchParams now also passes the
  "alt" name (reverse-aliased if URL has the backend name, forward-
  aliased if URL has the frontend name) for filters and sorts.
  serializeSearchParams does the symmetric thing for the writer
  (state name + wire name).

  Net effect: allowed/excludeKeys accept either vocabulary, and listing
  one form blocks both. The variadic some() check inside pass() adds
  one comparison per call — negligible.

  fields / includes / params are unchanged (they do not have aliases
  in this library — same contract as .build()).

#5 — useQueryBuilder config is read once
  Pure JSDoc note on the hook describing the lazy-init semantics: the
  config (including nested adapter) is captured on the first render,
  later renders do not rebuild the builder. If the consumer needs to
  react to a config change, remount with a `key` or mutate via the
  builder API.

Tests
  parseSearchParams + serializeSearchParams gain alias-aware policy
  cases (DX both directions + security bypass attempt blocked, both for
  filters and sorts). 246 tests green, 100% coverage on every new/
  changed module.

Docs
  Types JSDoc updated for `allowed` and `excludeKeys` to describe the
  alias-aware behaviour. README's "Details that apply to both" section
  adds a paragraph with an example for each direction. Playground
  runtime mirror updated.
builder.current is guaranteed non-null right after the `??=` assignment,
so the optional chain in useMount was suggesting a null case that cannot
happen. Pulls the ref value into a local `instance` so both useMount and
the return statement use the same non-null reference, removing the noise.
?sort=a&sort=b (two params) and ?sort=a,b (CSV) produce the same shape
in state. Same for include. Repeated filter[X] entries produce two
separate filter entries (not a merged one). The parser already supports
this — these tests pin the behaviour so a future refactor cannot
silently drop it.
bracketedKey("filter[a][b]", "filter") used to return "a][b" as the
attribute name, letting a malformed URL hydrate into state with a
garbage attribute that would then leak out via .build() in the next
fetch. The library does not model nested filters; the right answer is
to ignore the malformed entry on read.

The guard rejects any inner string containing `[` or `]`, so both
?filter[a][b]=... and ?fields[user][nested]=... are dropped silently,
the same way unknown URL keys are. Mirrored in the playground runtime.
Two tests added.
The export was incidental before — exposed via index.ts because
parseSearchParams sits in the same module. Adds a JSDoc clarifying
that consumers can reference it (e.g. to spread defaults under a single
override) and that its values are part of the semver contract.
The URL adapter feature has too many surfaces to fit on the home page
without burying everything else (reader + writer + sync modes + aliases
+ allowed/excludeKeys + custom adapters + lower-level utilities +
limitations). Splitting it out keeps the home focused and gives the
feature a proper narrative flow.

- New docs/url-adapter.html with the full guide: hero + on-page TOC
  (sticky, scroll-spy) + sections for use case, 30-second example,
  read-only mode, two-way binding, aliases, renaming URL keys, locking
  it down (allowed vs excludeKeys), custom adapters, low-level utils,
  known limitations. Styling matches index.html: same theme variables,
  same nav/footer scaffolding, same hljs setup. Accent colour shifted
  to purple to distinguish from the emerald home + playground.

- index.html gets a "URL Adapter" link in the desktop and mobile nav,
  plus a teaser card section between FEATURES and QUICK START with a
  short code snippet and CTAs to the guide and playground.

- playground.html top bar gets an inline "URL Adapter" link next to
  the existing Docs back-link.
The copy buttons on the code blocks had two related issues:

1. Different accent colours per page (emerald on index, purple on
   url-adapter) so the same UI element looked inconsistent.
2. The hover and `.copied` states were setting the same colour for
   `background` AND `color`, so the "Copied!" label was invisible —
   you saw a solid emerald/purple square with no text.

Unifies both pages to a single style:

- Default: subtle translucent background with muted text and the card
  border, plus a small backdrop-blur so the button reads cleanly on top
  of code blocks in both themes.
- Hover: tinted emerald background (15% alpha) with emerald text — keeps
  the accent visible without losing legibility.
- Copied: solid emerald with dark text (#0a0f1a) — high contrast for
  the brief confirmation flash.

The accent is emerald on both pages so the copy interaction matches the
home/brand colour even on the URL-adapter page (whose section accent
stays purple).
…s New banner

Splits responsibilities cleanly:

- Global navbar — only cross-page links (Home, URL Adapter, Playground,
  GitHub). Lives in docs/assets/navbar.js, injected on each page via a
  `<div id="navbar">` mount point. Each page declares its identity with
  `<html data-page="…">` so the script marks the active link.
  toggleTheme(), toggleMenu() and the mobile-icon sync moved out of the
  per-page inline scripts and into this one file — any nav change now
  propagates to every docs page automatically.

- In-page navigation — moved to a per-page sidebar TOC. The home page
  gains a floating TOC (visible from xl+ screens) listing Install /
  What's new / Features / Quick Start / API / Integrations, with
  scroll-spy highlighting. URL Adapter already had its own TOC; both
  use the same .toc-link styling for consistency.

- "What's new" banner — yellow/pink gradient eyebrow with a pulsing dot
  ("Just shipped"), bigger headline with a gradient highlight on
  "URL state sync", and a one-line value prop. Sits as a dedicated
  section between Features and Quick Start, replacing the previously
  understated purple teaser card. The card below still has the same
  CTAs (Read the guide / Try it live).

- Excludes docs/ from eslint — frontend docs are vanilla browser JS
  with their own conventions; running the TS lint rules on them
  produced false positives.
The previous 6 cards leaned on table-stakes claims (zero config, "fully
typed with TypeScript", "fluent chainable API") that any modern library
ships and that don't help a developer evaluating the lib. The new lineup
swaps the three weakest for the actual differentiators, reordered so the
top row covers what a visitor most likely scans first.

New order (top-left → bottom-right):

1. Sync with the URL — the new headline capability. Linkifies to
   url-adapter.html so the card itself is the entry point once "What's
   new" stops being new.
2. Frontend ↔ Backend aliases — practical, daily-use, not common.
3. Conflict-aware filters — unique to this library, retained.
4. Comparison operators — useful and not universally available.
5. Smart pagination — retained, copy tightened.
6. React-aware re-renders — what makes it a hook, retained, copy
   merged with the queryKey/TanStack note.

Dropped:
- Zero config — table stakes for any modern library
- Fully typed with TypeScript — already shown as a hero badge
- Fluent, chainable API — visible in every code snippet on the page
The Configuration code block was missing `adapter` — the central new
field of this PR — and never mentioned the lazy-init contract on
`useQueryBuilder(config)`. Two changes:

- Adds `adapter: createSearchParamsAdapter({ sync: true })` to the
  example, grouped under a labeled section so it doesn't get lost
  between the unrelated initial-state and behavior fields. Comment
  above describes when `read` and `write` run.
- Intro paragraph now states the config is read once on first render
  and links to the URL Adapter page for the adapter contract in detail.
- Trailing paragraph documents the precedence (explicit fields win
  over adapter.read), which is the most common gotcha when both are
  used together.
…tern

Codacy flagged `mount.outerHTML = navHTML` as a potential XSS sink. The
string is built entirely from module-local constants (logos, labels,
hrefs); the one external read — `CURRENT` from
`document.documentElement.dataset.page` — is only used for a boolean
comparison and never interpolated into HTML. In practice the call is
safe, but pattern-based scanners can't prove that.

Switches to `new DOMParser().parseFromString(navHTML, "text/html")` plus
`mount.replaceWith(navEl)`. DOMParser does not execute scripts and is
the conventional safe path for materialising an HTML string as nodes —
same behaviour as outerHTML for our case, but stops tripping static
analysers and protects against future edits that might sneak a
runtime-read value into the template.
The adapter's `aliases` option overloaded the name with
`BaseConfig.aliases` — both have the same shape and same direction
(`state → wire`), but the "wire" means different things (backend for
the builder, URL for the adapter). Calling them the same made
configurations like

  useQueryBuilder({
    aliases: { dni: 'code' },
    adapter: createSearchParamsAdapter({ aliases: { code: 'dni' } }),
  })

look reasonable while silently disagreeing about which name is the
frontend.

Renames `SearchParamsAdapterOptions.aliases` → `urlAliases`. The
builder's `BaseConfig.aliases` stays as-is (it's the canonical
state-→-backend map). The adapter still inherits from the builder when
`urlAliases` is omitted, so the common case (URL == backend) needs no
extra config; pass `urlAliases: {}` to opt out of any URL translation,
or pass an explicit map for the "URL ≠ backend" case.

Also documents the three-namespace setup (state / URL / backend) in
README, llms.txt, the url-adapter docs page and the playground type
defs — the use case the rename is meant to make obvious.

Drive-by: `tsconfig.json` had `"removeComments": true`, which stripped
JSDoc from the published `.d.ts` files — consumers got no hover docs
in their IDE. Switched to `false` so all the JSDoc on the public types
actually reaches the editor.

No behaviour change beyond the rename; all 251 tests pass.
…the URL

Real-world case: the backend always needs certain context (typical
include=organization,permissions) but the user shouldn't see that
noise in the URL bar. Until now there was no clean way to feed the
API and hide from the URL at the same time — `excludeKeys` blocks
both directions, `allowed` shrinks the policy in both directions.

`urlOmit` is the missing primitive: a bucket-scoped, writer-only
denylist. Listed names stay in state (so `.build()` keeps emitting
them to the API), they just don't reach the URL on write. The reader
is intentionally NOT affected — if a crafted URL contains one of the
omitted names, the existing policy still applies; add the same name
to `excludeKeys` if you want symmetric refusal.

```ts
useQueryBuilder({
  includes: ["organization", "permissions"], // always sent to API
  adapter: createSearchParamsAdapter({
    sync: true,
    urlOmit: { includes: ["organization", "permissions"] },
  }),
});

builder.filter("status", "active");
// .build() → ?filter[status]=active&include=organization,permissions
// URL bar  → ?filter[status]=active
```

Alias-aware on `filters` and `sorts` (parallels `excludeKeys`): list a
name in either vocabulary (state or URL) and both forms are skipped.

Implementation lives in `compilePolicy` as a new `omit(bucket, ...candidates)`
gate so the writer only does the lookup once per adapter creation.
serializeSearchParams calls `omit(…)` before `pass(…)` in every bucket.

Docs: README, llms.txt, url-adapter page (new section in the in-page
TOC), and the playground type defs.

2 new tests; total 253.
The previous behaviour required a mutation to flush the writer, so a
page loaded with a stale URL — or a state populated from
`BaseConfig.includes` defaults that `urlOmit` was meant to hide — would
keep showing the old URL until the user clicked something. This was the
common case behind "I configured urlOmit but the includes are still
there": the writer simply never ran.

Now the Builder fires `adapter.write(state)` once right after
construction (after `read()` has seeded and `BaseConfig` overrides have
been merged). The URL bar lands on the final state immediately.

No new public option. The behaviour is opinionated per `sync` mode:

- `sync: true` / `"replace"` — mount call uses `replaceState`,
  idempotent, no extra history entry.
- `sync: "push"` — the *first* call always uses `replaceState` (so the
  mount normalisation doesn't add a phantom back-button entry); every
  subsequent mutation uses `pushState` as configured.
- `sync: (search) => void` — the callback fires on mount too; consumer
  can guard with a closure if they truly don't want it.

Implementation lives inside `makeWriter` via a closure `isFirstCall`
flag, so the Builder side stays simple: it always calls write once after
registering it as a subscriber. Custom adapters wrapping
non-idempotent sources (e.g. router redirects) can do the same first-
call dance themselves.

Tests updated:
- useQueryBuilder hook: write fires N+1 times for N mutations.
- DOM dom test for sync:"push": first call no longer adds a history
  entry; second mutation does.

253 tests green, lint/types clean, 100% coverage on the touched modules.
The mount-fire behaviour (writer runs once right after construction so
the URL bar matches the final state without waiting for a mutation) was
shipped in 06c6522 but not documented anywhere. That gap was the source
of the most recent confusion: someone configured `urlOmit` correctly,
loaded a page with a stale URL, didn't trigger a mutation, and saw the
omitted entries still in the URL bar.

Calls it out in three places:

- README "What just happened" bullets + new paragraph under Customising
  the writer explaining the mount fire across all three sync modes.
- llms.txt JSDoc on `write?` covers mount fire + the "push uses
  replaceState on first call" detail.
- url-adapter docs page gets a dedicated tip callout under Two-way
  binding mentioning urlOmit, defaults and the push/custom edge cases.

No behaviour change; pure documentation.
Previously the URL adapter explicitly skipped pagination: the reader
never wrote into `state.pagination`, the writer never emitted `page` /
`limit`. The original concern was page numbers going stale ("page 5
yesterday ≠ page 5 today"), so leaving them out felt safer.

The opposite is worse: WITHOUT page in the URL, a shared link to "this
specific row on page 5" loses the row entirely — the recipient lands
on page 1 and never finds it. WITH page in the URL, the link is at
least correct until the data shifts. "Probably valid for a while"
beats "guaranteed wrong" for sharing.

Implementation:
- parseSearchParams intercepts `page` / `limit` before the params loop,
  parses them as `Number`, and writes into `state.pagination`. Invalid
  values (NaN) are ignored. Falls through to the next handler so neither
  ends up in the `params` catch-all even when `allowed.params: ['page']`
  would otherwise capture it.
- serializeSearchParams appends `page` / `limit` from `state.pagination`
  unconditionally when set. No policy bucket added — pagination is a
  built-in concept, not user-defined attributes. If a real need to hide
  it appears later we can add a `pagination` bucket then.
- Playground runtime mirror updated to keep the editor in sync.
- README, llms.txt: the "page/limit are NOT auto-hydrated" notes get
  swapped for "auto-hydrated out of the box".

8 new tests across parser and serializer (single field, both fields,
invalid values, no collision with allowed.params, round-trip with
filter+pagination). Total: 261.
The mount-time / mutation writer goes through `mergeManagedSearch` to
preserve unmanaged params (utm_source, theme, …) while updating
adapter-controlled keys. `isManagedKey` listed filter/sort/include/
fields/allowed-params but missed the new pagination keys, so:

  before write: ?page=1&limit=20
  serializer:   page=2&limit=20
  merge:        ?page=1&limit=20 (page/limit not deleted)
              + page=2&limit=20 (appended)
  result:       ?page=1&limit=20&page=2&limit=20  ← duplicates pile up

Two-line fix: `page` and `limit` join the managed-keys set so they get
deleted from the current URL before the new values are appended. URL
now overwrites cleanly on every mutation.

Regression test added: three consecutive writes with different
pagination values; `getAll("page")` / `getAll("limit")` must return a
single value each.
…he page

Two recent comment edits inside template-literal strings used backticks
to format inline code, which prematurely closed the surrounding template
literal:

  const EXAMPLES = {
    urlWriter: \`// inherits the builder's \`aliases\` below…\`,
    //                                      ^^^^^^^
    //                                      template closed here, parser
    //                                      now treats "aliases" as a
    //                                      bare identifier → SyntaxError
  }

Same shape in the TYPE_DEFS template literal ("Set to \`{}\` to opt
out…"). Either crashed the entire inline script, leaving the editor /
runtime / nav handlers unmounted — the page rendered but nothing
worked.

Removed the inline backticks: plain prose in both spots. Future drive-by
edits inside the EXAMPLES / TYPE_DEFS template literals should escape
backticks (\`\\\`\`) if they really need them — better to avoid them
entirely.

Verified with `node --check` on each `<script>` body.
Listing every field/include name to hide from the URL gets tedious and
brittle. `"*"` in any urlOmit bucket now means "drop every entry in
this bucket" — handy when fields are an internal API optimisation the
user shouldn't see in the URL bar (`urlOmit: { fields: ["*"] }`).

The list type is `("*" | (string & {}))[]` rather than just `string[]`.
The `(string & {})` half is a TypeScript idiom that keeps the literal
union intact instead of collapsing to plain `string`, so the IDE
suggests `"*"` in autocomplete while still accepting any attribute
name. Documented as `UrlOmitEntry` for reuse.

`compilePolicy` short-circuits via a per-bucket `omitAll` boolean built
once at adapter creation — no per-call wildcard scan.

Docs: JSDoc on `urlOmit` explains the wildcard with the fields example;
README + llms.txt + url-adapter page show the same snippet; playground
runtime and TYPE_DEFS mirrored.

Tests: two new — `"*"` drops every entry in its bucket, and `"*"` in
one bucket coexists cleanly with named entries in another.

Total: 264 tests, lint/types clean.
The adapter only ran read() once at mount. If the external source
changes after that — browser back/forward, another tab writing to
localStorage, a websocket pushing — the builder had no way to learn
about it. The previous workaround was "mutate the builder by hand from
the event listener", which is clunky for sources with many fields.

`builder.rehydrate()` re-runs `adapter.read({ aliases })` and replaces
the **data layer** of the state with the result. The **config layer**
(aliases, delimiters, pruneConflictingFilters, useQuestionMark) is
preserved.

Replace, not merge: if `read()` returns no `includes`, state.includes
becomes []. The URL adapter wants this — when the URL goes from
?include=a back to ?, the state needs to actually forget the include.
Adapters that store partial state (e.g. localStorage that only keeps
filters) will clear everything else; those use cases should call
individual builder methods instead.

The call goes through `setState`, so subscribers fire (component
re-renders) and `adapter.write` runs. The write is generally
idempotent — for the URL adapter it's a replaceState to the same URL
the source just gave us. Non-idempotent writes (websocket, fetch)
should be debounced in the write itself.

Wires into the existing API:

```ts
const builder = useQueryBuilder({ adapter: createSearchParamsAdapter({ sync: true }) });

useEffect(() => {
  const onPop = () => builder.rehydrate();
  window.addEventListener("popstate", onPop);
  return () => window.removeEventListener("popstate", onPop);
}, [builder]);
```

Implementation: Builder stores the adapter in a private field (it was
discarded after construction before). `rehydrate()` is a no-op when no
adapter is configured. Total: 5 tests across replace, clear-on-omit,
config-preservation, no-adapter no-op, and write-fires-on-rehydrate.

Total tests: 269.
Adds rehydrate() coverage to README, llms.txt, docs/url-adapter.html
(new "Reacting to external changes" section + TOC entry) and a brief
note in docs/index.html. Includes the popstate listener pattern and
the rationale for keeping it manual.
Existing test only covered one mutation past the mount-time
replaceState. Add a loop test that performs 5 consecutive mutations
and asserts window.history.length grows by 1 each time — confirming
the isFirstCall closure flag doesn't accidentally degrade subsequent
writes back to replaceState.
The docs site at cgarciagarcia.github.io/react-query-builder is more
useful than #readme for landing visitors — it has the playground, the
URL adapter guide and a real navigation.
@cgarciagarcia cgarciagarcia merged commit a6847b8 into main May 21, 2026
2 checks passed
@cgarciagarcia cgarciagarcia deleted the feature/adapter-hydration branch May 21, 2026 23:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant