-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat: hydratable and friends
#16960
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: hydratable and friends
#16960
Conversation
|
|
Very interesting approach |
|
|
|
||
| /** @typedef {{ count: number, item: any }} Entry */ | ||
| /** @type {Map<string, CacheEntry>} */ | ||
| const client_cache = new Map(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given that the intent here is for libraries to provide a prefix if they're going to use this API, should this be a two-tiered cache? Right now, if a library (like SvelteKit) wants to do something to everything it has in the cache, it has to iterate over all of the map entries, skipping the ones that don't start with its prefix, and refresh the things that do start with its prefix.
Maybe what we should do is make this two-tiered, where, if you don't provide a prefix, everything gets put into client_cache.get(''), but if you do, you get a namespaced cache. So in the case of SvelteKit, we'd end up with client_cache.get('@sveltejs/kit/remote'), which can be operated on as its own entity.
From an API perspective, you can provide a prefix as part of a third options argument to cache. Then, if you use CacheObserver, providing a prefix will automatically scope it to your cache.
The downside would be if you truly wanted to operate on the entire cache, which would be more complicated...
| * @returns {Resource<TReturn>} | ||
| */ | ||
| export function fetcher(url, init) { | ||
| const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given how simple this is I'm quite tempted to say "nah, this can be an example in the docs" instead of shipping it as a core API...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the toString() is implied
| const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; | |
| const key = `svelte/fetcher/${url}`; |
I definitely think there's value in shipping this — it'll be the most common way to consume resources other than remote functions (or maybe including remote functions), and even if we say 'it's a one-liner' it's a hell of a one-liner. Nesting three thunks and duplicating a key is kind of a lot, even once you understand why it's all necessary.
Of course it does mean we have to nail the fetcher API, though minus the TODO error message I'm not sure what could change. Unless we wanted to make any changes to the underlying primitives:
- we've probably had this convo already but are there any times we wouldn't want a resource to be cached, and if so does
resource(key, fn)make more sense thancache(key, () => resource(fn))? (We could still exposecachefor non-resource things of course) - do we have opinions about abort signals? obviously you could pass
signal: getAbortSignal()with yourinitargument but if you created/refreshed stuff outside the effect cycle then you can't use it. do we need to do something likeresource((signal) => ...)or no? (it would be a shame to have to create the abort signal eagerly. and maybe you don't want to abort previous fetches? so in summary, probably not, though i want to make sure we consider it)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cache / no cache
My main thought around this is for long-lived resources or cases where you need to handle caching that doesn't fit perfectly within our "cached as long as it's reactively referenced" paradigm. Eg. It would be valid to do this:
export const space_ship = resource(get_random_ship);Where the "cache" is the module, i.e. "this should always exist". This just straight up is not possible with how cache is right now, nor do I think it's a use case we should really try to tackle with cache. It would be really weird to have to provide a key to the above declaration, and even weirder when another declaration with the same key didn't refer to the same object.
abort
Given we allow you to pass your own init, I think it's okay to expect people to pass in getAbortSignal() if they need that functionality.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
HOLD ON A MINUTE
Why do we need to provide a cache key at all? The function is the key!
If we do that, then the space_ship example makes perfect sense — we just don't use the cache when cache is called outside a tracking context.
Of course there are still cases where you do want deduping, e.g. two fetcher(...) occurrences with the same argument. So maybe it's like this:
function cache(fn, key = fn) {
// ...
}Abort signals — actually I realised it's not as straightforward as passing it in init, because you might be calling fetcher outside an effect, in which case you can't call getAbortSignal. So if you did want cancellation it would have to be managed by fetcher rather than by Svelte's lifecycle, which suggests adding an options object separate from init... hmm. Maybe 'nail the fetcher API' isn't quite as trivial as we thought.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Errr... wait. I think I had a brain malfunction. That won't work in a lot of cases. But maybe it works in the cases like space_ship?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This just straight up is not possible with how
cacheis right now
To be clear, it is possible — it works fine. It just doesn't cache the result for longer than a microtask because it's being created outside a tracking context, and that's fine.
But, yeah... it's also pointless. The only times you'd call resource outside a tracking context are the times when you're holding a reference to the resource yourself, in which case you don't need it to be cached. When you do need it to be cached, it's because you're calling it with a fresh arrow function each time which is obviously no good as a key.
Look forward to having this conversation with myself again in a few weeks when I've forgotten this one
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll save a link to this just in case, will be ready to fire it off to you 😂
| static #hydratable_block(serialized) { | ||
| let entries = []; | ||
| for (const [k, v] of serialized) { | ||
| entries.push(`["${k}",${v}]`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've been struggling with what the correct thing to do here is. Is it to JSON.stringify the key, and leave the value to whatever value.toString results in? When a user provides encode, does that function have to result in a string? Or does it have to result in something that can be toString-ed? Or something else entirely?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we should probably JSON.stringify the key, yeah — avoids any weird edge cases around keys that contain the " character or whatever
(and yes, encode should always return a string)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So we're saying "We'll properly encode strings for you -- if you provide a custom encoder we expect it to emit valid JavaScript (which may be a string, in which case it would need to emit its own enclosing quotes)?"
| /** | ||
| * @template T | ||
| * @implements {ReadonlyMap<string, T>} */ | ||
| export class BaseCacheObserver { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same thing here -- should this actually be a two-tier cache, where the key provided here gives you access to a map specific to that key?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm trying to work out if we actually need this. In the Kit PR it's used for three things:
- refreshing the subset of resources that stay on the page after a
gotowithinvalidateAll: true - overriding resource values with new data after a command/form that calls
some_query().refresh() - refreshing all
queriesresources on the page
To be honest I'm not really sure why goto(url, { invalidateAll: true }) exists. It feels like a bit of an odd API — certainly not something I've ever reached for myself. Does it need to work with resources, or can it be consigned to the 'weird legacy stuff' bin?
Obviously we need a way to override resource values. But the intent is that if (say) a form calls item(id).set(data), and then redirects to /item/[id], the data will be waiting for the query when it gets rendered. That doesn't work if we're only setting resources that are currently cached; it needs to happen at a different layer.
So 1 is questionable and 2 is subtly broken. Which leaves 3. Obviously refreshing resources is a legitimate use case (albeit one that could be handled with a simpler dedicated API), but there's a problem — cache is agnostic about what goes in it, but sveltejs/kit#14872 assumes that everything in query_cache is a resource. (Notably, prerender values are no longer cached using the reactive caching mechanism, only via window.caches; I'd argue this is a regression albeit a minor one.)
What if cache wasn't global, but was more like this?
/**
* @template T;
*/
class ReactiveCache {
/** @type {Map<string, CacheEntry<T>>} */
#cache = new Map();
/**
* @param {string} key
* @param {() => T} fn
*/
add(key, fn) {
if (!async_mode_flag) {
e.experimental_async_required('cache');
}
let entry = this.#cache.get(key);
if (!entry) {
entry = { count: 0, value: fn() };
this.#cache.set(key, entry);
}
const clear = () => {
tick().then(() => {
if (entry.count === 0) {
this.#cache.delete(key);
}
});
}
if (active_effect !== null && !is_destroying_effect) {
render_effect(() => {
entry.count++;
return () => {
entry.count--;
clear();
};
});
} else {
clear();
}
return entry.value;
}
[Symbol.iterator]() {
return this.#cache.values();
}
}Then, each library that exposes a mechanism for creating cached values (be they resources or otherwise), including svelte itself (since it exposes fetcher) instantiates its own cache, and could (if it so chose) expose a way to refresh everything in that cache:
const cache = new ReactiveCache<Resource>();
export function fetcher(url: string | URL) {
if (!async_mode_flag) {
e.experimental_async_required('fetcher');
}
const key = `svelte/fetcher/${url}`;
return cache.add(key, () => resource(() => hydratable(key, () => fetch_json(url, init))));
}
fetcher.refreshAll = () => {
for (const resource of cache) {
resource.refresh();
}
}It would mean there was no global refreshResources API. Two possibilities:
- That's okay, we don't want one
- We do want one, and the way to get one is (revisiting https://github.com/sveltejs/svelte/pull/16960/files#r2501388324) to use
cacheinsideresourceafter all... E.g. your SvelteKit app might use a combination of remote functions andfetcher, and it's slightly better if you can refresh them all with one call
Not sure what the right answer is
| if (!key.startsWith(this.#prefix)) continue; | ||
| yield /** @type {[string, T]} */ ([key, entry.item]); | ||
| } | ||
| return undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is required for the implementation to succeed in typescript 🤔
| export type Transport<T> = | ||
| | { | ||
| encode: Encode<T>; | ||
| decode?: undefined; | ||
| } | ||
| | { | ||
| encode?: undefined; | ||
| decode: Decode<T>; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this rather unique way of describing this type will force people to use browser ? { decode: () => {} } : { encode: () => {} } so that encode/decode can be properly treeshaken in the correct environments
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
having second thoughts about this. I imagined we'd be able to do this, which is already a bit gnarly after Prettier gets its hands on it...
let answer = hydratable('answer', () => 42, {
encode: browser
? undefined
: (v) => {
console.log('encoding', v);
return JSON.stringify(v);
},
decode: browser
? (v) => {
console.log('decoding', v);
return JSON.parse(v) as number;
}
: undefined
});...but in reality it's more like this:
let answer = hydratable(
'answer',
() => 42,
browser
? {
decode: (v) => {
console.log('decoding', v);
return JSON.parse(v) as number;
}
}
: {
encode: (v) => {
console.log('encoding', v);
return JSON.stringify(v);
}
}
);If we add any other options they'll need to be in both objects. It just starts to feel super unwieldy. Maybe we should just do this? It would allow people to omit methods based on environment, without making their code weird:
export interface Transport<T> {
encode?: false | Encode<T>;
decode?: false | Decode<T>;
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, you're right. Originally I actually had this under a transport key, so maybe it's time to bring that back:
let answer = hydratable('answer', () => 42, {
transport: browser
? {
decode: (v) => {
console.log('decoding', v);
return JSON.parse(v);
}
}
: {
encode: (v) => {
console.log('encoding', v);
return JSON.stringify(v);
}
}
});That way additional config wouldn't have to be included in both objects. I would imagine any library using custom serialization would do something like this anyway:
import { hydratable as core } from 'svelte';
import { encode, decode } from '$lib/encoding';
import { BROWSER } from 'esm-env';
type Args = Parameters<typeof core>;
type Options = Omit<Args[2], 'transport'>;
export function hydratable<T>(key: string, fn: () => T, options?: Options) {
return core(key, fn, {
...options,
transport: BROWSER ? { decode } : { encode }
})
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's definitely the future-proof choice, though if we don't end up adding new options then we've just added a layer of uncomfortable nesting for no reason. Maybe this is a case where we we're better off not worrying too much about future options, and just do this...
import { hydratable as core } from 'svelte';
import { encode, decode } from '$lib/encoding';
import { BROWSER } from 'esm-env';
export function hydratable<T>(key: string, fn: () => T) {
return core(key, fn, BROWSER ? { decode } : { encode })
}...with the option (heh) to do this in future:
import { hydratable as core } from 'svelte';
import { encode, decode } from '$lib/encoding';
import { BROWSER } from 'esm-env';
type Options = Parameters<typeof core>[2];
export function hydratable<T>(key: string, fn: () => T, options?: Options) {
return core(key, fn, BROWSER ? { decode } : { encode }, options);
}Separately: is it okay to rely on esm-env for this, if it's a key element of the design? Or should we add something like this?
import { browser } from 'svelte/environment';There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I'd be okay with both of these options. I think I slightly prefer transport as it's just a single key's worth of extra nesting on an already-primitive API.
As for esm-env -- I don't think it's particularly onerous to ask people to use it rather than basically reimplementing part of it in svelte core.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Plus, if you're writing for SvelteKit, you can always use browser from there)
| export async function fetch_json(url, init) { | ||
| const response = await fetch(url, init); | ||
| if (!response.ok) { | ||
| throw new Error(`TODO error: Fetch error: ${response.status} ${response.statusText}`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO
| /** @type {Hydratable} */ | ||
| const hydratable = isomorphic_hydratable; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why the indirection? why not export function hydratable(...) {...}?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's the only way I could figure out to type it correctly
Co-authored-by: Rich Harris <rich.harris@vercel.com>
|
|
||
| export type Decode<T> = (value: any) => T; | ||
|
|
||
| export type Encode<T> = (value: T) => unknown; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it should always be a string IIUC?
| export type Encode<T> = (value: T) => unknown; | |
| export type Encode<T> = (value: T) => string; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're correct, leftover from experimentation -- going to wait to touch it until you're done reviewing because it'll touch a few files
Co-authored-by: Rich Harris <rich.harris@vercel.com>
| export function validate_effect(rune) { | ||
| const code = get_effect_validation_error_code(); | ||
| if (code === null) return; | ||
| e[code](rune); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this will break tree-shaking. we need to use dot notation, even if it's more code at the usage site
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
booo, I was wondering if that was the case. sad sad
| get finally() { | ||
| get(this.#then); | ||
| return (/** @type {any} */ fn) => { | ||
| return get(this.#then)().finally(fn); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should it be this? then we should be able to simplify #then
| return get(this.#then)().finally(fn); | |
| return get(this.#then)(fn, fn); |
(we should add some tests so that we can try out changes like this!)

This adds a couple of low-level APIs to enhance the experience of client/server rendering and communication in Svelte. These APIs are similar to
createSubscriberin that you're unlikely to regularly use them in your own application code, but they're crucially important low-level building blocks for metaframework and library authors (like ourselves, with SvelteKit).hydratableThis adds a new export from
svelte, calledhydratable. This has an isomorphic API (the one you're likely to use most often) along with an imperative server/client one (the one you're likely to use if you split your code into separate server/client entrypoints, eg. using export conditions).Isomorphic
You'd use it like this:
When server rendered, the result of
slow_random_numberwill be serialized along with your HTML, so when you later hydrate the same component on the client, it can synchronously pull the data from the serialized cache. This has two benefits: First, you don't have to wait for the async operation on the client during hydration, and second, the value is guaranteed to be the same as it was on the server (slow_random_numberwon't run again, so you won't see the dreaded "flash of previous content" you'd get from a hydration mismatch).You can provide custom serialization and deserialization options as well. The API for this is a little bit nasty: You have to pass either
encodeordecode, not both. This forces library authors to do something like the following, meaning your final code is maximally treeshakeable (decodeisn't needed on the server,encodeisn't needed on the client):It is an error to set the same
hydratablekey more than once, as this behavior is undefined.Imperative
If you're writing a really advanced library, you may need to actually split your code into separate client / server entrypoints and use export maps to load the correct version. In these cases, it can be better to use the imperative API:
hydratable.sethas the same no-multiple-sets rule as above.cacheThis adds two new exports from
'svelte/reactivity'calledcacheandCacheObserver. When provided with a key and a function,cachewill do what it sounds like it will do: Make sure that function is only ever called once, and return the resulting value in all other cases:On the server, this cache lives for the lifetime of a request: For a given cache key, the function passed to
cachewill only ever be executed once. On the client, a given cache key will live as long as there are reactive references to the result.resourceThis adds another new export from
'svelte/reactivity':resource. If you're familiar with TanStack Query, SvelteKit's remote functions feature, or SWR, this will be familiar to you:useristhenable, meaning it can beawaited:If you need it, you can also use the imperative API:
{#if user.error} <Error msg={user.error.message} /> {:else if !user.ready || user.loading} <Loading /> {:else} <User user={user.current} /> {/if}The resource also has
refresh(rerun the function) andset(synchronously update the resource's value) methods.Composition
These APIs compose quite nicely. For example, here's how you'd implement a simple fetcher:
fetcherwill:resourceresult hydratable, so that it's synchronously available on the client if it was previously rendered on the serverBefore submitting the PR, please make sure you do the following
feat:,fix:,chore:, ordocs:.packages/svelte/src, add a changeset (npx changeset).Tests and linting
pnpm testand lint the project withpnpm lint