From d7f5d863174a210e4da22f30c3c8618d12a224bd Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Tue, 22 Jul 2025 20:36:57 -0600 Subject: [PATCH 001/109] feat: First pass at payload --- packages/svelte/src/internal/server/index.js | 7 +- .../svelte/src/internal/server/payload.js | 108 ++++++++++++++++-- 2 files changed, 106 insertions(+), 9 deletions(-) diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 62ee22d6fcda..3064ee88d71f 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -68,7 +68,7 @@ export let on_destroy = []; */ export function render(component, options = {}) { try { - const payload = new Payload(options.idPrefix ? options.idPrefix + '-' : ''); + const payload = new Payload({ id_prefix: options.idPrefix ? options.idPrefix + '-' : '' }); const prev_on_destroy = on_destroy; on_destroy = []; @@ -107,7 +107,7 @@ export function render(component, options = {}) { head += ``; } - const body = payload.out.join(''); + const body = payload.collect(); return { head, @@ -569,6 +569,9 @@ export function valueless_option(payload, children) { if (body.replace(//g, '') === payload.select_value) { // replace '>' with ' selected>' (closing tag will be added later) var last_item = payload.out[i - 1]; + if (typeof last_item !== 'string') { + throw new Error('TODO something very bad has happened, this should be very impossible'); + } payload.out[i - 1] = last_item.slice(0, -1) + ' selected>'; // Remove the old items after position i and add the body as a single item payload.out.splice(i, payload.out.length - i, body); diff --git a/packages/svelte/src/internal/server/payload.js b/packages/svelte/src/internal/server/payload.js index 195488e06127..315f8af985e6 100644 --- a/packages/svelte/src/internal/server/payload.js +++ b/packages/svelte/src/internal/server/payload.js @@ -1,3 +1,5 @@ +import { deferred } from '../shared/utils'; + export class HeadPayload { /** @type {Set<{ hash: string; code: string }>} */ css = new Set(); @@ -16,17 +18,109 @@ export class HeadPayload { export class Payload { /** @type {Set<{ hash: string; code: string }>} */ - css = new Set(); - /** @type {string[]} */ + css; + /** @type {(string | ChildPayload)[]} */ out = []; - uid = () => ''; + /** @type {() => string} */ + uid; + /** @type {string | undefined} */ select_value = undefined; + /** @type {HeadPayload} */ + head; + /** @type {'sync' | 'async'} */ + mode; + /** @type {Promise[]} */ + tail = []; + + /** + * @param {{ id_prefix?: string, mode?: 'sync' | 'async', head?: HeadPayload, uid?: () => string, out?: (string | ChildPayload)[], css?: Set<{ hash: string; code: string }>, select_value?: any }} args + */ + constructor({ + id_prefix = '', + mode = 'sync', + head = new HeadPayload(), + uid = props_id_generator(id_prefix), + css = new Set() + } = {}) { + this.uid = uid; + this.head = head; + this.mode = mode; + this.css = css; + } + + /** + * Create a child scope. `front` represents the initial, synchronous code, and `back` represents all code from the first `await` onwards. + * Typically a child will be created for each component. + * @param {{ front: (args: { payload: Payload }) => void, back: (args: { payload: Payload }) => Promise }} args + * @returns {void} + */ + child({ front, back }) { + const child = new ChildPayload(this); + front({ payload: child }); + // TODO: boundary stuff? Or does this go inside the `back` function? + back({ payload: child }).then(() => child.deferred.resolve()); + } + + /** + * Waits for all child payloads to finish their blocking asynchronous work, then returns the generated HTML. + * @returns {Promise} + */ + async collect_async() { + // TODO throw in `sync` mode + /** @type {Promise[]} */ + const promises = []; + + /** + * @param {(string | ChildPayload)[]} items + */ + function collect_promises(items) { + for (const item of items) { + if (item instanceof ChildPayload) { + promises.push(item.deferred.promise); + collect_promises(item.out); + } + } + } + + collect_promises(this.out); + await Promise.all(promises); + return this.collect(); + } + + /** + * Collect all of the code from the `out` array and return it as a string. If in `async` mode, wait on + * `finished` prior to collecting. + * @returns {string} + */ + collect() { + // TODO throw in `async` mode + let html = ''; + for (const item of this.out) { + if (typeof item === 'string') { + html += item; + } else { + html += item.collect(); + } + } + return html; + } +} - head = new HeadPayload(); +class ChildPayload extends Payload { + deferred = /** @type {ReturnType>} */ (deferred()); - constructor(id_prefix = '') { - this.uid = props_id_generator(id_prefix); - this.head.uid = this.uid; + /** + * @param {Payload} parent + */ + constructor(parent) { + super({ + mode: parent.mode, + head: parent.head, + uid: parent.uid, + css: parent.css + }); + this.root = parent; + parent.out.push(this); } } From c5b639c108e266498547e700e85d37eb7e814306 Mon Sep 17 00:00:00 2001 From: "S. Elliott Johnson" Date: Tue, 19 Aug 2025 19:44:32 -0600 Subject: [PATCH 002/109] first crack --- .../src/compiler/phases/1-parse/state/tag.js | 1 + .../src/compiler/phases/2-analyze/index.js | 6 +- .../src/compiler/phases/2-analyze/types.d.ts | 1 + .../2-analyze/visitors/AwaitExpression.js | 6 + .../phases/2-analyze/visitors/SnippetBlock.js | 2 +- .../3-transform/server/transform-server.js | 17 +- .../server/visitors/AwaitExpression.js | 25 -- .../server/visitors/SnippetBlock.js | 3 +- .../server/visitors/shared/component.js | 16 +- .../svelte/src/compiler/phases/types.d.ts | 2 + .../svelte/src/compiler/types/template.d.ts | 1 + packages/svelte/src/internal/server/index.js | 2 +- .../svelte/src/internal/server/payload.js | 273 +++++++++++------- 13 files changed, 219 insertions(+), 136 deletions(-) delete mode 100644 packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitExpression.js diff --git a/packages/svelte/src/compiler/phases/1-parse/state/tag.js b/packages/svelte/src/compiler/phases/1-parse/state/tag.js index ba091ef7ec41..6c2162ebcc4c 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -426,6 +426,7 @@ function open(parser) { body: create_fragment(), metadata: { can_hoist: false, + has_await: false, sites: new Set() } }); diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 92b89c588ee2..55f7b2d64a6e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -303,6 +303,7 @@ export function analyze_module(source, options) { has_props_rune: false, options: /** @type {ValidatedCompileOptions} */ (options), fragment: null, + snippet: null, parent_element: null, reactive_statement: null }, @@ -531,7 +532,8 @@ export function analyze_component(root, source, options) { source, snippet_renderers: new Map(), snippets: new Set(), - async_deriveds: new Set() + async_deriveds: new Set(), + suspends: false }; state.adjust({ @@ -691,6 +693,7 @@ export function analyze_component(root, source, options) { options, ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module', fragment: ast === template.ast ? ast : null, + snippet: null, parent_element: null, has_props_rune: false, component_slots: new Set(), @@ -757,6 +760,7 @@ export function analyze_component(root, source, options) { analysis, options, fragment: ast === template.ast ? ast : null, + snippet: null, parent_element: null, has_props_rune: false, ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module', diff --git a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts index 2d99a2e155f6..2573b05f2116 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts +++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts @@ -9,6 +9,7 @@ export interface AnalysisState { options: ValidatedCompileOptions; ast_type: 'instance' | 'template' | 'module'; fragment: AST.Fragment | null; + snippet: AST.SnippetBlock | null; /** * Tag name of the parent element. `null` if the parent is `svelte:element`, `#snippet`, a component or the root. * Parent doesn't necessarily mean direct path predecessor because there could be `#each`, `#if` etc in-between. diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js index b2f59b849b42..68480f2c3b27 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js @@ -23,6 +23,10 @@ export function AwaitExpression(node, context) { suspend = true; } + if (context.state.snippet) { + context.state.snippet.metadata.has_await = true; + } + // disallow top-level `await` or `await` in template expressions // unless a) in runes mode and b) opted into `experimental.async` if (suspend) { @@ -33,6 +37,8 @@ export function AwaitExpression(node, context) { if (!context.state.analysis.runes) { e.legacy_await_invalid(node); } + + context.state.analysis.suspends = true; } context.next(); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js index 7930c2b1a7b8..a30c38490485 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/SnippetBlock.js @@ -23,7 +23,7 @@ export function SnippetBlock(node, context) { } } - context.next({ ...context.state, parent_element: null }); + context.next({ ...context.state, parent_element: null, snippet: node }); const can_hoist = context.path.length === 1 && diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 10c92242d4da..df7ba38544f8 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -10,7 +10,6 @@ import { dev, filename } from '../../../state.js'; import { render_stylesheet } from '../css/index.js'; import { AssignmentExpression } from './visitors/AssignmentExpression.js'; import { AwaitBlock } from './visitors/AwaitBlock.js'; -import { AwaitExpression } from './visitors/AwaitExpression.js'; import { CallExpression } from './visitors/CallExpression.js'; import { ClassBody } from './visitors/ClassBody.js'; import { Component } from './visitors/Component.js'; @@ -45,7 +44,6 @@ import { SvelteBoundary } from './visitors/SvelteBoundary.js'; const global_visitors = { _: set_scope, AssignmentExpression, - AwaitExpression, CallExpression, ClassBody, ExpressionStatement, @@ -240,8 +238,19 @@ export function server_component(analysis, options) { } const component_block = b.block([ - .../** @type {Statement[]} */ (instance.body), - .../** @type {Statement[]} */ (template.body) + b.stmt( + b.call( + '$$payload.child', + b.arrow( + [], + b.block([ + .../** @type {Statement[]} */ (instance.body), + .../** @type {Statement[]} */ (template.body) + ]), + analysis.suspends + ) + ) + ) ]); // trick esrap into including comments diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitExpression.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitExpression.js deleted file mode 100644 index 9135892dbd60..000000000000 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitExpression.js +++ /dev/null @@ -1,25 +0,0 @@ -/** @import { AwaitExpression } from 'estree' */ -/** @import { Context } from '../types.js' */ -import * as b from '../../../../utils/builders.js'; - -/** - * @param {AwaitExpression} node - * @param {Context} context - */ -export function AwaitExpression(node, context) { - // if `await` is inside a function, or inside ``); + payload.out.push({ + type: 'head', + content: `` + }); } export function reset_elements() { @@ -100,7 +103,7 @@ export function validate_snippet_args(payload) { if ( typeof payload !== 'object' || // for some reason typescript consider the type of payload as never after the first instanceof - !(payload instanceof Payload || /** @type {any} */ (payload) instanceof HeadPayload) + !(payload instanceof Payload) ) { e.invalid_snippet_arguments(); } diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 2729fc0d0bca..fdbe07f48c2f 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -17,7 +17,7 @@ import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydra import { validate_store } from '../shared/validate.js'; import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js'; import { reset_elements } from './dev.js'; -import { Payload } from './payload.js'; +import { Payload, TreeState } from './payload.js'; import { abort } from './abort-signal.js'; // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 @@ -33,23 +33,23 @@ const INVALID_ATTR_NAME_CHAR_REGEX = * @returns {void} */ export function element(payload, tag, attributes_fn = noop, children_fn = noop) { - payload.out.push(''); + payload.push(''); if (tag) { - payload.out.push(`<${tag}`); + payload.push(`<${tag}`); attributes_fn(); - payload.out.push(`>`); + payload.push(`>`); if (!is_void(tag)) { children_fn(); if (!is_raw_text_element(tag)) { - payload.out.push(EMPTY_COMMENT); + payload.push(EMPTY_COMMENT); } - payload.out.push(``); + payload.push(``); } } - payload.out.push(''); + payload.push(''); } /** @@ -68,11 +68,11 @@ export let on_destroy = []; */ export function render(component, options = {}) { try { - const payload = new Payload({ id_prefix: options.idPrefix ? options.idPrefix + '-' : '' }); + const payload = new Payload(new TreeState(options.idPrefix ? options.idPrefix + '-' : '')); const prev_on_destroy = on_destroy; on_destroy = []; - payload.out.push(BLOCK_OPEN); + payload.push(BLOCK_OPEN); let reset_reset_element; @@ -97,24 +97,83 @@ export function render(component, options = {}) { reset_reset_element(); } - payload.out.push(BLOCK_CLOSE); + payload.push(BLOCK_CLOSE); for (const cleanup of on_destroy) cleanup(); on_destroy = prev_on_destroy; - let head = payload.head.collect(); + let { head, body } = payload.collect(); + head += payload.global.head.title.value; - if (typeof payload.head.title.value !== 'string') { - throw new Error( - 'TODO -- should encorporate this into the collect/collect_async logic somewhere' - ); + for (const { hash, code } of payload.global.css) { + head += ``; } - head += payload.head.title.value; - for (const { hash, code } of payload.css) { - head += ``; + return { + head, + html: body, + body: body + }; + } finally { + abort(); + } +} + +/** + * TODO THIS NEEDS TO ACTUALLY BE DONE + * Array of `onDestroy` callbacks that should be called at the end of the server render function + * @type {Function[]} + */ +export let async_on_destroy = []; + +/** + * Only available on the server and when compiling with the `server` option. + * Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app. + * @template {Record} Props + * @param {import('svelte').Component | ComponentType>} component + * @param {{ props?: Omit; context?: Map; idPrefix?: string }} [options] + * @returns {Promise} + */ +export async function render_async(component, options = {}) { + try { + const payload = new Payload(new TreeState(options.idPrefix ? options.idPrefix + '-' : '')); + + const prev_on_destroy = async_on_destroy; + async_on_destroy = []; + payload.push(BLOCK_OPEN); + + let reset_reset_element; + + if (DEV) { + // prevent parent/child element state being corrupted by a bad render + reset_reset_element = reset_elements(); + } + + if (options.context) { + push(); + /** @type {Component} */ (current_component).c = options.context; + } + + // @ts-expect-error + component(payload, options.props ?? {}, {}, {}); + + if (options.context) { + pop(); } - const body = payload.collect(); + if (reset_reset_element) { + reset_reset_element(); + } + + payload.push(BLOCK_CLOSE); + for (const cleanup of async_on_destroy) cleanup(); + async_on_destroy = prev_on_destroy; + + let { head, body } = await payload.collect_async(); + head += payload.global.head.title.value; + + for (const { hash, code } of payload.global.css) { + head += ``; + } return { head, @@ -128,13 +187,13 @@ export function render(component, options = {}) { /** * @param {Payload} payload - * @param {(head_payload: Payload['head']) => void} fn + * @param {(payload: Payload) => Promise | void} fn * @returns {void} */ export function head(payload, fn) { - payload.head.out.push(BLOCK_OPEN); - payload.head.child(({ $$payload }) => fn($$payload)); - payload.head.out.push(BLOCK_CLOSE); + payload.out.push({ type: 'head', content: BLOCK_OPEN }); + payload.child(fn, 'head'); + payload.out.push({ type: 'head', content: BLOCK_CLOSE }); } /** @@ -149,21 +208,21 @@ export function css_props(payload, is_html, props, component, dynamic = false) { const styles = style_object_to_string(props); if (is_html) { - payload.out.push(``); + payload.push(``); } else { - payload.out.push(``); + payload.push(``); } if (dynamic) { - payload.out.push(''); + payload.push(''); } component(); if (is_html) { - payload.out.push(``); + payload.push(``); } else { - payload.out.push(``); + payload.push(``); } } @@ -448,13 +507,13 @@ export function bind_props(props_parent, props_now) { */ function await_block(payload, promise, pending_fn, then_fn) { if (is_promise(promise)) { - payload.out.push(BLOCK_OPEN); + payload.push(BLOCK_OPEN); promise.then(null, noop); if (pending_fn !== null) { pending_fn(); } } else if (then_fn !== null) { - payload.out.push(BLOCK_OPEN_ELSE); + payload.push(BLOCK_OPEN_ELSE); then_fn(promise); } } @@ -500,8 +559,8 @@ export function once(get_value) { * @returns {string} */ export function props_id(payload) { - const uid = payload.uid(); - payload.out.push(''); + const uid = payload.global.uid(); + payload.push(''); return uid; } @@ -555,37 +614,37 @@ export function derived(fn) { * @param {*} value */ export function maybe_selected(payload, value) { - return value === payload.select_value ? ' selected' : ''; + return value === payload.local.select_value ? ' selected' : ''; } /** * @param {Payload} payload - * @param {() => void} children + * @param {(payload: Payload) => void | Promise} children * @returns {void} */ export function valueless_option(payload, children) { var i = payload.out.length; // prior to children, `payload` has some combination of string/unresolved payload that ends in `