maximum hydration

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

@ -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"
}
}

@ -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<T>} fn
* @returns {Promise<T>}
* @type {Hydratable<T>}
*/
export function hydratable(key, fn) {
export function hydratable(key, fn, { transport } = {}) {
if (!hydrating) {
return fn();
return Promise.resolve(fn());
}
/** @type {Map<string, unknown> | 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<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 { 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;
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 * 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<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
* @returns {Promise<T>}
*/
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);
}

@ -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<T>} MaybePromise<T>
*/
/**
* @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
*/
@ -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 = '<script>(window.__svelte ??= {}).h = ';
let resolved_map = new Map();
/** @type {[string, string][]} */
let entries = [];
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
resolved_map.set(k, await v.value);
entries.push([k, serialize(await v.value)]);
}
resolved += uneval(resolved_map) + '</script>';
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 `
<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 {
@ -614,9 +626,6 @@ export class SSRState {
/** @readonly @type {Set<{ hash: string; code: string }>} */
css = new Set();
/** @type {Map<string, { blocking: boolean, value: Promise<unknown> }>} */
hydratables = new Map();
/** @type {{ path: number[], value: string }} */
#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 { Renderer } from './renderer';
@ -14,6 +15,16 @@ export interface SSRContext {
element?: Element;
}
export interface ALSContext {
hydratables: Map<
string,
{
value: MaybePromise<unknown>;
transport: Transport<any> | undefined;
}
>;
}
export interface SyncRenderOutput {
/** HTML that goes into the `<head>` */
head: string;

@ -8,3 +8,16 @@ export type Getters<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>;
/** Anything except a function */
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`.
* 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 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.
* 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 { head, body } = await render(App);
console.log(head);
const html = transformed_template
.replace(`<!--ssr-head-->`, head)
.replace(`<!--ssr-body-->`, body)

@ -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: {}

Loading…
Cancel
Save