From ed3645dbbe92daf4262e27b2d55eb1a65e8b7af1 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 19 Mar 2026 15:30:58 -0600 Subject: [PATCH 01/14] feat: improve remote function caching by sorting object keys --- .changeset/eager-crabs-study.md | 5 + .changeset/social-cups-play.md | 5 + .../20-core-concepts/60-remote-functions.md | 2 +- packages/kit/src/core/postbuild/prerender.js | 6 +- .../runtime/app/server/remote/prerender.js | 4 +- .../src/runtime/app/server/remote/query.js | 4 +- .../src/runtime/app/server/remote/shared.js | 2 +- .../client/remote-functions/command.svelte.js | 2 +- .../remote-functions/prerender.svelte.js | 2 +- .../client/remote-functions/query.svelte.js | 2 +- packages/kit/src/runtime/shared.js | 89 +++++++++- packages/kit/src/runtime/shared.spec.js | 152 ++++++++++++++++++ 12 files changed, 253 insertions(+), 22 deletions(-) create mode 100644 .changeset/eager-crabs-study.md create mode 100644 .changeset/social-cups-play.md create mode 100644 packages/kit/src/runtime/shared.spec.js diff --git a/.changeset/eager-crabs-study.md b/.changeset/eager-crabs-study.md new file mode 100644 index 000000000000..f11b8f92676e --- /dev/null +++ b/.changeset/eager-crabs-study.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: stabilize remote function caching by sorting object keys diff --git a/.changeset/social-cups-play.md b/.changeset/social-cups-play.md new file mode 100644 index 000000000000..fc865c9c4a84 --- /dev/null +++ b/.changeset/social-cups-play.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +breaking: disallow `Map`, `Set`, `RegExp`, and custom types as remote function arguments diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index dedfc0f06e17..2fc5584ee8c3 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -158,7 +158,7 @@ export const getPost = query(v.string(), async (slug) => { }); ``` -Both the argument and the return value are serialized with [devalue](https://github.com/sveltejs/devalue), which handles types like `Date` and `Map` (and custom types defined in your [transport hook](hooks#Universal-hooks-transport)) in addition to JSON. +Both the argument and the return value are serialized with [devalue](https://github.com/sveltejs/devalue). Remote function arguments can contain JSON-compatible data plus devalue-native types like `Date`, `URL`, `ArrayBuffer`, typed arrays and Temporal values, but cannot contain `Map`, `Set`, `RegExp`, or custom class instances. Return values can use any devalue-supported types (and custom types defined in your [transport hook](hooks#Universal-hooks-transport)). ### Refreshing queries diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 806952e39268..4af5ad83d8dd 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -548,14 +548,10 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { } } - const transport = (await internal.get_hooks()).transport ?? {}; for (const internals of prerender_functions) { if (internals.has_arg) { for (const arg of (await internals.inputs?.()) ?? []) { - void enqueue( - null, - remote_prefix + internals.id + '/' + stringify_remote_arg(arg, transport) - ); + void enqueue(null, remote_prefix + internals.id + '/' + stringify_remote_arg(arg)); } } else { void enqueue(null, remote_prefix + internals.id); diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index 405014cc69cc..86cdf51b8149 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -91,14 +91,14 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { /** @type {Promise & Partial>} */ const promise = (async () => { const { event, state } = get_request_store(); - const payload = stringify_remote_arg(arg, state.transport); + const payload = stringify_remote_arg(arg); const id = __.id; const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`; if (!state.prerendering && !DEV && !event.isRemoteRequest) { try { return await get_response(__, arg, state, async () => { - const key = stringify_remote_arg(arg, state.transport); + const key = stringify_remote_arg(arg); const cache = get_cache(__, state); // TODO adapters can provide prerendered data more efficiently than diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index 57c11c65056a..b2f204d25cb2 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -181,7 +181,7 @@ function batch(validate_or_fn, maybe_fn) { // Collect all the calls to the same query in the same macrotask, // then execute them as one backend request. return new Promise((resolve, reject) => { - const key = stringify_remote_arg(arg, state.transport); + const key = stringify_remote_arg(arg); const entry = batching.get(key); if (entry) { @@ -326,7 +326,7 @@ function get_refresh_context(__, action, arg) { } const cache = get_cache(__, state); - const cache_key = stringify_remote_arg(arg, state.transport); + const cache_key = stringify_remote_arg(arg); const refreshes_key = create_remote_key(__.id, cache_key); return { __, state, refreshes, refreshes_key, cache, cache_key }; diff --git a/packages/kit/src/runtime/app/server/remote/shared.js b/packages/kit/src/runtime/app/server/remote/shared.js index e42e69d5bb6d..c661962c5150 100644 --- a/packages/kit/src/runtime/app/server/remote/shared.js +++ b/packages/kit/src/runtime/app/server/remote/shared.js @@ -78,7 +78,7 @@ export async function get_response(internals, arg, state, get_result) { await 0; const cache = get_cache(internals, state); - const key = stringify_remote_arg(arg, state.transport); + const key = stringify_remote_arg(arg); const entry = (cache[key] ??= { serialize: false, data: get_result() diff --git a/packages/kit/src/runtime/client/remote-functions/command.svelte.js b/packages/kit/src/runtime/client/remote-functions/command.svelte.js index 5838f406e88f..660220c5fa9e 100644 --- a/packages/kit/src/runtime/client/remote-functions/command.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/command.svelte.js @@ -43,7 +43,7 @@ export function command(id) { const response = await fetch(`${base}/${app_dir}/remote/${id}`, { method: 'POST', body: JSON.stringify({ - payload: stringify_remote_arg(arg, app.hooks.transport), + payload: stringify_remote_arg(arg, app.hooks.transport, false), refreshes: updates.map((u) => u._key) }), headers diff --git a/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js b/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js index 3ab177c090b9..86bd8c5fb310 100644 --- a/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js @@ -56,7 +56,7 @@ function put(url, encoded) { */ export function prerender(id) { return (arg) => { - const payload = stringify_remote_arg(arg, app.hooks.transport); + const payload = stringify_remote_arg(arg); const cache_key = create_remote_key(id, payload); let resource = prerender_resources.get(cache_key)?.deref(); diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index e95b64cff5e9..8a1a7d712b26 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -386,7 +386,7 @@ class QueryProxy { * @param {(key: string, payload: string) => Promise} fn */ constructor(id, arg, fn) { - this.#payload = stringify_remote_arg(arg, app.hooks.transport); + this.#payload = stringify_remote_arg(arg); this._key = create_remote_key(id, this.#payload); this.#fn = fn; diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index a38f9249ac7f..35966c4cfe38 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -51,17 +51,91 @@ export function stringify(data, transport) { return devalue.stringify(data, encoders); } +const object_proto_names = /* @__PURE__ */ Object.getOwnPropertyNames(Object.prototype) + .sort() + .join('\0'); + +/** @param {any} thing */ +function is_plain_object(thing) { + const proto = Object.getPrototypeOf(thing); + + return ( + proto === Object.prototype || + proto === null || + Object.getPrototypeOf(proto) === null || + Object.getOwnPropertyNames(proto).sort().join('\0') === object_proto_names + ); +} + +/** + * @param {Record} value + * @param {Map} clones + */ +function to_sorted(value, clones) { + const clone = Object.getPrototypeOf(value) === null ? Object.create(null) : {}; + clones.set(value, clone); + Object.defineProperty(clone, remote_arg_marker, { value: true }); + + for (const key of Object.keys(value).sort()) { + const property = value[key]; + Object.defineProperty(clone, key, { + value: clones.get(property) ?? property, + enumerable: true, + configurable: true, + writable: true + }); + } + + return clone; +} + +const remote_arg_clones = new Map(); + +// "sveltekit remote arg" +const remote_arg_reducer = '__skra'; +const remote_arg_marker = Symbol(remote_arg_reducer); + +const remote_arg_reducers = { + [remote_arg_reducer]: + /** @type {(value: unknown) => unknown} */ + (value) => { + if (typeof value !== 'object' || value === null) { + return; + } + + if (Object.hasOwn(value, remote_arg_marker)) { + return; + } + + if (value instanceof Map) { + throw new Error('Maps are not valid remote function arguments'); + } + + if (value instanceof Set) { + throw new Error('Sets are not valid remote function arguments'); + } + + if (value instanceof RegExp) { + throw new Error('Regular expressions are not valid remote function arguments'); + } + + if (is_plain_object(value)) { + return remote_arg_clones.get(value) ?? to_sorted(value, remote_arg_clones); + } + } +}; + /** * Stringifies the argument (if any) for a remote function in such a way that * it is both a valid URL and a valid file name (necessary for prerendering). * @param {any} value - * @param {Transport} transport */ -export function stringify_remote_arg(value, transport) { +export function stringify_remote_arg(value) { if (value === undefined) return ''; // If people hit file/url size limits, we can look into using something like compress_and_encode_text from svelte.dev beyond a certain size - const json_string = stringify(value, transport); + const json_string = devalue.stringify(value, remote_arg_reducers); + remote_arg_clones.clear(); const bytes = new TextEncoder().encode(json_string); return base64_encode(bytes).replaceAll('=', '').replaceAll('+', '-').replaceAll('/', '_'); @@ -70,9 +144,8 @@ export function stringify_remote_arg(value, transport) { /** * Parses the argument (if any) for a remote function * @param {string} string - * @param {Transport} transport */ -export function parse_remote_arg(string, transport) { +export function parse_remote_arg(string) { if (!string) return undefined; const json_string = text_decoder.decode( @@ -80,9 +153,9 @@ export function parse_remote_arg(string, transport) { base64_decode(string.replaceAll('-', '+').replaceAll('_', '/')) ); - const decoders = Object.fromEntries(Object.entries(transport).map(([k, v]) => [k, v.decode])); - - return devalue.parse(json_string, decoders); + return devalue.parse(json_string, { + [remote_arg_reducer]: (value) => value + }); } /** diff --git a/packages/kit/src/runtime/shared.spec.js b/packages/kit/src/runtime/shared.spec.js new file mode 100644 index 000000000000..07e33f9f7eb2 --- /dev/null +++ b/packages/kit/src/runtime/shared.spec.js @@ -0,0 +1,152 @@ +import { describe, expect, test } from 'vitest'; +import { parse_remote_arg, stringify_remote_arg } from './shared.js'; + +describe('stringify_remote_arg', () => { + test('produces the same key for reordered plain object properties', () => { + const a = stringify_remote_arg({ limit: 10, offset: 20 }); + const b = stringify_remote_arg({ offset: 20, limit: 10 }); + + expect(a).toBe(b); + }); + + test('produces the same key for reordered nested plain object properties', () => { + const a = stringify_remote_arg({ + filter: { + range: { min: 1, max: 5 }, + tags: ['a', 'b'] + } + }); + + const b = stringify_remote_arg({ + filter: { + tags: ['a', 'b'], + range: { max: 5, min: 1 } + } + }); + + expect(a).toBe(b); + }); + + test('produces the same key for reordered null-prototype object properties', () => { + const a = Object.assign(Object.create(null), { limit: 10, offset: 20 }); + const b = Object.assign(Object.create(null), { offset: 20, limit: 10 }); + + expect(stringify_remote_arg(a)).toBe(stringify_remote_arg(b)); + }); + + test('does not mutate input objects while canonicalizing keys', () => { + const value = { + z: 1, + nested: { + b: 2, + a: 1 + } + }; + + stringify_remote_arg(value); + + expect(Object.keys(value)).toEqual(['z', 'nested']); + expect(Object.keys(value.nested)).toEqual(['b', 'a']); + }); + + test('round-trips cycles and repeated plain-object references', () => { + const shared = { z: 1, a: 2 }; + const value = { + items: [shared, shared] + }; + // @ts-expect-error + value.self = value; + + const parsed = parse_remote_arg(stringify_remote_arg(value)); + + expect(parsed.self).toBe(parsed); + expect(parsed.items[0]).toBe(parsed.items[1]); + expect(Object.keys(parsed.items[0])).toEqual(['a', 'z']); + }); + + test('round-trips allowed devalue builtins', () => { + const value = { + date: new Date('2024-01-01T00:00:00.000Z'), + buffer: new Uint8Array([3, 1, 2]), + url: new URL('https://example.com/?a=1') + }; + + const parsed = parse_remote_arg(stringify_remote_arg(value)); + + expect(parsed.date.toISOString()).toBe('2024-01-01T00:00:00.000Z'); + expect(Array.from(parsed.buffer)).toEqual([3, 1, 2]); + expect(parsed.url.toString()).toBe('https://example.com/?a=1'); + }); + + test('rejects Map arguments', () => { + expect(() => stringify_remote_arg(new Map())).toThrow( + 'Maps are not valid remote function arguments' + ); + }); + + test('rejects RegExp arguments', () => { + expect(() => stringify_remote_arg(/a/)).toThrow( + 'Regular expressions are not valid remote function arguments' + ); + }); + + test('rejects Set arguments', () => { + expect(() => stringify_remote_arg(new Set())).toThrow( + 'Sets are not valid remote function arguments' + ); + }); + + test('rejects class instances via devalue', () => { + class Thing {} + + expect(() => stringify_remote_arg(new Thing())).toThrow('Cannot stringify arbitrary non-POJOs'); + }); + + test('round-trips sparse arrays while sorting nested plain objects', () => { + const value = []; + value[1_000_000] = { b: 2, a: 1 }; + + const parsed = parse_remote_arg(stringify_remote_arg(value)); + + expect(parsed).toHaveLength(1_000_001); + expect(0 in parsed).toBe(false); + expect(Object.keys(parsed[1_000_000])).toEqual(['a', 'b']); + }); +}); + +describe('parse_remote_arg', () => { + test('returns undefined for an empty payload', () => { + expect(parse_remote_arg('')).toBeUndefined(); + }); + + test('parses remote-arg reducer payloads without transport decoders', () => { + const parsed = parse_remote_arg(stringify_remote_arg({ z: 1, nested: { b: 2, a: 1 } })); + + expect(parsed).toEqual({ nested: { a: 1, b: 2 }, z: 1 }); + }); + + test('round-trips self-referential objects', () => { + const value = { z: 1, a: 2 }; + // @ts-expect-error + value.self = value; + + const parsed = parse_remote_arg(stringify_remote_arg(value)); + + expect(parsed.self).toBe(parsed); + expect(Object.keys(parsed)).toEqual(['a', 'self', 'z']); + }); + + test('restores null-prototype objects', () => { + const value = Object.assign(Object.create(null), { + z: 1, + nested: Object.assign(Object.create(null), { b: 2, a: 1 }) + }); + + const parsed = parse_remote_arg(stringify_remote_arg(value)); + + expect(Object.getPrototypeOf(parsed)).toBeNull(); + expect(Object.getPrototypeOf(parsed.nested)).toBeNull(); + expect(Object.keys(parsed)).toEqual(['nested', 'z']); + expect(Object.keys(parsed.nested)).toEqual(['a', 'b']); + }); +}); From 1e89259df458a9238197d4060c11003bfea102af Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Mon, 23 Mar 2026 18:14:25 -0600 Subject: [PATCH 02/14] this is obvious in hindsight --- packages/kit/src/runtime/shared.js | 138 ++++++++++++++++++--- packages/kit/src/runtime/shared.spec.js | 156 ++++++++++++++++++++++-- 2 files changed, 262 insertions(+), 32 deletions(-) diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index 35966c4cfe38..ad11610e744f 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -55,8 +55,12 @@ const object_proto_names = /* @__PURE__ */ Object.getOwnPropertyNames(Object.pro .sort() .join('\0'); -/** @param {any} thing */ +/** + * @param {unknown} thing + * @returns {thing is Record} + */ function is_plain_object(thing) { + if (typeof thing !== 'object' || thing === null) return false; const proto = Object.getPrototypeOf(thing); return ( @@ -92,14 +96,67 @@ function to_sorted(value, clones) { const remote_arg_clones = new Map(); // "sveltekit remote arg" -const remote_arg_reducer = '__skra'; -const remote_arg_marker = Symbol(remote_arg_reducer); +const remote_object = '__skrao'; +const remote_map = '__skram'; +const remote_set = '__skras'; +const remote_regex_guard = '__skrag'; +const remote_arg_marker = Symbol(remote_object); const remote_arg_reducers = { - [remote_arg_reducer]: - /** @type {(value: unknown) => unknown} */ + [remote_regex_guard]: + /** @type {(value: unknown) => void} */ + (value) => { + if (value instanceof RegExp) { + throw new Error('Regular expressions are not valid remote function arguments'); + } + }, + [remote_map]: + /** @type {(value: unknown) => Array<[unknown, unknown]> | undefined} */ + (value) => { + if (!(value instanceof Map)) { + return; + } + + /** @type {Array<[string, string]>} */ + const entries = []; + + for (const [key, val] of value) { + entries.push([ + devalue.stringify(key, remote_arg_reducers), + devalue.stringify(val, remote_arg_reducers) + ]); + } + + entries.sort(([a1, a2], [b1, b2]) => { + if (a1 < b1) return -1; + if (a1 > b1) return 1; + if (a2 < b2) return -1; + if (a2 > b2) return 1; + return 0; + }); + return entries; + }, + [remote_set]: + /** @type {(value: unknown) => unknown[] | undefined} */ + (value) => { + if (!(value instanceof Set)) { + return; + } + + /** @type {string[]} */ + const items = []; + + for (const item of value) { + items.push(devalue.stringify(item, remote_arg_reducers)); + } + + items.sort(); + return items; + }, + [remote_object]: + /** @type {(value: unknown) => Record | undefined} */ (value) => { - if (typeof value !== 'object' || value === null) { + if (!is_plain_object(value)) { return; } @@ -107,21 +164,59 @@ const remote_arg_reducers = { return; } - if (value instanceof Map) { - throw new Error('Maps are not valid remote function arguments'); + if (remote_arg_clones.has(value)) { + return remote_arg_clones.get(value); } - if (value instanceof Set) { - throw new Error('Sets are not valid remote function arguments'); + return to_sorted(value, remote_arg_clones); + } +}; + +const remote_arg_revivers = { + [remote_object]: + /** @type {(value: unknown) => unknown} */ + (value) => value, + [remote_map]: + /** @type {(value: unknown) => Map} */ + (value) => { + if (!Array.isArray(value)) { + throw new Error('Invalid data for Map reviver'); } - if (value instanceof RegExp) { - throw new Error('Regular expressions are not valid remote function arguments'); + const map = new Map(); + + for (const item of value) { + if ( + !Array.isArray(item) || + item.length !== 2 || + typeof item[0] !== 'string' || + typeof item[1] !== 'string' + ) { + throw new Error('Invalid data for Map reviver'); + } + const [key, val] = item; + map.set(devalue.parse(key, remote_arg_revivers), devalue.parse(val, remote_arg_revivers)); } - if (is_plain_object(value)) { - return remote_arg_clones.get(value) ?? to_sorted(value, remote_arg_clones); + return map; + }, + [remote_set]: + /** @type {(value: unknown) => Set} */ + (value) => { + if (!Array.isArray(value)) { + throw new Error('Invalid data for Set reviver'); } + + const set = new Set(); + + for (const item of value) { + if (typeof item !== 'string') { + throw new Error('Invalid data for Set reviver'); + } + set.add(devalue.parse(item, remote_arg_revivers)); + } + + return set; } }; @@ -133,9 +228,14 @@ const remote_arg_reducers = { export function stringify_remote_arg(value) { if (value === undefined) return ''; - // If people hit file/url size limits, we can look into using something like compress_and_encode_text from svelte.dev beyond a certain size - const json_string = devalue.stringify(value, remote_arg_reducers); - remote_arg_clones.clear(); + let json_string; + + try { + // If people hit file/url size limits, we can look into using something like compress_and_encode_text from svelte.dev beyond a certain size + json_string = devalue.stringify(value, remote_arg_reducers); + } finally { + remote_arg_clones.clear(); + } const bytes = new TextEncoder().encode(json_string); return base64_encode(bytes).replaceAll('=', '').replaceAll('+', '-').replaceAll('/', '_'); @@ -153,9 +253,7 @@ export function parse_remote_arg(string) { base64_decode(string.replaceAll('-', '+').replaceAll('_', '/')) ); - return devalue.parse(json_string, { - [remote_arg_reducer]: (value) => value - }); + return devalue.parse(json_string, remote_arg_revivers); } /** diff --git a/packages/kit/src/runtime/shared.spec.js b/packages/kit/src/runtime/shared.spec.js index 07e33f9f7eb2..277e6f2befec 100644 --- a/packages/kit/src/runtime/shared.spec.js +++ b/packages/kit/src/runtime/shared.spec.js @@ -34,6 +34,78 @@ describe('stringify_remote_arg', () => { expect(stringify_remote_arg(a)).toBe(stringify_remote_arg(b)); }); + test('produces the same key for reordered Map entries', () => { + const a = stringify_remote_arg( + new Map([ + [ + 'second', + new Map([ + ['y', { d: 4, c: 3 }], + ['x', { b: 2, a: 1 }] + ]) + ], + ['first', { nested: { z: 1, a: 2 } }] + ]) + ); + + const b = stringify_remote_arg( + new Map([ + ['first', { nested: { a: 2, z: 1 } }], + [ + 'second', + new Map([ + ['x', { a: 1, b: 2 }], + ['y', { c: 3, d: 4 }] + ]) + ] + ]) + ); + + expect(a).toBe(b); + }); + + test('produces the same key for reordered Set items', () => { + const a = stringify_remote_arg( + new Set([ + new Map([ + ['b', { y: 2, x: 1 }], + ['a', { b: 2, a: 1 }] + ]), + new Map([ + [ + 'd', + new Set([ + { d: 4, c: 3 }, + { b: 2, a: 1 } + ]) + ], + ['c', { z: 1, y: 2 }] + ]) + ]) + ); + + const b = stringify_remote_arg( + new Set([ + new Map([ + ['c', { y: 2, z: 1 }], + [ + 'd', + new Set([ + { a: 1, b: 2 }, + { c: 3, d: 4 } + ]) + ] + ]), + new Map([ + ['a', { a: 1, b: 2 }], + ['b', { x: 1, y: 2 }] + ]) + ]) + ); + + expect(a).toBe(b); + }); + test('does not mutate input objects while canonicalizing keys', () => { const value = { z: 1, @@ -78,24 +150,12 @@ describe('stringify_remote_arg', () => { expect(parsed.url.toString()).toBe('https://example.com/?a=1'); }); - test('rejects Map arguments', () => { - expect(() => stringify_remote_arg(new Map())).toThrow( - 'Maps are not valid remote function arguments' - ); - }); - test('rejects RegExp arguments', () => { expect(() => stringify_remote_arg(/a/)).toThrow( 'Regular expressions are not valid remote function arguments' ); }); - test('rejects Set arguments', () => { - expect(() => stringify_remote_arg(new Set())).toThrow( - 'Sets are not valid remote function arguments' - ); - }); - test('rejects class instances via devalue', () => { class Thing {} @@ -136,6 +196,78 @@ describe('parse_remote_arg', () => { expect(Object.keys(parsed)).toEqual(['a', 'self', 'z']); }); + test('round-trips Maps with stable ordering and nested data structures', () => { + const value = new Map([ + [ + 'second', + new Map([ + ['y', { d: 4, c: 3 }], + [ + 'x', + new Set([ + { d: 4, c: 3 }, + { b: 2, a: 1 } + ]) + ] + ]) + ], + ['first', { nested: { z: 1, a: 2 } }] + ]); + + const parsed = parse_remote_arg(stringify_remote_arg(value)); + + expect(parsed).toBeInstanceOf(Map); + expect(Array.from(parsed.keys())).toEqual(['first', 'second']); + expect(Object.keys(parsed.get('first'))).toEqual(['nested']); + expect(Object.keys(parsed.get('first').nested)).toEqual(['a', 'z']); + + const nested_map = parsed.get('second'); + expect(nested_map).toBeInstanceOf(Map); + expect(Array.from(nested_map.keys())).toEqual(['x', 'y']); + expect(Array.from(nested_map.get('x'))).toEqual([ + { a: 1, b: 2 }, + { c: 3, d: 4 } + ]); + expect(nested_map.get('y')).toEqual({ c: 3, d: 4 }); + }); + + test('round-trips Sets with stable ordering and nested data structures', () => { + const value = new Set([ + new Map([ + ['b', { y: 2, x: 1 }], + ['a', { b: 2, a: 1 }] + ]), + new Map([ + [ + 'd', + new Set([ + { d: 4, c: 3 }, + { b: 2, a: 1 } + ]) + ], + ['c', { z: 1, y: 2 }] + ]) + ]); + + const parsed = parse_remote_arg(stringify_remote_arg(value)); + + expect(parsed).toBeInstanceOf(Set); + + const [first, second] = Array.from(parsed); + expect(first).toBeInstanceOf(Map); + expect(Array.from(first.keys())).toEqual(['a', 'b']); + expect(first.get('a')).toEqual({ a: 1, b: 2 }); + expect(first.get('b')).toEqual({ x: 1, y: 2 }); + + expect(second).toBeInstanceOf(Map); + expect(Array.from(second.keys())).toEqual(['c', 'd']); + expect(second.get('c')).toEqual({ y: 2, z: 1 }); + expect(Array.from(second.get('d'))).toEqual([ + { a: 1, b: 2 }, + { c: 3, d: 4 } + ]); + }); + test('restores null-prototype objects', () => { const value = Object.assign(Object.create(null), { z: 1, From 76284d90499fc8d47270e1f5d53c9165cf811fda Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 24 Mar 2026 11:38:49 -0600 Subject: [PATCH 03/14] we can support it all --- .../20-core-concepts/60-remote-functions.md | 2 +- packages/kit/src/core/postbuild/prerender.js | 7 +- .../runtime/app/server/remote/prerender.js | 4 +- .../src/runtime/app/server/remote/query.js | 4 +- .../src/runtime/app/server/remote/shared.js | 2 +- .../remote-functions/prerender.svelte.js | 2 +- .../client/remote-functions/query.svelte.js | 2 +- packages/kit/src/runtime/shared.js | 237 ++++++++++-------- packages/kit/src/runtime/shared.spec.js | 188 ++++++++++---- 9 files changed, 287 insertions(+), 161 deletions(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 2fc5584ee8c3..f5927f5fc331 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -158,7 +158,7 @@ export const getPost = query(v.string(), async (slug) => { }); ``` -Both the argument and the return value are serialized with [devalue](https://github.com/sveltejs/devalue). Remote function arguments can contain JSON-compatible data plus devalue-native types like `Date`, `URL`, `ArrayBuffer`, typed arrays and Temporal values, but cannot contain `Map`, `Set`, `RegExp`, or custom class instances. Return values can use any devalue-supported types (and custom types defined in your [transport hook](hooks#Universal-hooks-transport)). +Both the argument and the return value are serialized with [devalue](https://github.com/sveltejs/devalue) (plus your optional [transport hook](hooks#Universal-hooks-transport)). For `query` and `prerender` _arguments_ (but not return values), objects, maps, and sets are sorted so that instances with the same members result in the same cache key. For example, `getPosts({ limit: 10, offset: 10 })` and `getPosts({ offset: 10, limit: 10 })` will result in the same cache key. If order is important to you, you'll have to use an array. ### Refreshing queries diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 4af5ad83d8dd..d392f46ba809 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -548,10 +548,15 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { } } + const transport = (await internal.get_hooks()).transport ?? {}; + for (const internals of prerender_functions) { if (internals.has_arg) { for (const arg of (await internals.inputs?.()) ?? []) { - void enqueue(null, remote_prefix + internals.id + '/' + stringify_remote_arg(arg)); + void enqueue( + null, + remote_prefix + internals.id + '/' + stringify_remote_arg(arg, transport) + ); } } else { void enqueue(null, remote_prefix + internals.id); diff --git a/packages/kit/src/runtime/app/server/remote/prerender.js b/packages/kit/src/runtime/app/server/remote/prerender.js index 86cdf51b8149..405014cc69cc 100644 --- a/packages/kit/src/runtime/app/server/remote/prerender.js +++ b/packages/kit/src/runtime/app/server/remote/prerender.js @@ -91,14 +91,14 @@ export function prerender(validate_or_fn, fn_or_options, maybe_options) { /** @type {Promise & Partial>} */ const promise = (async () => { const { event, state } = get_request_store(); - const payload = stringify_remote_arg(arg); + const payload = stringify_remote_arg(arg, state.transport); const id = __.id; const url = `${base}/${app_dir}/remote/${id}${payload ? `/${payload}` : ''}`; if (!state.prerendering && !DEV && !event.isRemoteRequest) { try { return await get_response(__, arg, state, async () => { - const key = stringify_remote_arg(arg); + const key = stringify_remote_arg(arg, state.transport); const cache = get_cache(__, state); // TODO adapters can provide prerendered data more efficiently than diff --git a/packages/kit/src/runtime/app/server/remote/query.js b/packages/kit/src/runtime/app/server/remote/query.js index b2f204d25cb2..57c11c65056a 100644 --- a/packages/kit/src/runtime/app/server/remote/query.js +++ b/packages/kit/src/runtime/app/server/remote/query.js @@ -181,7 +181,7 @@ function batch(validate_or_fn, maybe_fn) { // Collect all the calls to the same query in the same macrotask, // then execute them as one backend request. return new Promise((resolve, reject) => { - const key = stringify_remote_arg(arg); + const key = stringify_remote_arg(arg, state.transport); const entry = batching.get(key); if (entry) { @@ -326,7 +326,7 @@ function get_refresh_context(__, action, arg) { } const cache = get_cache(__, state); - const cache_key = stringify_remote_arg(arg); + const cache_key = stringify_remote_arg(arg, state.transport); const refreshes_key = create_remote_key(__.id, cache_key); return { __, state, refreshes, refreshes_key, cache, cache_key }; diff --git a/packages/kit/src/runtime/app/server/remote/shared.js b/packages/kit/src/runtime/app/server/remote/shared.js index c661962c5150..e42e69d5bb6d 100644 --- a/packages/kit/src/runtime/app/server/remote/shared.js +++ b/packages/kit/src/runtime/app/server/remote/shared.js @@ -78,7 +78,7 @@ export async function get_response(internals, arg, state, get_result) { await 0; const cache = get_cache(internals, state); - const key = stringify_remote_arg(arg); + const key = stringify_remote_arg(arg, state.transport); const entry = (cache[key] ??= { serialize: false, data: get_result() diff --git a/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js b/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js index 86bd8c5fb310..3ab177c090b9 100644 --- a/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/prerender.svelte.js @@ -56,7 +56,7 @@ function put(url, encoded) { */ export function prerender(id) { return (arg) => { - const payload = stringify_remote_arg(arg); + const payload = stringify_remote_arg(arg, app.hooks.transport); const cache_key = create_remote_key(id, payload); let resource = prerender_resources.get(cache_key)?.deref(); diff --git a/packages/kit/src/runtime/client/remote-functions/query.svelte.js b/packages/kit/src/runtime/client/remote-functions/query.svelte.js index 8a1a7d712b26..e95b64cff5e9 100644 --- a/packages/kit/src/runtime/client/remote-functions/query.svelte.js +++ b/packages/kit/src/runtime/client/remote-functions/query.svelte.js @@ -386,7 +386,7 @@ class QueryProxy { * @param {(key: string, payload: string) => Promise} fn */ constructor(id, arg, fn) { - this.#payload = stringify_remote_arg(arg); + this.#payload = stringify_remote_arg(arg, app.hooks.transport); this._key = create_remote_key(id, this.#payload); this.#fn = fn; diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index ad11610e744f..7af34221b190 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -102,137 +102,171 @@ const remote_set = '__skras'; const remote_regex_guard = '__skrag'; const remote_arg_marker = Symbol(remote_object); -const remote_arg_reducers = { - [remote_regex_guard]: - /** @type {(value: unknown) => void} */ - (value) => { - if (value instanceof RegExp) { - throw new Error('Regular expressions are not valid remote function arguments'); - } - }, - [remote_map]: - /** @type {(value: unknown) => Array<[unknown, unknown]> | undefined} */ - (value) => { - if (!(value instanceof Map)) { - return; +/** + * @param {Transport} transport + * @param {boolean} sort + */ +function create_remote_arg_reducers(transport, sort) { + /** @type {Record unknown>} */ + const remote_fns_reducers = { + [remote_regex_guard]: + /** @type {(value: unknown) => void} */ + (value) => { + if (value instanceof RegExp) { + throw new Error('Regular expressions are not valid remote function arguments'); + } } + }; + + if (sort) { + remote_fns_reducers[remote_map] = + /** @type {(value: unknown) => Array<[unknown, unknown]> | undefined} */ + (value) => { + if (!(value instanceof Map)) { + return; + } - /** @type {Array<[string, string]>} */ - const entries = []; + /** @type {Array<[string, string]>} */ + const entries = []; - for (const [key, val] of value) { - entries.push([ - devalue.stringify(key, remote_arg_reducers), - devalue.stringify(val, remote_arg_reducers) - ]); - } + for (const [key, val] of value) { + entries.push([stringify(key), stringify(val)]); + } - entries.sort(([a1, a2], [b1, b2]) => { - if (a1 < b1) return -1; - if (a1 > b1) return 1; - if (a2 < b2) return -1; - if (a2 > b2) return 1; - return 0; - }); - return entries; - }, - [remote_set]: - /** @type {(value: unknown) => unknown[] | undefined} */ - (value) => { - if (!(value instanceof Set)) { - return; - } + entries.sort(([a1, a2], [b1, b2]) => { + if (a1 < b1) return -1; + if (a1 > b1) return 1; + if (a2 < b2) return -1; + if (a2 > b2) return 1; + return 0; + }); + return entries; + }; + + remote_fns_reducers[remote_set] = + /** @type {(value: unknown) => unknown[] | undefined} */ + (value) => { + if (!(value instanceof Set)) { + return; + } - /** @type {string[]} */ - const items = []; + /** @type {string[]} */ + const items = []; - for (const item of value) { - items.push(devalue.stringify(item, remote_arg_reducers)); - } + for (const item of value) { + items.push(stringify(item)); + } - items.sort(); - return items; - }, - [remote_object]: - /** @type {(value: unknown) => Record | undefined} */ - (value) => { - if (!is_plain_object(value)) { - return; - } + items.sort(); + return items; + }; - if (Object.hasOwn(value, remote_arg_marker)) { - return; - } + remote_fns_reducers[remote_object] = + /** @type {(value: unknown) => Record | undefined} */ + (value) => { + if (!is_plain_object(value)) { + return; + } - if (remote_arg_clones.has(value)) { - return remote_arg_clones.get(value); - } + if (Object.hasOwn(value, remote_arg_marker)) { + return; + } - return to_sorted(value, remote_arg_clones); - } -}; - -const remote_arg_revivers = { - [remote_object]: - /** @type {(value: unknown) => unknown} */ - (value) => value, - [remote_map]: - /** @type {(value: unknown) => Map} */ - (value) => { - if (!Array.isArray(value)) { - throw new Error('Invalid data for Map reviver'); - } + if (remote_arg_clones.has(value)) { + return remote_arg_clones.get(value); + } - const map = new Map(); + return to_sorted(value, remote_arg_clones); + }; + } - for (const item of value) { - if ( - !Array.isArray(item) || - item.length !== 2 || - typeof item[0] !== 'string' || - typeof item[1] !== 'string' - ) { + const user_reducers = Object.fromEntries( + Object.entries(transport).map(([k, v]) => [k, v.encode]) + ); + const all_reducers = { ...remote_fns_reducers, ...user_reducers }; + + /** @type {(value: unknown) => string} */ + const stringify = (value) => devalue.stringify(value, all_reducers); + + return all_reducers; +} + +/** @param {Transport} transport */ +function create_remote_arg_revivers(transport) { + const remote_fns_revivers = { + [remote_object]: + /** @type {(value: unknown) => unknown} */ + (value) => value, + [remote_map]: + /** @type {(value: unknown) => Map} */ + (value) => { + if (!Array.isArray(value)) { throw new Error('Invalid data for Map reviver'); } - const [key, val] = item; - map.set(devalue.parse(key, remote_arg_revivers), devalue.parse(val, remote_arg_revivers)); - } - - return map; - }, - [remote_set]: - /** @type {(value: unknown) => Set} */ - (value) => { - if (!Array.isArray(value)) { - throw new Error('Invalid data for Set reviver'); - } - const set = new Set(); + const map = new Map(); + + for (const item of value) { + if ( + !Array.isArray(item) || + item.length !== 2 || + typeof item[0] !== 'string' || + typeof item[1] !== 'string' + ) { + throw new Error('Invalid data for Map reviver'); + } + const [key, val] = item; + map.set(parse(key), parse(val)); + } - for (const item of value) { - if (typeof item !== 'string') { + return map; + }, + [remote_set]: + /** @type {(value: unknown) => Set} */ + (value) => { + if (!Array.isArray(value)) { throw new Error('Invalid data for Set reviver'); } - set.add(devalue.parse(item, remote_arg_revivers)); + + const set = new Set(); + + for (const item of value) { + if (typeof item !== 'string') { + throw new Error('Invalid data for Set reviver'); + } + set.add(parse(item)); + } + + return set; } + }; + + const user_revivers = Object.fromEntries( + Object.entries(transport).map(([k, v]) => [k, v.decode]) + ); + const all_revivers = { ...remote_fns_revivers, ...user_revivers }; - return set; - } -}; + /** @type {(data: string) => unknown} */ + const parse = (data) => devalue.parse(data, all_revivers); + + return all_revivers; +} /** * Stringifies the argument (if any) for a remote function in such a way that * it is both a valid URL and a valid file name (necessary for prerendering). * @param {any} value + * @param {Transport} transport + * @param {boolean} [sort] */ -export function stringify_remote_arg(value) { +export function stringify_remote_arg(value, transport, sort = true) { if (value === undefined) return ''; let json_string; try { // If people hit file/url size limits, we can look into using something like compress_and_encode_text from svelte.dev beyond a certain size - json_string = devalue.stringify(value, remote_arg_reducers); + json_string = devalue.stringify(value, create_remote_arg_reducers(transport, sort)); } finally { remote_arg_clones.clear(); } @@ -244,8 +278,9 @@ export function stringify_remote_arg(value) { /** * Parses the argument (if any) for a remote function * @param {string} string + * @param {Transport} transport */ -export function parse_remote_arg(string) { +export function parse_remote_arg(string, transport) { if (!string) return undefined; const json_string = text_decoder.decode( @@ -253,7 +288,7 @@ export function parse_remote_arg(string) { base64_decode(string.replaceAll('-', '+').replaceAll('_', '/')) ); - return devalue.parse(json_string, remote_arg_revivers); + return devalue.parse(json_string, create_remote_arg_revivers(transport)); } /** diff --git a/packages/kit/src/runtime/shared.spec.js b/packages/kit/src/runtime/shared.spec.js index 277e6f2befec..b9d18e0616e1 100644 --- a/packages/kit/src/runtime/shared.spec.js +++ b/packages/kit/src/runtime/shared.spec.js @@ -1,28 +1,61 @@ import { describe, expect, test } from 'vitest'; import { parse_remote_arg, stringify_remote_arg } from './shared.js'; +class Thing { + /** @param {number} a @param {number} z */ + constructor(a, z) { + this.a = a; + this.z = z; + } +} + +const transport = { + Thing: { + /** @param {unknown} value */ + encode: (value) => (value instanceof Thing ? { z: value.z, a: value.a } : undefined), + /** @param {{ a: number; z: number }} value */ + decode: (value) => new Thing(value.a, value.z) + } +}; + +/** @param {Array<[any, any]>} entries */ +function map(entries) { + return /** @type {Map} */ (new Map(entries)); +} + +/** @param {any[]} items */ +function set(items) { + return /** @type {Set} */ (new Set(items)); +} + describe('stringify_remote_arg', () => { test('produces the same key for reordered plain object properties', () => { - const a = stringify_remote_arg({ limit: 10, offset: 20 }); - const b = stringify_remote_arg({ offset: 20, limit: 10 }); + const a = stringify_remote_arg({ limit: 10, offset: 20 }, {}); + const b = stringify_remote_arg({ offset: 20, limit: 10 }, {}); expect(a).toBe(b); }); test('produces the same key for reordered nested plain object properties', () => { - const a = stringify_remote_arg({ - filter: { - range: { min: 1, max: 5 }, - tags: ['a', 'b'] - } - }); + const a = stringify_remote_arg( + { + filter: { + range: { min: 1, max: 5 }, + tags: ['a', 'b'] + } + }, + {} + ); - const b = stringify_remote_arg({ - filter: { - tags: ['a', 'b'], - range: { max: 5, min: 1 } - } - }); + const b = stringify_remote_arg( + { + filter: { + tags: ['a', 'b'], + range: { max: 5, min: 1 } + } + }, + {} + ); expect(a).toBe(b); }); @@ -31,34 +64,36 @@ describe('stringify_remote_arg', () => { const a = Object.assign(Object.create(null), { limit: 10, offset: 20 }); const b = Object.assign(Object.create(null), { offset: 20, limit: 10 }); - expect(stringify_remote_arg(a)).toBe(stringify_remote_arg(b)); + expect(stringify_remote_arg(a, {})).toBe(stringify_remote_arg(b, {})); }); test('produces the same key for reordered Map entries', () => { const a = stringify_remote_arg( - new Map([ + map([ [ 'second', - new Map([ + map([ ['y', { d: 4, c: 3 }], ['x', { b: 2, a: 1 }] ]) ], ['first', { nested: { z: 1, a: 2 } }] - ]) + ]), + {} ); const b = stringify_remote_arg( - new Map([ + map([ ['first', { nested: { a: 2, z: 1 } }], [ 'second', - new Map([ + map([ ['x', { a: 1, b: 2 }], ['y', { c: 3, d: 4 }] ]) ] - ]) + ]), + {} ); expect(a).toBe(b); @@ -66,41 +101,70 @@ describe('stringify_remote_arg', () => { test('produces the same key for reordered Set items', () => { const a = stringify_remote_arg( - new Set([ - new Map([ + set([ + map([ ['b', { y: 2, x: 1 }], ['a', { b: 2, a: 1 }] ]), - new Map([ + map([ [ 'd', - new Set([ + set([ { d: 4, c: 3 }, { b: 2, a: 1 } ]) ], ['c', { z: 1, y: 2 }] ]) - ]) + ]), + {} ); const b = stringify_remote_arg( - new Set([ - new Map([ + set([ + map([ ['c', { y: 2, z: 1 }], [ 'd', - new Set([ + set([ { a: 1, b: 2 }, { c: 3, d: 4 } ]) ] ]), - new Map([ + map([ ['a', { a: 1, b: 2 }], ['b', { x: 1, y: 2 }] ]) - ]) + ]), + {} + ); + + expect(a).toBe(b); + }); + + test('preserves input ordering when sort is false', () => { + const a = stringify_remote_arg({ limit: 10, offset: 20 }, {}, false); + const b = stringify_remote_arg({ offset: 20, limit: 10 }, {}, false); + + expect(a).not.toBe(b); + }); + + test('produces the same key for transported values nested inside Maps and Sets', () => { + const a = stringify_remote_arg( + map([ + ['second', set([new Thing(4, 5), new Thing(2, 3)])], + ['first', new Thing(1, 2)] + ]), + transport + ); + + const b = stringify_remote_arg( + map([ + ['first', new Thing(1, 2)], + ['second', set([new Thing(2, 3), new Thing(4, 5)])] + ]), + transport ); expect(a).toBe(b); @@ -115,7 +179,7 @@ describe('stringify_remote_arg', () => { } }; - stringify_remote_arg(value); + stringify_remote_arg(value, {}); expect(Object.keys(value)).toEqual(['z', 'nested']); expect(Object.keys(value.nested)).toEqual(['b', 'a']); @@ -129,7 +193,7 @@ describe('stringify_remote_arg', () => { // @ts-expect-error value.self = value; - const parsed = parse_remote_arg(stringify_remote_arg(value)); + const parsed = parse_remote_arg(stringify_remote_arg(value, {}), {}); expect(parsed.self).toBe(parsed); expect(parsed.items[0]).toBe(parsed.items[1]); @@ -143,7 +207,7 @@ describe('stringify_remote_arg', () => { url: new URL('https://example.com/?a=1') }; - const parsed = parse_remote_arg(stringify_remote_arg(value)); + const parsed = parse_remote_arg(stringify_remote_arg(value, {}), {}); expect(parsed.date.toISOString()).toBe('2024-01-01T00:00:00.000Z'); expect(Array.from(parsed.buffer)).toEqual([3, 1, 2]); @@ -151,7 +215,7 @@ describe('stringify_remote_arg', () => { }); test('rejects RegExp arguments', () => { - expect(() => stringify_remote_arg(/a/)).toThrow( + expect(() => stringify_remote_arg(/a/, {})).toThrow( 'Regular expressions are not valid remote function arguments' ); }); @@ -159,14 +223,16 @@ describe('stringify_remote_arg', () => { test('rejects class instances via devalue', () => { class Thing {} - expect(() => stringify_remote_arg(new Thing())).toThrow('Cannot stringify arbitrary non-POJOs'); + expect(() => stringify_remote_arg(new Thing(), {})).toThrow( + 'Cannot stringify arbitrary non-POJOs' + ); }); test('round-trips sparse arrays while sorting nested plain objects', () => { const value = []; value[1_000_000] = { b: 2, a: 1 }; - const parsed = parse_remote_arg(stringify_remote_arg(value)); + const parsed = parse_remote_arg(stringify_remote_arg(value, {}), {}); expect(parsed).toHaveLength(1_000_001); expect(0 in parsed).toBe(false); @@ -176,35 +242,34 @@ describe('stringify_remote_arg', () => { describe('parse_remote_arg', () => { test('returns undefined for an empty payload', () => { - expect(parse_remote_arg('')).toBeUndefined(); + expect(parse_remote_arg('', {})).toBeUndefined(); }); test('parses remote-arg reducer payloads without transport decoders', () => { - const parsed = parse_remote_arg(stringify_remote_arg({ z: 1, nested: { b: 2, a: 1 } })); + const parsed = parse_remote_arg(stringify_remote_arg({ z: 1, nested: { b: 2, a: 1 } }, {}), {}); expect(parsed).toEqual({ nested: { a: 1, b: 2 }, z: 1 }); }); test('round-trips self-referential objects', () => { const value = { z: 1, a: 2 }; - // @ts-expect-error value.self = value; - const parsed = parse_remote_arg(stringify_remote_arg(value)); + const parsed = parse_remote_arg(stringify_remote_arg(value, {}), {}); expect(parsed.self).toBe(parsed); expect(Object.keys(parsed)).toEqual(['a', 'self', 'z']); }); test('round-trips Maps with stable ordering and nested data structures', () => { - const value = new Map([ + const value = map([ [ 'second', - new Map([ + map([ ['y', { d: 4, c: 3 }], [ 'x', - new Set([ + set([ { d: 4, c: 3 }, { b: 2, a: 1 } ]) @@ -214,7 +279,7 @@ describe('parse_remote_arg', () => { ['first', { nested: { z: 1, a: 2 } }] ]); - const parsed = parse_remote_arg(stringify_remote_arg(value)); + const parsed = parse_remote_arg(stringify_remote_arg(value, {}), {}); expect(parsed).toBeInstanceOf(Map); expect(Array.from(parsed.keys())).toEqual(['first', 'second']); @@ -232,15 +297,15 @@ describe('parse_remote_arg', () => { }); test('round-trips Sets with stable ordering and nested data structures', () => { - const value = new Set([ - new Map([ + const value = set([ + map([ ['b', { y: 2, x: 1 }], ['a', { b: 2, a: 1 }] ]), - new Map([ + map([ [ 'd', - new Set([ + set([ { d: 4, c: 3 }, { b: 2, a: 1 } ]) @@ -249,7 +314,7 @@ describe('parse_remote_arg', () => { ]) ]); - const parsed = parse_remote_arg(stringify_remote_arg(value)); + const parsed = parse_remote_arg(stringify_remote_arg(value, {}), {}); expect(parsed).toBeInstanceOf(Set); @@ -268,13 +333,34 @@ describe('parse_remote_arg', () => { ]); }); + test('round-trips transport values nested inside Maps and Sets', () => { + const value = map([ + ['second', set([new Thing(4, 5), new Thing(2, 3)])], + ['first', new Thing(1, 2)] + ]); + + const parsed = parse_remote_arg(stringify_remote_arg(value, transport), transport); + + expect(parsed).toBeInstanceOf(Map); + expect(Array.from(parsed.keys())).toEqual(['first', 'second']); + expect(parsed.get('first')).toBeInstanceOf(Thing); + expect(parsed.get('first')).toMatchObject({ a: 1, z: 2 }); + + const nested = parsed.get('second'); + expect(nested).toBeInstanceOf(Set); + expect(Array.from(nested)).toEqual([ + expect.objectContaining({ a: 2, z: 3 }), + expect.objectContaining({ a: 4, z: 5 }) + ]); + }); + test('restores null-prototype objects', () => { const value = Object.assign(Object.create(null), { z: 1, nested: Object.assign(Object.create(null), { b: 2, a: 1 }) }); - const parsed = parse_remote_arg(stringify_remote_arg(value)); + const parsed = parse_remote_arg(stringify_remote_arg(value, {}), {}); expect(Object.getPrototypeOf(parsed)).toBeNull(); expect(Object.getPrototypeOf(parsed.nested)).toBeNull(); From ca274a6ab7ffcb9450302146cb74db967f326226 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 24 Mar 2026 12:05:45 -0600 Subject: [PATCH 04/14] reorder --- packages/kit/src/runtime/shared.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index 7af34221b190..46322d6a9f44 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -183,7 +183,7 @@ function create_remote_arg_reducers(transport, sort) { const user_reducers = Object.fromEntries( Object.entries(transport).map(([k, v]) => [k, v.encode]) ); - const all_reducers = { ...remote_fns_reducers, ...user_reducers }; + const all_reducers = { ...user_reducers, ...remote_fns_reducers }; /** @type {(value: unknown) => string} */ const stringify = (value) => devalue.stringify(value, all_reducers); @@ -244,7 +244,7 @@ function create_remote_arg_revivers(transport) { const user_revivers = Object.fromEntries( Object.entries(transport).map(([k, v]) => [k, v.decode]) ); - const all_revivers = { ...remote_fns_revivers, ...user_revivers }; + const all_revivers = { ...user_revivers, ...remote_fns_revivers }; /** @type {(data: string) => unknown} */ const parse = (data) => devalue.parse(data, all_revivers); From 237c05f4bd38ac126c911614201574176d2e973a Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 24 Mar 2026 12:06:15 -0600 Subject: [PATCH 05/14] remove whitespace --- packages/kit/src/core/postbuild/prerender.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index d392f46ba809..806952e39268 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -549,7 +549,6 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env }) { } const transport = (await internal.get_hooks()).transport ?? {}; - for (const internals of prerender_functions) { if (internals.has_arg) { for (const arg of (await internals.inputs?.()) ?? []) { From 261b0e5d26aafff4a3e193b026ad9d79af8c0381 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 24 Mar 2026 12:06:57 -0600 Subject: [PATCH 06/14] docs --- documentation/docs/20-core-concepts/60-remote-functions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index f5927f5fc331..6e1a01450ff8 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -158,7 +158,7 @@ export const getPost = query(v.string(), async (slug) => { }); ``` -Both the argument and the return value are serialized with [devalue](https://github.com/sveltejs/devalue) (plus your optional [transport hook](hooks#Universal-hooks-transport)). For `query` and `prerender` _arguments_ (but not return values), objects, maps, and sets are sorted so that instances with the same members result in the same cache key. For example, `getPosts({ limit: 10, offset: 10 })` and `getPosts({ offset: 10, limit: 10 })` will result in the same cache key. If order is important to you, you'll have to use an array. +Both the argument and the return value are serialized with [devalue](https://github.com/sveltejs/devalue), which handles types like `Date` and `Map` (and custom types defined in your [transport hook](hooks#Universal-hooks-transport)) in addition to JSON. For `query` and `prerender` _arguments_ (but not return values), objects, maps, and sets are sorted so that instances with the same members result in the same cache key. For example, `getPosts({ limit: 10, offset: 10 })` and `getPosts({ offset: 10, limit: 10 })` will result in the same cache key. If order is important to you, you'll have to use an array. ### Refreshing queries From 1709d338af9ae99dc548997c5030995480f43a91 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 24 Mar 2026 12:07:37 -0600 Subject: [PATCH 07/14] changeset --- .changeset/eager-crabs-study.md | 2 +- .changeset/social-cups-play.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/social-cups-play.md diff --git a/.changeset/eager-crabs-study.md b/.changeset/eager-crabs-study.md index f11b8f92676e..9179a6d2bcac 100644 --- a/.changeset/eager-crabs-study.md +++ b/.changeset/eager-crabs-study.md @@ -2,4 +2,4 @@ '@sveltejs/kit': minor --- -feat: stabilize remote function caching by sorting object keys +breaking: stabilize remote function caching by sorting object keys diff --git a/.changeset/social-cups-play.md b/.changeset/social-cups-play.md deleted file mode 100644 index fc865c9c4a84..000000000000 --- a/.changeset/social-cups-play.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@sveltejs/kit': minor ---- - -breaking: disallow `Map`, `Set`, `RegExp`, and custom types as remote function arguments From fcab419bcb369a4b4a28f539b5b1ffd735e1dd53 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 31 Mar 2026 10:03:54 -0400 Subject: [PATCH 08/14] Apply suggestion from @Rich-Harris --- packages/kit/src/runtime/shared.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index 46322d6a9f44..5f2d9ad4f322 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -133,14 +133,7 @@ function create_remote_arg_reducers(transport, sort) { entries.push([stringify(key), stringify(val)]); } - entries.sort(([a1, a2], [b1, b2]) => { - if (a1 < b1) return -1; - if (a1 > b1) return 1; - if (a2 < b2) return -1; - if (a2 > b2) return 1; - return 0; - }); - return entries; + return entries.sort(([a], [b]) => (a < b ? -1 : 1)); }; remote_fns_reducers[remote_set] = From 1253250946a50c87c8b766533e2141abc98728c5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 31 Mar 2026 10:04:28 -0400 Subject: [PATCH 09/14] nicer formatting --- packages/kit/src/runtime/shared.js | 79 ++++++++++++++---------------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index 5f2d9ad4f322..3b7c787cf804 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -119,58 +119,55 @@ function create_remote_arg_reducers(transport, sort) { }; if (sort) { - remote_fns_reducers[remote_map] = - /** @type {(value: unknown) => Array<[unknown, unknown]> | undefined} */ - (value) => { - if (!(value instanceof Map)) { - return; - } + /** @type {(value: unknown) => Array<[unknown, unknown]> | undefined} */ + remote_fns_reducers[remote_map] = (value) => { + if (!(value instanceof Map)) { + return; + } - /** @type {Array<[string, string]>} */ - const entries = []; + /** @type {Array<[string, string]>} */ + const entries = []; - for (const [key, val] of value) { - entries.push([stringify(key), stringify(val)]); - } + for (const [key, val] of value) { + entries.push([stringify(key), stringify(val)]); + } - return entries.sort(([a], [b]) => (a < b ? -1 : 1)); - }; + return entries.sort(([a], [b]) => (a < b ? -1 : 1)); + }; - remote_fns_reducers[remote_set] = - /** @type {(value: unknown) => unknown[] | undefined} */ - (value) => { - if (!(value instanceof Set)) { - return; - } + /** @type {(value: unknown) => unknown[] | undefined} */ + remote_fns_reducers[remote_set] = (value) => { + if (!(value instanceof Set)) { + return; + } - /** @type {string[]} */ - const items = []; + /** @type {string[]} */ + const items = []; - for (const item of value) { - items.push(stringify(item)); - } + for (const item of value) { + items.push(stringify(item)); + } - items.sort(); - return items; - }; + items.sort(); + return items; + }; - remote_fns_reducers[remote_object] = - /** @type {(value: unknown) => Record | undefined} */ - (value) => { - if (!is_plain_object(value)) { - return; - } + /** @type {(value: unknown) => Record | undefined} */ + remote_fns_reducers[remote_object] = (value) => { + if (!is_plain_object(value)) { + return; + } - if (Object.hasOwn(value, remote_arg_marker)) { - return; - } + if (Object.hasOwn(value, remote_arg_marker)) { + return; + } - if (remote_arg_clones.has(value)) { - return remote_arg_clones.get(value); - } + if (remote_arg_clones.has(value)) { + return remote_arg_clones.get(value); + } - return to_sorted(value, remote_arg_clones); - }; + return to_sorted(value, remote_arg_clones); + }; } const user_reducers = Object.fromEntries( From cfee667272dc407219b4ffbf637fe87fabf627f3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 31 Mar 2026 10:05:25 -0400 Subject: [PATCH 10/14] nicer formatting --- packages/kit/src/runtime/shared.js | 74 +++++++++++++++--------------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index 3b7c787cf804..4a8e0cf4e341 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -184,56 +184,54 @@ function create_remote_arg_reducers(transport, sort) { /** @param {Transport} transport */ function create_remote_arg_revivers(transport) { const remote_fns_revivers = { - [remote_object]: - /** @type {(value: unknown) => unknown} */ - (value) => value, - [remote_map]: - /** @type {(value: unknown) => Map} */ - (value) => { - if (!Array.isArray(value)) { - throw new Error('Invalid data for Map reviver'); - } + /** @type {(value: unknown) => unknown} */ + [remote_object]: (value) => value, + /** @type {(value: unknown) => Map} */ + [remote_map]: (value) => { + if (!Array.isArray(value)) { + throw new Error('Invalid data for Map reviver'); + } - const map = new Map(); - - for (const item of value) { - if ( - !Array.isArray(item) || - item.length !== 2 || - typeof item[0] !== 'string' || - typeof item[1] !== 'string' - ) { - throw new Error('Invalid data for Map reviver'); - } - const [key, val] = item; - map.set(parse(key), parse(val)); - } + const map = new Map(); - return map; - }, - [remote_set]: - /** @type {(value: unknown) => Set} */ - (value) => { - if (!Array.isArray(value)) { - throw new Error('Invalid data for Set reviver'); + for (const item of value) { + if ( + !Array.isArray(item) || + item.length !== 2 || + typeof item[0] !== 'string' || + typeof item[1] !== 'string' + ) { + throw new Error('Invalid data for Map reviver'); } + const [key, val] = item; + map.set(parse(key), parse(val)); + } - const set = new Set(); + return map; + }, + /** @type {(value: unknown) => Set} */ + [remote_set]: (value) => { + if (!Array.isArray(value)) { + throw new Error('Invalid data for Set reviver'); + } - for (const item of value) { - if (typeof item !== 'string') { - throw new Error('Invalid data for Set reviver'); - } - set.add(parse(item)); - } + const set = new Set(); - return set; + for (const item of value) { + if (typeof item !== 'string') { + throw new Error('Invalid data for Set reviver'); + } + set.add(parse(item)); } + + return set; + } }; const user_revivers = Object.fromEntries( Object.entries(transport).map(([k, v]) => [k, v.decode]) ); + const all_revivers = { ...user_revivers, ...remote_fns_revivers }; /** @type {(data: string) => unknown} */ From 8e9ebe0ec3c778395496071bf57c2096d1e3fd95 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 31 Mar 2026 10:09:22 -0400 Subject: [PATCH 11/14] account for mad bastards --- packages/kit/src/runtime/shared.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index 4a8e0cf4e341..f6beaf7c1fb9 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -132,7 +132,13 @@ function create_remote_arg_reducers(transport, sort) { entries.push([stringify(key), stringify(val)]); } - return entries.sort(([a], [b]) => (a < b ? -1 : 1)); + return entries.sort(([a1, a2], [b1, b2]) => { + if (a1 < b1) return -1; + if (a1 > b1) return 1; + if (a2 < b2) return -1; + if (a2 > b2) return 1; + return 0; + }); }; /** @type {(value: unknown) => unknown[] | undefined} */ From c3378f0d3867967c76521589078148c2f8327ca5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 31 Mar 2026 10:24:46 -0400 Subject: [PATCH 12/14] simplify --- packages/kit/src/runtime/shared.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index f6beaf7c1fb9..ebcbb5163c38 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -93,8 +93,6 @@ function to_sorted(value, clones) { return clone; } -const remote_arg_clones = new Map(); - // "sveltekit remote arg" const remote_object = '__skrao'; const remote_map = '__skram'; @@ -105,8 +103,9 @@ const remote_arg_marker = Symbol(remote_object); /** * @param {Transport} transport * @param {boolean} sort + * @param {Map} remote_arg_clones */ -function create_remote_arg_reducers(transport, sort) { +function create_remote_arg_reducers(transport, sort, remote_arg_clones) { /** @type {Record unknown>} */ const remote_fns_reducers = { [remote_regex_guard]: @@ -256,14 +255,11 @@ function create_remote_arg_revivers(transport) { export function stringify_remote_arg(value, transport, sort = true) { if (value === undefined) return ''; - let json_string; - - try { - // If people hit file/url size limits, we can look into using something like compress_and_encode_text from svelte.dev beyond a certain size - json_string = devalue.stringify(value, create_remote_arg_reducers(transport, sort)); - } finally { - remote_arg_clones.clear(); - } + // If people hit file/url size limits, we can look into using something like compress_and_encode_text from svelte.dev beyond a certain size + const json_string = devalue.stringify( + value, + create_remote_arg_reducers(transport, sort, new Map()) + ); const bytes = new TextEncoder().encode(json_string); return base64_encode(bytes).replaceAll('=', '').replaceAll('+', '-').replaceAll('/', '_'); From eaea3d08bffdfca0f013c55fe391284e6e44a7e4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 31 Mar 2026 10:38:43 -0400 Subject: [PATCH 13/14] ts-ignore --- packages/kit/src/runtime/shared.spec.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kit/src/runtime/shared.spec.js b/packages/kit/src/runtime/shared.spec.js index b9d18e0616e1..48a00e6eb6f1 100644 --- a/packages/kit/src/runtime/shared.spec.js +++ b/packages/kit/src/runtime/shared.spec.js @@ -253,6 +253,7 @@ describe('parse_remote_arg', () => { test('round-trips self-referential objects', () => { const value = { z: 1, a: 2 }; + // @ts-expect-error value.self = value; const parsed = parse_remote_arg(stringify_remote_arg(value, {}), {}); From 6606c09c910ba166690f450a02664428328ddbcb Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 31 Mar 2026 08:56:44 -0600 Subject: [PATCH 14/14] Update documentation/docs/20-core-concepts/60-remote-functions.md Co-authored-by: Rich Harris --- documentation/docs/20-core-concepts/60-remote-functions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 6e1a01450ff8..75fee4217169 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -158,7 +158,9 @@ export const getPost = query(v.string(), async (slug) => { }); ``` -Both the argument and the return value are serialized with [devalue](https://github.com/sveltejs/devalue), which handles types like `Date` and `Map` (and custom types defined in your [transport hook](hooks#Universal-hooks-transport)) in addition to JSON. For `query` and `prerender` _arguments_ (but not return values), objects, maps, and sets are sorted so that instances with the same members result in the same cache key. For example, `getPosts({ limit: 10, offset: 10 })` and `getPosts({ offset: 10, limit: 10 })` will result in the same cache key. If order is important to you, you'll have to use an array. +Both the argument and the return value are serialized with [devalue](https://github.com/sveltejs/devalue), which handles types like `Date` and `Map` (and custom types defined in your [transport hook](hooks#Universal-hooks-transport)) in addition to JSON. + +> [!NOTE] For `query` and `prerender` arguments (but not return values), objects, maps, and sets are sorted so that instances with the same members result in the same cache key. For example, `getPosts({ limit: 10, offset: 10 })` and `getPosts({ offset: 10, limit: 10 })` will result in the same cache key. If order is important to you, you'll have to use an array. ### Refreshing queries