feat(adapter): add QueryBuilderAdapter for one-shot hydration and two-way binding#86
Merged
Conversation
…-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).
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 71 |
| Duplication | 0 |
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.
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
QueryBuilderAdapter(read()+ optionalwrite(state)) onBaseConfig.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.createSearchParamsAdapterfor the URL case (configurable keys forfilter/sort/include/fields, explicitallowedParamsallowlist, injectablesourcefor SSR/tests).parseSearchParamshelper for ad-hoc use without the adapter wrapper.useQueryBuilderwhereuseRef(new Builder(config))re-evaluatednew Builder()on every render (only the first ref value was kept). The new lazy-ref pattern guaranteesadapter.read()runs exactly once.README.md, "Adapters" section inllms.txt, and two new live examples indocs/playground.html(URL Adapter and localStorage Adapter).rqb-playground-demokey 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
Precedence on seed: defaults <
adapter.read()< explicitBaseConfigfields — explicit config always wins.Patterns used
useState(() => …)semantics.window;window.location.searchlives inside the opt-in factory's defaultsource.Test plan
pnpm test— 188/188 passing (3 new hook tests + dedicated suites for parser and adapter)pnpm lint— cleanpnpm build:types— cleandocs/playground.htmland click URL Adapter → confirm the hydrated state appears in the right paneldocs/playground.html, click localStorage Adapter, then Sample → Save & Run → confirm the builder hydrates from the textarea JSONwrite()persisted?filt[status]=active&srt=-name&locale=eswith the renamed keys and confirmbuilder.build()produces the canonical?filter[status]=active&sort=-name&locale=es