diff --git a/.changeset/forty-insects-cheat.md b/.changeset/forty-insects-cheat.md new file mode 100644 index 0000000000..993a8fdb24 --- /dev/null +++ b/.changeset/forty-insects-cheat.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: experimental async SSR diff --git a/.prettierignore b/.prettierignore index 5e1d9b1aa7..9cf9a4bfe1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,6 +15,7 @@ packages/svelte/src/internal/client/warnings.js packages/svelte/src/internal/shared/errors.js packages/svelte/src/internal/shared/warnings.js packages/svelte/src/internal/server/errors.js +packages/svelte/src/internal/server/warnings.js packages/svelte/tests/migrate/samples/*/output.svelte packages/svelte/tests/**/*.svelte packages/svelte/tests/**/_expected* diff --git a/documentation/docs/98-reference/.generated/server-errors.md b/documentation/docs/98-reference/.generated/server-errors.md index c3e8b53c31..6263032212 100644 --- a/documentation/docs/98-reference/.generated/server-errors.md +++ b/documentation/docs/98-reference/.generated/server-errors.md @@ -1,5 +1,19 @@ +### await_invalid + +``` +Encountered asynchronous work while rendering synchronously. +``` + +You (or the framework you're using) called [`render(...)`](svelte-server#render) with a component containing an `await` expression. Either `await` the result of `render` or wrap the `await` (or the component containing it) in a [``](svelte-boundary) with a `pending` snippet. + +### html_deprecated + +``` +The `html` property of server render results has been deprecated. Use `body` instead. +``` + ### lifecycle_function_unavailable ``` diff --git a/documentation/docs/98-reference/.generated/server-warnings.md b/documentation/docs/98-reference/.generated/server-warnings.md new file mode 100644 index 0000000000..26b3628be9 --- /dev/null +++ b/documentation/docs/98-reference/.generated/server-warnings.md @@ -0,0 +1,9 @@ + + +### experimental_async_ssr + +``` +Attempted to use asynchronous rendering without `experimental.async` enabled +``` + +Set `experimental.async: true` in your compiler options (usually in `svelte.config.js`) to use async server rendering. This render ran synchronously. diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index de34b3f5da..6c31aaafd0 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -1,25 +1,5 @@ -### await_outside_boundary - -``` -Cannot await outside a `` with a `pending` snippet -``` - -The `await` keyword can only appear in a `$derived(...)` or template expression, or at the top level of a component's ``); -} - -export function reset_elements() { - let old_parent = parent; - parent = null; - return () => { - parent = old_parent; - }; + payload.child( + (payload) => payload.push(``), + 'head' + ); } /** @@ -58,10 +52,12 @@ export function reset_elements() { * @param {number} column */ export function push_element(payload, tag, line, column) { - var filename = /** @type {Component} */ (current_component).function[FILENAME]; - var child = { tag, parent, filename, line, column }; + var context = /** @type {SSRContext} */ (ssr_context); + var filename = context.function[FILENAME]; + var parent = context.element; + var element = { tag, parent, filename, line, column }; - if (parent !== null) { + if (parent !== undefined) { var ancestor = parent.parent; var ancestors = [parent.tag]; @@ -86,11 +82,11 @@ export function push_element(payload, tag, line, column) { } } - parent = child; + set_ssr_context({ ...context, p: context, element }); } export function pop_element() { - parent = /** @type {Element} */ (parent).parent; + set_ssr_context(/** @type {SSRContext} */ (ssr_context).p); } /** @@ -100,7 +96,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/errors.js b/packages/svelte/src/internal/server/errors.js index 458937218f..bde49fe935 100644 --- a/packages/svelte/src/internal/server/errors.js +++ b/packages/svelte/src/internal/server/errors.js @@ -2,6 +2,30 @@ export * from '../shared/errors.js'; +/** + * Encountered asynchronous work while rendering synchronously. + * @returns {never} + */ +export function await_invalid() { + const error = new Error(`await_invalid\nEncountered asynchronous work while rendering synchronously.\nhttps://svelte.dev/e/await_invalid`); + + error.name = 'Svelte error'; + + throw error; +} + +/** + * The `html` property of server render results has been deprecated. Use `body` instead. + * @returns {never} + */ +export function html_deprecated() { + const error = new Error(`html_deprecated\nThe \`html\` property of server render results has been deprecated. Use \`body\` instead.\nhttps://svelte.dev/e/html_deprecated`); + + error.name = 'Svelte error'; + + throw error; +} + /** * `%name%(...)` is not available on the server * @param {string} name diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 3aa44f2daa..a2cf222da6 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -1,6 +1,7 @@ -/** @import { ComponentType, SvelteComponent } from 'svelte' */ -/** @import { Component, RenderOutput } from '#server' */ +/** @import { ComponentType, SvelteComponent, Component } from 'svelte' */ +/** @import { RenderOutput, SSRContext } from '#server' */ /** @import { Store } from '#shared' */ +/** @import { AccumulatedContent } from './payload.js' */ export { FILENAME, HMR } from '../../constants.js'; import { attr, clsx, to_class, to_style } from '../shared/attributes.js'; import { is_promise, noop } from '../shared/utils.js'; @@ -13,13 +14,14 @@ import { } from '../../constants.js'; import { escape_html } from '../../escaping.js'; import { DEV } from 'esm-env'; -import { current_component, pop, push } from './context.js'; +import { ssr_context, pop, push, set_ssr_context } from './context.js'; import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydration.js'; 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, SSRState } from './payload.js'; import { abort } from './abort-signal.js'; +import { async_mode_flag } from '../flags/index.js'; +import * as e from './errors.js'; // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 // https://infra.spec.whatwg.org/#noncharacter @@ -34,102 +36,48 @@ 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(''); } -/** - * Array of `onDestroy` callbacks that should be called at the end of the server render function - * @type {Function[]} - */ -export let 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 {Component | ComponentType>} component * @param {{ props?: Omit; context?: Map; idPrefix?: string }} [options] * @returns {RenderOutput} */ export function render(component, options = {}) { - try { - const payload = new Payload(options.idPrefix ? options.idPrefix + '-' : ''); - - const prev_on_destroy = on_destroy; - on_destroy = []; - payload.out.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(); - } - - if (reset_reset_element) { - reset_reset_element(); - } - - payload.out.push(BLOCK_CLOSE); - for (const cleanup of on_destroy) cleanup(); - on_destroy = prev_on_destroy; - - let head = payload.head.out.join('') + payload.head.title; - - for (const { hash, code } of payload.css) { - head += ``; - } - - const body = payload.out.join(''); - - return { - head, - html: body, - body: body - }; - } finally { - abort(); - } + return Payload.render(/** @type {Component} */ (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) { - const head_payload = payload.head; - head_payload.out.push(BLOCK_OPEN); - fn(head_payload); - head_payload.out.push(BLOCK_CLOSE); + payload.child((payload) => { + payload.push(BLOCK_OPEN); + payload.child(fn); + payload.push(BLOCK_CLOSE); + }, 'head'); } /** @@ -144,21 +92,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(``); } } @@ -451,13 +399,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); } } @@ -503,8 +451,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; } @@ -512,12 +460,10 @@ export { attr, clsx }; export { html } from './blocks/html.js'; -export { push, pop } from './context.js'; +export { save } from './context.js'; export { push_element, pop_element, validate_snippet_args } from './dev.js'; -export { assign_payload, copy_payload } from './payload.js'; - export { snapshot } from '../shared/clone.js'; export { fallback, to_array } from '../shared/utils.js'; @@ -531,8 +477,6 @@ export { export { escape_html as escape }; -export { await_outside_boundary } from '../shared/errors.js'; - /** * @template T * @param {()=>T} fn @@ -557,29 +501,117 @@ export function derived(fn) { /** * * @param {Payload} payload - * @param {*} value + * @param {unknown} value */ export function maybe_selected(payload, value) { - return value === payload.select_value ? ' selected' : ''; + return value === payload.local.select_value ? ' selected' : ''; } /** + * When an `option` element has no `value` attribute, we need to treat the child + * content as its `value` to determine whether we should apply the `selected` attribute. + * This has to be done at runtime, for hopefully obvious reasons. It is also complicated, + * for sad reasons. * @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; + const i = payload.length; + + // prior to children, `payload` has some combination of string/unresolved payload that ends in `