Skip to content

Conversation

@elliott-with-the-longest-name-on-github
Copy link
Contributor

@elliott-with-the-longest-name-on-github elliott-with-the-longest-name-on-github commented Oct 15, 2025

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 createSubscriber in 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).

hydratable

This adds a new export from svelte, called hydratable. 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:

<script>
  import { hydratable } from 'svelte';
  import { slow_random_number } from '$lib/rand';

  const random_number = await hydratable('a_unique_key', slow_random_number);
</script>

When server rendered, the result of slow_random_number will 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_number won'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 encode or decode, not both. This forces library authors to do something like the following, meaning your final code is maximally treeshakeable (decode isn't needed on the server, encode isn't needed on the client):

<script>
  import { hydratable } from 'svelte';
  import { slow_random_number } from '$lib/rand';
  import { BROWSER } from 'esm-env';

  const random_number = await hydratable(
    'a_unique_key',
    slow_random_number,
    BROWSER ? {
      decode: (val) => my_custom_deserializer(val),
    } : {
      encode: (val) => my_custom_serializer(val),
    }
  );
</script>

It is an error to set the same hydratable key 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:

import { hydratable } from 'svelte';

const val = hydratable.get('key'); // only works on the client, accepts a custom decoder
const has_val = hydratable.has('key');
hydratable.set('key', val); // only works on the server, accepts a custom encoder

hydratable.set has the same no-multiple-sets rule as above.

cache

This adds two new exports from 'svelte/reactivity' called cache and CacheObserver. When provided with a key and a function, cache will 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:

<script>
  import { cache } from 'svelte/reactivity';
  
  function expensive_fetch(url) {
    return cache(`expensive_fetch/${url}`, () => fetch(url));
  }

  const req_1 = expensive_fetch('/foo');
  const req_2 = expensive_fetch('/foo');
  const req_3 = expensive_fetch('/bar');

  req_1 === req_2; // true
  req_2 === req_3; // false, different keys
</script>

On the server, this cache lives for the lifetime of a request: For a given cache key, the function passed to cache will only ever be executed once. On the client, a given cache key will live as long as there are reactive references to the result.

resource

This 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:

import { resource } from 'svelte/reactivity';
import { get_user } from '$lib';

const id = 'yo momma';
const user = resource(() => get_user(id));

user is thenable, meaning it can be awaited:

<h1>{(await user).name}</h1>

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) and set (synchronously update the resource's value) methods.

Composition

These APIs compose quite nicely. For example, here's how you'd implement a simple fetcher:

export function fetcher(url, init) {
	const key = `fetcher/${typeof url === 'string' ? url : url.toString()}`;
	return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init))));
}

fetcher will:

  • Cache calls, so that multiple requests for the same URL don't result in multiple network requests
  • Wrap its result in a resource, so you get all of the thenability/refreshability/typing benefits
  • Make the resource result hydratable, so that it's synchronously available on the client if it was previously rendered on the server

Before submitting the PR, please make sure you do the following

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • Prefix your PR title with feat:, fix:, chore:, or docs:.
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.
  • If this PR changes code within packages/svelte/src, add a changeset (npx changeset).

Tests and linting

  • Run the tests with pnpm test and lint the project with pnpm lint

@changeset-bot
Copy link

changeset-bot bot commented Oct 15, 2025

⚠️ No Changeset found

Latest commit: 1a6b53b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@svelte-docs-bot
Copy link

@paoloricciuti
Copy link
Member

This is a draft, it's not ready, leave me alone

17605526257128346051930270607464

@nosovk
Copy link
Contributor

nosovk commented Oct 25, 2025

Very interesting approach

@github-actions
Copy link
Contributor

Playground

pnpm add https://pkg.pr.new/svelte@16960


/** @typedef {{ count: number, item: any }} Entry */
/** @type {Map<string, CacheEntry>} */
const client_cache = new Map();

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()}`;

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...

Copy link
Member

Choose a reason for hiding this comment

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

the toString() is implied

Suggested change
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 than cache(key, () => resource(fn))? (We could still expose cache for non-resource things of course)
  • do we have opinions about abort signals? obviously you could pass signal: getAbortSignal() with your init argument but if you created/refreshed stuff outside the effect cycle then you can't use it. do we need to do something like resource((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)

Copy link
Contributor Author

@elliott-with-the-longest-name-on-github elliott-with-the-longest-name-on-github Nov 6, 2025

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.

Copy link
Member

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.

Copy link
Member

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?

Copy link
Member

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 cache is 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

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}]`);

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?

Copy link
Member

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)

Copy link
Contributor Author

@elliott-with-the-longest-name-on-github elliott-with-the-longest-name-on-github Nov 6, 2025

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 {

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?

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 trying to work out if we actually need this. In the Kit PR it's used for three things:

  1. refreshing the subset of resources that stay on the page after a goto with invalidateAll: true
  2. overriding resource values with new data after a command/form that calls some_query().refresh()
  3. refreshing all queries resources 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:

  1. That's okay, we don't want one
  2. We do want one, and the way to get one is (revisiting https://github.com/sveltejs/svelte/pull/16960/files#r2501388324) to use cache inside resource after all... E.g. your SvelteKit app might use a combination of remote functions and fetcher, 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;

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 🤔

Comment on lines +18 to +26
export type Transport<T> =
| {
encode: Encode<T>;
decode?: undefined;
}
| {
encode?: undefined;
decode: Decode<T>;
};

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

Copy link
Member

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>;
}

Copy link
Contributor Author

@elliott-with-the-longest-name-on-github elliott-with-the-longest-name-on-github Nov 6, 2025

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 }
  })
}

Copy link
Member

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';

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.

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}`);

Choose a reason for hiding this comment

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

TODO

@elliott-with-the-longest-name-on-github elliott-with-the-longest-name-on-github marked this pull request as ready for review November 6, 2025 16:08
Comment on lines +35 to +36
/** @type {Hydratable} */
const hydratable = isomorphic_hydratable;
Copy link
Member

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(...) {...}?

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


export type Decode<T> = (value: any) => T;

export type Encode<T> = (value: T) => unknown;
Copy link
Member

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?

Suggested change
export type Encode<T> = (value: T) => unknown;
export type Encode<T> = (value: T) => string;

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);
Copy link
Member

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

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);
Copy link
Member

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

Suggested change
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!)

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.

5 participants