Skip to content

feat: add URL state persistence of CollectionControl#293

Merged
IzumiSy merged 14 commits into
mainfrom
improve-use-url-collection-state
Jun 29, 2026
Merged

feat: add URL state persistence of CollectionControl#293
IzumiSy merged 14 commits into
mainfrom
improve-use-url-collection-state

Conversation

@IzumiSy

@IzumiSy IzumiSy commented May 27, 2026

Copy link
Copy Markdown
Contributor

Overview

A feature to persist CollectionControl state (filters, sort, page size) to URL query parameters.

This enables:

  • Bookmarkable page state — Users can share or save URLs with applied filters and sort
  • Browser back/forward support — State is restored through URL history
  • Entity-agnostic design — Uses short key names and automatic encoding derived from the Filter shape, making it usable with any resource

Cursor/pagination direction state is intentionally not persisted — a page refresh resets to page 1.

@IzumiSy IzumiSy changed the title improve: simplify and harden useUrlCollectionState feat: add useUrlCollectionState hook May 27, 2026
@interacsean

Copy link
Copy Markdown
Contributor

Heads-up from the downstream side 👋 — we've been running this hook in the Denim Tears IMS app (apps/ims/frontend/src/hooks/use-url-collection-state.ts), and this PR looks like it was derived from a mid-May snapshot of it (same doc comment, p/s/f. keys). We landed a few fixes on our copy on June 2, after this PR opened (May 27), that are worth folding in here — one is a real bug.

1. between (object-valued) filters don't round-trip 🐛

encodeFilterValue writes objects as JSON, but decodeFilterValue only parses arrays back — objects fall through to the raw string:

const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;   // objects dropped → returned as a string
return raw;

The Filter type here defines between{ min, max } (OperatorValueType), so any between filter (date ranges, numeric ranges) gets written to the URL as {"min":…,"max":…} and comes back as a string on reload — the filter silently breaks. One-line fix:

const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
if (parsed && typeof parsed === "object") return parsed;  // round-trip between/{min,max}
return raw;

(Primitives like "5"/"true" parse but aren't objects, so they still return as strings — string-vs-numeric filter semantics preserved.)

2. Equality check is order- / &=-sensitive (robustness, lower priority)

next.toString() === prev.toString() is sensitive to key-insertion order and to &/= inside values. We switched to a sorted, JSON-encoded snapshot of the entries so reordered or edge-char params compare equal. Your functional-updater approach (omitting params from the deps) already sidesteps the churn loop this originally guarded against, so it's more of a latent sharp edge than a live bug — flagging in case it's cheap to fold in.

3. Multi-value encoding — heads-up, not a request

We independently fixed the same comma-splitting bug ("Apparel, LLC"["Apparel"," LLC"]), but chose repeated params (f.status:in=a&f.status:in=b) where you chose a JSON array (f.status:in=["a","b"]). Both are valid. Yours actually keeps single-element arrays as arrays (ours round-trips them to scalars and re-wraps downstream), so no change requested — just flagging that whichever format ships becomes the URL contract consumers bookmark against.

Nice to see the test coverage — our copy has none, so we'll likely adopt this hook and retire ours once it lands. Only #1 is a must-fix from our experience.

@pkg-pr-new

pkg-pr-new Bot commented Jun 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/tailor-platform/app-shell/@tailor-platform/app-shell@293
npm i https://pkg.pr.new/tailor-platform/app-shell/@tailor-platform/app-shell-sdk-plugin@293
npm i https://pkg.pr.new/tailor-platform/app-shell/@tailor-platform/app-shell-vite-plugin@293

commit: 82856e6

@IzumiSy IzumiSy force-pushed the improve-use-url-collection-state branch from 82856e6 to c25039d Compare June 19, 2026 06:19
@IzumiSy IzumiSy changed the title feat: add useUrlCollectionState hook feat: add URL state persistence of CollectionControl Jun 19, 2026
@IzumiSy

IzumiSy commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

After comparing this with the old useEffect-based approach, I think the current API is the better direction for AppShell.

The old design hydrated internal collection state after mount and synchronized it back to the URL through effects. While functional, that approach leans on post-render state synchronization in a way that is less aligned with the React style we should aim for.

The current API is closer to the React way:

  • parse URL state up front
  • treat params as initial values
  • initialize internal state from them
  • report later changes via onParamsChange

This gives clearer initialization semantics and avoids effect-based external-to-internal state synchronization after mount.

I also think this matters from a maintenance and tooling perspective. If we keep introducing patterns like the old one, they are more likely to be repeated by AI-assisted development as well. I’d rather have the codebase teach and reinforce the React-idiomatic pattern wherever we can.

IzumiSy and others added 9 commits June 23, 2026 10:55
- Remove params from write effect deps using function updater to eliminate
  feedback loop (setParams → params change → effect re-runs)
- Fix filter value encoding: use JSON for arrays to correctly handle values
  containing commas (e.g. "Smith, John")
- Fix hydration race condition: introduce SyncPhase lifecycle (pending →
  hydrated → ready) to prevent writing stale defaults to URL before
  hydrated state propagates
- Simplify decodeFilterValue: remove startsWith check, rely on
  JSON.parse + Array.isArray for correctness
- Replace Array.from(next.keys()) with spread syntax
decodeFilterValue only parsed JSON arrays back, so object-valued filters
(the `between` operator's { min, max } shape) round-tripped to a raw
string on reload and silently broke. Decode objects too; primitives like
"5"/"true" still fall through to strings, preserving string-vs-numeric
filter semantics.

Also harden the write-effect bail-out: compare on a sorted, JSON-encoded
snapshot (stableQueryString) rather than `.toString()`, which is
sensitive to filter key-insertion order and to `&`/`=` inside values.

Folds in fixes already running in the Denim Tears IMS copy of this hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@IzumiSy IzumiSy force-pushed the improve-use-url-collection-state branch from 80da0f9 to 874480d Compare June 23, 2026 01:55
@interacsean interacsean requested a review from a team as a code owner June 24, 2026 00:37
interacsean and others added 2 commits June 27, 2026 09:21
…+ typed filter coercion (#335)

* refactor(collection): single useURLCollectionVariables hook + coerce typed filter values

Proposed refinements on top of improve-use-url-collection-state.

Interface: collapse the URL-state common path into one hook.
useURLCollectionState() only ever wrapped react-router's useSearchParams,
so it added no flexibility over a fused hook while forcing a two-hook dance.
Replace it with useURLCollectionVariables(options) (read + write URL state in
one call). Keep useCollectionVariables (router-free primitive) and the pure
withURLCollectionState(options, binding) decorator (custom-binding escape hatch).

Fix: parseCollectionSearchParams now coerces decoded filter values to the
field's declared metadata type (number/boolean, incl. in/nin arrays and between
bounds). Previously a URL-restored numeric filter such as f.price:gt=130 came
back as the string "130", contradicting TypedCollectionVariables and silently
breaking type-aware filtering. The untyped overload (no metadata) is unchanged.

Update the nextjs data-table-demo to the new hook, refresh the changeset, and
add tests for coercion and the numeric write -> parse round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(example): add vite-app Products page demoing useURLCollectionVariables

Runnable DataTable example with a mock remote query (filters, sort, cursor
pagination, multi-select) wired through useURLCollectionVariables, so state is
persisted to and hydrated from the URL. Doubles as the repro for the typed
filter-value coercion fix (e.g. ?f.price:gt=130 now hydrates correctly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@IzumiSy IzumiSy merged commit ae2396b into main Jun 29, 2026
4 checks passed
@IzumiSy IzumiSy deleted the improve-use-url-collection-state branch June 29, 2026 05:32
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.

3 participants