diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index 84a4d7034a..76865b0bbb 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -224,14 +224,60 @@ export function is_runes() { return !legacy_mode_flag || (component_context !== null && component_context.l === null); } +/** @type {string | null} */ +export let hydratable_key = null; + +/** @param {string | null} key */ +export function set_hydratable_key(key) { + hydratable_key = key; +} + /** * @template T + * @overload * @param {string} key * @param {() => T} fn * @param {{ transport?: Transport }} [options] - * @returns {Promise} + * @returns {Promise>} + */ +/** + * @template T + * @overload + * @param {() => T} fn + * @param {{ transport?: Transport }} [options] + * @returns {Promise>} + */ +/** + * @template T + * @param {string | (() => T)} key_or_fn + * @param {(() => T) | { transport?: Transport }} [fn_or_options] + * @param {{ transport?: Transport }} [maybe_options] + * @returns {Promise>} */ -export function hydratable(key, fn, { transport } = {}) { +export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { + /** @type {string} */ + let key; + /** @type {() => T} */ + let fn; + /** @type {{ transport?: Transport }} */ + let options; + + if (typeof key_or_fn === 'string') { + key = key_or_fn; + fn = /** @type {() => T} */ (fn_or_options); + options = /** @type {{ transport?: Transport }} */ (maybe_options); + } else { + if (hydratable_key === null) { + throw new Error( + 'TODO error: `hydratable` must be called synchronously within `cache` in order to omit the key' + ); + } else { + key = hydratable_key; + } + fn = /** @type {() => T} */ (key_or_fn); + options = /** @type {{ transport?: Transport }} */ (fn_or_options); + } + if (!hydrating) { return Promise.resolve(fn()); } @@ -245,7 +291,7 @@ export function hydratable(key, fn, { transport } = {}) { ); } const entry = /** @type {string} */ (store.get(key)); - const parse = transport?.parse ?? ((val) => new Function(`return (${val})`)()); + const parse = options.transport?.parse ?? ((val) => new Function(`return (${val})`)()); return Promise.resolve(/** @type {T} */ (parse(entry))); } diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js index 316dc9844e..224de6bd9b 100644 --- a/packages/svelte/src/internal/client/reactivity/cache.js +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -1,3 +1,4 @@ +import { set_hydratable_key } from '../context.js'; import { tick } from '../runtime.js'; import { render_effect } from './effects.js'; @@ -6,52 +7,49 @@ import { render_effect } from './effects.js'; const client_cache = new Map(); /** - * @template TReturn - * @template {unknown} TArg - * @param {string} name - * @param {(arg: TArg, key: string) => TReturn} fn - * @param {{ hash?: (arg: TArg) => string }} [options] - * @returns {(arg: TArg) => TReturn} + * @template {(...args: any[]) => any} TFn + * @param {string} key + * @param {TFn} fn + * @returns {ReturnType} */ -export function cache(name, fn, { hash = default_hash } = {}) { - return (arg) => { - const key = `${name}::::${hash(arg)}`; - const cached = client_cache.has(key); - const entry = client_cache.get(key); - const maybe_remove = create_remover(key); - - let tracking = true; - try { - render_effect(() => { - if (entry) entry.count++; - return () => { - const entry = client_cache.get(key); - if (!entry) return; - entry.count--; - maybe_remove(entry); - }; - }); - } catch { - tracking = false; - } +export function cache(key, fn) { + const cached = client_cache.has(key); + const entry = client_cache.get(key); + const maybe_remove = create_remover(key); + + let tracking = true; + try { + render_effect(() => { + if (entry) entry.count++; + return () => { + const entry = client_cache.get(key); + if (!entry) return; + entry.count--; + maybe_remove(entry); + }; + }); + } catch { + tracking = false; + } - if (cached) { - return entry?.item; - } + if (cached) { + return entry?.item; + } - const item = fn(arg, key); - const new_entry = { - item, - count: tracking ? 1 : 0 - }; - client_cache.set(key, new_entry); - - Promise.resolve(item).then( - () => maybe_remove(new_entry), - () => maybe_remove(new_entry) - ); - return item; + set_hydratable_key(key); + const item = fn(); + set_hydratable_key(null); + const new_entry = { + item, + count: tracking ? 1 : 0 }; + client_cache.set(key, new_entry); + + Promise.resolve(item).then( + () => maybe_remove(new_entry), + () => maybe_remove(new_entry) + ); + return item; } /** @@ -124,11 +122,3 @@ const readonly_cache = new ReadonlyCache(); export function get_cache() { return readonly_cache; } - -/** - * @param {...any} args - * @returns - */ -function default_hash(...args) { - return JSON.stringify(args); -} diff --git a/packages/svelte/src/internal/client/reactivity/fetcher.js b/packages/svelte/src/internal/client/reactivity/fetcher.js index 15df1e9d0c..dc3671be18 100644 --- a/packages/svelte/src/internal/client/reactivity/fetcher.js +++ b/packages/svelte/src/internal/client/reactivity/fetcher.js @@ -1,44 +1,17 @@ -/** @import { StandardSchemaV1 } from '@standard-schema/spec' */ +/** @import { GetRequestInit, Resource } from '#shared' */ import { cache } from './cache'; +import { fetch_json } from '../../shared/utils.js'; +import { hydratable } from '../context'; +import { resource } from './resource'; /** - * @template {StandardSchemaV1} TSchema - * @param {{ schema?: TSchema, url: string | URL, init?: RequestInit }} args - * @param {string} [key] + * @template TReturn + * @param {string | URL} url + * @param {GetRequestInit} [init] + * @returns {Resource} */ -async function fetcher_impl({ schema, url, init }, key) { - const response = await fetch(url, init); - if (!response.ok) { - throw new Error(`Fetch error: ${response.status} ${response.statusText}`); - } - if (schema) { - const data = await response.json(); - return schema['~standard'].validate(data); - } - return response.json(); -} - -const cached_fetch = cache('svelte/fetcher', fetcher_impl, { - hash: (arg) => { - return `${typeof arg.url === 'string' ? arg.url : arg.url.toString()}}`; - } -}); - -/** - * @template {StandardSchemaV1} TSchema - * @overload - * @param {{ schema: TSchema, url: string | URL, init?: RequestInit }} arg - * @returns {Promise>} - */ -/** - * @overload - * @param {{ schema?: undefined, url: string | URL, init?: RequestInit }} arg - * @returns {Promise} - */ -/** - * @template {StandardSchemaV1} TSchema - * @param {{ schema?: TSchema, url: string | URL, init?: RequestInit }} arg - */ -export function fetcher(arg) { - return cached_fetch(arg); +export function fetcher(url, init) { + return cache(`svelte/fetcher::::${typeof url === 'string' ? url : url.toString()}`, () => + resource(() => hydratable(() => fetch_json(url, init))) + ); } diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index 7ab3e2665b..54fc122077 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -125,14 +125,60 @@ export async function save(promise) { }; } +/** @type {string | null} */ +export let hydratable_key = null; + +/** @param {string | null} key */ +export function set_hydratable_key(key) { + hydratable_key = key; +} + /** * @template T + * @overload * @param {string} key * @param {() => T} fn * @param {{ transport?: Transport }} [options] - * @returns {Promise} + * @returns {Promise>} */ -export function hydratable(key, fn, { transport } = {}) { +/** + * @template T + * @overload + * @param {() => T} fn + * @param {{ transport?: Transport }} [options] + * @returns {Promise>} + */ +/** + * @template T + * @param {string | (() => T)} key_or_fn + * @param {(() => T) | { transport?: Transport }} [fn_or_options] + * @param {{ transport?: Transport }} [maybe_options] + * @returns {Promise>} + */ +export function hydratable(key_or_fn, fn_or_options = {}, maybe_options = {}) { + // TODO DRY out with #shared + /** @type {string} */ + let key; + /** @type {() => T} */ + let fn; + /** @type {{ transport?: Transport }} */ + let options; + + if (typeof key_or_fn === 'string') { + key = key_or_fn; + fn = /** @type {() => T} */ (fn_or_options); + options = /** @type {{ transport?: Transport }} */ (maybe_options); + } else { + if (hydratable_key === null) { + throw new Error( + 'TODO error: `hydratable` must be called synchronously within `cache` in order to omit the key' + ); + } else { + key = hydratable_key; + } + fn = /** @type {() => T} */ (key_or_fn); + options = /** @type {{ transport?: Transport }} */ (fn_or_options); + } const store = get_render_store(); if (store.hydratables.has(key)) { @@ -141,7 +187,7 @@ export function hydratable(key, fn, { transport } = {}) { } const result = fn(); - store.hydratables.set(key, { value: result, transport }); + store.hydratables.set(key, { value: result, transport: options.transport }); return Promise.resolve(result); } diff --git a/packages/svelte/src/internal/server/reactivity/cache.js b/packages/svelte/src/internal/server/reactivity/cache.js index ed93539e3f..4904633863 100644 --- a/packages/svelte/src/internal/server/reactivity/cache.js +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -1,33 +1,22 @@ -import { get_render_store } from '../context'; +import { get_render_store, set_hydratable_key } from '../context'; /** - * @template TReturn - * @template {unknown} TArg - * @param {string} name - * @param {(arg: TArg, key: string) => TReturn} fn - * @param {{ hash?: (arg: TArg) => string }} [options] - * @returns {(arg: TArg) => TReturn} + * @template {(...args: any[]) => any} TFn + * @param {string} key + * @param {TFn} fn + * @returns {ReturnType} */ -export function cache(name, fn, { hash = default_hash } = {}) { - return (arg) => { - const cache = get_render_store().cache; - const key = `${name}::::${hash(arg)}`; - const entry = cache.get(key); - if (entry) { - return /** @type {TReturn} */ (entry); - } - const new_entry = fn(arg, key); - cache.set(key, new_entry); - return new_entry; - }; -} - -/** - * @param {any} arg - * @returns {string} - */ -function default_hash(arg) { - return JSON.stringify(arg); +export function cache(key, fn) { + const cache = get_render_store().cache; + const entry = cache.get(key); + if (entry) { + return /** @type {ReturnType} */ (entry); + } + set_hydratable_key(key); + const new_entry = fn(); + set_hydratable_key(null); + cache.set(key, new_entry); + return new_entry; } export function get_cache() { diff --git a/packages/svelte/src/internal/server/reactivity/fetcher.js b/packages/svelte/src/internal/server/reactivity/fetcher.js new file mode 100644 index 0000000000..9e4870cf2b --- /dev/null +++ b/packages/svelte/src/internal/server/reactivity/fetcher.js @@ -0,0 +1,17 @@ +/** @import { GetRequestInit, Resource } from '#shared' */ +import { fetch_json } from '../../shared/utils.js'; +import { hydratable } from '../context.js'; +import { cache } from './cache'; +import { resource } from './resource.js'; + +/** + * @template TReturn + * @param {string | URL} url + * @param {GetRequestInit} [init] + * @returns {Resource} + */ +export function fetcher(url, init) { + return cache(`svelte/fetcher::::${typeof url === 'string' ? url : url.toString()}`, () => + resource(() => hydratable(() => fetch_json(url, init))) + ); +} diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 583b444b58..598bcd73a3 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -375,7 +375,7 @@ export class Renderer { }); return Promise.resolve(user_result); } - async ??= with_render_store({ hydratables: new Map(), resources: new Map() }, () => + async ??= with_render_store({ hydratables: new Map(), cache: new Map() }, () => Renderer.#render_async(component, options) ); return async.then((result) => { diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index f092a8c931..549d870d88 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -35,3 +35,5 @@ export type Resource = { error: any; } ); + +export type GetRequestInit = Omit & { method?: 'GET' }; diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index 10f8597520..1f375c4001 100644 --- a/packages/svelte/src/internal/shared/utils.js +++ b/packages/svelte/src/internal/shared/utils.js @@ -1,3 +1,5 @@ +/** @import { GetRequestInit } from '#shared' */ + // Store the references to globals in case someone tries to monkey patch these, causing the below // to de-opt (this occurs often when using popular extensions). export var is_array = Array.isArray; @@ -116,3 +118,15 @@ export function to_array(value, n) { return array; } + +/** + * @param {string | URL} url + * @param {GetRequestInit} [init] + */ +export async function fetch_json(url, init) { + const response = await fetch(url, init); + if (!response.ok) { + throw new Error(`TODO error: Fetch error: ${response.status} ${response.statusText}`); + } + return response.json(); +} diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index e5be6774bd..cc65588c89 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -7,3 +7,4 @@ export { MediaQuery } from './media-query.js'; export { createSubscriber } from './create-subscriber.js'; export { resource } from '../internal/client/reactivity/resource.js'; export { cache, get_cache as getCache } from '../internal/client/reactivity/cache.js'; +export { fetcher } from '../internal/client/reactivity/fetcher.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 67ea76f65a..231741028d 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,5 +1,6 @@ export { resource } from '../internal/server/reactivity/resource.js'; export { cache, get_cache as getCache } from '../internal/server/reactivity/cache.js'; +export { fetcher } from '../internal/server/reactivity/fetcher.js'; export const SvelteDate = globalThis.Date; export const SvelteSet = globalThis.Set; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index e8e3d02c3a..e5cfbdb0ac 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -489,9 +489,13 @@ declare module 'svelte' { * */ export function getAllContexts = Map>(): T; - export function hydratable(key: string, fn: () => T, { transport }?: { + export function hydratable(key: string, fn: () => T, options?: { transport?: Transport; - } | undefined): Promise; + } | undefined): Promise>; + + export function hydratable(fn: () => T, options?: { + transport?: Transport; + } | undefined): Promise>; /** * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component. * Transitions will play during the initial render unless the `intro` option is set to `false`. @@ -2412,9 +2416,8 @@ declare module 'svelte/reactivity' { */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; export function resource(fn: () => Promise): Resource; - export function cache(name: string, fn: (arg: TArg, key: string) => TReturn, { hash }?: { - hash?: (arg: TArg) => string; - } | undefined): (arg: TArg) => TReturn; + export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource; + export function cache any>(key: string, fn: TFn): ReturnType; export function getCache(): ReadonlyMap; class ReactiveValue { @@ -2443,6 +2446,8 @@ declare module 'svelte/reactivity' { } ); + type GetRequestInit = Omit & { method?: 'GET' }; + export {}; }