Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/eager-crabs-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

breaking: stabilize remote function caching by sorting object keys
2 changes: 2 additions & 0 deletions documentation/docs/20-core-concepts/60-remote-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ 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.

> [!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

Any query can be re-fetched via its `refresh` method, which retrieves the latest value from the server:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
206 changes: 201 additions & 5 deletions packages/kit/src/runtime/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,215 @@ export function stringify(data, transport) {
return devalue.stringify(data, encoders);
}

const object_proto_names = /* @__PURE__ */ Object.getOwnPropertyNames(Object.prototype)
.sort()
.join('\0');

/**
* @param {unknown} thing
* @returns {thing is Record<PropertyKey, unknown>}
*/
function is_plain_object(thing) {
if (typeof thing !== 'object' || thing === null) return false;
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<string, any>} value
* @param {Map<object, any>} 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;
}

// "sveltekit remote arg"
const remote_object = '__skrao';
const remote_map = '__skram';
const remote_set = '__skras';
const remote_regex_guard = '__skrag';
const remote_arg_marker = Symbol(remote_object);

/**
* @param {Transport} transport
* @param {boolean} sort
* @param {Map<any, any>} remote_arg_clones
*/
function create_remote_arg_reducers(transport, sort, remote_arg_clones) {
/** @type {Record<string, (value: unknown) => 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) {
/** @type {(value: unknown) => Array<[unknown, unknown]> | undefined} */
remote_fns_reducers[remote_map] = (value) => {
if (!(value instanceof Map)) {
return;
}

/** @type {Array<[string, string]>} */
const entries = [];

for (const [key, val] of value) {
entries.push([stringify(key), stringify(val)]);
}

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} */
remote_fns_reducers[remote_set] = (value) => {
if (!(value instanceof Set)) {
return;
}

/** @type {string[]} */
const items = [];

for (const item of value) {
items.push(stringify(item));
}

items.sort();
return items;
};

/** @type {(value: unknown) => Record<PropertyKey, unknown> | undefined} */
remote_fns_reducers[remote_object] = (value) => {
if (!is_plain_object(value)) {
return;
}

if (Object.hasOwn(value, remote_arg_marker)) {
return;
}

if (remote_arg_clones.has(value)) {
return remote_arg_clones.get(value);
}

return to_sorted(value, remote_arg_clones);
};
}

const user_reducers = Object.fromEntries(
Object.entries(transport).map(([k, v]) => [k, v.encode])
);
const all_reducers = { ...user_reducers, ...remote_fns_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 = {
/** @type {(value: unknown) => unknown} */
[remote_object]: (value) => value,
/** @type {(value: unknown) => Map<unknown, unknown>} */
[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));
}

return map;
},
/** @type {(value: unknown) => Set<unknown>} */
[remote_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(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} */
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, transport) {
export function stringify_remote_arg(value, transport, sort = true) {
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,
create_remote_arg_reducers(transport, sort, new Map())
);

const bytes = new TextEncoder().encode(json_string);
return base64_encode(bytes).replaceAll('=', '').replaceAll('+', '-').replaceAll('/', '_');
Expand All @@ -80,9 +278,7 @@ 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, create_remote_arg_revivers(transport));
}

/**
Expand Down
Loading
Loading