Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
68c96a1
feat: `hydratable` and a more consistent remote functions model
elliott-with-the-longest-name-on-github Mar 11, 2026
7079723
another batch reviewed
elliott-with-the-longest-name-on-github Mar 11, 2026
3b0ed66
i think it work
elliott-with-the-longest-name-on-github Mar 13, 2026
6370070
types
elliott-with-the-longest-name-on-github Mar 13, 2026
0a8ba50
fix test
elliott-with-the-longest-name-on-github Mar 13, 2026
af3a09c
Fix: Debug console.log statements left in production code will spam b…
vercel[bot] Mar 13, 2026
e2ab37f
whoops
elliott-with-the-longest-name-on-github Mar 13, 2026
ec96caf
LazyPromise
elliott-with-the-longest-name-on-github Mar 13, 2026
b4b5c9b
promise chain correction
elliott-with-the-longest-name-on-github Mar 13, 2026
72bc665
better cleanup
elliott-with-the-longest-name-on-github Mar 13, 2026
903abe8
fix options-2 tests
Rich-Harris Mar 14, 2026
9862e78
cache bust
Rich-Harris Mar 15, 2026
abe6f28
Merge branch 'elliott/remote-functions-hydratable-take-2' of github.c…
Rich-Harris Mar 15, 2026
ebcc1ed
merge main
Rich-Harris Mar 15, 2026
5621893
i'm going to become the joker
Rich-Harris Mar 15, 2026
226c237
wtf wtf wtf
Rich-Harris Mar 15, 2026
cb3a98c
doh
Rich-Harris Mar 15, 2026
9e987c4
finally, it fails locally
Rich-Harris Mar 15, 2026
317230b
fix
Rich-Harris Mar 15, 2026
34295c7
simplify
Rich-Harris Mar 15, 2026
89f345f
Merge branch 'main' into elliott/remote-functions-hydratable-take-2
Rich-Harris Mar 16, 2026
c253e62
chore: hydratable remote functions tweaks (#15548)
Rich-Harris Mar 16, 2026
a4a834f
take one
elliott-with-the-longest-name-on-github Mar 16, 2026
cca9288
better
elliott-with-the-longest-name-on-github Mar 16, 2026
fb03b36
fixes
elliott-with-the-longest-name-on-github Mar 16, 2026
5895a4d
deprecate unfriendly_hydratable
Rich-Harris Mar 17, 2026
d066e33
robustify event state
Rich-Harris Mar 17, 2026
fb672bf
make it readonly
Rich-Harris Mar 17, 2026
9525d11
fix/simplify
Rich-Harris Mar 17, 2026
93fc91b
simplify
Rich-Harris Mar 17, 2026
cd0aee2
disallow run() in effects
Rich-Harris Mar 17, 2026
35da696
get rid of all the Awaited stuff, we definitely don't want that
Rich-Harris Mar 17, 2026
dd8f8fb
changeset
Rich-Harris Mar 17, 2026
7636377
lint
Rich-Harris Mar 17, 2026
4e7550d
chore: start queries on data property access, remove init
elliott-with-the-longest-name-on-github Mar 17, 2026
e593610
meh
elliott-with-the-longest-name-on-github Mar 17, 2026
e6dd225
misc
elliott-with-the-longest-name-on-github Mar 17, 2026
362d566
giddy up
elliott-with-the-longest-name-on-github Mar 17, 2026
2b94ad6
tests + batch fix
elliott-with-the-longest-name-on-github Mar 17, 2026
e3c0764
changesets
elliott-with-the-longest-name-on-github Mar 17, 2026
95fdd23
better
elliott-with-the-longest-name-on-github Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2123,6 +2123,12 @@ export type RemoteResource<T> = Promise<Awaited<T>> & {
get error(): any;
/** `true` before the first result is available and during refreshes */
get loading(): boolean;
/**
* Returns a plain promise with the result.
* Unlike awaiting the resource directly, this can be used anywhere
* (load functions, event handlers, etc) without requiring a reactive context.
*/
run(): Promise<Awaited<T>>;
} & (
| {
/** The current value of the query. Undefined until `ready` is `true` */
Expand Down
17 changes: 15 additions & 2 deletions packages/kit/src/runtime/app/server/remote/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { parse } from 'devalue';
import { error } from '@sveltejs/kit';
import { with_request_store, get_request_store } from '@sveltejs/kit/internal/server';
import { stringify_remote_arg } from '../../../shared.js';
import { stringify_remote_arg, create_remote_key, unfriendly_hydratable } from '../../../shared.js';

/**
* @param {any} validate_or_fn
Expand Down Expand Up @@ -73,8 +73,21 @@ export async function get_response(info, arg, state, get_result) {
await 0;

const cache = get_cache(info, state);
const key = stringify_remote_arg(arg, state.transport);
const entry = (cache[key] ??= {
serialize: false,
data: get_result()
});

return (cache[stringify_remote_arg(arg, state.transport)] ??= get_result());
entry.serialize ||= !!state.is_in_universal_load;

if (state.is_in_render && info.id) {
const remote_key = create_remote_key(info.id, key);

unfriendly_hydratable(remote_key, () => entry.data);
}

return entry.data;
}

/**
Expand Down
8 changes: 3 additions & 5 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ const preload_tokens = new Set();
export let pending_invalidate;

/**
* @type {Map<string, {count: number, resource: any}>}
* @type {Map<string, { count: number, resource: any }>}
* A map of id -> query info with all queries that currently exist in the app.
*/
export const query_map = new Map();
Expand Down Expand Up @@ -1535,8 +1535,6 @@ async function navigate({
block = noop,
event
}) {
remote_responses = {};

const prev_token = token;
token = nav_token;

Expand Down Expand Up @@ -2177,8 +2175,6 @@ export function refreshAll({ includeLoadFunctions = true } = {}) {
throw new Error('Cannot call refreshAll() on the server');
}

remote_responses = {};

force_invalidation = true;
return _invalidate(includeLoadFunctions, false);
}
Expand Down Expand Up @@ -2855,6 +2851,8 @@ async function _hydrate(

target.textContent = '';
hydrate = false;
} finally {
remote_responses = {};
}

if (result.props.page) {
Expand Down
222 changes: 165 additions & 57 deletions packages/kit/src/runtime/client/remote-functions/prerender.svelte.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** @import { RemotePrerenderFunction } from '@sveltejs/kit' */
import { app_dir, base } from '$app/paths/internal/client';
import { version } from '__sveltekit/environment';
import * as devalue from 'devalue';
Expand All @@ -8,6 +9,7 @@ import {
get_remote_request_headers,
remote_request
} from './shared.svelte.js';
import { unfriendly_hydratable } from '../../shared.js';

// Initialize Cache API for prerender functions
const CACHE_NAME = DEV ? `sveltekit:${Date.now()}` : `sveltekit:${version}`;
Expand All @@ -32,31 +34,117 @@ const prerender_cache_ready = (async () => {
}
})();

/**
* @param {string} url
* @param {string} encoded
*/
function put(url, encoded) {
return /** @type {Cache} */ (prerender_cache)
.put(
url,
// We need to create a new response because the original response is already consumed
new Response(encoded, {
headers: {
'Content-Type': 'application/json'
}
})
)
.catch(() => {
// Nothing we can do here
});
}

/**
* @param {string} id
* @returns {RemotePrerenderFunction<any, any>}
*/
export function prerender(id) {
const fn = create_remote_function(
id,
({ cache_key, payload }) => {
const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`;

return new Prerender(
cache_key,
(encoded) => put(url, encoded),
() => {
return unfriendly_hydratable(cache_key, async () => {
await prerender_cache_ready;
// Do this here, after await Svelte' reactivity context is gone.
const headers = get_remote_request_headers();

// Check the Cache API first
if (prerender_cache) {
try {
const cached_response = await prerender_cache.match(url);

if (cached_response) {
const cached_result = await cached_response.text();
return devalue.parse(cached_result, app.decoders);
}
} catch {
// Nothing we can do here
}
}

const encoded = await remote_request(url, headers);

// For successful prerender requests, save to cache
if (prerender_cache) {
void put(url, encoded);
}

return devalue.parse(encoded, app.decoders);
});
}
);
},
({ cache_key, get_resource }) => {
return new LimitedPrerender(cache_key, get_resource);
}
);

return fn;
}

/**
* @template T
* @implements {Partial<Promise<T>>}
* @implements {Promise<T>}
*/
class Prerender {
/** @type {string} */
_key;

/** @type {Promise<T>} */
#promise;

#loading = $state(true);
#ready = $state(false);
/** @type {(encoded: string) => Promise<void>} */
#put_to_cache;
/** @type {() => Promise<T>} */
#fn;

/** @type {T | undefined} */
#current = $state.raw();

#error = $state.raw(undefined);

/**
* @param {string} key
* @param {(encoded: string) => Promise<void>} put_to_cache
* @param {() => Promise<T>} fn
*/
constructor(fn) {
constructor(key, put_to_cache, fn) {
this._key = key;
this.#put_to_cache = put_to_cache;
this.#fn = fn;
this.#promise = fn().then(
(value) => {
this.#loading = false;
this.#ready = true;
this.#current = value;
this.#error = undefined;
return value;
},
(error) => {
Expand All @@ -67,6 +155,19 @@ class Prerender {
);
}

/** @returns {Promise<T>} */
async run() {
if (Object.hasOwn(remote_responses, this._key)) {
const data = remote_responses[this._key];

if (prerender_cache) {
await prerender_cache_ready;
void this.#put_to_cache(devalue.stringify(data, app.encoders));
}
}
return this.#fn();
}

/**
*
* @param {((value: any) => any) | null | undefined} onfulfilled
Expand Down Expand Up @@ -112,73 +213,80 @@ class Prerender {
get ready() {
return this.#ready;
}
}

/**
* @param {string} url
* @param {string} encoded
*/
function put(url, encoded) {
return /** @type {Cache} */ (prerender_cache)
.put(
url,
// We need to create a new response because the original response is already consumed
new Response(encoded, {
headers: {
'Content-Type': 'application/json'
}
})
)
.catch(() => {
// Nothing we can do here
});
get [Symbol.toStringTag]() {
return 'Prerender';
}
}

/**
* @param {string} id
* @template T
* @implements {Promise<T>}
*/
export function prerender(id) {
return create_remote_function(id, (cache_key, payload) => {
return new Prerender(async () => {
await prerender_cache_ready;
class LimitedPrerender {
/** @type {string} */
_key;

const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`;
/** @type {() => { cached: boolean, resource: Prerender<T> }} */
#get_prerender;

if (Object.hasOwn(remote_responses, cache_key)) {
const data = remote_responses[cache_key];
/** @returns {never} */
#limited_error() {
throw new Error(
'This prerender function was not created in a reactive context and is limited to calling `.run`.'
);
}

if (prerender_cache) {
void put(url, devalue.stringify(data, app.encoders));
}
/**
* @param {string} key
* @param {() => { cached: boolean, resource: Prerender<T> }} get_prerender
*/
constructor(key, get_prerender) {
this._key = key;
this.#get_prerender = get_prerender;
}

return data;
}
/** @returns {Promise<T>} */
run() {
return this.#get_prerender().resource.run();
}

// Do this here, after await Svelte' reactivity context is gone.
const headers = get_remote_request_headers();
/** @type {Promise<T>['then']} */
then() {
this.#limited_error();
}

// Check the Cache API first
if (prerender_cache) {
try {
const cached_response = await prerender_cache.match(url);

if (cached_response) {
const cached_result = await cached_response.text();
return devalue.parse(cached_result, app.decoders);
}
} catch {
// Nothing we can do here
}
}
/** @type {Promise<T>['catch']} */
catch() {
this.#limited_error();
}

const encoded = await remote_request(url, headers);
/** @type {Promise<T>['finally']} */
finally() {
this.#limited_error();
}

// For successful prerender requests, save to cache
if (prerender_cache) {
void put(url, encoded);
}
/** @type {Prerender<T>['current']} */
get current() {
return this.#limited_error();
}

return devalue.parse(encoded, app.decoders);
});
});
/** @type {Prerender<T>['error']} */
get error() {
return this.#limited_error();
}

/** @type {Prerender<T>['loading']} */
get loading() {
return this.#limited_error();
}

/** @type {Prerender<T>['ready']} */
get ready() {
return this.#limited_error();
}

get [Symbol.toStringTag]() {
return 'LimitedPrerender';
}
}
Loading
Loading