diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51408fc8cc..94fb391258 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm playwright install chromium - run: pnpm test runtime-runes + - run: pnpm test server-side-rendering env: CI: true SVELTE_NO_ASYNC: true 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 d420720dea..4f164a8e1b 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 @@ -240,7 +240,7 @@ export function server_component(analysis, options) { b.call( '$$payload.child', b.arrow( - [b.object_pattern([b.init('$$payload', b.id('$$payload'))])], + [b.id('$$payload')], b.block([ .../** @type {Statement[]} */ (instance.body), .../** @type {Statement[]} */ (template.body) @@ -304,7 +304,7 @@ export function server_component(analysis, options) { const code = b.literal(render_stylesheet(analysis.source, analysis, options).code); body.push(b.const('$$css', b.object([b.init('hash', hash), b.init('code', code)]))); - component_block.body.unshift(b.stmt(b.call('$$payload.css.add', b.id('$$css')))); + component_block.body.unshift(b.stmt(b.call('$$payload.global.css.add', b.id('$$css')))); } let should_inject_props = diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js index ee0086de2e..a060fdf6b4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js @@ -44,12 +44,12 @@ export function EachBlock(node, context) { ); if (node.fallback) { - const open = b.stmt(b.call(b.member(b.id('$$payload.out'), b.id('push')), block_open)); + const open = b.stmt(b.call(b.member(b.id('$$payload'), b.id('push')), block_open)); const fallback = /** @type {BlockStatement} */ (context.visit(node.fallback)); fallback.body.unshift( - b.stmt(b.call(b.member(b.id('$$payload.out'), b.id('push')), b.literal(BLOCK_OPEN_ELSE))) + b.stmt(b.call(b.member(b.id('$$payload'), b.id('push')), b.literal(BLOCK_OPEN_ELSE))) ); state.template.push( diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js index 8c082f38d5..ad2058a34e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/IfBlock.js @@ -17,12 +17,10 @@ export function IfBlock(node, context) { ? /** @type {BlockStatement} */ (context.visit(node.alternate)) : b.block([]); - consequent.body.unshift( - b.stmt(b.call(b.member(b.id('$$payload.out'), b.id('push')), block_open)) - ); + consequent.body.unshift(b.stmt(b.call(b.member(b.id('$$payload'), b.id('push')), block_open))); alternate.body.unshift( - b.stmt(b.call(b.member(b.id('$$payload.out'), b.id('push')), b.literal(BLOCK_OPEN_ELSE))) + b.stmt(b.call(b.member(b.id('$$payload'), b.id('push')), b.literal(BLOCK_OPEN_ELSE))) ); context.state.template.push(b.if(test, consequent, alternate), block_close); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js index ddf84a84bb..cdc1a14f84 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/RegularElement.js @@ -92,7 +92,7 @@ export function RegularElement(node, context) { b.stmt( b.assignment( '=', - b.id('$$payload.select_value'), + b.id('$$payload.local.select_value'), b.member( build_spread_object( node, @@ -113,7 +113,7 @@ export function RegularElement(node, context) { ); } else if (value) { select_with_value = true; - const left = b.id('$$payload.select_value'); + const left = b.id('$$payload.local.select_value'); if (value.type === 'Attribute') { state.template.push( b.stmt(b.assignment('=', left, build_attribute_value(value.value, context))) @@ -151,7 +151,11 @@ export function RegularElement(node, context) { b.call( '$.valueless_option', b.id('$$payload'), - b.thunk(b.block([...inner_state.init, ...build_template(inner_state.template)])) + b.arrow( + [b.id('$$payload')], + b.block([...inner_state.init, ...build_template(inner_state.template)]), + context.state.analysis.has_blocking_await + ) ) ) ); diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SnippetBlock.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SnippetBlock.js index e8e7a9cb65..9aeb4674d7 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SnippetBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SnippetBlock.js @@ -17,7 +17,7 @@ export function SnippetBlock(node, context) { b.call( '$$payload.child', b.arrow( - [b.object_pattern([b.init('$$payload', b.id('$$payload'))])], + [b.id('$$payload')], /** @type {BlockStatement} */ (context.visit(node.body)), node.metadata.has_await ) diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHead.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHead.js index 7d064ffbf5..4b67cdc111 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHead.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHead.js @@ -11,6 +11,14 @@ export function SvelteHead(node, context) { const block = /** @type {BlockStatement} */ (context.visit(node.fragment)); context.state.template.push( - b.stmt(b.call('$.head', b.id('$$payload'), b.arrow([b.id('$$payload')], block))) + b.stmt( + b.call( + '$.head', + b.id('$$payload'), + // same thing as elsewhere; this will create more async functions than necessary but should never be _wrong_ + // because the component rendering this head block will always be async if the head block is async + b.arrow([b.id('$$payload')], block, context.state.analysis.has_blocking_await) + ) + ) ); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/TitleElement.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/TitleElement.js index f1d1e1cfc9..27bdb4ee9e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/TitleElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/TitleElement.js @@ -13,20 +13,30 @@ export function TitleElement(node, context) { process_children(node.fragment.nodes, { ...context, state: { ...context.state, template } }); template.push(b.literal('')); - if (!node.metadata.has_await) { - context.state.init.push(...build_template(template, b.id('$$payload.title.value'), '=')); - } else { - const async_template = b.thunk( - // TODO I'm sure there is a better way to do this - b.block([ - b.let('title'), - ...build_template(template, b.id('title'), '='), - b.return(b.id('title')) - ]), - true - ); - context.state.init.push( - b.stmt(b.assignment('=', b.id('$$payload.title.value'), b.call(async_template))) - ); - } + context.state.init.push( + b.stmt( + b.call( + '$$payload.child', + // this nonsense is necessary so that the write to the title is as tightly scoped to a specific location + // in the async tree as possible. This lets us use `get_path` to compare this assignment to other assignments + // so that we can overwrite earlier assignments with later ones. + b.arrow( + [b.id('$$payload')], + b.block([ + b.const('path', b.call('$$payload.get_path')), + b.let('title'), + ...build_template(template, b.id('title'), '='), + b.stmt( + b.assignment( + '=', + b.id('$$payload.global.head.title'), + b.object([b.init('path', b.id('path')), b.init('value', b.id('title'))]) + ) + ) + ]), + node.metadata.has_await + ) + ) + ) + ); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js index afeeb12d1b..043b4b2dfe 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/component.js @@ -238,7 +238,7 @@ export function build_inline_component(node, expression, context) { b.call( '$$payload.child', b.arrow( - [b.object_pattern([b.init('$$payload', b.id('$$payload'))])], + [b.id('$$payload')], b.block(block.body), context.state.analysis.has_blocking_await ) diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js index c9f85c8429..78130b1e83 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js @@ -99,7 +99,7 @@ function is_statement(node) { * @param {AssignmentOperator | 'push'} operator * @returns {Statement[]} */ -export function build_template(template, out = b.id('$$payload.out'), operator = 'push') { +export function build_template(template, out = b.id('$$payload'), operator = 'push') { /** @type {string[]} */ let strings = []; @@ -264,10 +264,5 @@ export function build_getter(node, state) { * @returns {Statement} */ export function wrap_in_child_payload(body, async) { - return b.stmt( - b.call( - '$$payload.child', - b.arrow([b.object_pattern([b.init('$$payload', b.id('$$payload'))])], body, async) - ) - ); + return b.stmt(b.call('$$payload.child', b.arrow([b.id('$$payload')], body, async))); } diff --git a/packages/svelte/src/internal/server/blocks/snippet.js b/packages/svelte/src/internal/server/blocks/snippet.js index bb82ca97d0..109dbf683a 100644 --- a/packages/svelte/src/internal/server/blocks/snippet.js +++ b/packages/svelte/src/internal/server/blocks/snippet.js @@ -15,7 +15,7 @@ export function createRawSnippet(fn) { // @ts-expect-error the types are a lie return (/** @type {Payload} */ payload, /** @type {Params} */ ...args) => { var getters = /** @type {Getters} */ (args.map((value) => () => value)); - payload.out.push( + payload.push( fn(...getters) .render() .trim() diff --git a/packages/svelte/src/internal/server/dev.js b/packages/svelte/src/internal/server/dev.js index 2a0cb057a3..6edc1d277a 100644 --- a/packages/svelte/src/internal/server/dev.js +++ b/packages/svelte/src/internal/server/dev.js @@ -6,7 +6,7 @@ import { } from '../../html-tree-validation.js'; import { current_component } from './context.js'; import * as e from './errors.js'; -import { HeadPayload, Payload } from './payload.js'; +import { Payload } from './payload.js'; /** * @typedef {{ @@ -40,7 +40,10 @@ function print_error(payload, message) { // eslint-disable-next-line no-console console.error(message); - payload.head.out.push(``); + 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 2729fc0d0b..fdbe07f48c 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 `