diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 0868714f6f83..22459f02e808 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -95,6 +95,10 @@ "types": "./types/index.d.ts", "default": "./src/server/index.js" }, + "./client": { + "types": "./types/index.d.ts", + "default": "./src/client/index.js" + }, "./store": { "types": "./types/index.d.ts", "worker": "./src/store/index-server.js", @@ -168,12 +172,14 @@ "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", + "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.4.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", diff --git a/packages/svelte/scripts/generate-types.js b/packages/svelte/scripts/generate-types.js index 0ee6004d4a2c..10d3caafa3b3 100644 --- a/packages/svelte/scripts/generate-types.js +++ b/packages/svelte/scripts/generate-types.js @@ -45,6 +45,7 @@ await createBundle({ [`${pkg.name}/reactivity`]: `${dir}/src/reactivity/index-client.js`, [`${pkg.name}/reactivity/window`]: `${dir}/src/reactivity/window/index.js`, [`${pkg.name}/server`]: `${dir}/src/server/index.d.ts`, + [`${pkg.name}/client`]: `${dir}/src/client/index.d.ts`, [`${pkg.name}/store`]: `${dir}/src/store/public.d.ts`, [`${pkg.name}/transition`]: `${dir}/src/transition/public.d.ts`, [`${pkg.name}/events`]: `${dir}/src/events/public.d.ts`, diff --git a/packages/svelte/src/client/index.js b/packages/svelte/src/client/index.js new file mode 100644 index 000000000000..1cd028d123d6 --- /dev/null +++ b/packages/svelte/src/client/index.js @@ -0,0 +1,4 @@ +export { + get_hydratable_value as getHydratableValue, + has_hydratable_value as hasHydratableValue +} from '../internal/client/hydratable.js'; diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js index 337cbb500b39..9d3703950e86 100644 --- a/packages/svelte/src/index-client.js +++ b/packages/svelte/src/index-client.js @@ -249,6 +249,7 @@ export { hasContext, setContext } from './internal/client/context.js'; +export { hydratable } from './internal/client/hydratable.js'; export { hydrate, mount, unmount } from './internal/client/render.js'; export { tick, untrack, settled } from './internal/client/runtime.js'; export { createRawSnippet } from './internal/client/dom/blocks/snippet.js'; diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index 223ce6a4cde1..87d7bb84d453 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -47,4 +47,6 @@ export { setContext } from './internal/server/context.js'; +export { hydratable } from './internal/server/hydratable.js'; + export { createRawSnippet } from './internal/server/blocks/snippet.js'; diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index 751a35321ac0..8330eb588c1a 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -1,4 +1,5 @@ /** @import { ComponentContext, DevStackEntry, Effect } from '#client' */ +/** @import { Transport } from '#shared' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; import { active_effect, active_reaction } from './runtime.js'; @@ -6,6 +7,7 @@ import { create_user_effect } from './reactivity/effects.js'; import { async_mode_flag, legacy_mode_flag } from '../flags/index.js'; import { FILENAME } from '../../constants.js'; import { BRANCH_EFFECT, EFFECT_RAN } from './constants.js'; +import { hydrating } from './dom/hydration.js'; /** @type {ComponentContext | null} */ export let component_context = null; diff --git a/packages/svelte/src/internal/client/hydratable.js b/packages/svelte/src/internal/client/hydratable.js new file mode 100644 index 000000000000..84523e581bed --- /dev/null +++ b/packages/svelte/src/internal/client/hydratable.js @@ -0,0 +1,66 @@ +/** @import { Parse, Transport } from '#shared' */ +import { hydrating } from './dom/hydration'; + +/** + * @template T + * @param {string} key + * @param {() => T} fn + * @param {{ transport?: Transport }} [options] + * @returns {T} + */ +export function hydratable(key, fn, options = {}) { + if (!hydrating) { + return fn(); + } + var store = window.__svelte?.h; + const val = store?.get(key); + if (val === undefined) { + // TODO this should really be an error or at least a warning because it would be disastrous to expect + // something to be synchronously hydratable and then have it not be + return fn(); + } + return parse(val, options.transport?.parse); +} + +/** + * @template T + * @param {string} key + * @param {{ parse?: Parse }} [options] + * @returns {T | undefined} + */ +export function get_hydratable_value(key, options = {}) { + // TODO probably can DRY this out with the above + if (!hydrating) { + return undefined; + } + + var store = window.__svelte?.h; + const val = store?.get(key); + if (val === undefined) { + return undefined; + } + + return parse(val, options.parse); +} + +/** + * @param {string} key + * @returns {boolean} + */ +export function has_hydratable_value(key) { + if (!hydrating) { + return false; + } + var store = window.__svelte?.h; + return store?.has(key) ?? false; +} + +/** + * @template T + * @param {string} val + * @param {Parse | undefined} parse + * @returns {T} + */ +function parse(val, parse) { + return (parse ?? ((val) => new Function(`return (${val})`)()))(val); +} diff --git a/packages/svelte/src/internal/client/reactivity/cache.js b/packages/svelte/src/internal/client/reactivity/cache.js new file mode 100644 index 000000000000..132b904af8d1 --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/cache.js @@ -0,0 +1,77 @@ +import { BaseCacheObserver } from '../../shared/cache-observer.js'; +import { ObservableCache } from '../../shared/observable-cache.js'; +import { tick } from '../runtime.js'; +import { render_effect } from './effects.js'; + +/** @typedef {{ count: number, item: any }} Entry */ +/** @type {ObservableCache} */ +const client_cache = new ObservableCache(); + +/** + * @template {(...args: any[]) => any} TFn + * @param {string} key + * @param {TFn} fn + * @returns {ReturnType} + */ +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; + } + + const item = fn(); + 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; +} + +/** + * @param {string} key + */ +function create_remover(key) { + /** + * @param {Entry | undefined} entry + */ + return (entry) => + tick().then(() => { + if (!entry?.count && entry === client_cache.get(key)) { + client_cache.delete(key); + } + }); +} + +/** + * @template T + * @extends BaseCacheObserver + */ +export class CacheObserver extends BaseCacheObserver { + constructor(prefix = '') { + super(() => client_cache, prefix); + } +} diff --git a/packages/svelte/src/internal/client/reactivity/fetcher.js b/packages/svelte/src/internal/client/reactivity/fetcher.js new file mode 100644 index 000000000000..49cecaf6d623 --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/fetcher.js @@ -0,0 +1,16 @@ +/** @import { GetRequestInit, Resource } from '#shared' */ +import { cache } from './cache'; +import { fetch_json } from '../../shared/utils.js'; +import { hydratable } from '../hydratable'; +import { resource } from './resource'; + +/** + * @template TReturn + * @param {string | URL} url + * @param {GetRequestInit} [init] + * @returns {Resource} + */ +export function fetcher(url, init) { + const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; + return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init)))); +} diff --git a/packages/svelte/src/internal/client/reactivity/resource.js b/packages/svelte/src/internal/client/reactivity/resource.js new file mode 100644 index 000000000000..c0267cb1e4bc --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/resource.js @@ -0,0 +1,178 @@ +/** @import { Source, Derived } from '#client' */ +/** @import { Resource as ResourceType } from '#shared' */ +import { state, derived, set, get, tick } from '../index.js'; +import { deferred } from '../../shared/utils.js'; + +/** + * @template T + * @param {() => T} fn + * @returns {ResourceType} + */ +export function resource(fn) { + return /** @type {ResourceType} */ (new Resource(fn)); +} + +/** + * @template T + * @implements {Partial>>} + */ +class Resource { + #init = false; + + /** @type {() => T} */ + #fn; + + /** @type {Source} */ + #loading = state(true); + + /** @type {Array<(...args: any[]) => void>} */ + #latest = []; + + /** @type {Source} */ + #ready = state(false); + + /** @type {Source | undefined>} */ + #raw = state(undefined); + + /** @type {Source>} */ + #promise; + + /** @type {Derived | undefined>} */ + #current = derived(() => { + if (!get(this.#ready)) return undefined; + return get(this.#raw); + }); + + /** {@type Source} */ + #error = state(undefined); + + /** @type {Derived>['then']>} */ + // @ts-expect-error - I feel this might actually be incorrect but I can't prove it yet. + // we are technically not returning a promise that resolves to the correct type... but it _is_ a promise that resolves at the correct time + #then = derived(() => { + const p = get(this.#promise); + + return async (resolve, reject) => { + try { + await p; + await tick(); + + resolve?.(/** @type {Awaited} */ (get(this.#current))); + } catch (error) { + reject?.(error); + } + }; + }); + + /** + * @param {() => T} fn + */ + constructor(fn) { + this.#fn = fn; + this.#promise = state(this.#run()); + } + + #run() { + if (this.#init) { + tick().then(() => { + // opt this out of async coordination + set(this.#loading, true); + }); + } else { + this.#init = true; + } + + const { resolve, reject, promise } = deferred(); + + this.#latest.push(resolve); + + Promise.resolve(this.#fn()) + .then((value) => { + // Skip the response if resource was refreshed with a later promise while we were waiting for this one to resolve + const idx = this.#latest.indexOf(resolve); + if (idx === -1) return; + + this.#latest.splice(0, idx).forEach((r) => r()); + set(this.#ready, true); + set(this.#loading, false); + set(this.#raw, value); + set(this.#error, undefined); + + resolve(undefined); + }) + .catch((e) => { + const idx = this.#latest.indexOf(resolve); + if (idx === -1) return; + + this.#latest.splice(0, idx).forEach((r) => r()); + set(this.#error, e); + set(this.#loading, false); + reject(e); + }); + + return promise; + } + + get then() { + return get(this.#then); + } + + get catch() { + get(this.#then); + return (/** @type {any} */ reject) => { + return get(this.#then)(undefined, reject); + }; + } + + get finally() { + get(this.#then); + return (/** @type {any} */ fn) => { + return get(this.#then)( + () => fn(), + () => fn() + ); + }; + } + + get current() { + return get(this.#current); + } + + get error() { + return get(this.#error); + } + + /** + * Returns true if the resource is loading or reloading. + */ + get loading() { + return get(this.#loading); + } + + /** + * Returns true once the resource has been loaded for the first time. + */ + get ready() { + return get(this.#ready); + } + + /** + * @returns {Promise} + */ + refresh = async () => { + const promise = this.#run(); + set(this.#promise, promise); + await promise; + }; + + /** + * @param {Awaited} value + */ + set = (value) => { + set(this.#ready, true); + set(this.#loading, false); + set(this.#error, undefined); + set(this.#raw, value); + set(this.#promise, Promise.resolve()); + }; +} diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index d24218c4d3b0..b4780b44e46a 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -1,6 +1,15 @@ import type { Store } from '#shared'; import { STATE_SYMBOL } from './constants.js'; -import type { Effect, Source, Value, Reaction } from './reactivity/types.js'; +import type { Effect, Source, Value } from './reactivity/types.js'; + +declare global { + interface Window { + __svelte?: { + /** hydratables */ + h?: Map; + }; + } +} type EventCallback = (event: Event) => boolean; export type EventCallbackMap = Record; diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index 7779da4c1d09..eeb7fdaa6111 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -1,6 +1,9 @@ -/** @import { SSRContext } from '#server' */ +/** @import { RenderContext, SSRContext } from '#server' */ +/** @import { AsyncLocalStorage } from 'node:async_hooks' */ +/** @import { Transport } from '#shared' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; +import { save_render_context } from './render-context.js'; /** @type {SSRContext | null} */ export var ssr_context = null; @@ -113,10 +116,10 @@ function get_parent_context(ssr_context) { */ export async function save(promise) { var previous_context = ssr_context; - var value = await promise; + const restore_render_context = await save_render_context(promise); return () => { ssr_context = previous_context; - return value; + return restore_render_context(); }; } diff --git a/packages/svelte/src/internal/server/hydratable.js b/packages/svelte/src/internal/server/hydratable.js new file mode 100644 index 000000000000..f3855f4a7b21 --- /dev/null +++ b/packages/svelte/src/internal/server/hydratable.js @@ -0,0 +1,50 @@ +/** @import { Stringify, Transport } from '#shared' */ + +import { get_render_context } from './render-context'; + +/** @type {string | null} */ +export let hydratable_key = null; + +/** @param {string | null} key */ +export function set_hydratable_key(key) { + hydratable_key = key; +} + +/** + * @template T + * @param {string} key + * @param {() => T} fn + * @param {{ transport?: Transport }} [options] + * @returns {T} + */ +export function hydratable(key, fn, options = {}) { + const store = get_render_context(); + + if (store.hydratables.has(key)) { + // TODO error + throw new Error("can't have two hydratables with the same key"); + } + + const result = fn(); + store.hydratables.set(key, { value: result, stringify: options.transport?.stringify }); + return result; +} +/** + * @template T + * @param {string} key + * @param {T} value + * @param {{ stringify?: Stringify }} [options] + */ +export function set_hydratable_value(key, value, options = {}) { + const store = get_render_context(); + + if (store.hydratables.has(key)) { + // TODO error + throw new Error("can't have two hydratables with the same key"); + } + + store.hydratables.set(key, { + value, + stringify: options.stringify + }); +} diff --git a/packages/svelte/src/internal/server/reactivity/cache.js b/packages/svelte/src/internal/server/reactivity/cache.js new file mode 100644 index 000000000000..d38be324eca2 --- /dev/null +++ b/packages/svelte/src/internal/server/reactivity/cache.js @@ -0,0 +1,32 @@ +import { BaseCacheObserver } from '../../shared/cache-observer.js'; +import { set_hydratable_key } from '../hydratable.js'; +import { get_render_context } from '../render-context.js'; + +/** + * @template {(...args: any[]) => any} TFn + * @param {string} key + * @param {TFn} fn + * @returns {ReturnType} + */ +export function cache(key, fn) { + const cache = get_render_context().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; +} + +/** + * @template T + * @extends BaseCacheObserver + */ +export class CacheObserver extends BaseCacheObserver { + constructor(prefix = '') { + super(() => get_render_context().cache, prefix); + } +} 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 000000000000..5db1db00e7b7 --- /dev/null +++ b/packages/svelte/src/internal/server/reactivity/fetcher.js @@ -0,0 +1,16 @@ +/** @import { GetRequestInit, Resource } from '#shared' */ +import { fetch_json } from '../../shared/utils.js'; +import { hydratable } from '../hydratable.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) { + const key = `svelte/fetcher/${typeof url === 'string' ? url : url.toString()}`; + return cache(key, () => resource(() => hydratable(key, () => fetch_json(url, init)))); +} diff --git a/packages/svelte/src/internal/server/reactivity/resource.js b/packages/svelte/src/internal/server/reactivity/resource.js new file mode 100644 index 000000000000..57c651152fb2 --- /dev/null +++ b/packages/svelte/src/internal/server/reactivity/resource.js @@ -0,0 +1,97 @@ +/** @import { Resource as ResourceType } from '#shared' */ + +/** + * @template T + * @param {() => T} fn + * @returns {ResourceType} + */ +export function resource(fn) { + return /** @type {ResourceType} */ (new Resource(fn)); +} + +/** + * @template T + * @implements {Partial>>} + */ +class Resource { + /** @type {Promise} */ + #promise; + #ready = false; + #loading = true; + + /** @type {Awaited | undefined} */ + #current = undefined; + #error = undefined; + + /** + * @param {() => T} fn + */ + constructor(fn) { + this.#promise = Promise.resolve(fn()).then( + (val) => { + this.#ready = true; + this.#loading = false; + this.#current = val; + this.#error = undefined; + }, + (error) => { + this.#error = error; + this.#loading = false; + } + ); + } + + get then() { + // @ts-expect-error + return (onfulfilled, onrejected) => + this.#promise.then( + () => onfulfilled?.(this.#current), + () => onrejected?.(this.#error) + ); + } + + get catch() { + return (/** @type {any} */ onrejected) => this.then(undefined, onrejected); + } + + get finally() { + return (/** @type {any} */ onfinally) => this.then(onfinally, onfinally); + } + + get current() { + return this.#current; + } + + get error() { + return this.#error; + } + + /** + * Returns true if the resource is loading or reloading. + */ + get loading() { + return this.#loading; + } + + /** + * Returns true once the resource has been loaded for the first time. + */ + get ready() { + return this.#ready; + } + + refresh = () => { + throw new Error('TODO Cannot refresh a resource on the server'); + }; + + /** + * @param {Awaited} value + */ + set = (value) => { + this.#ready = true; + this.#loading = false; + this.#error = undefined; + this.#current = value; + this.#promise = Promise.resolve(); + }; +} diff --git a/packages/svelte/src/internal/server/render-context.js b/packages/svelte/src/internal/server/render-context.js new file mode 100644 index 000000000000..83d3b3b3f39c --- /dev/null +++ b/packages/svelte/src/internal/server/render-context.js @@ -0,0 +1,98 @@ +/** @import { AsyncLocalStorage } from 'node:async_hooks' */ +/** @import { RenderContext } from '#server' */ + +import { ObservableCache } from '../shared/observable-cache'; +import { deferred } from '../shared/utils'; + +/** @type {Promise | null} */ +let current_render = null; + +/** @type {RenderContext | null} */ +let sync_context = null; + +/** + * @template T + * @param {Promise} promise + * @returns {Promise<() => T>} + */ +export async function save_render_context(promise) { + var previous_context = sync_context; + var value = await promise; + + return () => { + sync_context = previous_context; + return value; + }; +} + +/** @returns {RenderContext | null} */ +export function try_get_render_context() { + if (sync_context !== null) { + return sync_context; + } + return als?.getStore() ?? null; +} + +/** @returns {RenderContext} */ +export function get_render_context() { + const store = try_get_render_context(); + + if (!store) { + // TODO make this a proper e.error + let message = 'Could not get rendering context.'; + + if (als) { + message += ' You may have called `hydratable` or `cache` outside of the render lifecycle.'; + } else { + message += + ' In environments without `AsyncLocalStorage`, `hydratable` must be accessed synchronously, not after an `await`.' + + ' If it was accessed synchronously then this is an internal error or you may have called `hydratable` or `cache` outside of the render lifecycle.'; + } + + throw new Error(message); + } + + return store; +} + +/** + * @template T + * @param {() => Promise} fn + * @returns {Promise} + */ +export async function with_render_context(fn) { + try { + sync_context = { + hydratables: new Map(), + cache: new ObservableCache() + }; + if (in_webcontainer()) { + const { promise, resolve } = deferred(); + const previous_render = current_render; + current_render = promise; + await previous_render; + return fn().finally(resolve); + } + return als ? als.run(sync_context, fn) : fn(); + } finally { + if (!in_webcontainer()) { + sync_context = null; + } + } +} + +/** @type {AsyncLocalStorage | null} */ +let als = null; + +export async function init_render_context() { + if (als !== null) return; + try { + const { AsyncLocalStorage } = await import('node:async_hooks'); + als = new AsyncLocalStorage(); + } catch {} +} + +function in_webcontainer() { + // eslint-disable-next-line n/prefer-global/process + return !!globalThis.process?.versions?.webcontainer; +} diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index 602c680c08ff..7d8dd4eb785e 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -1,18 +1,17 @@ /** @import { Component } from 'svelte' */ /** @import { RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */ +/** @import { MaybePromise } from '#shared' */ import { async_mode_flag } from '../flags/index.js'; import { abort } from './abort-signal.js'; -import { pop, push, set_ssr_context, ssr_context } from './context.js'; +import { pop, push, set_ssr_context, ssr_context, save } from './context.js'; import * as e from './errors.js'; import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; import { attributes } from './index.js'; +import { uneval } from 'devalue'; +import { get_render_context, with_render_context, init_render_context } from './render-context.js'; /** @typedef {'head' | 'body'} RendererType */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ -/** - * @template T - * @typedef {T | Promise} MaybePromise - */ /** * @typedef {string | Renderer} RendererItem */ @@ -368,7 +367,9 @@ export class Renderer { }); return Promise.resolve(user_result); } - async ??= Renderer.#render_async(component, options); + async ??= init_render_context().then(() => + with_render_context(() => Renderer.#render_async(component, options)) + ); return async.then((result) => { Object.defineProperty(result, 'html', { // eslint-disable-next-line getter-return @@ -460,16 +461,24 @@ export class Renderer { * @returns {Promise} */ static async #render_async(component, options) { - var previous_context = ssr_context; - try { - const renderer = Renderer.#open_render('async', component, options); + const restore = await save( + (async () => { + try { + const renderer = Renderer.#open_render('async', component, options); + + const content = await renderer.#collect_content_async(); + const hydratables = await renderer.#collect_hydratables(); + if (hydratables !== null) { + content.head = hydratables + content.head; + } + return Renderer.#close_render(content, renderer); + } finally { + abort(); + } + })() + ); - const content = await renderer.#collect_content_async(); - return Renderer.#close_render(content, renderer); - } finally { - abort(); - set_ssr_context(previous_context); - } + return restore(); } /** @@ -509,6 +518,22 @@ export class Renderer { return content; } + async #collect_hydratables() { + const map = get_render_context().hydratables; + /** @type {(value: unknown) => string} */ + let default_stringify; + + /** @type {[string, string][]} */ + let entries = []; + for (const [k, v] of map) { + const serialize = v.stringify ?? (default_stringify ??= uneval); + // sequential await is okay here -- all the work is already kicked off + entries.push([k, serialize(await v.value)]); + } + if (entries.length === 0) return null; + return Renderer.#hydratable_block(JSON.stringify(entries)); + } + /** * @template {Record} Props * @param {'sync' | 'async'} mode @@ -562,6 +587,27 @@ export class Renderer { body }; } + + /** @param {string} serialized */ + static #hydratable_block(serialized) { + // TODO csp? + // TODO how can we communicate this error better? Is there a way to not just send it to the console? + // (it is probably very rare so... not too worried) + return ` +`; + } } export class SSRState { @@ -618,3 +664,33 @@ export class SSRState { } } } + +export class MemoizedUneval { + /** @type {Map} */ + #cache = new Map(); + + /** + * @param {unknown} value + * @returns {string} + */ + uneval = (value) => { + return uneval(value, (value, uneval) => { + const cached = this.#cache.get(value); + if (cached) { + // this breaks my brain a bit, but: + // - when the entry is defined but its value is `undefined`, calling `uneval` below will cause the custom replacer to be called again + // - because the custom replacer returns this, which is `undefined`, it will fall back to the default serialization + // - ...which causes it to return a string + // - ...which is then added to this cache before being returned + return cached.value; + } + + const stub = {}; + this.#cache.set(value, stub); + + const result = uneval(value); + stub.value = result; + return result; + }); + }; +} diff --git a/packages/svelte/src/internal/server/renderer.test.ts b/packages/svelte/src/internal/server/renderer.test.ts index 8e9a377a5b15..a2a979aeb4d4 100644 --- a/packages/svelte/src/internal/server/renderer.test.ts +++ b/packages/svelte/src/internal/server/renderer.test.ts @@ -1,7 +1,8 @@ import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { Renderer, SSRState } from './renderer.js'; +import { MemoizedUneval, Renderer, SSRState } from './renderer.js'; import type { Component } from 'svelte'; import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js'; +import { uneval } from 'devalue'; test('collects synchronous body content by default', () => { const component = (renderer: Renderer) => { @@ -355,3 +356,39 @@ describe('async', () => { expect(destroyed).toEqual(['c', 'e', 'a', 'b', 'b*', 'd']); }); }); + +describe('MemoizedDevalue', () => { + test.each([ + 1, + 'general kenobi', + { foo: 'bar' }, + [1, 2], + null, + undefined, + new Map([[1, '2']]) + ] as const)('has same behavior as unmemoized devalue for %s', (input) => { + expect(new MemoizedUneval().uneval(input)).toBe(uneval(input)); + }); + + test('caches results', () => { + const memoized = new MemoizedUneval(); + let calls = 0; + + const input = { + get only_once() { + calls++; + return 42; + } + }; + + const first = memoized.uneval(input); + const max_calls = calls; + const second = memoized.uneval(input); + memoized.uneval(input); + + expect(first).toBe(second); + // for reasons I don't quite comprehend, it does get called twice, but both calls happen in the first + // serialization, and don't increase afterwards + expect(calls).toBe(max_calls); + }); +}); diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 53cefabc69c4..c45c8a74a8fc 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,3 +1,5 @@ +import type { Stringify, Transport } from '#shared'; +import type { ObservableCache } from '../shared/observable-cache'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -14,6 +16,17 @@ export interface SSRContext { element?: Element; } +export interface RenderContext { + hydratables: Map< + string, + { + value: unknown; + stringify: Stringify | undefined; + } + >; + cache: ObservableCache; +} + export interface SyncRenderOutput { /** HTML that goes into the `` */ head: string; diff --git a/packages/svelte/src/internal/shared/cache-observer.js b/packages/svelte/src/internal/shared/cache-observer.js new file mode 100644 index 000000000000..b51aa2e1a0c1 --- /dev/null +++ b/packages/svelte/src/internal/shared/cache-observer.js @@ -0,0 +1,112 @@ +/** @import { ObservableCache } from './observable-cache.js' */ + +/** + * @template T + * @implements {ReadonlyMap} */ +export class BaseCacheObserver { + /** + * This is a function so that you can create an ObservableCache instance globally and as long as you don't actually + * use it until you're inside the server render lifecycle you'll be okay + * @type {() => ObservableCache} + */ + #get_cache; + + /** @type {string} */ + #prefix; + + /** + * @param {() => ObservableCache} get_cache + * @param {string} [prefix] + */ + constructor(get_cache, prefix = '') { + this.#get_cache = get_cache; + this.#prefix = prefix; + } + + /** + * Register a callback to be called when a new key is inserted + * @param {(key: string, value: T) => void} callback + * @returns {() => void} Function to unregister the callback + */ + onInsert(callback) { + return this.#get_cache().on_insert((key, value) => { + if (!key.startsWith(this.#prefix)) return; + callback(key, value.item); + }); + } + + /** + * Register a callback to be called when an existing key is updated + * @param {(key: string, value: T, old_value: T) => void} callback + * @returns {() => void} Function to unregister the callback + */ + onUpdate(callback) { + return this.#get_cache().on_update((key, value, old_value) => { + if (!key.startsWith(this.#prefix)) return; + callback(key, value.item, old_value.item); + }); + } + + /** + * Register a callback to be called when a key is deleted + * @param {(key: string, old_value: T) => void} callback + * @returns {() => void} Function to unregister the callback + */ + onDelete(callback) { + return this.#get_cache().on_delete((key, old_value) => { + if (!key.startsWith(this.#prefix)) return; + callback(key, old_value.item); + }); + } + + /** @param {string} key */ + get(key) { + const entry = this.#get_cache().get(this.#key(key)); + return entry?.item; + } + + /** @param {string} key */ + has(key) { + return this.#get_cache().has(this.#key(key)); + } + + get size() { + return [...this.keys()].length; + } + + /** @param {(item: T, key: string, map: ReadonlyMap) => void} cb */ + forEach(cb) { + this.entries().forEach(([key, entry]) => cb(entry, key, this)); + } + + *entries() { + for (const [key, entry] of this.#get_cache().entries()) { + if (!key.startsWith(this.#prefix)) continue; + yield /** @type {[string, T]} */ ([key, entry.item]); + } + return undefined; + } + + *keys() { + for (const [key] of this.entries()) { + yield key; + } + return undefined; + } + + *values() { + for (const [, entry] of this.entries()) { + yield entry; + } + return undefined; + } + + [Symbol.iterator]() { + return this.entries(); + } + + /** @param {string} key */ + #key(key) { + return this.#prefix + key; + } +} diff --git a/packages/svelte/src/internal/shared/observable-cache.js b/packages/svelte/src/internal/shared/observable-cache.js new file mode 100644 index 000000000000..30a68a4c673e --- /dev/null +++ b/packages/svelte/src/internal/shared/observable-cache.js @@ -0,0 +1,88 @@ +/** @import { CacheEntry } from '#shared' */ + +/** + * @extends {Map} + */ +export class ObservableCache extends Map { + /** @type {Set<(key: string, value: CacheEntry) => void>} */ + #insert_callbacks = new Set(); + + /** @type {Set<(key: string, value: CacheEntry, old_value: CacheEntry) => void>} */ + #update_callbacks = new Set(); + + /** @type {Set<(key: string, old_value: CacheEntry) => void>} */ + #delete_callbacks = new Set(); + + /** + * @param {(key: string, value: CacheEntry) => void} callback + * @returns {() => void} Function to unregister the callback + */ + on_insert(callback) { + this.#insert_callbacks.add(callback); + return () => this.#insert_callbacks.delete(callback); + } + + /** + * @param {(key: string, value: CacheEntry, old_value: CacheEntry) => void} callback + * @returns {() => void} Function to unregister the callback + */ + on_update(callback) { + this.#update_callbacks.add(callback); + return () => this.#update_callbacks.delete(callback); + } + + /** + * @param {(key: string, old_value: CacheEntry) => void} callback + * @returns {() => void} Function to unregister the callback + */ + on_delete(callback) { + this.#delete_callbacks.add(callback); + return () => this.#delete_callbacks.delete(callback); + } + + /** + * @param {string} key + * @param {CacheEntry} value + * @returns {this} + */ + set(key, value) { + const had = this.has(key); + if (had) { + const old_value = /** @type {CacheEntry} */ (super.get(key)); + super.set(key, value); + for (const callback of this.#update_callbacks) { + callback(key, value, old_value); + } + } else { + super.set(key, value); + for (const callback of this.#insert_callbacks) { + callback(key, value); + } + } + return this; + } + + /** + * @param {string} key + * @returns {boolean} + */ + delete(key) { + const old_value = super.get(key); + const deleted = super.delete(key); + if (deleted) { + for (const callback of this.#delete_callbacks) { + callback(key, /** @type {CacheEntry} */ (old_value)); + } + } + return deleted; + } + + clear() { + for (const [key, value] of this) { + for (const callback of this.#delete_callbacks) { + callback(key, value); + } + } + super.clear(); + } +} diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 4deeb76b2f4b..f5e3665db6e8 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -8,3 +8,42 @@ export type Getters = { }; export type Snapshot = ReturnType>; + +export type MaybePromise = T | Promise; + +export type Parse = (value: string) => T; + +export type Stringify = (value: T) => string; + +export type Transport = + | { + stringify: Stringify; + parse?: undefined; + } + | { + stringify?: undefined; + parse: Parse; + }; + +export type Resource = { + then: Promise>['then']; + catch: Promise>['catch']; + finally: Promise>['finally']; + refresh: () => Promise; + set: (value: Awaited) => void; + loading: boolean; + error: any; +} & ( + | { + ready: false; + current: undefined; + } + | { + ready: true; + current: Awaited; + } +); + +export type GetRequestInit = Omit & { method?: 'GET' }; + +export type CacheEntry = { count: number; item: any }; diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index 10f8597520d9..53700df8e8d7 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; @@ -48,7 +50,7 @@ export function run_all(arr) { /** * TODO replace with Promise.withResolvers once supported widely enough - * @template T + * @template [T=void] */ export function deferred() { /** @type {(value: T) => void} */ @@ -116,3 +118,17 @@ export function to_array(value, n) { return array; } + +/** + * @template [TReturn=any] + * @param {string | URL} url + * @param {GetRequestInit} [init] + * @returns {Promise} + */ +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 3eb9b95333ab..8b2abd9076ba 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -1,3 +1,4 @@ +/** @import { Resource as ResourceType } from '#shared' */ export { SvelteDate } from './date.js'; export { SvelteSet } from './set.js'; export { SvelteMap } from './map.js'; @@ -5,3 +6,11 @@ export { SvelteURL } from './url.js'; export { SvelteURLSearchParams } from './url-search-params.js'; export { MediaQuery } from './media-query.js'; export { createSubscriber } from './create-subscriber.js'; +export { resource } from '../internal/client/reactivity/resource.js'; +export { cache, CacheObserver } from '../internal/client/reactivity/cache.js'; +export { fetcher } from '../internal/client/reactivity/fetcher.js'; + +/** + * @template T + * @typedef {ResourceType} Resource + */ diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 6a6c9dcf1360..0dcc459e64eb 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -1,3 +1,8 @@ +/** @import { Resource as ResourceType } from '#shared' */ +export { resource } from '../internal/server/reactivity/resource.js'; +export { cache, CacheObserver } from '../internal/server/reactivity/cache.js'; +export { fetcher } from '../internal/server/reactivity/fetcher.js'; + export const SvelteDate = globalThis.Date; export const SvelteSet = globalThis.Set; export const SvelteMap = globalThis.Map; @@ -21,3 +26,8 @@ export class MediaQuery { export function createSubscriber(_) { return () => {}; } + +/** + * @template T + * @typedef {ResourceType} Resource + */ diff --git a/packages/svelte/src/server/index.d.ts b/packages/svelte/src/server/index.d.ts index d5a3b813e6cb..7eed5ea951bc 100644 --- a/packages/svelte/src/server/index.d.ts +++ b/packages/svelte/src/server/index.d.ts @@ -27,3 +27,5 @@ export function render< } ] ): RenderOutput; + +export type { set_hydratable_value as setHydratableValue } from '../internal/server/hydratable.js'; diff --git a/packages/svelte/src/server/index.js b/packages/svelte/src/server/index.js index c02e9d05fb38..04d80400887f 100644 --- a/packages/svelte/src/server/index.js +++ b/packages/svelte/src/server/index.js @@ -1 +1,2 @@ export { render } from '../internal/server/index.js'; +export { set_hydratable_value as setHydratableValue } from '../internal/server/hydratable.js'; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index f01edd947f45..d376e484084d 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -439,6 +439,9 @@ declare module 'svelte' { * Returns void if no callback is provided, otherwise returns the result of calling the callback. * */ export function flushSync(fn?: (() => T) | undefined): T; + export function hydratable(key: string, fn: () => T, options?: { + transport?: Transport; + } | undefined): T; /** * Create a snippet programmatically * */ @@ -561,6 +564,20 @@ declare module 'svelte' { [K in keyof T]: () => T[K]; }; + type Parse = (value: string) => T; + + type Stringify = (value: T) => string; + + type Transport = + | { + stringify: Stringify; + parse?: undefined; + } + | { + stringify?: undefined; + parse: Parse; + }; + export {}; } @@ -2139,6 +2156,7 @@ declare module 'svelte/motion' { } declare module 'svelte/reactivity' { + export type Resource = Resource_1; /** * A reactive version of the built-in [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) object. * Reading the date (whether with methods like `date.getTime()` or `date.toString()`, or via things like [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat)) @@ -2402,12 +2420,93 @@ declare module 'svelte/reactivity' { * @since 5.7.0 */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; + export function resource(fn: () => T): Resource_1>; + export function fetcher(url: string | URL, init?: GetRequestInit | undefined): Resource_1; + type Resource_1 = { + then: Promise['then']; + catch: Promise['catch']; + finally: Promise['finally']; + refresh: () => Promise; + set: (value: T) => void; + loading: boolean; + error: any; + } & ( + | { + ready: false; + current: undefined; + } + | { + ready: true; + current: T; + } + ); + + type GetRequestInit = Omit & { method?: 'GET' }; + + type CacheEntry = { count: number; item: any }; + export function cache any>(key: string, fn: TFn): ReturnType; + + export class CacheObserver extends BaseCacheObserver { + constructor(prefix?: string); + } class ReactiveValue { constructor(fn: () => T, onsubscribe: (update: () => void) => void); get current(): T; #private; } + class BaseCacheObserver implements ReadonlyMap { + + constructor(get_cache: () => ObservableCache, prefix?: string | undefined); + /** + * Register a callback to be called when a new key is inserted + * @returns Function to unregister the callback + */ + onInsert(callback: (key: string, value: T) => void): () => void; + /** + * Register a callback to be called when an existing key is updated + * @returns Function to unregister the callback + */ + onUpdate(callback: (key: string, value: T, old_value: T) => void): () => void; + /** + * Register a callback to be called when a key is deleted + * @returns Function to unregister the callback + */ + onDelete(callback: (key: string, old_value: T) => void): () => void; + + get(key: string): any; + + has(key: string): boolean; + get size(): number; + + forEach(cb: (item: T, key: string, map: ReadonlyMap) => void): void; + entries(): Generator<[string, T], undefined, unknown>; + keys(): Generator; + values(): Generator; + [Symbol.iterator](): Generator<[string, T], undefined, unknown>; + #private; + } + class ObservableCache extends Map { + constructor(); + constructor(entries?: readonly (readonly [string, CacheEntry])[] | null | undefined); + constructor(); + constructor(iterable?: Iterable | null | undefined); + /** + * @returns Function to unregister the callback + */ + on_insert(callback: (key: string, value: CacheEntry) => void): () => void; + /** + * @returns Function to unregister the callback + */ + on_update(callback: (key: string, value: CacheEntry, old_value: CacheEntry) => void): () => void; + /** + * @returns Function to unregister the callback + */ + on_delete(callback: (key: string, old_value: CacheEntry) => void): () => void; + + set(key: string, value: CacheEntry): this; + #private; + } export {}; } @@ -2519,6 +2618,15 @@ declare module 'svelte/server' { export {}; } +declare module 'svelte/client' { + export function getHydratableValue(key: string, options?: { + parse?: Parse; + } | undefined): T | undefined; + type Parse = (value: string) => T; + + export {}; +} + declare module 'svelte/store' { /** Callback to inform of a value updates. */ export type Subscriber = (value: T) => void; diff --git a/playgrounds/sandbox/ssr-dev.js b/playgrounds/sandbox/ssr-dev.js index 8a0c063d4751..0ad2e5b2b8b6 100644 --- a/playgrounds/sandbox/ssr-dev.js +++ b/playgrounds/sandbox/ssr-dev.js @@ -27,7 +27,6 @@ polka() const { default: App } = await vite.ssrLoadModule('/src/App.svelte'); const { head, body } = await render(App); - const html = transformed_template .replace(``, head) .replace(``, body) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5856192528b..85a958f3357a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@jridgewell/sourcemap-codec': specifier: ^1.5.0 version: 1.5.0 + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 '@sveltejs/acorn-typescript': specifier: ^1.0.5 version: 1.0.5(acorn@8.15.0) @@ -89,6 +92,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + devalue: + specifier: ^5.4.1 + version: 5.4.1 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -822,6 +828,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stylistic/eslint-plugin-js@1.8.0': resolution: {integrity: sha512-jdvnzt+pZPg8TfclZlTZPiUbbima93ylvQ+wNgHLNmup3obY6heQvgewSu9i2CfS61BnRByv+F9fxQLPoNeHag==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1236,6 +1245,9 @@ packages: engines: {node: '>=0.10'} hasBin: true + devalue@5.4.1: + resolution: {integrity: sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3096,6 +3108,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.50.1': optional: true + '@standard-schema/spec@1.0.0': {} + '@stylistic/eslint-plugin-js@1.8.0(eslint@9.9.1)': dependencies: '@types/eslint': 8.56.12 @@ -3546,6 +3560,8 @@ snapshots: detect-libc@1.0.3: optional: true + devalue@5.4.1: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0