diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 5808876234..c2fd676a1b 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -175,13 +175,12 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.3.2", + "devalue": "^5.4.0", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", - "path-to-regexp": "^8.3.0", "zimmerframe": "^1.1.2" } } diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index d7586515dd..0e516f48b6 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 { Hydratable, Transport } from '#shared' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; import { active_effect, active_reaction } from './runtime.js'; @@ -197,16 +198,12 @@ export function is_runes() { /** * @template T - * @param {string} key - * @param {() => Promise} fn - * @returns {Promise} + * @type {Hydratable} */ -export function hydratable(key, fn) { +export function hydratable(key, fn, { transport } = {}) { if (!hydrating) { - return fn(); + return Promise.resolve(fn()); } - /** @type {Map | undefined} */ - // @ts-expect-error var store = window.__svelte?.h; if (store === undefined) { throw new Error('TODO this should be impossible?'); @@ -216,7 +213,9 @@ export function hydratable(key, fn) { `TODO Expected hydratable key "${key}" to exist during hydration, but it does not` ); } - return /** @type {Promise} */ (store.get(key)); + const entry = /** @type {string} */ (store.get(key)); + const parse = transport?.parse ?? ((val) => new Function(`return (${val})`)()); + return Promise.resolve(/** @type {T} */ (parse(entry))); } /** diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index d24218c4d3..b4780b44e4 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 09d29d195f..2b3e142ec1 100644 --- a/packages/svelte/src/internal/server/context.js +++ b/packages/svelte/src/internal/server/context.js @@ -1,4 +1,6 @@ -/** @import { SSRContext } from '#server' */ +/** @import { ALSContext, SSRContext } from '#server' */ +/** @import { AsyncLocalStorage } from 'node:async_hooks' */ +/** @import { Hydratable } from '#shared' */ import { DEV } from 'esm-env'; import * as e from './errors.js'; @@ -103,26 +105,88 @@ function get_parent_context(ssr_context) { */ export async function save(promise) { var previous_context = ssr_context; + var previous_sync_store = sync_store; var value = await promise; return () => { ssr_context = previous_context; + sync_store = previous_sync_store; return value; }; } /** * @template T - * @param {string} key + * @type {Hydratable} + */ +export async function hydratable(key, fn, { transport } = {}) { + const store = await get_render_store(); + + 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, transport }); + return result; +} + +/** @type {ALSContext | null} */ +export let sync_store = null; + +/** @param {ALSContext | null} store */ +export function set_sync_store(store) { + sync_store = store; +} + +/** @type {Promise | null>} */ +const als = import('node:async_hooks') + .then((hooks) => new hooks.AsyncLocalStorage()) + .catch(() => { + // can't use ALS but can still use manual context preservation + return null; + }); + +/** @returns {Promise} */ +async function try_get_render_store() { + return sync_store ?? (await als)?.getStore() ?? null; +} + +/** @returns {Promise} */ +export async function get_render_store() { + const store = await try_get_render_store(); + + if (!store) { + // TODO make this a proper e.error + let message = 'Could not get rendering context.'; + + if (await als) { + message += ' This is an internal error.'; + } 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.'; + } + + throw new Error(message); + } + + return store; +} + +/** + * @template T + * @param {ALSContext} store * @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'); +export async function with_render_store(store, fn) { + try { + sync_store = store; + const storage = await als; + return storage ? storage.run(store, fn) : fn(); + } finally { + sync_store = null; } - - 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 b2f1f45363..ed759a6503 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -1,8 +1,18 @@ /** @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 { + get_render_store, + pop, + push, + set_ssr_context, + set_sync_store, + ssr_context, + sync_store, + with_render_store +} from './context.js'; import * as e from './errors.js'; import * as w from './warnings.js'; import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; @@ -11,10 +21,6 @@ import { uneval } from 'devalue'; /** @typedef {'head' | 'body'} RendererType */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ -/** - * @template T - * @typedef {T | Promise} MaybePromise - */ /** * @typedef {string | Renderer} RendererItem */ @@ -267,25 +273,6 @@ 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 */ @@ -390,7 +377,9 @@ export class Renderer { }); return Promise.resolve(user_result); } - async ??= Renderer.#render_async(component, options); + async ??= with_render_store({ hydratables: new Map() }, () => + Renderer.#render_async(component, options) + ); return async.then((result) => { Object.defineProperty(result, 'html', { // eslint-disable-next-line getter-return @@ -483,6 +472,8 @@ export class Renderer { */ static async #render_async(component, options) { var previous_context = ssr_context; + var previous_sync_store = sync_store; + try { const renderer = Renderer.#open_render('async', component, options); @@ -492,6 +483,7 @@ export class Renderer { } finally { abort(); set_ssr_context(previous_context); + set_sync_store(previous_sync_store); } } @@ -533,20 +525,19 @@ export class Renderer { } async #collect_hydratables() { - const map = this.global.hydratables; - if (!map) return ''; + const map = (await get_render_store()).hydratables; + /** @type {(value: unknown) => string} */ + let default_stringify; - // TODO add namespacing for multiple apps, nonce, csp, whatever -- not sure what we need to do there - /** @type {string} */ - let resolved = ''; - return resolved; + return Renderer.#hydratable_block(JSON.stringify(entries)); } /** @@ -602,6 +593,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 { @@ -614,9 +626,6 @@ 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: '' }; @@ -661,3 +670,36 @@ export class SSRState { } } } + +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); + // TODO upgrade uneval, this should always be a string + if (typeof result === 'string') { + stub.value = result; + return result; + } + }); + }; +} diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index 53cefabc69..d8698fd4a4 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -1,3 +1,4 @@ +import type { MaybePromise, Transport } from '#shared'; import type { Element } from './dev'; import type { Renderer } from './renderer'; @@ -14,6 +15,16 @@ export interface SSRContext { element?: Element; } +export interface ALSContext { + hydratables: Map< + string, + { + value: MaybePromise; + transport: Transport | undefined; + } + >; +} + export interface SyncRenderOutput { /** HTML that goes into the `` */ head: string; diff --git a/packages/svelte/src/internal/shared/types.d.ts b/packages/svelte/src/internal/shared/types.d.ts index 4deeb76b2f..3823eec5c9 100644 --- a/packages/svelte/src/internal/shared/types.d.ts +++ b/packages/svelte/src/internal/shared/types.d.ts @@ -8,3 +8,16 @@ export type Getters = { }; export type Snapshot = ReturnType>; + +export type MaybePromise = T | Promise; + +export type Hydratable = ( + key: string, + fn: () => T, + options?: { transport?: Transport } +) => Promise; + +export type Transport = { + stringify: (value: T) => string; + parse: (value: string) => T; +}; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index f0dee993ae..2aedd1d923 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -448,6 +448,8 @@ declare module 'svelte' { }): Snippet; /** Anything except a function */ type NotFunction = T extends Function ? never : T; + + type MaybePromise = T | Promise; /** * Retrieves the context that belongs to the closest parent component with the specified `key`. * Must be called during component initialisation. @@ -477,7 +479,7 @@ declare module 'svelte' { * */ export function getAllContexts = Map>(): T; - export function hydratable(key: string, fn: () => Promise): Promise; + export function hydratable(key: string, fn: () => T): MaybePromise; /** * 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`. diff --git a/playgrounds/sandbox/ssr-dev.js b/playgrounds/sandbox/ssr-dev.js index 9486d2304e..0ad2e5b2b8 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); - console.log(head); const html = transformed_template .replace(``, head) .replace(``, body) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e58abc0696..ebf900a9b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,8 +93,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 devalue: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.4.0 + version: 5.4.0 esm-env: specifier: ^1.2.1 version: 1.2.1 @@ -110,9 +110,6 @@ importers: magic-string: specifier: ^0.30.11 version: 0.30.17 - path-to-regexp: - specifier: ^8.3.0 - version: 8.3.0 zimmerframe: specifier: ^1.1.2 version: 1.1.2 @@ -1248,8 +1245,8 @@ packages: engines: {node: '>=0.10'} hasBin: true - devalue@5.3.2: - resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} + devalue@5.4.0: + resolution: {integrity: sha512-n0h6iYbR4IyHe4SdGzm0yV0q4v1aD/maadVkjZC+wICyhWrf1fyNEJTaF7BhlDcKar46tCn5wIMRgTLrrPgmQQ==} dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} @@ -1930,9 +1927,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -3566,7 +3560,7 @@ snapshots: detect-libc@1.0.3: optional: true - devalue@5.3.2: {} + devalue@5.4.0: {} dir-glob@3.0.1: dependencies: @@ -4297,8 +4291,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@8.3.0: {} - path-type@4.0.0: {} pathe@1.1.2: {}