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: 85abdbd

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

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