paoloricciuti/sveltekit-search-params

Svelte 5 support

Opened this issue ยท 20 comments

Svelte 5 is right around the corner and while stores are still supported i want to upgrade this library to take advantage of runes.

This however require some changes that are not back compatible so my plan is to continue supporting stores in sveltekit-search-params@^2 and upgrade to runes in sveltekit-search-params@^3.

The new version however will likely face some api changes because of how runes behave.

queryParameters

The function queryParameters will benefit a lot from runes: today this function return an unique store object with each query parameter as the key.

<script>
    import { ssp, queryParameters } from "sveltekit-search-params";
    const params = queryParameters({
        search: ssp.string(),
    });
</script>

<input bind:value={$params.search} />

with runes the syntax will not change that much but the updates should be much more fine grained which is good

<script>
    import { ssp, queryParameters } from "sveltekit-search-params";
    const params = queryParameters({
        search: ssp.string(),
    });
</script>

<input bind:value={params.search} />

queryParam

Here's where the change will be much more intensive so first thing first sorry if you are using this intensively...i'll try to see if i can create a migration CLI to help you with your upgrade.

Today queryParam accept the key of the query parameter as input and returns a store with that param by itself. If the query parameter is a string the store will contain the string itself.

<script>
    import { ssp, queryParam } from "sveltekit-search-params";
    const search = queryParam("search");
</script>

<input bind:value={$search} />

This is not possible with runes so we have three options:

The boxed value

The simplest option would to return a ref or a boxed instead of the actual value. The downside is that you need to access it with .value everywhere.

<script>
    import { ssp, queryParam } from "sveltekit-search-params";
    const search = queryParam("search");
</script>

<input bind:value={search.value} />

This is simple to refactor but once you need to access it with .value everywhere is it really worth it over using queryParameters with a short name and just access it like qp.search everywhere?

The function

Another option could be have the returned value be a function...you could access it by calling it and set it by calling it with a typesafe parameter.

<script>
    import { ssp, queryParam } from "sveltekit-search-params";
    const search = queryParam("search");
</script>

<input value={search()} oninput={(e)=>{search(e.target.value)}} />

this is nice to see but it can get complex with Typescript type narrowing and it get's pretty hairy with objects and arrays.

The derived

A somewhat nice option would be to make use of $derived.by to destructure the return value of queryParam. This would give us a simple value to use everywhere and we could even return an input object to spread into your inputs that automatically set the oninput property to mimic the bind behaviour.

<script>
    import { ssp, queryParam } from "sveltekit-search-params";
    const [search, inputsearch, setsearch] = $derived.by(queryParam("search"));
</script>

<input {...inputsearch} />
The search is {search}

but this again become very hairy with object and arrays (especially if you want to bind a property of that object instead of the whole object).

Conclusion

I'm not really satisfied with any of the options for queryParam and i wonder if we should just drop that api but i would love to have YOUR opinion and ideas on this.

Here is a bit of a crazy take, why don't you remove queryParam from the API.
Just read the conclusion, it's like you're reading my mind!
But yeah, I think having 2 ways to do the same thing only hurts in any API. I also don't see the benefit. If all examples use qp as the variable I am A-ok with using qp.<my-value> since they're using $state it should be fine grained and thus not cause any unwanted updates correct?

I agree with @Hugos68 i don't think it's needed.

I'm currency using the syntax const search = queryParam("search"); but to be honest, I wasn't aware of the other approach. I like the benefits of having more fine-grained control and it would be very simple for us to switch over to when we upgrade to svelte 5.

<script>
    import { ssp, queryParameters } from "sveltekit-search-params";
    const params = queryParameters({
        search: ssp.string(),
    });
</script>

@paoloricciuti โš  Little plug
I created a library to handles queryParamaters state management in svelte 5. I would love your feedback on it. And maybe you'll find some ideas for your svelte 5 migration.
https://github.com/beynar/kit-state-params

const searchParams = stateParams({
schema: {
	search: 'string',
	tags: ['number'],
	sortBy: '<asc,dec>',
	range:{
		from:"date",
		to:"date"
		}		
}
});

It does not use the boxed value pattern and behave like a normal svelte 5 state.
Ps: i used your library in a lot of projects, so thank you ๐Ÿ™

@paoloricciuti โš  Little plug I created a library to handles queryParamaters state management in svelte 5. I would love your feedback on it. And maybe you'll find some ideas for your svelte 5 migration. https://github.com/beynar/kit-state-params

const searchParams = stateParams({
schema: {
	search: 'string',
	tags: ['number'],
	sortBy: '<asc,dec>',
	range:{
		from:"date",
		to:"date"
		}		
}
});

It does not use the boxed value pattern and behave like a normal svelte 5 state. Ps: i used your library in a lot of projects, so thank you ๐Ÿ™

Hey just noticed this...i'm actually working on the svelte 5 version right now...i like the added validation and i will definitely take a look at this ๐Ÿ˜„

@paoloricciuti If you want to add validation maybe it's possible to re-use existing validation libraries like zod?
Would be a shame to reinvent the wheel.

@paoloricciuti If you want to add validation maybe it's possible to re-use existing validation libraries like zod? Would be a shame to reinvent the wheel.

I don't plan to add validations during the svelte 5 conversion but if i will do something similar in the future i will definitely try to make it with some existing library (possibly more than one with the adapter pattern)

Thanks everyone for the feedback...i've opened this PR to close this issue.

There are a few breaking changes (the biggest is the removal of queryParam and the move of equalityFn from general options to specific option) but for the rest it should more or less work the same (obviously using runes instead of stores).

You can try it out today by doing

pnpm add https://pkg.pr.new/sveltekit-search-params@126

and i setup a prerelease that i plan to keep around for a bit to gather feedback before pushing to the new Major (probably after sveltekit will introduce the stateful version of the stores).

Please let me know if there are any bugs.

How to achieve the showDefault: false in the new rune mode ?
The following code always ads ?page=1 in my url, I don't want the default page 1 to appear in my URL.

const params = queryParameters({ page: ssp.number(1) })

How to achieve the showDefault: false in the new rune mode ? The following code always ads ?page=1 in my url, I don't want the default page 1 to appear in my URL.

const params = queryParameters({ page: ssp.number(1) })

There's the showDefaults option as the second argument of the queryParameter function

Apologies for missing that earlier. I just reviewed the documentation and came back to update my comment, only to realize you already addressed it.

Additionally, I was thinking it might be useful to introduce a resetDefault: true option. This would ensure that parameters with default values reset when other parameters change.

For instance, if this is used as a search result filter, and the user is on page 20 but applies additional filters that reduce the total number of pages below 20, they would still be on page 20 and see no results.

If you have an even better approach in mind, Iโ€™m all ears!

@paoloricciuti!

Before switching to v4, I was able to set different debounce times for individual parameters. However, it seems that this is no longer possible. Iโ€™m using this setup in search forms and would like to apply different debounce behaviors based on the input type:

  • No debounce for parameters rendered with elements like <Select>, <Checkbox>, etc.
  • Debounce only for parameters with <input type="text"> elements.

Is there a way to achieve this in the current setup, or any workaround youโ€™d recommend?

Hi @paoloricciuti , where would you like to track issues with @126? I am unclear about how we should be reacting to query changes. For example, this seems to cause an infinite number of function calls:

const params = queryParameters({ selected: ssp.array() }, { pushHistory: false })
$effect(() => {
  if (params.selected) {
    fetchProperties()
  }
})

Hi @paoloricciuti , where would you like to track issues with @126? I am unclear about how we should be reacting to query changes. For example, this seems to cause an infinite number of function calls:

const params = queryParameters({ selected: ssp.array() }, { pushHistory: false })
$effect(() => {
  if (params.selected) {
    fetchProperties()
  }
})

You can just open an issue in the repo...what are you doing inside fetchProperties? If you can provide an actual reproduction I'll look into it

Just tried and it seems to work fine

https://www.sveltelab.dev/amzeoot1b0l7bq4

It's probably a poor design decision on my part, but I'm not sure how to get around it: I fetch each item in the array and add it to another $state. There's also some memoization in here to avoid re-fetching items:

  let properties = $state([])
  const fetchProperties = async () => {
    const newProperties = {}
    for (const id of $params.selected) {
      if (!newProperties[id] && !properties.some((e) => e.id == id) {
          newProperties[id] =  await fetch(`/api/property/${id}`).then(e => e.json())
      }
    }
    properties = properties
      .filter((e) => $params.selected?.some((id) => e.id == id)
      .concat(Object.values(newProperties))
  }

Wrapping the async potion of this in a setTimeout(0) made the infinte loop go away, but there's still some weird behavior.
Basically, I don't know how to load data based on changes to params. With regular stores, .subscribe() seems to work well.

I'll add that I appreciate your effort on this, I'm surprised query-string stores aren't a native feature in svelte(kit), it seems like a required feature for deep linking in webapps.

It's probably a poor design decision on my part, but I'm not sure how to get around it: I fetch each item in the array and add it to another $state. There's also some memoization in here to avoid re-fetching items:

  let properties = $state([])
  const fetchProperties = async () => {
    const newProperties = {}
    for (const id of $params.selected) {
      if (!newProperties[id] && !properties.some((e) => e.id == id) {
          newProperties[id] =  await fetch(`/api/property/${id}`).then(e => e.json())
      }
    }
    properties = properties
      .filter((e) => $params.selected?.some((id) => e.id == id)
      .concat(Object.values(newProperties))
  }

Wrapping the async potion of this in a setTimeout(0) made the infinte loop go away, but there's still some weird behavior. Basically, I don't know how to load data based on changes to params. With regular stores, .subscribe() seems to work well.

I'll add that I appreciate your effort on this, I'm surprised query-string stores aren't a native feature in svelte(kit), it seems like a required feature for deep linking in webapps.

This is the source of the infinite loop...you are reading and writing to the same state (properties)...so that will trigger the infinite loop. This has nothing to do with the library tho, you probably just need to untrack the read since you don't really want the effect to rerun when properties changes.

Is there a prerelease version of svelte 5 that we can try out and give feedback / help contribute? I don't see any pr's

Is there a prerelease version of svelte 5 that we can try out and give feedback / help contribute? I don't see any pr's

Yes you can install the @next tag

techniq commented

Was a little surprised/confused that $inspect(params) returns an empty object unless wrapped in JSON.stringify().

With query string ?sort=desc...

const params = queryParameters({ sort: true });
$inspect(params);

returns an empty object {} but....

const params = queryParameters({ sort: true });
$inspect(JSON.stringify(params));

returns the expected {"sort":"desc"}.

Am I misunderstanding how $inspect() / queryParameters should work?

Since queryParameters is a Proxy/state rune, I thought $inspect() would handle this.
console.log(JSON.stringify(params)) also works, but also needs to be wrapped in an $effect() to be reactive.