diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 21752e2d4b..d806aa09a5 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -174,6 +174,7 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.3.2", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js index 85eeab7de9..0dd330ea42 100644 --- a/packages/svelte/src/index-client.js +++ b/packages/svelte/src/index-client.js @@ -242,7 +242,13 @@ function init_update_callbacks(context) { } export { flushSync } from './internal/client/reactivity/batch.js'; -export { getContext, getAllContexts, hasContext, setContext } from './internal/client/context.js'; +export { + getContext, + getAllContexts, + hasContext, + setContext, + hydratable +} from './internal/client/context.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 f193c46894..7606129493 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -39,6 +39,12 @@ export async function settled() {} export { getAbortSignal } from './internal/server/abort-signal.js'; -export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js'; +export { + getAllContexts, + getContext, + hasContext, + setContext, + hydratable +} from './internal/server/context.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 cad75546d4..d7586515dd 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -6,6 +6,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; @@ -194,6 +195,30 @@ export function is_runes() { return !legacy_mode_flag || (component_context !== null && component_context.l === null); } +/** + * @template T + * @param {string} key + * @param {() => Promise} fn + * @returns {Promise} + */ +export function hydratable(key, fn) { + if (!hydrating) { + return fn(); + } + /** @type {Map | undefined} */ + // @ts-expect-error + var store = window.__svelte?.h; + if (store === undefined) { + throw new Error('TODO this should be impossible?'); + } + if (!store.has(key)) { + throw new Error( + `TODO Expected hydratable key "${key}" to exist during hydration, but it does not` + ); + } + return /** @type {Promise} */ (store.get(key)); +} + /** * @param {string} name * @returns {Map} diff --git a/packages/svelte/src/internal/server/context.js b/packages/svelte/src/internal/server/context.js index c59b2d260a..09d29d195f 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -110,3 +110,19 @@ export async function save(promise) { return value; }; } + +/** + * @template T + * @param {string} key + * @param {() => Promise} fn + * @returns {Promise} + */ +export function hydratable(key, fn) { + if (ssr_context === null || ssr_context.r === null) { + // TODO probably should make this a different error like await_reactivity_loss + // also when can context be defined but r be null? just when context isn't used at all? + e.lifecycle_outside_component('hydratable'); + } + + return ssr_context.r.register_hydratable(key, fn); +} diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index bbb43a6f3b..b2f1f45363 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -7,6 +7,7 @@ import * as e from './errors.js'; import * as w from './warnings.js'; import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; import { attributes } from './index.js'; +import { uneval } from 'devalue'; /** @typedef {'head' | 'body'} RendererType */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ @@ -266,6 +267,25 @@ export class Renderer { } } + /** + * @template T + * @param {string} key + * @param {() => Promise} fn + */ + register_hydratable(key, fn) { + if (this.global.mode === 'sync') { + // TODO + throw new Error('no no'); + } + if (this.global.hydratables.has(key)) { + // TODO error + throw new Error("can't have two hydratables with the same key"); + } + const result = fn(); + this.global.hydratables.set(key, { blocking: true, value: result }); + return result; + } + /** * @param {() => void} fn */ @@ -467,6 +487,7 @@ export class Renderer { const renderer = Renderer.#open_render('async', component, options); const content = await renderer.#collect_content_async(); + content.head = (await renderer.#collect_hydratables()) + content.head; return Renderer.#close_render(content, renderer); } finally { abort(); @@ -511,6 +532,23 @@ export class Renderer { return content; } + async #collect_hydratables() { + const map = this.global.hydratables; + if (!map) return ''; + + // TODO add namespacing for multiple apps, nonce, csp, whatever -- not sure what we need to do there + /** @type {string} */ + let resolved = ''; + return resolved; + } + /** * @template {Record} Props * @param {'sync' | 'async'} mode @@ -576,6 +614,9 @@ export class SSRState { /** @readonly @type {Set<{ hash: string; code: string }>} */ css = new Set(); + /** @type {Map }>} */ + hydratables = new Map(); + /** @type {{ path: number[], value: string }} */ #title = { path: [], value: '' }; diff --git a/packages/svelte/src/reactivity/index-client.js b/packages/svelte/src/reactivity/index-client.js index 3eb9b95333..beadbe9d10 100644 --- a/packages/svelte/src/reactivity/index-client.js +++ b/packages/svelte/src/reactivity/index-client.js @@ -5,3 +5,4 @@ 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 './resource.js'; diff --git a/packages/svelte/src/reactivity/index-server.js b/packages/svelte/src/reactivity/index-server.js index 6a6c9dcf13..49c2f8597b 100644 --- a/packages/svelte/src/reactivity/index-server.js +++ b/packages/svelte/src/reactivity/index-server.js @@ -21,3 +21,91 @@ export class MediaQuery { export function createSubscriber(_) { return () => {}; } + +/** + * @template T + * @implements {Partial>} + */ +export class Resource { + /** @type {Promise} */ + #promise; + #ready = false; + #loading = true; + + /** @type {T | undefined} */ + #current = undefined; + #error = undefined; + + /** + * @param {() => Promise} fn + * @param {() => Promise} [init] + */ + constructor(fn, init = fn) { + this.#promise = Promise.resolve(init()).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 {T} 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/reactivity/resource.js b/packages/svelte/src/reactivity/resource.js new file mode 100644 index 0000000000..4d423ec2b0 --- /dev/null +++ b/packages/svelte/src/reactivity/resource.js @@ -0,0 +1,172 @@ +/** @import { Source, Derived } from '#client' */ +import { state, derived, set, get, tick } from 'svelte/internal/client'; +import { deferred, noop } from '../internal/shared/utils'; + +/** + * @template T + * @implements {Partial>} + */ +export class Resource { + #init = false; + + /** @type {() => Promise} */ + #fn; + + /** @type {Source} */ + #loading = state(true); + + /** @type {Array<(...args: any[]) => void>} */ + #latest = []; + + /** @type {Source} */ + #ready = state(false); + + /** @type {Source} */ + #raw = state(undefined); + + /** @type {Source>} */ + #promise; + + /** @type {Derived} */ + #current = derived(() => { + if (!get(this.#ready)) return undefined; + return get(this.#raw); + }); + + #onrefresh; + + /** {@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 {T} */ (get(this.#current))); + } catch (error) { + reject?.(error); + } + }; + }); + + /** + * @param {() => Promise} fn + * @param {() => Promise} [init] + * @param {() => void} [onrefresh] + */ + constructor(fn, init = fn, onrefresh = noop) { + this.#fn = fn; + this.#promise = state(this.#run(init)); + this.#onrefresh = onrefresh; + } + + /** @param {() => Promise} fn */ + #run(fn = this.#fn) { + if (this.#init) { + set(this.#loading, true); + } else { + this.#init = true; + } + + const { resolve, reject, promise } = deferred(); + + this.#latest.push(resolve); + + Promise.resolve(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() { + this.#onrefresh(); + const promise = this.#run(); + set(this.#promise, promise); + return promise; + } + + /** + * @param {T} 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/types/index.d.ts b/packages/svelte/types/index.d.ts index 6dc6629faa..08875314ea 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -476,6 +476,8 @@ declare module 'svelte' { * * */ export function getAllContexts = Map>(): T; + + export function hydratable(key: string, fn: () => Promise): 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`. @@ -2390,6 +2392,28 @@ declare module 'svelte/reactivity' { * @since 5.7.0 */ export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void; + export class Resource implements Partial> { + + constructor(fn: () => Promise, init?: (() => Promise) | undefined); + get then(): (onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined) => Promise; + get catch(): (reject: any) => Promise; + get finally(): (fn: any) => Promise; + get current(): T | undefined; + get error(): undefined; + /** + * Returns true if the resource is loading or reloading. + */ + get loading(): boolean; + /** + * Returns true once the resource has been loaded for the first time. + */ + get ready(): boolean; + + refresh(): Promise; + + set(value: T): void; + #private; + } class ReactiveValue { constructor(fn: () => T, onsubscribe: (update: () => void) => void); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f585619252..973896f406 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + devalue: + specifier: ^5.3.2 + version: 5.3.2 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -1236,6 +1239,9 @@ packages: engines: {node: '>=0.10'} hasBin: true + devalue@5.3.2: + resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3546,6 +3552,8 @@ snapshots: detect-libc@1.0.3: optional: true + devalue@5.3.2: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0