Skip to content

Conversation

@TkDodo
Copy link
Contributor

@TkDodo TkDodo commented Sep 13, 2025

note: Description by @franky47

MultiParsers allow key repetition in the URL.

E.g.: with the built-in parseAsNativeArrayOf:

const [state] = useQueryState('key', parseAsNativeArrayOf(parseAsInteger))
// url:   /?key=1&key=2&key=3
// state: [1, 2, 3]

Creating custom multi-parsers allow reducing the array of query values into more complex objects:

// /?kv=foo:bar&kv=baz:qux
{
  foo: 'bar',
  baz: 'qux'
}

@vercel
Copy link

vercel bot commented Sep 13, 2025

@TkDodo is attempting to deploy a commit to the 47ng Team on Vercel.

A member of the Team first needs to authorize it.

@vercel
Copy link

vercel bot commented Sep 13, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
nuqs Ready Ready Preview Comment Sep 26, 2025 9:05pm

@TkDodo
Copy link
Contributor Author

TkDodo commented Sep 13, 2025

size-limit fails, and this runs right after build, so before any tests are running or even type-checking is done. I don’t think that’s the best workflow. yes size-check is important and should block a merge, but not tests from running 😅

@franky47
Copy link
Member

franky47 commented Sep 13, 2025

Yeah I agree, feel free to bump it high enough (in nuqs' package.json) for it to pass and I'll see how to move it cleanly to another step (the docs build needs the actual size to display it on the landing page).

@TkDodo
Copy link
Contributor Author

TkDodo commented Sep 13, 2025

I’ve increased the relevant size-limit for now a bit:

db3c11e

Comment on lines 395 to 396
// todo this === comparison likely won't work with arrays
if (cachedQuery && cachedState && (cachedQuery[urlKey] ?? null) === query) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cachedQueries now contain Iterable<string>, so we can’t really compare them with triple equals.

I’m wondering why the eq method of the parser isn’t used for comparison here?

But, we could also extract this “shallow equals” array comparison to a shared util:

eq(a, b) {
if (a === b) {
return true // Referentially stable
}
if (a.length !== b.length) {
return false
}
return a.every((value, index) => itemEq(value, b[index]!))
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m wondering why the eq method of the parser isn’t used for comparison here?

We're comparing serialised versions here (the eq function compares state types), so yeah we'd likely have to branch for string comparison with === vs array comparison for iterables.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into this a bit more, but I’m a bit confused which types get stored where:

const query =
  queuedQuery === undefined
    ? parser.type === 'multi'
      ? (searchParams?.getAll(urlKey) ?? [])
      : (searchParams?.get(urlKey) ?? null)
   : queuedQuery

now when we read the Query from the searchParams, it can only be string | null | Array<string> - I’ve extracted that to its own type:

export type SearchParamQuery = string | Array<string>

but when I look at queuedQuery, it seems that we store whatever parser.serialize has returned, which used to only be strings, but now it can be Iterable<string>, so I’ve also made type for that:

export type SerializedQuery = string | Iterable<string>

now when trying to compare the two:

compareQuery(cachedQuery[urlKey] ?? null, query)

cachedQuery contains a SerializedQuery, and query can contain that too when we get it from queuedQuery, but if we read it directly from the searchParams, it will be a SearchParamQuery.

I don’t see how we could effectively compare the result of searchParams.getAll() with the result of a serialized Query, so maybe we need to run that trough parser.serialize before?

Copy link
Member

@franky47 franky47 Sep 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, if .getAll() returns a Array<string>, that is already an Iterable<string>, so maybe we could have a comparison function dedicated to serialised representations (either read from the URL or serialised before an update)

so maybe we need to run that trough parser.serialize before?

The input of serialize being a state ("hydrated"/deserialised) data type, that wouldn't work, what we get from searchParams.get{,All}() is already in a serialised form.

The issue might be from the ?? null part in cachedQuery[urlKey] ?? null, as this makes sense for a single parser, but not much for a multi one (where we'd default to an empty array).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so maybe we could have a comparison function dedicated to serialised representations (either read from the URL or serialised before an update)

yeah can try that.

The issue might be from the ?? null

does it even make sense to call the comparison function if cachedQuery[urlKey] doesn’t exist? this would never yield a cache hit imo...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

untested, but here is an attempt at that:

e0021b1

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to look out for with cached queries is that null and undefined have different meanings: undefined means there is no cache in memory, but null here means we requested to remove that key from the URL when setting the state.

If you have suggestions for a better internal way of encoding these intents, I'm very much open to suggestions (maybe symbols?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I kept that intent the same now (I think), and only fallback to ?? defaultValue instead of ?? null, where defaultValue = parser.type === 'multi' ? [] : null

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but note that ?? will use the fallback for both null and undefined, so I’m not sure if that differentiation between null and undefined was there before in all places 🤔

@TkDodo
Copy link
Contributor Author

TkDodo commented Sep 13, 2025

As I was trying to add some end-to-end tests, I’m stumbling over some issues I’m not quite sure how to fix / debug:

  1. when multi-parsers are used, it would be great if useQueryState wouldn’t yield null, but always empty array instead. With searchParams.getAll(), it’s not possible to get null back.

I can see that parseAsArray also returns Array<T> | null so maybe that’s fine for multi parsers too, but I think when working with arrays, null is a bit annoying.

  1. I’m not quite sure as to why yet, but when calling setState([1, 2]) the url updates correctly but the state in the component reverts back to what it was from before the state update. It’s like it’s lagging behind or immediately gets re-set. Just leaving this here in case this sounds familiar to you :) I can commit the failing e2e test if you like.

otherwise, the default is never applied
@franky47
Copy link
Member

I think when working with arrays, null is a bit annoying.

I agree, in a lot of cases .withDefault([]) makes it easier. There have been cases where having a non-empty array as a default made sense for the domain, but then the URL would contain ?key= for the empty array case, which is ugly and counter-intuitive (same as parseAsBoolean.withDefault(true))

@TkDodo
Copy link
Contributor Author

TkDodo commented Sep 14, 2025

react-router v7 seems to fail because of this key isolation that does a !== check directly on searchParams.get:

: keys.some(key => oldValue.get(key) !== newValue.get(key))

not sure if we can switch this conditionally to getAll because there’s no parser available anywhere 🤔

this is necessary for multi-parsers to not bail-out wrongly
@TkDodo TkDodo marked this pull request as ready for review September 25, 2025 13:29
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TkDodo I added utilities to test bijectivity of custom multi-parsers. The overloads should take care of retro-compatibility, but if you see some issue, let me know.

Copy link
Member

@franky47 franky47 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, go for beta! 🚀

@franky47 franky47 merged commit 8f89690 into 47ng:next Sep 26, 2025
31 checks passed
@github-actions
Copy link

🎉 This PR is included in version 2.7.0-beta.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

@TkDodo TkDodo deleted the feature/multi-parsers branch September 27, 2025 08:21
@github-actions
Copy link

🎉 This PR is included in version 2.7.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants