feat: Isomorphic caching for queries#15779
Conversation
🦋 Changeset detectedLatest commit: 2629fc1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Co-authored-by: Copilot <copilot@github.com>
…ching # Conflicts: # pnpm-lock.yaml
dummdidumm
left a comment
There was a problem hiding this comment.
Impressive work - it's a bit hard to follow which things exactly changed because of all the file moves but what I saw makes sense
|
The test matrix with the Vite 8 pnpm override had a Vitest peer dep version incompatibility that caused the Unit tests are now passing so we should be able to merge this 😄 EDIT: I spoke too soon |
|
The new test failure looks legit. I can't reproduce it if I run playwright with EDIT: Oddly enough, I think this is some kind of race condition. I had another PR's test fail on Node 24 because it was "too fast". If I change the test to wait for |
|
Definitely a race condition, yeah. The |
This isn't _really_ a fix since these should never surface to the user, but it's useful for debugging when they do, as in sveltejs/kit#15779. Instead of seeing `Symbol()` we see e.g. `Symbol(uninitialized)` which makes it easier to understand where a bug is coming from.
e4c8448
into
main
We've struggled for a while to settle on a caching model for remote functions that make sense. Since the beginning, the core of the system has basically been this: If you create a remote query instance in a _reactive context_, all other references to the same remote query will resolve to the same instance. This has run into a number of complications, mostly around using remote queries outside of reactive contexts. At first, you could use a query anywhere, and we'd do our best to cache it. Unfortunately, this model just can't work -- you can end up with "orphaned" instances of the same query, where calling `get_post(1).refresh()` will fail to refresh `get_post(1)` elsewhere, because they resolved to different instances. To fix this, we created a rule: You just have to create your query in a reactive context. To help with this, we also created `query.run`, which allows you to bypass the cache and "just run" the remote function. Unfortunately, this model is still confusing (#15742). Also, `.run` is an ugly wart. In a perfect world, we shouldn't need it. Thankfully, we have a solution. To understand it, let's go back to the core reason we tied queries into the reactivity system in the first place: determinism. The reasoning was: "You should be able to know whether or not this query is currently in the cache based on how you use it". Example: ```svelte {#if toggle} <div>{await get_post(1)}</div> {/if} ``` We figured that the above should rerun the query every time the toggle flips. When it becomes `false`, the query is destroyed and uncached, and when it becomes `true`, the query is created, run, and added to the cache. As we thought about this more, though, we realized that this doesn't make sense. While it's deterministic in the limited sense, it's actually very fragile code. If you're relying on it rerunning every time the toggle flips, you're going to be sorely disappointed when Bob over on the Accounts team references `get_post(1)` somewhere else in the app, which causes it to never become uncached, which breaks your assumption. That's bad. So we realized something: The important thing is that you know "If I have a reference to a query, it is _definitely_ cached. Otherwise, it might be cached. If I need fresh data, I should invalidate the query." This means we can more or less tie the cache to the lifecycle of its references, which is... basically what `FinalizationRegistry` allows us to do. So, TL;DR, now you can use queries anywhere. So long as your app is referencing the query, it will remain cached. It _may_ stick around after you're done with it for a time, until it gets garbage collected. When you want fresh data, you invalidate the query, either by calling `query.refresh`, by issuing a single-flight mutation through a command, or by calling `refreshAll`/`invalidateAll`. --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Rich Harris <richard.a.harris@gmail.com> Co-authored-by: Rich Harris <rich.harris@vercel.com> Co-authored-by: Tee Ming <chewteeming01@gmail.com>
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## @sveltejs/kit@2.61.0 ### Minor Changes - breaking: the `.run()` method has been removed from remote queries on both the client and the server. Use `await query()` directly instead — it now works everywhere ([#15779](#15779)) - feat: remote queries can now be awaited in any context (event handlers, module scope, async callbacks), not just inside reactive contexts. The cache is shared across reactive and non-reactive subscribers, so awaiting a query in an event handler will dedupe with components that have already subscribed to the same query. ([#15779](#15779)) - feat: live query instances are now themselves async-iterable ([#15878](#15878)) - feat: add programmatic `submit` method to `form` remote function instances ([#15657](#15657)) - feat: pass `form` remote function instance into `enhance` callback ([#15657](#15657)) ### Patch Changes - fix: resolve the app payload without using `process.env.NODE_ENV` ([#15852](#15852)) - fix: support `exactOptionalPropertyTypes` for optional route params ([#15825](#15825)) - fix: correctly send `true` value to the server for 'submit' and 'hidden' form fields ([#15858](#15858)) - fix: avoid build warnings about undefined universal hooks ([#15895](#15895)) - fix: prefer default error page when failing to decode the URL pathname ([#15744](#15744)) - fix: disable link prefetching on slow internet connections ([#15885](#15885)) - fix: allow routes ending with optional parameters next to more specific routes ([#15861](#15861)) - fix: remove reliance on Content-Length header in deserialize_binary_form, which caused failures when proxies (e.g. Vercel, Azure) strip the header and use chunked transfer encoding ([#15796](#15796)) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Summary - Bumps deps (commit c861d37). - Adapts the codebase to API changes that landed in SvelteKit 2.61: - **RemoteForm enhance callback** ([kit#15657](sveltejs/kit#15657)): the callback now receives the form instance directly. The old `form` field on the callback arg is gone; the underlying `HTMLFormElement` is now `element`. Migrated `konto/+page.svelte` (changeName, changeEmail, changePassword) and `admin/[username]/+page.svelte` (updateUserName, updateUserEmail). - **Calendar prop drop**: bits-ui's Calendar no longer accepts the `slotCount` prop. Removed the unused derived value and prop pass in `BookingPage.svelte`. Other SvelteKit changes reviewed but no code changes needed here: - [kit#15779](sveltejs/kit#15779) (isomorphic query caching, `.run` removal): we don't call `.run` anywhere. - [kit#15878](sveltejs/kit#15878) (LiveQuery self-iterability): additive; existing `await live` pattern still works. - [kit#15802](sveltejs/kit#15802) (hidden/submit accept numbers/booleans): no `as('hidden'|'submit')` call sites today. - [kit#15653](sveltejs/kit#15653) (warn on unread validation issues): every form already renders `fields.allIssues()`. ## Test plan - [x] `pnpm check` — 0 errors / 0 warnings - [x] `pnpm test` — 55 unit tests passed, 36 e2e tests passed
We've struggled for a while to settle on a caching model for remote functions that make sense. Since the beginning, the core of the system has basically been this: If you create a remote query instance in a reactive context, all other references to the same remote query will resolve to the same instance. This has run into a number of complications, mostly around using remote queries outside of reactive contexts.
At first, you could use a query anywhere, and we'd do our best to cache it. Unfortunately, this model just can't work -- you can end up with "orphaned" instances of the same query, where calling
get_post(1).refresh()will fail to refreshget_post(1)elsewhere, because they resolved to different instances. To fix this, we created a rule: You just have to create your query in a reactive context. To help with this, we also createdquery.run, which allows you to bypass the cache and "just run" the remote function. Unfortunately, this model is still confusing (#15742). Also,.runis an ugly wart. In a perfect world, we shouldn't need it.Thankfully, we have a solution. To understand it, let's go back to the core reason we tied queries into the reactivity system in the first place: determinism. The reasoning was: "You should be able to know whether or not this query is currently in the cache based on how you use it". Example:
{#if toggle} <div>{await get_post(1)}</div> {/if}We figured that the above should rerun the query every time the toggle flips. When it becomes
false, the query is destroyed and uncached, and when it becomestrue, the query is created, run, and added to the cache. As we thought about this more, though, we realized that this doesn't make sense. While it's deterministic in the limited sense, it's actually very fragile code. If you're relying on it rerunning every time the toggle flips, you're going to be sorely disappointed when Bob over on the Accounts team referencesget_post(1)somewhere else in the app, which causes it to never become uncached, which breaks your assumption. That's bad.So we realized something: The important thing is that you know "If I have a reference to a query, it is definitely cached. Otherwise, it might be cached. If I need fresh data, I should invalidate the query." This means we can more or less tie the cache to the lifecycle of its references, which is... basically what
FinalizationRegistryallows us to do.So, TL;DR, now you can use queries anywhere. So long as your app is referencing the query, it will remain cached. It may stick around after you're done with it for a time, until it gets garbage collected. When you want fresh data, you invalidate the query, either by calling
query.refresh, by issuing a single-flight mutation through a command, or by callingrefreshAll/invalidateAll.