maximum hydration

elliott/resources
Elliott Johnson 4 days ago
parent 4d766f87b9
commit 7c8c1ad0e6

@ -175,13 +175,12 @@
"aria-query": "^5.3.1", "aria-query": "^5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.3.2", "devalue": "^5.4.0",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.1.0", "esrap": "^2.1.0",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",
"path-to-regexp": "^8.3.0",
"zimmerframe": "^1.1.2" "zimmerframe": "^1.1.2"
} }
} }

@ -1,4 +1,5 @@
/** @import { ComponentContext, DevStackEntry, Effect } from '#client' */ /** @import { ComponentContext, DevStackEntry, Effect } from '#client' */
/** @import { Hydratable, Transport } from '#shared' */
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import * as e from './errors.js'; import * as e from './errors.js';
import { active_effect, active_reaction } from './runtime.js'; import { active_effect, active_reaction } from './runtime.js';
@ -197,16 +198,12 @@ export function is_runes() {
/** /**
* @template T * @template T
* @param {string} key * @type {Hydratable<T>}
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/ */
export function hydratable(key, fn) { export function hydratable(key, fn, { transport } = {}) {
if (!hydrating) { if (!hydrating) {
return fn(); return Promise.resolve(fn());
} }
/** @type {Map<string, unknown> | undefined} */
// @ts-expect-error
var store = window.__svelte?.h; var store = window.__svelte?.h;
if (store === undefined) { if (store === undefined) {
throw new Error('TODO this should be impossible?'); 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` `TODO Expected hydratable key "${key}" to exist during hydration, but it does not`
); );
} }
return /** @type {Promise<T>} */ (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)));
} }
/** /**

@ -1,6 +1,15 @@
import type { Store } from '#shared'; import type { Store } from '#shared';
import { STATE_SYMBOL } from './constants.js'; 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<string, string>;
};
}
}
type EventCallback = (event: Event) => boolean; type EventCallback = (event: Event) => boolean;
export type EventCallbackMap = Record<string, EventCallback | EventCallback[]>; export type EventCallbackMap = Record<string, EventCallback | EventCallback[]>;

@ -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 { DEV } from 'esm-env';
import * as e from './errors.js'; import * as e from './errors.js';
@ -103,26 +105,88 @@ function get_parent_context(ssr_context) {
*/ */
export async function save(promise) { export async function save(promise) {
var previous_context = ssr_context; var previous_context = ssr_context;
var previous_sync_store = sync_store;
var value = await promise; var value = await promise;
return () => { return () => {
ssr_context = previous_context; ssr_context = previous_context;
sync_store = previous_sync_store;
return value; return value;
}; };
} }
/** /**
* @template T * @template T
* @param {string} key * @type {Hydratable<T>}
*/
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<AsyncLocalStorage<ALSContext | null> | 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<ALSContext | null>} */
async function try_get_render_store() {
return sync_store ?? (await als)?.getStore() ?? null;
}
/** @returns {Promise<ALSContext>} */
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<T>} fn * @param {() => Promise<T>} fn
* @returns {Promise<T>} * @returns {Promise<T>}
*/ */
export function hydratable(key, fn) { export async function with_render_store(store, fn) {
if (ssr_context === null || ssr_context.r === null) { try {
// TODO probably should make this a different error like await_reactivity_loss sync_store = store;
// also when can context be defined but r be null? just when context isn't used at all? const storage = await als;
e.lifecycle_outside_component('hydratable'); return storage ? storage.run(store, fn) : fn();
} finally {
sync_store = null;
} }
return ssr_context.r.register_hydratable(key, fn);
} }

@ -1,8 +1,18 @@
/** @import { Component } from 'svelte' */ /** @import { Component } from 'svelte' */
/** @import { RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */ /** @import { RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
/** @import { MaybePromise } from '#shared' */
import { async_mode_flag } from '../flags/index.js'; import { async_mode_flag } from '../flags/index.js';
import { abort } from './abort-signal.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 e from './errors.js';
import * as w from './warnings.js'; import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js'; import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
@ -11,10 +21,6 @@ import { uneval } from 'devalue';
/** @typedef {'head' | 'body'} RendererType */ /** @typedef {'head' | 'body'} RendererType */
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */ /** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
/**
* @template T
* @typedef {T | Promise<T>} MaybePromise<T>
*/
/** /**
* @typedef {string | Renderer} RendererItem * @typedef {string | Renderer} RendererItem
*/ */
@ -267,25 +273,6 @@ export class Renderer {
} }
} }
/**
* @template T
* @param {string} key
* @param {() => Promise<T>} 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 * @param {() => void} fn
*/ */
@ -390,7 +377,9 @@ export class Renderer {
}); });
return Promise.resolve(user_result); 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) => { return async.then((result) => {
Object.defineProperty(result, 'html', { Object.defineProperty(result, 'html', {
// eslint-disable-next-line getter-return // eslint-disable-next-line getter-return
@ -483,6 +472,8 @@ export class Renderer {
*/ */
static async #render_async(component, options) { static async #render_async(component, options) {
var previous_context = ssr_context; var previous_context = ssr_context;
var previous_sync_store = sync_store;
try { try {
const renderer = Renderer.#open_render('async', component, options); const renderer = Renderer.#open_render('async', component, options);
@ -492,6 +483,7 @@ export class Renderer {
} finally { } finally {
abort(); abort();
set_ssr_context(previous_context); set_ssr_context(previous_context);
set_sync_store(previous_sync_store);
} }
} }
@ -533,20 +525,19 @@ export class Renderer {
} }
async #collect_hydratables() { async #collect_hydratables() {
const map = this.global.hydratables; const map = (await get_render_store()).hydratables;
if (!map) return ''; /** @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, string][]} */
/** @type {string} */ let entries = [];
let resolved = '<script>(window.__svelte ??= {}).h = ';
let resolved_map = new Map();
for (const [k, v] of map) { for (const [k, v] of map) {
if (!v.blocking) continue; const serialize =
v.transport?.stringify ?? (default_stringify ??= new MemoizedUneval().uneval);
// sequential await is okay here -- all the work is already kicked off // sequential await is okay here -- all the work is already kicked off
resolved_map.set(k, await v.value); entries.push([k, serialize(await v.value)]);
} }
resolved += uneval(resolved_map) + '</script>'; return Renderer.#hydratable_block(JSON.stringify(entries));
return resolved;
} }
/** /**
@ -602,6 +593,27 @@ export class Renderer {
body 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 `
<script>
var store = (window.__svelte ??= {}).h ??= new Map();
for (const [k,v] of ${serialized}) {
if (!store.has(k)) {
store.set(k, v);
continue;
}
var stored_val = store.get(k);
if (stored_val.value !== v) {
throw new Error('TODO tried to populate the same hydratable key twice with different values');
}
}
</script>`;
}
} }
export class SSRState { export class SSRState {
@ -614,9 +626,6 @@ export class SSRState {
/** @readonly @type {Set<{ hash: string; code: string }>} */ /** @readonly @type {Set<{ hash: string; code: string }>} */
css = new Set(); css = new Set();
/** @type {Map<string, { blocking: boolean, value: Promise<unknown> }>} */
hydratables = new Map();
/** @type {{ path: number[], value: string }} */ /** @type {{ path: number[], value: string }} */
#title = { path: [], value: '' }; #title = { path: [], value: '' };
@ -661,3 +670,36 @@ export class SSRState {
} }
} }
} }
class MemoizedUneval {
/** @type {Map<unknown, { value?: string }>} */
#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;
}
});
};
}

@ -1,3 +1,4 @@
import type { MaybePromise, Transport } from '#shared';
import type { Element } from './dev'; import type { Element } from './dev';
import type { Renderer } from './renderer'; import type { Renderer } from './renderer';
@ -14,6 +15,16 @@ export interface SSRContext {
element?: Element; element?: Element;
} }
export interface ALSContext {
hydratables: Map<
string,
{
value: MaybePromise<unknown>;
transport: Transport<any> | undefined;
}
>;
}
export interface SyncRenderOutput { export interface SyncRenderOutput {
/** HTML that goes into the `<head>` */ /** HTML that goes into the `<head>` */
head: string; head: string;

@ -8,3 +8,16 @@ export type Getters<T> = {
}; };
export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>; export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>;
export type MaybePromise<T> = T | Promise<T>;
export type Hydratable<T> = (
key: string,
fn: () => T,
options?: { transport?: Transport<T> }
) => Promise<T>;
export type Transport<T> = {
stringify: (value: T) => string;
parse: (value: string) => T;
};

@ -448,6 +448,8 @@ declare module 'svelte' {
}): Snippet<Params>; }): Snippet<Params>;
/** Anything except a function */ /** Anything except a function */
type NotFunction<T> = T extends Function ? never : T; type NotFunction<T> = T extends Function ? never : T;
type MaybePromise<T> = T | Promise<T>;
/** /**
* Retrieves the context that belongs to the closest parent component with the specified `key`. * Retrieves the context that belongs to the closest parent component with the specified `key`.
* Must be called during component initialisation. * Must be called during component initialisation.
@ -477,7 +479,7 @@ declare module 'svelte' {
* */ * */
export function getAllContexts<T extends Map<any, any> = Map<any, any>>(): T; export function getAllContexts<T extends Map<any, any> = Map<any, any>>(): T;
export function hydratable<T>(key: string, fn: () => Promise<T>): Promise<T>; export function hydratable<T>(key: string, fn: () => T): MaybePromise<T>;
/** /**
* Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component. * 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`. * Transitions will play during the initial render unless the `intro` option is set to `false`.

@ -27,7 +27,6 @@ polka()
const { default: App } = await vite.ssrLoadModule('/src/App.svelte'); const { default: App } = await vite.ssrLoadModule('/src/App.svelte');
const { head, body } = await render(App); const { head, body } = await render(App);
console.log(head);
const html = transformed_template const html = transformed_template
.replace(`<!--ssr-head-->`, head) .replace(`<!--ssr-head-->`, head)
.replace(`<!--ssr-body-->`, body) .replace(`<!--ssr-body-->`, body)

@ -93,8 +93,8 @@ importers:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
devalue: devalue:
specifier: ^5.3.2 specifier: ^5.4.0
version: 5.3.2 version: 5.4.0
esm-env: esm-env:
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1 version: 1.2.1
@ -110,9 +110,6 @@ importers:
magic-string: magic-string:
specifier: ^0.30.11 specifier: ^0.30.11
version: 0.30.17 version: 0.30.17
path-to-regexp:
specifier: ^8.3.0
version: 8.3.0
zimmerframe: zimmerframe:
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2 version: 1.1.2
@ -1248,8 +1245,8 @@ packages:
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
hasBin: true hasBin: true
devalue@5.3.2: devalue@5.4.0:
resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} resolution: {integrity: sha512-n0h6iYbR4IyHe4SdGzm0yV0q4v1aD/maadVkjZC+wICyhWrf1fyNEJTaF7BhlDcKar46tCn5wIMRgTLrrPgmQQ==}
dir-glob@3.0.1: dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
@ -1930,9 +1927,6 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'} engines: {node: '>=16 || 14 >=14.18'}
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
path-type@4.0.0: path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -3566,7 +3560,7 @@ snapshots:
detect-libc@1.0.3: detect-libc@1.0.3:
optional: true optional: true
devalue@5.3.2: {} devalue@5.4.0: {}
dir-glob@3.0.1: dir-glob@3.0.1:
dependencies: dependencies:
@ -4297,8 +4291,6 @@ snapshots:
lru-cache: 10.4.3 lru-cache: 10.4.3
minipass: 7.1.2 minipass: 7.1.2
path-to-regexp@8.3.0: {}
path-type@4.0.0: {} path-type@4.0.0: {}
pathe@1.1.2: {} pathe@1.1.2: {}

Loading…
Cancel
Save