feat: async SSR (#16748)

* feat: First pass at payload

* first crack

* snapshots

* checkpoint

* fix: cloning

* add test option

* big dumb

* today's hard work; few tests left to fix

* improve

* tests passing no wayyyyy yo

* lots of progress, couple of failing tests around selects

* meh

* solve async tree stuff

* fix select/option stuff

* whoop, tests

* simplify

* feat: hoisting

* fix: `$effect.pending` sends updates to incorrect boundary

* changeset

* stuff from upstream

* feat: first hydrationgaa

* remove docs

* snapshots

* silly fix

* checkpoint

* meh

* ALKASJDFALSKDFJ the test passes

* chore: Update a bunch of tests for hydration markers

* chore: remove snippet and is_async

* naming

* better errors for sync-in-async

* test improvements

* idk man

* merge local branches (#16757)

* use fragment as async hoist boundary

* remove async_hoist_boundary

* only dewaterfall when necessary

* unused

* simplify/fix

* de-waterfall awaits in separate elements

* update snapshots

* remove unnecessary wrapper

* fix

* fix

* remove suspends_without_fallback

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* Update payload.js

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* checkpoint

* got the extra children to go away

* just gonna go ahead and merge this as the review comments take up too much space

* chore: remove hoisted_promises (#16766)

* chore: remove hoisted_promises

* WIP optimise promises

* WIP

* fix <slot> with await in prop

* tweak

* fix type error

* Update packages/svelte/src/compiler/phases/3-transform/server/visitors/SvelteHead.js

* chore: fix hydration treeshaking (#16767)

* chore: fix hydration treeshaking

* fix

* remove await_outside_boundary error (#16762)

* chore: remove unused analysis.boundary (#16763)

* chore: simplify slots (#16765)

* chore: simplify slots

* unused

* Apply suggestions from code review

* chore: remove metadata.pending (#16764)

* Update packages/svelte/src/compiler/phases/3-transform/server/visitors/SnippetBlock.js

* put this back where it was, keep the diff small

* Update packages/svelte/src/compiler/phases/types.d.ts

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* chore: remove analysis.state.title (#16771)

* chore: remove analysis.state.title

* unused

* chore: remove is_async (#16769)

* chore: remove is_async

* unused

* Apply suggestions from code review

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* cleanup

* lint

* clean up payload a bit

* compiler work

* run ssr on sync and async

* prettier

* inline capture

* Update packages/svelte/src/compiler/phases/3-transform/server/visitors/EachBlock.js

* chore: simplify build_template (#16780)

* small tweak to aid greppability

* chore: fix SSR context (#16781)

* at least passing

* cleanup

* fix

* remove push/pop from exports, not needed with payload

* I think this is better but tbh not sure

* async SSR

* qualification

* errors:

* I have lost the plot

* finally

* ugh

* tweak error codes to better align with existing conventions, such as they are

* tweak messages

* remove unused args

* DRY out a bit

* unused

* unused

* unused

* simplify - we can enforce readonly at a type level

* unused

* simplify

* avoid magical accessors

* simplify algorithm

* unused

* unused

* reduce indirection

* TreeState -> SSRState

* mark deprecated methods

* grab this.local from parent directly

* rename render -> fn per conventions (fn indicates 'arbitrary code')

* reduce indirection

* Revert "reduce indirection"

This reverts commit 3ec461baad.

* tweak

* okay works this time

* no way chat, it works

* fix context stuff

* tweak

* make it chainable

* lint

* clean up

* lint

* Update packages/svelte/src/internal/server/types.d.ts

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* sunset html for async

* types

* we use 'deprecated' in other messages

* oops

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/16774/head
Elliott Johnson 13 hours ago committed by GitHub
parent 8c982f6101
commit b8fd326d96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: experimental async SSR

@ -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*

@ -1,5 +1,19 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->
### 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>`](svelte-boundary) with a `pending` snippet.
### html_deprecated
```
The `html` property of server render results has been deprecated. Use `body` instead.
```
### lifecycle_function_unavailable
```

@ -0,0 +1,9 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->
### 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.

@ -1,25 +1,5 @@
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->
### await_outside_boundary
```
Cannot await outside a `<svelte:boundary>` 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 `<script>` block, if it is inside a [`<svelte:boundary>`](/docs/svelte/svelte-boundary) that has a `pending` snippet:
```svelte
<svelte:boundary>
<p>{await getData()}</p>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>
```
This restriction may be lifted in a future version of Svelte.
### invalid_default_snippet
```

@ -94,6 +94,7 @@ export default [
'packages/svelte/src/internal/client/errors.js',
'packages/svelte/src/internal/client/warnings.js',
'packages/svelte/src/internal/shared/warnings.js',
'packages/svelte/src/internal/server/warnings.js',
'packages/svelte/compiler/index.js',
// stuff we don't want to lint
'benchmarking/**',

@ -0,0 +1,15 @@
## 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>`](svelte-boundary) with a `pending` snippet.
## html_deprecated
> The `html` property of server render results has been deprecated. Use `body` instead.
## lifecycle_function_unavailable
> `%name%(...)` is not available on the server
Certain methods such as `mount` cannot be invoked while running in a server context. Avoid calling them eagerly, i.e. not during render.

@ -1,5 +0,0 @@
## lifecycle_function_unavailable
> `%name%(...)` is not available on the server
Certain methods such as `mount` cannot be invoked while running in a server context. Avoid calling them eagerly, i.e. not during render.

@ -0,0 +1,5 @@
## 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.

@ -1,21 +1,3 @@
## await_outside_boundary
> Cannot await outside a `<svelte:boundary>` 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 `<script>` block, if it is inside a [`<svelte:boundary>`](/docs/svelte/svelte-boundary) that has a `pending` snippet:
```svelte
<svelte:boundary>
<p>{await getData()}</p>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>
```
This restriction may be lifted in a future version of Svelte.
## invalid_default_snippet
> Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead

@ -401,6 +401,7 @@ function run() {
transform('client-warnings', 'src/internal/client/warnings.js');
transform('client-errors', 'src/internal/client/errors.js');
transform('server-warnings', 'src/internal/server/warnings.js');
transform('server-errors', 'src/internal/server/errors.js');
transform('shared-errors', 'src/internal/shared/errors.js');
transform('shared-warnings', 'src/internal/shared/warnings.js');

@ -0,0 +1,20 @@
import { DEV } from 'esm-env';
var bold = 'font-weight: bold';
var normal = 'font-weight: normal';
/**
* MESSAGE
* @param {string} PARAMETER
*/
export function CODE(PARAMETER) {
if (DEV) {
console.warn(
`%c[svelte] ${'CODE'}\n%c${MESSAGE}\nhttps://svelte.dev/e/${'CODE'}`,
bold,
normal
);
} else {
console.warn(`https://svelte.dev/e/${'CODE'}`);
}
}

@ -177,7 +177,8 @@ export default function element(parser) {
mathml: false,
scoped: false,
has_spread: false,
path: []
path: [],
synthetic_value_node: null
}
}
: /** @type {AST.ElementLike} */ ({

@ -278,7 +278,8 @@ export function analyze_module(source, options) {
tracing: false,
async_deriveds: new Set(),
comments,
classes: new Map()
classes: new Map(),
pickled_awaits: new Set()
};
state.adjust({
@ -304,7 +305,8 @@ export function analyze_module(source, options) {
options: /** @type {ValidatedCompileOptions} */ (options),
fragment: null,
parent_element: null,
reactive_statement: null
reactive_statement: null,
in_derived: false
},
visitors
);
@ -540,7 +542,8 @@ export function analyze_component(root, source, options) {
source,
snippet_renderers: new Map(),
snippets: new Set(),
async_deriveds: new Set()
async_deriveds: new Set(),
pickled_awaits: new Set()
};
if (!runes) {
@ -699,7 +702,8 @@ export function analyze_component(root, source, options) {
expression: null,
state_fields: new Map(),
function_depth: scope.function_depth,
reactive_statement: null
reactive_statement: null,
in_derived: false
};
walk(/** @type {AST.SvelteNode} */ (ast), state, visitors);
@ -766,7 +770,8 @@ export function analyze_component(root, source, options) {
component_slots: new Set(),
expression: null,
state_fields: new Map(),
function_depth: scope.function_depth
function_depth: scope.function_depth,
in_derived: false
};
walk(/** @type {AST.SvelteNode} */ (ast), state, visitors);

@ -27,6 +27,11 @@ export interface AnalysisState {
// legacy stuff
reactive_statement: null | ReactiveStatement;
/**
* True if we're directly inside a `$derived(...)` expression (but not `$derived.by(...)`)
*/
in_derived: boolean;
}
export type Context<State extends AnalysisState = AnalysisState> = import('zimmerframe').Context<

@ -1,5 +1,6 @@
/** @import { AwaitExpression } from 'estree' */
/** @import { AwaitExpression, Expression, SpreadElement, Property } from 'estree' */
/** @import { Context } from '../types' */
/** @import { AST } from '#compiler' */
import * as e from '../../../errors.js';
/**
@ -7,16 +8,25 @@ import * as e from '../../../errors.js';
* @param {Context} context
*/
export function AwaitExpression(node, context) {
let suspend = context.state.ast_type === 'instance' && context.state.function_depth === 1;
const tla = context.state.ast_type === 'instance' && context.state.function_depth === 1;
// preserve context for
// a) top-level await and
// b) awaits that precede other expressions in template or `$derived(...)`
if (
tla ||
(is_reactive_expression(context.path, context.state.in_derived) &&
!is_last_evaluated_expression(context.path, node))
) {
context.state.analysis.pickled_awaits.add(node);
}
let suspend = tla;
if (context.state.expression) {
context.state.expression.has_await = true;
if (
context.state.fragment &&
// TODO there's probably a better way to do this
context.path.some((node) => node.type === 'ConstTag')
) {
if (context.state.fragment && context.path.some((node) => node.type === 'ConstTag')) {
context.state.fragment.metadata.has_await = true;
}
@ -37,3 +47,101 @@ export function AwaitExpression(node, context) {
context.next();
}
/**
* @param {AST.SvelteNode[]} path
* @param {boolean} in_derived
*/
export function is_reactive_expression(path, in_derived) {
if (in_derived) {
return true;
}
let i = path.length;
while (i--) {
const parent = path[i];
if (
parent.type === 'ArrowFunctionExpression' ||
parent.type === 'FunctionExpression' ||
parent.type === 'FunctionDeclaration'
) {
return false;
}
// @ts-expect-error we could probably use a neater/more robust mechanism
if (parent.metadata) {
return true;
}
}
return false;
}
/**
* @param {AST.SvelteNode[]} path
* @param {Expression | SpreadElement | Property} node
*/
export function is_last_evaluated_expression(path, node) {
let i = path.length;
while (i--) {
const parent = /** @type {Expression | Property | SpreadElement} */ (path[i]);
// @ts-expect-error we could probably use a neater/more robust mechanism
if (parent.metadata) {
return true;
}
switch (parent.type) {
case 'ArrayExpression':
if (node !== parent.elements.at(-1)) return false;
break;
case 'AssignmentExpression':
case 'BinaryExpression':
case 'LogicalExpression':
if (node === parent.left) return false;
break;
case 'CallExpression':
case 'NewExpression':
if (node !== parent.arguments.at(-1)) return false;
break;
case 'ConditionalExpression':
if (node === parent.test) return false;
break;
case 'MemberExpression':
if (parent.computed && node === parent.object) return false;
break;
case 'ObjectExpression':
if (node !== parent.properties.at(-1)) return false;
break;
case 'Property':
if (node === parent.key) return false;
break;
case 'SequenceExpression':
if (node !== parent.expressions.at(-1)) return false;
break;
case 'TaggedTemplateExpression':
if (node !== parent.quasi.expressions.at(-1)) return false;
break;
case 'TemplateLiteral':
if (node !== parent.expressions.at(-1)) return false;
break;
default:
return false;
}
node = parent;
}
}

@ -241,6 +241,7 @@ export function CallExpression(node, context) {
context.next({
...context.state,
function_depth: context.state.function_depth + 1,
in_derived: true,
expression
});

@ -35,5 +35,9 @@ export function ConstTag(node, context) {
const declaration = node.declaration.declarations[0];
context.visit(declaration.id);
context.visit(declaration.init, { ...context.state, expression: node.metadata.expression });
context.visit(declaration.init, {
...context.state,
expression: node.metadata.expression,
in_derived: true
});
}

@ -70,7 +70,7 @@ export function RegularElement(node, context) {
)
) {
const child = node.fragment.nodes[0];
node.attributes.push(create_attribute('value', child.start, child.end, [child]));
node.metadata.synthetic_value_node = child;
}
const binding = context.state.scope.get(node.name);

@ -49,6 +49,12 @@ export function VariableDeclarator(node, context) {
}
}
if (rune === '$derived') {
context.visit(node.id);
context.visit(/** @type {Expression} */ (node.init), { ...context.state, in_derived: true });
return;
}
if (rune === '$props') {
if (node.id.type !== 'ObjectPattern' && node.id.type !== 'Identifier') {
e.props_invalid_identifier(node);

@ -166,7 +166,6 @@ export function client_component(analysis, options) {
state_fields: new Map(),
transform: {},
in_constructor: false,
in_derived: false,
instance_level_snippets: [],
module_level_snippets: [],
@ -712,7 +711,6 @@ export function client_module(analysis, options) {
state_fields: new Map(),
transform: {},
in_constructor: false,
in_derived: false,
is_instance: false
};

@ -6,8 +6,7 @@ import type {
Expression,
AssignmentExpression,
UpdateExpression,
VariableDeclaration,
Declaration
VariableDeclaration
} from 'estree';
import type { AST, Namespace, ValidatedCompileOptions } from '#compiler';
import type { TransformState } from '../types.js';
@ -22,11 +21,6 @@ export interface ClientTransformState extends TransformState {
*/
readonly in_constructor: boolean;
/**
* True if we're directly inside a `$derived(...)` expression (but not `$derived.by(...)`)
*/
readonly in_derived: boolean;
/** `true` if we're transforming the contents of `<script>` */
readonly is_instance: boolean;

@ -1,4 +1,4 @@
/** @import { AwaitExpression, Expression, Property, SpreadElement } from 'estree' */
/** @import { AwaitExpression, Expression } from 'estree' */
/** @import { Context } from '../types' */
import { dev, is_ignored } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';
@ -10,12 +10,7 @@ import * as b from '../../../../utils/builders.js';
export function AwaitExpression(node, context) {
const argument = /** @type {Expression} */ (context.visit(node.argument));
const tla = context.state.is_instance && context.state.scope.function_depth === 1;
// preserve context for
// a) top-level await and
// b) awaits that precede other expressions in template or `$derived(...)`
if (tla || (is_reactive_expression(context) && !is_last_evaluated_expression(context, node))) {
if (context.state.analysis.pickled_awaits.has(node)) {
return b.call(b.await(b.call('$.save', argument)));
}
@ -27,100 +22,3 @@ export function AwaitExpression(node, context) {
return argument === node.argument ? node : { ...node, argument };
}
/**
* @param {Context} context
*/
function is_reactive_expression(context) {
if (context.state.in_derived) {
return true;
}
let i = context.path.length;
while (i--) {
const parent = context.path[i];
if (
parent.type === 'ArrowFunctionExpression' ||
parent.type === 'FunctionExpression' ||
parent.type === 'FunctionDeclaration'
) {
return false;
}
// @ts-expect-error we could probably use a neater/more robust mechanism
if (parent.metadata) {
return true;
}
}
return false;
}
/**
* @param {Context} context
* @param {Expression | SpreadElement | Property} node
*/
function is_last_evaluated_expression(context, node) {
let i = context.path.length;
while (i--) {
const parent = /** @type {Expression | Property | SpreadElement} */ (context.path[i]);
// @ts-expect-error we could probably use a neater/more robust mechanism
if (parent.metadata) {
return true;
}
switch (parent.type) {
case 'ArrayExpression':
if (node !== parent.elements.at(-1)) return false;
break;
case 'AssignmentExpression':
case 'BinaryExpression':
case 'LogicalExpression':
if (node === parent.left) return false;
break;
case 'CallExpression':
case 'NewExpression':
if (node !== parent.arguments.at(-1)) return false;
break;
case 'ConditionalExpression':
if (node === parent.test) return false;
break;
case 'MemberExpression':
if (parent.computed && node === parent.object) return false;
break;
case 'ObjectExpression':
if (node !== parent.properties.at(-1)) return false;
break;
case 'Property':
if (node === parent.key) return false;
break;
case 'SequenceExpression':
if (node !== parent.expressions.at(-1)) return false;
break;
case 'TaggedTemplateExpression':
if (node !== parent.quasi.expressions.at(-1)) return false;
break;
case 'TemplateLiteral':
if (node !== parent.expressions.at(-1)) return false;
break;
default:
return false;
}
node = parent;
}
}

@ -44,9 +44,7 @@ export function CallExpression(node, context) {
case '$derived':
case '$derived.by': {
let fn = /** @type {Expression} */ (
context.visit(node.arguments[0], { ...context.state, in_derived: rune === '$derived' })
);
let fn = /** @type {Expression} */ (context.visit(node.arguments[0]));
return b.call('$.derived', rune === '$derived' ? b.thunk(fn) : fn);
}

@ -16,11 +16,7 @@ export function ConstTag(node, context) {
const declaration = node.declaration.declarations[0];
// TODO we can almost certainly share some code with $derived(...)
if (declaration.id.type === 'Identifier') {
const init = build_expression(
{ ...context, state: { ...context.state, in_derived: true } },
declaration.init,
node.metadata.expression
);
const init = build_expression(context, declaration.init, node.metadata.expression);
let expression = create_derived(context.state, init, node.metadata.expression.has_await);
@ -51,8 +47,7 @@ export function ConstTag(node, context) {
const child_state = /** @type {ComponentContext['state']} */ ({
...context.state,
transform,
in_derived: true
transform
});
// TODO optimise the simple `{ x } = y` case — we can just return `y`

@ -11,7 +11,7 @@ import {
import { is_ignored } from '../../../../state.js';
import { is_event_attribute, is_text_attribute } from '../../../../utils/ast.js';
import * as b from '#compiler/builders';
import { is_custom_element_node } from '../../../nodes.js';
import { create_attribute, is_custom_element_node } from '../../../nodes.js';
import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
import { build_getter } from '../utils.js';
import {
@ -406,10 +406,24 @@ export function RegularElement(node, context) {
}
if (!has_spread && needs_special_value_handling) {
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (attribute.name === 'value') {
build_element_special_value_attribute(node.name, node_id, attribute, context);
break;
if (node.metadata.synthetic_value_node) {
const synthetic_node = node.metadata.synthetic_value_node;
const synthetic_attribute = create_attribute(
'value',
synthetic_node.start,
synthetic_node.end,
[synthetic_node]
);
// this node is an `option` that didn't have a `value` attribute, but had
// a single-expression child, so we treat the value of that expression as
// the value of the option
build_element_special_value_attribute(node.name, node_id, synthetic_attribute, context, true);
} else {
for (const attribute of /** @type {AST.Attribute[]} */ (attributes)) {
if (attribute.name === 'value') {
build_element_special_value_attribute(node.name, node_id, attribute, context);
break;
}
}
}
}
@ -645,8 +659,15 @@ function build_custom_element_attribute_update_assignment(node_id, attribute, co
* @param {Identifier} node_id
* @param {AST.Attribute} attribute
* @param {ComponentContext} context
* @param {boolean} [synthetic] - true if this should not sync to the DOM
*/
function build_element_special_value_attribute(element, node_id, attribute, context) {
function build_element_special_value_attribute(
element,
node_id,
attribute,
context,
synthetic = false
) {
const state = context.state;
const is_select_with_value =
// attribute.metadata.dynamic would give false negatives because even if the value does not change,
@ -660,7 +681,7 @@ function build_element_special_value_attribute(element, node_id, attribute, cont
const evaluated = context.state.scope.evaluate(value);
const assignment = b.assignment('=', b.member(node_id, '__value'), value);
const inner_assignment = b.assignment(
const set_value_assignment = b.assignment(
'=',
b.member(node_id, 'value'),
evaluated.is_defined ? assignment : b.logical('??', assignment, b.literal(''))
@ -669,14 +690,16 @@ function build_element_special_value_attribute(element, node_id, attribute, cont
const update = b.stmt(
is_select_with_value
? b.sequence([
inner_assignment,
set_value_assignment,
// This ensures a one-way street to the DOM in case it's <select {value}>
// and not <select bind:value>. We need it in addition to $.init_select
// because the select value is not reflected as an attribute, so the
// mutation observer wouldn't notice.
b.call('$.select_option', node_id, value)
])
: inner_assignment
: synthetic
? assignment
: set_value_assignment
);
if (has_state) {

@ -202,12 +202,7 @@ export function VariableDeclaration(node, context) {
);
if (declarator.id.type === 'Identifier') {
let expression = /** @type {Expression} */ (
context.visit(value, {
...context.state,
in_derived: rune === '$derived'
})
);
let expression = /** @type {Expression} */ (context.visit(value));
if (is_async) {
const location = dev && !is_ignored(init, 'await_waterfall') && locate_node(init);
@ -231,12 +226,7 @@ export function VariableDeclaration(node, context) {
}
} else {
const init = /** @type {CallExpression} */ (declarator.init);
let expression = /** @type {Expression} */ (
context.visit(value, {
...context.state,
in_derived: rune === '$derived'
})
);
let expression = /** @type {Expression} */ (context.visit(value));
let rhs = value;

@ -6,7 +6,7 @@ import { walk } from 'zimmerframe';
import { set_scope } from '../../scope.js';
import { extract_identifiers } from '../../../utils/ast.js';
import * as b from '#compiler/builders';
import { dev, filename } from '../../../state.js';
import { component_name, dev, filename } from '../../../state.js';
import { render_stylesheet } from '../css/index.js';
import { AssignmentExpression } from './visitors/AssignmentExpression.js';
import { AwaitBlock } from './visitors/AwaitBlock.js';
@ -40,6 +40,7 @@ import { TitleElement } from './visitors/TitleElement.js';
import { UpdateExpression } from './visitors/UpdateExpression.js';
import { VariableDeclaration } from './visitors/VariableDeclaration.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { call_child_payload, call_component_payload } from './visitors/shared/utils.js';
/** @type {Visitors} */
const global_visitors = {
@ -197,13 +198,11 @@ export function server_component(analysis, options) {
b.unary('!', b.id('$$settled')),
b.block([
b.stmt(b.assignment('=', b.id('$$settled'), b.true)),
b.stmt(
b.assignment('=', b.id('$$inner_payload'), b.call('$.copy_payload', b.id('$$payload')))
),
b.stmt(b.assignment('=', b.id('$$inner_payload'), b.call('$$payload.copy'))),
b.stmt(b.call('$$render_inner', b.id('$$inner_payload')))
])
),
b.stmt(b.call('$.assign_payload', b.id('$$payload'), b.id('$$inner_payload')))
b.stmt(b.call('$$payload.subsume', b.id('$$inner_payload')))
];
}
@ -239,11 +238,15 @@ export function server_component(analysis, options) {
template.body.push(b.stmt(b.call('$.bind_props', b.id('$$props'), b.object(props))));
}
const component_block = b.block([
let component_block = b.block([
.../** @type {Statement[]} */ (instance.body),
.../** @type {Statement[]} */ (template.body)
]);
if (analysis.instance.has_await) {
component_block = b.block([call_child_payload(component_block, true)]);
}
// trick esrap into including comments
component_block.loc = instance.loc;
@ -257,8 +260,9 @@ export function server_component(analysis, options) {
let should_inject_context = dev || analysis.needs_context;
if (should_inject_context) {
component_block.body.unshift(b.stmt(b.call('$.push', dev && b.id(analysis.name))));
component_block.body.push(b.stmt(b.call('$.pop')));
component_block = b.block([
call_component_payload(component_block, dev && b.id(component_name))
]);
}
if (analysis.uses_rest_props) {
@ -297,7 +301,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 =
@ -377,6 +381,10 @@ export function server_component(analysis, options) {
);
}
if (options.experimental.async) {
body.unshift(b.imports([], 'svelte/internal/flags/async'));
}
return {
type: 'Program',
sourceType: 'module',

@ -1,29 +1,33 @@
/** @import { BlockStatement, Expression, Pattern } from 'estree' */
/** @import { BlockStatement, Expression, Pattern, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders';
import { block_close } from './shared/utils.js';
import { block_close, call_child_payload } from './shared/utils.js';
/**
* @param {AST.AwaitBlock} node
* @param {ComponentContext} context
*/
export function AwaitBlock(node, context) {
context.state.template.push(
b.stmt(
b.call(
'$.await',
b.id('$$payload'),
/** @type {Expression} */ (context.visit(node.expression)),
b.thunk(
node.pending ? /** @type {BlockStatement} */ (context.visit(node.pending)) : b.block([])
),
b.arrow(
node.value ? [/** @type {Pattern} */ (context.visit(node.value))] : [],
node.then ? /** @type {BlockStatement} */ (context.visit(node.then)) : b.block([])
)
/** @type {Statement} */
let statement = b.stmt(
b.call(
'$.await',
b.id('$$payload'),
/** @type {Expression} */ (context.visit(node.expression)),
b.thunk(
node.pending ? /** @type {BlockStatement} */ (context.visit(node.pending)) : b.block([])
),
b.arrow(
node.value ? [/** @type {Pattern} */ (context.visit(node.value))] : [],
node.then ? /** @type {BlockStatement} */ (context.visit(node.then)) : b.block([])
)
),
block_close
)
);
if (node.metadata.expression.has_await) {
statement = call_child_payload(b.block([statement]), true);
}
context.state.template.push(statement, block_close);
}

@ -1,5 +1,5 @@
/** @import { AwaitExpression } from 'estree' */
/** @import { Context } from '../types.js' */
/** @import { AwaitExpression, Expression } from 'estree' */
/** @import { Context } from '../types' */
import * as b from '../../../../utils/builders.js';
/**
@ -7,19 +7,11 @@ import * as b from '../../../../utils/builders.js';
* @param {Context} context
*/
export function AwaitExpression(node, context) {
// if `await` is inside a function, or inside `<script module>`,
// allow it, otherwise error
if (
context.state.scope.function_depth === 0 ||
context.path.some(
(node) =>
node.type === 'ArrowFunctionExpression' ||
node.type === 'FunctionDeclaration' ||
node.type === 'FunctionExpression'
)
) {
return context.next();
const argument = /** @type {Expression} */ (context.visit(node.argument));
if (context.state.analysis.pickled_awaits.has(node)) {
return b.call(b.await(b.call('$.save', argument)));
}
return b.call('$.await_outside_boundary');
return argument === node.argument ? node : { ...node, argument };
}

@ -1,9 +1,8 @@
/** @import { BlockStatement, Expression, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import { BLOCK_OPEN_ELSE } from '../../../../../internal/server/hydration.js';
import * as b from '#compiler/builders';
import { block_close, block_open } from './shared/utils.js';
import { block_close, block_open, block_open_else, call_child_payload } from './shared/utils.js';
/**
* @param {AST.EachBlock} node
@ -18,7 +17,9 @@ export function EachBlock(node, context) {
each_node_meta.contains_group_binding || !node.index ? each_node_meta.index : b.id(node.index);
const array_id = state.scope.root.unique('each_array');
state.init.push(b.const(array_id, b.call('$.ensure_array_like', collection)));
/** @type {Statement} */
let block = b.block([b.const(array_id, b.call('$.ensure_array_like', collection))]);
/** @type {Statement[]} */
const each = [];
@ -44,23 +45,27 @@ 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.id('$$payload.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)))
);
fallback.body.unshift(b.stmt(b.call(b.id('$$payload.push'), block_open_else)));
state.template.push(
block.body.push(
b.if(
b.binary('!==', b.member(array_id, 'length'), b.literal(0)),
b.block([open, for_loop]),
fallback
),
block_close
)
);
} else {
state.template.push(block_open, for_loop, block_close);
state.template.push(block_open);
block.body.push(for_loop);
}
if (node.metadata.expression.has_await) {
state.template.push(call_child_payload(block, true), block_close);
} else {
state.template.push(...block.body, block_close);
}
}

@ -1,9 +1,8 @@
/** @import { BlockStatement, Expression } from 'estree' */
/** @import { BlockStatement, Expression, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import { BLOCK_OPEN_ELSE } from '../../../../../internal/server/hydration.js';
import * as b from '#compiler/builders';
import { block_close, block_open } from './shared/utils.js';
import { block_close, block_open, block_open_else, call_child_payload } from './shared/utils.js';
/**
* @param {AST.IfBlock} node
@ -17,13 +16,16 @@ 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.id('$$payload.push'), block_open)));
alternate.body.unshift(
b.stmt(b.call(b.member(b.id('$$payload.out'), b.id('push')), b.literal(BLOCK_OPEN_ELSE)))
);
alternate.body.unshift(b.stmt(b.call(b.id('$$payload.push'), block_open_else)));
context.state.template.push(b.if(test, consequent, alternate), block_close);
/** @type {Statement} */
let statement = b.if(test, consequent, alternate);
if (node.metadata.expression.has_await) {
statement = call_child_payload(b.block([statement]), true);
}
context.state.template.push(statement, block_close);
}

@ -1,4 +1,4 @@
/** @import { Expression } from 'estree' */
/** @import { Expression, Statement } from 'estree' */
/** @import { Location } from 'locate-character' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */
@ -8,7 +8,12 @@ import { dev, locator } from '../../../../state.js';
import * as b from '#compiler/builders';
import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
import { build_element_attributes, build_spread_object } from './shared/element.js';
import { process_children, build_template, build_attribute_value } from './shared/utils.js';
import {
process_children,
build_template,
build_attribute_value,
call_child_payload
} from './shared/utils.js';
/**
* @param {AST.RegularElement} node
@ -73,6 +78,8 @@ export function RegularElement(node, context) {
}
let select_with_value = false;
let select_with_value_async = false;
const template_start = state.template.length;
if (node.name === 'select') {
const value = node.attributes.find(
@ -80,13 +87,17 @@ export function RegularElement(node, context) {
(attribute.type === 'Attribute' || attribute.type === 'BindDirective') &&
attribute.name === 'value'
);
if (node.attributes.some((attribute) => attribute.type === 'SpreadAttribute')) {
const spread = node.attributes.find((attribute) => attribute.type === 'SpreadAttribute');
if (spread) {
select_with_value = true;
select_with_value_async ||= spread.metadata.expression.has_await;
state.template.push(
b.stmt(
b.assignment(
'=',
b.id('$$payload.select_value'),
b.id('$$payload.local.select_value'),
b.member(
build_spread_object(
node,
@ -107,7 +118,14 @@ export function RegularElement(node, context) {
);
} else if (value) {
select_with_value = true;
const left = b.id('$$payload.select_value');
if (value.type === 'Attribute' && value.value !== true) {
select_with_value_async ||= (Array.isArray(value.value) ? value.value : [value.value]).some(
(tag) => tag.type === 'ExpressionTag' && tag.metadata.expression.has_await
);
}
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)))
@ -137,18 +155,35 @@ export function RegularElement(node, context) {
attribute.name === 'value')
)
) {
const inner_state = { ...state, template: [], init: [] };
process_children(trimmed, { ...context, state: inner_state });
state.template.push(
b.stmt(
b.call(
'$.valueless_option',
b.id('$$payload'),
b.thunk(b.block([...inner_state.init, ...build_template(inner_state.template)]))
if (node.metadata.synthetic_value_node) {
state.template.push(
b.stmt(
b.call(
'$.simple_valueless_option',
b.id('$$payload'),
b.thunk(
node.metadata.synthetic_value_node.expression,
node.metadata.synthetic_value_node.metadata.expression.has_await
)
)
)
)
);
);
} else {
const inner_state = { ...state, template: [], init: [] };
process_children(trimmed, { ...context, state: inner_state });
state.template.push(
b.stmt(
b.call(
'$.valueless_option',
b.id('$$payload'),
b.arrow(
[b.id('$$payload')],
b.block([...inner_state.init, ...build_template(inner_state.template)])
)
)
)
);
}
} else if (body !== null) {
// if this is a `<textarea>` value or a contenteditable binding, we only add
// the body if the attribute/binding is falsy
@ -175,7 +210,14 @@ export function RegularElement(node, context) {
}
if (select_with_value) {
state.template.push(b.stmt(b.assignment('=', b.id('$$payload.select_value'), b.void0)));
// we need to create a child scope so that the `select_value` only applies children of this select element
// in an async world, we could technically have two adjacent select elements with async children, in which case
// the second element's select_value would override the first element's select_value if the children of the first
// element hadn't resolved prior to hitting the second element.
const elements = state.template.splice(template_start, Infinity);
state.template.push(
call_child_payload(b.block(build_template(elements)), select_with_value_async)
);
}
if (!node_is_void) {

@ -2,7 +2,12 @@
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import * as b from '#compiler/builders';
import { empty_comment, build_attribute_value } from './shared/utils.js';
import {
empty_comment,
build_attribute_value,
PromiseOptimiser,
call_child_payload
} from './shared/utils.js';
/**
* @param {AST.SlotElement} node
@ -15,13 +20,22 @@ export function SlotElement(node, context) {
/** @type {Expression[]} */
const spreads = [];
const optimiser = new PromiseOptimiser();
let name = b.literal('default');
for (const attribute of node.attributes) {
if (attribute.type === 'SpreadAttribute') {
spreads.push(/** @type {Expression} */ (context.visit(attribute)));
let expression = /** @type {Expression} */ (context.visit(attribute));
spreads.push(optimiser.transform(expression, attribute.metadata.expression));
} else if (attribute.type === 'Attribute') {
const value = build_attribute_value(attribute.value, context, false, true);
const value = build_attribute_value(
attribute.value,
context,
false,
true,
optimiser.transform
);
if (attribute.name === 'name') {
name = /** @type {Literal} */ (value);
@ -50,5 +64,10 @@ export function SlotElement(node, context) {
fallback
);
context.state.template.push(empty_comment, b.stmt(slot), empty_comment);
const statement =
optimiser.expressions.length > 0
? call_child_payload(b.block([optimiser.apply(), b.stmt(slot)]), true)
: b.stmt(slot);
context.state.template.push(empty_comment, statement, empty_comment);
}

@ -1,17 +1,14 @@
/** @import { BlockStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { BLOCK_CLOSE, BLOCK_OPEN } from '../../../../../internal/server/hydration.js';
import * as b from '#compiler/builders';
import { build_attribute_value } from './shared/utils.js';
import { block_close, block_open, block_open_else, build_attribute_value } from './shared/utils.js';
/**
* @param {AST.SvelteBoundary} node
* @param {ComponentContext} context
*/
export function SvelteBoundary(node, context) {
context.state.template.push(b.literal(BLOCK_OPEN));
// if this has a `pending` snippet, render it
const pending_attribute = /** @type {AST.Attribute} */ (
node.attributes.find((node) => node.type === 'Attribute' && node.name === 'pending')
@ -23,16 +20,17 @@ export function SvelteBoundary(node, context) {
)
);
if (pending_attribute) {
const value = build_attribute_value(pending_attribute.value, context, false, true);
context.state.template.push(b.call(value, b.id('$$payload')));
} else if (pending_snippet) {
context.state.template.push(
/** @type {BlockStatement} */ (context.visit(pending_snippet.body))
);
if (pending_attribute || pending_snippet) {
const pending = pending_attribute
? b.call(
build_attribute_value(pending_attribute.value, context, false, true),
b.id('$$payload')
)
: /** @type {BlockStatement} */ (context.visit(pending_snippet.body));
context.state.template.push(block_open_else, pending, block_close);
} else {
context.state.template.push(/** @type {BlockStatement} */ (context.visit(node.fragment)));
const block = /** @type {BlockStatement} */ (context.visit(node.fragment));
context.state.template.push(block_open, block, block_close);
}
context.state.template.push(b.literal(BLOCK_CLOSE));
}

@ -13,5 +13,7 @@ export function TitleElement(node, context) {
process_children(node.fragment.nodes, { ...context, state: { ...context.state, template } });
template.push(b.literal('</title>'));
context.state.init.push(...build_template(template, b.id('$$payload.title'), '='));
context.state.init.push(
b.stmt(b.call('$.build_title', b.id('$$payload'), b.thunk(b.block(build_template(template)))))
);
}

@ -1,7 +1,12 @@
/** @import { BlockStatement, Expression, Pattern, Property, SequenceExpression, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../../types.js' */
import { empty_comment, build_attribute_value } from './utils.js';
import {
empty_comment,
build_attribute_value,
call_child_payload,
PromiseOptimiser
} from './utils.js';
import * as b from '#compiler/builders';
import { is_element_node } from '../../../../nodes.js';
import { dev } from '../../../../../state.js';
@ -72,16 +77,26 @@ export function build_inline_component(node, expression, context) {
}
}
const optimiser = new PromiseOptimiser();
for (const attribute of node.attributes) {
if (attribute.type === 'LetDirective') {
if (!slot_scope_applies_to_itself) {
lets.default.push(attribute);
}
} else if (attribute.type === 'SpreadAttribute') {
props_and_spreads.push(/** @type {Expression} */ (context.visit(attribute)));
let expression = /** @type {Expression} */ (context.visit(attribute));
props_and_spreads.push(optimiser.transform(expression, attribute.metadata.expression));
} else if (attribute.type === 'Attribute') {
const value = build_attribute_value(
attribute.value,
context,
false,
true,
optimiser.transform
);
if (attribute.name.startsWith('--')) {
const value = build_attribute_value(attribute.value, context, false, true);
custom_css_props.push(b.init(attribute.name, value));
continue;
}
@ -90,7 +105,6 @@ export function build_inline_component(node, expression, context) {
has_children_prop = true;
}
const value = build_attribute_value(attribute.value, context, false, true);
push_prop(b.prop('init', b.key(attribute.name), value));
} else if (attribute.type === 'BindDirective' && attribute.name !== 'this') {
if (attribute.expression.type === 'SequenceExpression') {
@ -291,27 +305,29 @@ export function build_inline_component(node, expression, context) {
node.type === 'SvelteComponent' || (node.type === 'Component' && node.metadata.dynamic);
if (custom_css_props.length > 0) {
context.state.template.push(
b.stmt(
b.call(
'$.css_props',
b.id('$$payload'),
b.literal(context.state.namespace === 'svg' ? false : true),
b.object(custom_css_props),
b.thunk(b.block([statement])),
dynamic && b.true
)
statement = b.stmt(
b.call(
'$.css_props',
b.id('$$payload'),
b.literal(context.state.namespace === 'svg' ? false : true),
b.object(custom_css_props),
b.thunk(b.block([statement])),
dynamic && b.true
)
);
} else {
if (dynamic) {
context.state.template.push(empty_comment);
}
}
context.state.template.push(statement);
if (optimiser.expressions.length > 0) {
statement = call_child_payload(b.block([optimiser.apply(), statement]), true);
}
if (!context.state.skip_hydration_boundaries) {
context.state.template.push(empty_comment);
}
if (dynamic && custom_css_props.length === 0) {
context.state.template.push(empty_comment);
}
context.state.template.push(statement);
if (!context.state.skip_hydration_boundaries && custom_css_props.length === 0) {
context.state.template.push(empty_comment);
}
}

@ -1,20 +1,25 @@
/** @import { AssignmentOperator, Expression, Identifier, Node, Statement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { AssignmentOperator, Expression, Identifier, Node, Statement, BlockStatement } from 'estree' */
/** @import { AST, ExpressionMetadata } from '#compiler' */
/** @import { ComponentContext, ServerTransformState } from '../../types.js' */
import { escape_html } from '../../../../../../escaping.js';
import {
BLOCK_CLOSE,
BLOCK_OPEN,
BLOCK_OPEN_ELSE,
EMPTY_COMMENT
} from '../../../../../../internal/server/hydration.js';
import * as b from '#compiler/builders';
import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js';
import { regex_whitespaces_strict } from '../../../../patterns.js';
import { has_await } from '../../../../../utils/ast.js';
/** Opens an if/each block, so that we can remove nodes in the case of a mismatch */
export const block_open = b.literal(BLOCK_OPEN);
/** Opens an if/each block, so that we can remove nodes in the case of a mismatch */
export const block_open_else = b.literal(BLOCK_OPEN_ELSE);
/** Closes an if/each block, so that we can remove nodes in the case of a mismatch. Also serves as an anchor for these blocks */
export const block_close = b.literal(BLOCK_CLOSE);
@ -32,6 +37,10 @@ export function process_children(nodes, { visit, state }) {
let sequence = [];
function flush() {
if (sequence.length === 0) {
return;
}
let quasi = b.quasi('', false);
const quasis = [quasi];
@ -63,26 +72,25 @@ export function process_children(nodes, { visit, state }) {
}
state.template.push(b.template(quasis, expressions));
sequence = [];
}
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
if (node.type === 'Text' || node.type === 'Comment' || node.type === 'ExpressionTag') {
for (const node of nodes) {
if (node.type === 'ExpressionTag' && node.metadata.expression.has_await) {
flush();
const visited = /** @type {Expression} */ (visit(node.expression));
state.template.push(
b.stmt(b.call('$$payload.push', b.thunk(b.call('$.escape', visited), true)))
);
} else if (node.type === 'Text' || node.type === 'Comment' || node.type === 'ExpressionTag') {
sequence.push(node);
} else {
if (sequence.length > 0) {
flush();
sequence = [];
}
flush();
visit(node, { ...state });
}
}
if (sequence.length > 0) {
flush();
}
flush();
}
/**
@ -95,11 +103,9 @@ function is_statement(node) {
/**
* @param {Array<Statement | Expression>} template
* @param {Identifier} out
* @param {AssignmentOperator | 'push'} operator
* @returns {Statement[]}
*/
export function build_template(template, out = b.id('$$payload.out'), operator = 'push') {
export function build_template(template) {
/** @type {string[]} */
let strings = [];
@ -110,32 +116,18 @@ export function build_template(template, out = b.id('$$payload.out'), operator =
const statements = [];
const flush = () => {
if (operator === 'push') {
statements.push(
b.stmt(
b.call(
b.member(out, b.id('push')),
b.template(
strings.map((cooked, i) => b.quasi(cooked, i === strings.length - 1)),
expressions
)
statements.push(
b.stmt(
b.call(
b.id('$$payload.push'),
b.template(
strings.map((cooked, i) => b.quasi(cooked, i === strings.length - 1)),
expressions
)
)
);
} else {
statements.push(
b.stmt(
b.assignment(
operator,
out,
b.template(
strings.map((cooked, i) => b.quasi(cooked, i === strings.length - 1)),
expressions
)
)
)
);
}
)
);
strings = [];
expressions = [];
};
@ -180,13 +172,15 @@ export function build_template(template, out = b.id('$$payload.out'), operator =
* @param {ComponentContext} context
* @param {boolean} trim_whitespace
* @param {boolean} is_component
* @param {(expression: Expression, metadata: ExpressionMetadata) => Expression} transform
* @returns {Expression}
*/
export function build_attribute_value(
value,
context,
trim_whitespace = false,
is_component = false
is_component = false,
transform = (expression) => expression
) {
if (value === true) {
return b.true;
@ -203,7 +197,10 @@ export function build_attribute_value(
return b.literal(is_component ? data : escape_html(data, true));
}
return /** @type {Expression} */ (context.visit(chunk.expression));
return transform(
/** @type {Expression} */ (context.visit(chunk.expression)),
chunk.metadata.expression
);
}
let quasi = b.quasi('', false);
@ -221,7 +218,13 @@ export function build_attribute_value(
: node.data;
} else {
expressions.push(
b.call('$.stringify', /** @type {Expression} */ (context.visit(node.expression)))
b.call(
'$.stringify',
transform(
/** @type {Expression} */ (context.visit(node.expression)),
node.metadata.expression
)
)
);
quasi = b.quasi('', i + 1 === value.length);
@ -257,3 +260,61 @@ export function build_getter(node, state) {
return node;
}
/**
* @param {BlockStatement | Expression} body
* @param {boolean} async
* @returns {Statement}
*/
export function call_child_payload(body, async) {
return b.stmt(b.call('$$payload.child', b.arrow([b.id('$$payload')], body, async)));
}
/**
* @param {BlockStatement | Expression} body
* @param {Identifier | false} component_fn_id
* @returns {Statement}
*/
export function call_component_payload(body, component_fn_id) {
return b.stmt(
b.call('$$payload.component', b.arrow([b.id('$$payload')], body, false), component_fn_id)
);
}
export class PromiseOptimiser {
/** @type {Expression[]} */
expressions = [];
/**
*
* @param {Expression} expression
* @param {ExpressionMetadata} metadata
*/
transform = (expression, metadata) => {
if (metadata.has_await) {
const length = this.expressions.push(expression);
return b.id(`$$${length - 1}`);
}
return expression;
};
apply() {
if (this.expressions.length === 1) {
return b.const('$$0', this.expressions[0]);
}
const promises = b.array(
this.expressions.map((expression) => {
return expression.type === 'AwaitExpression' && !has_await(expression.argument)
? expression.argument
: b.call(b.thunk(expression, true));
})
);
return b.const(
b.array_pattern(this.expressions.map((_, i) => b.id(`$$${i}`))),
b.await(b.call('Promise.all', promises))
);
}
}

@ -1,10 +1,10 @@
import type { AST, Binding, StateField } from '#compiler';
import type {
AwaitExpression,
CallExpression,
ClassBody,
Identifier,
LabeledStatement,
Node,
Program
} from 'estree';
import type { Scope, ScopeRoot } from './scope.js';
@ -47,6 +47,8 @@ export interface Analysis {
/** A set of deriveds that contain `await` expressions */
async_deriveds: Set<CallExpression>;
/** Awaits needing context preservation */
pickled_awaits: Set<AwaitExpression>;
}
export interface ComponentAnalysis extends Analysis {

@ -344,6 +344,8 @@ export namespace AST {
has_spread: boolean;
scoped: boolean;
path: SvelteNode[];
/** Synthetic value attribute for <option> with single expression child, used for client-only handling */
synthetic_value_node: ExpressionTag | null;
};
}

@ -609,3 +609,19 @@ export function build_assignment_value(operator, left, right) {
? b.logical(/** @type {ESTree.LogicalOperator} */ (operator.slice(0, -1)), left, right)
: b.binary(/** @type {ESTree.BinaryOperator} */ (operator.slice(0, -1)), left, right);
}
/**
* @param {ESTree.Expression} expression
*/
export function has_await(expression) {
let has_await = false;
walk(expression, null, {
AwaitExpression(_node, context) {
has_await = true;
context.stop();
}
});
return has_await;
}

@ -2,6 +2,7 @@
import { walk } from 'zimmerframe';
import { regex_is_valid_identifier } from '../phases/patterns.js';
import { sanitize_template_string } from './sanitize_template_string.js';
import { has_await } from './ast.js';
/**
* @param {Array<ESTree.Expression | ESTree.SpreadElement | null>} elements
@ -443,16 +444,7 @@ export function thunk(expression, async = false) {
export function unthunk(expression) {
// optimize `async () => await x()`, but not `async () => await x(await y)`
if (expression.async && expression.body.type === 'AwaitExpression') {
let has_await = false;
walk(expression.body.argument, null, {
AwaitExpression(_node, context) {
has_await = true;
context.stop();
}
});
if (!has_await) {
if (!has_await(expression.body.argument)) {
return unthunk(arrow(expression.params, expression.body.argument));
}
}

@ -1,12 +1,12 @@
/** @import { Component } from '#server' */
import { current_component } from './internal/server/context.js';
/** @import { SSRContext } from '#server' */
/** @import { Payload } from './internal/server/payload.js' */
import { ssr_context } from './internal/server/context.js';
import { noop } from './internal/shared/utils.js';
import * as e from './internal/server/errors.js';
/** @param {() => void} fn */
export function onDestroy(fn) {
var context = /** @type {Component} */ (current_component);
(context.d ??= []).push(fn);
/** @type {Payload} */ (/** @type {SSRContext} */ (ssr_context).r).on_destroy(fn);
}
export {

@ -1,5 +1,11 @@
/** @import { Effect, Source, TemplateNode, } from '#client' */
import { BOUNDARY_EFFECT, EFFECT_PRESERVED, EFFECT_TRANSPARENT } from '#client/constants';
import {
BOUNDARY_EFFECT,
COMMENT_NODE,
EFFECT_PRESERVED,
EFFECT_TRANSPARENT
} from '#client/constants';
import { HYDRATION_START_ELSE } from '../../../../constants.js';
import { component_context, set_component_context } from '../../context.js';
import { handle_error, invoke_error_boundary } from '../../error-handling.js';
import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js';
@ -57,8 +63,8 @@ export class Boundary {
/** @type {TemplateNode} */
#anchor;
/** @type {TemplateNode} */
#hydrate_open;
/** @type {TemplateNode | null} */
#hydrate_open = hydrating ? hydrate_node : null;
/** @type {BoundaryProps} */
#props;
@ -83,6 +89,7 @@ export class Boundary {
#local_pending_count = 0;
#pending_count = 0;
#is_creating_fallback = false;
/**
@ -122,8 +129,6 @@ export class Boundary {
this.#props = props;
this.#children = children;
this.#hydrate_open = hydrate_node;
this.parent = /** @type {Effect} */ (active_effect).b;
this.#pending = !!this.#props.pending;
@ -132,34 +137,18 @@ export class Boundary {
/** @type {Effect} */ (active_effect).b = this;
if (hydrating) {
const comment = this.#hydrate_open;
hydrate_next();
}
const pending = this.#props.pending;
if (hydrating && pending) {
this.#pending_effect = branch(() => pending(this.#anchor));
// future work: when we have some form of async SSR, we will
// need to use hydration boundary comments to report whether
// the pending or main block was rendered for a given
// boundary, and hydrate accordingly
Batch.enqueue(() => {
this.#main_effect = this.#run(() => {
Batch.ensure();
return branch(() => this.#children(this.#anchor));
});
if (this.#pending_count > 0) {
this.#show_pending_snippet();
} else {
pause_effect(/** @type {Effect} */ (this.#pending_effect), () => {
this.#pending_effect = null;
});
const server_rendered_pending =
/** @type {Comment} */ (comment).nodeType === COMMENT_NODE &&
/** @type {Comment} */ (comment).data === HYDRATION_START_ELSE;
this.#pending = false;
}
});
if (server_rendered_pending) {
this.#hydrate_pending_content();
} else {
this.#hydrate_resolved_content();
}
} else {
try {
this.#main_effect = branch(() => children(this.#anchor));
@ -180,6 +169,43 @@ export class Boundary {
}
}
#hydrate_resolved_content() {
try {
this.#main_effect = branch(() => this.#children(this.#anchor));
} catch (error) {
this.error(error);
}
// Since server rendered resolved content, we never show pending state
// Even if client-side async operations are still running, the content is already displayed
this.#pending = false;
}
#hydrate_pending_content() {
const pending = this.#props.pending;
if (!pending) {
return;
}
this.#pending_effect = branch(() => pending(this.#anchor));
Batch.enqueue(() => {
this.#main_effect = this.#run(() => {
Batch.ensure();
return branch(() => this.#children(this.#anchor));
});
if (this.#pending_count > 0) {
this.#show_pending_snippet();
} else {
pause_effect(/** @type {Effect} */ (this.#pending_effect), () => {
this.#pending_effect = null;
});
this.#pending = false;
}
});
}
/**
* Returns `true` if the effect exists inside a boundary whose pending snippet is shown
* @returns {boolean}
@ -238,10 +264,10 @@ export class Boundary {
if (!this.has_pending_snippet()) {
if (this.parent) {
this.parent.#update_pending_count(d);
return;
}
e.await_outside_boundary();
// if there's no parent, we're in a scope with no pending snippet
return;
}
this.#pending_count += d;
@ -307,7 +333,7 @@ export class Boundary {
}
if (hydrating) {
set_hydrate_node(this.#hydrate_open);
set_hydrate_node(/** @type {TemplateNode} */ (this.#hydrate_open));
next();
set_hydrate_node(remove_nodes());
}
@ -330,7 +356,7 @@ export class Boundary {
// If the failure happened while flushing effects, current_batch can be null
Batch.ensure();
this.#pending_count = 0;
this.#local_pending_count = 0;
if (this.#failed_effect !== null) {
pause_effect(this.#failed_effect, () => {
@ -338,7 +364,9 @@ export class Boundary {
});
}
this.#pending = true;
// we intentionally do not try to find the nearest pending boundary. If this boundary has one, we'll render it on reset
// but it would be really weird to show the parent's boundary on a child reset.
this.#pending = this.has_pending_snippet();
this.#main_effect = this.#run(() => {
this.#is_creating_fallback = false;
@ -409,13 +437,7 @@ function move_effect(effect, fragment) {
}
export function get_boundary() {
const boundary = /** @type {Effect} */ (active_effect).b;
if (boundary === null) {
e.await_outside_boundary();
}
return boundary;
return /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b);
}
export function pending() {

@ -39,7 +39,6 @@ export function flatten(sync, async, fn) {
var parent = /** @type {Effect} */ (active_effect);
var restore = capture();
var boundary = get_boundary();
Promise.all(async.map((expression) => async_derived(expression)))
.then((result) => {
@ -60,7 +59,7 @@ export function flatten(sync, async, fn) {
unset_context();
})
.catch((error) => {
boundary.error(error);
invoke_error_boundary(error, parent);
});
}

@ -11,7 +11,7 @@ import {
import { HYDRATION_END, HYDRATION_ERROR, HYDRATION_START } from '../../constants.js';
import { active_effect } from './runtime.js';
import { push, pop, component_context } from './context.js';
import { component_root, branch } from './reactivity/effects.js';
import { component_root } from './reactivity/effects.js';
import {
hydrate_next,
hydrate_node,
@ -31,6 +31,7 @@ import * as e from './errors.js';
import { assign_nodes } from './dom/template.js';
import { is_passive_event } from '../../utils.js';
import { COMMENT_NODE } from './constants.js';
import { boundary } from './dom/blocks/boundary.js';
/**
* This is normally true block effects should run their intro transitions
@ -218,35 +219,41 @@ function _mount(Component, { target, anchor, props = {}, events, context, intro
var unmount = component_root(() => {
var anchor_node = anchor ?? target.appendChild(create_text());
branch(() => {
if (context) {
push({});
var ctx = /** @type {ComponentContext} */ (component_context);
ctx.c = context;
}
boundary(
/** @type {TemplateNode} */ (anchor_node),
{
pending: () => {}
},
(anchor_node) => {
if (context) {
push({});
var ctx = /** @type {ComponentContext} */ (component_context);
ctx.c = context;
}
if (events) {
// We can't spread the object or else we'd lose the state proxy stuff, if it is one
/** @type {any} */ (props).$$events = events;
}
if (events) {
// We can't spread the object or else we'd lose the state proxy stuff, if it is one
/** @type {any} */ (props).$$events = events;
}
if (hydrating) {
assign_nodes(/** @type {TemplateNode} */ (anchor_node), null);
}
if (hydrating) {
assign_nodes(/** @type {TemplateNode} */ (anchor_node), null);
}
should_intro = intro;
// @ts-expect-error the public typings are not what the actual function looks like
component = Component(anchor_node, props) || {};
should_intro = true;
should_intro = intro;
// @ts-expect-error the public typings are not what the actual function looks like
component = Component(anchor_node, props) || {};
should_intro = true;
if (hydrating) {
/** @type {Effect} */ (active_effect).nodes_end = hydrate_node;
}
if (hydrating) {
/** @type {Effect} */ (active_effect).nodes_end = hydrate_node;
}
if (context) {
pop();
if (context) {
pop();
}
}
});
);
return () => {
for (var event_name of registered_events) {

@ -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<Params>} */ (args.map((value) => () => value));
payload.out.push(
payload.push(
fn(...getters)
.render()
.trim()

@ -1,10 +1,14 @@
/** @import { Component } from '#server' */
/** @import { SSRContext } from '#server' */
import { DEV } from 'esm-env';
import { on_destroy } from './index.js';
import * as e from './errors.js';
/** @type {Component | null} */
export var current_component = null;
/** @type {SSRContext | null} */
export var ssr_context = null;
/** @param {SSRContext | null} v */
export function set_ssr_context(v) {
ssr_context = v;
}
/**
* @template T
@ -47,42 +51,35 @@ export function getAllContexts() {
* @returns {Map<unknown, unknown>}
*/
function get_or_init_context_map(name) {
if (current_component === null) {
if (ssr_context === null) {
e.lifecycle_outside_component(name);
}
return (current_component.c ??= new Map(get_parent_context(current_component) || undefined));
return (ssr_context.c ??= new Map(get_parent_context(ssr_context) || undefined));
}
/**
* @param {Function} [fn]
*/
export function push(fn) {
current_component = { p: current_component, c: null, d: null };
ssr_context = { p: ssr_context, c: null, r: null };
if (DEV) {
// component function
current_component.function = fn;
ssr_context.function = fn;
ssr_context.element = ssr_context.p?.element;
}
}
export function pop() {
var component = /** @type {Component} */ (current_component);
var ondestroy = component.d;
if (ondestroy) {
on_destroy.push(...ondestroy);
}
current_component = component.p;
ssr_context = /** @type {SSRContext} */ (ssr_context).p;
}
/**
* @param {Component} component_context
* @param {SSRContext} ssr_context
* @returns {Map<unknown, unknown> | null}
*/
function get_parent_context(component_context) {
let parent = component_context.p;
function get_parent_context(ssr_context) {
let parent = ssr_context.p;
while (parent !== null) {
const context_map = parent.c;
@ -94,3 +91,22 @@ function get_parent_context(component_context) {
return null;
}
/**
* Wraps an `await` expression in such a way that the component context that was
* active before the expression evaluated can be reapplied afterwards
* `await a + b()` becomes `(await $.save(a))() + b()`, meaning `b()` will have access
* to the context of its component.
* @template T
* @param {Promise<T>} promise
* @returns {Promise<() => T>}
*/
export async function save(promise) {
var previous_context = ssr_context;
var value = await promise;
return () => {
ssr_context = previous_context;
return value;
};
}

@ -1,30 +1,29 @@
/** @import { Component } from '#server' */
/** @import { SSRContext } from '#server' */
import { FILENAME } from '../../constants.js';
import {
is_tag_valid_with_ancestor,
is_tag_valid_with_parent
} from '../../html-tree-validation.js';
import { current_component } from './context.js';
import { set_ssr_context, ssr_context } from './context.js';
import * as e from './errors.js';
import { HeadPayload, Payload } from './payload.js';
import { Payload } from './payload.js';
// TODO move this
/**
* @typedef {{
* tag: string;
* parent: null | Element;
* filename: null | string;
* parent: undefined | Element;
* filename: undefined | string;
* line: number;
* column: number;
* }} Element
*/
/**
* @type {Element | null}
* This is exported so that it can be cleared between tests
* @type {Set<string>}
*/
let parent = null;
/** @type {Set<string>} */
let seen;
export let seen;
/**
* @param {Payload} payload
@ -40,15 +39,10 @@ function print_error(payload, message) {
// eslint-disable-next-line no-console
console.error(message);
payload.head.out.push(`<script>console.error(${JSON.stringify(message)})</script>`);
}
export function reset_elements() {
let old_parent = parent;
parent = null;
return () => {
parent = old_parent;
};
payload.child(
(payload) => payload.push(`<script>console.error(${JSON.stringify(message)})</script>`),
'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();
}

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

@ -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(`</${tag}>`);
payload.push(`</${tag}>`);
}
}
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<string, any>} Props
* @param {import('svelte').Component<Props> | ComponentType<SvelteComponent<Props>>} component
* @param {Component<Props> | ComponentType<SvelteComponent<Props>>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; 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 += `<style id="${hash}">${code}</style>`;
}
const body = payload.out.join('');
return {
head,
html: body,
body: body
};
} finally {
abort();
}
return Payload.render(/** @type {Component<Props>} */ (component), options);
}
/**
* @param {Payload} payload
* @param {(head_payload: Payload['head']) => void} fn
* @param {(payload: Payload) => Promise<void> | 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(`<svelte-css-wrapper style="display: contents; ${styles}">`);
payload.push(`<svelte-css-wrapper style="display: contents; ${styles}">`);
} else {
payload.out.push(`<g style="${styles}">`);
payload.push(`<g style="${styles}">`);
}
if (dynamic) {
payload.out.push('<!---->');
payload.push('<!---->');
}
component();
if (is_html) {
payload.out.push(`<!----></svelte-css-wrapper>`);
payload.push(`<!----></svelte-css-wrapper>`);
} else {
payload.out.push(`<!----></g>`);
payload.push(`<!----></g>`);
}
}
@ -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('<!--#' + uid + '-->');
const uid = payload.global.uid();
payload.push('<!--#' + uid + '-->');
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<void>)} 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 `<option ...>`
payload.child(children);
// post-children, `payload` has child content, possibly also with some number of hydration comments.
// we can compact this last chunk of content to see if it matches the select value...
payload.compact({
start: i,
fn: (content) => {
if (content.body.replace(/<!---->/g, '') === payload.local.select_value) {
// ...and if it does match the select value, we can compact the part of the payload representing the `<option ...>`
// to add the `selected` attribute to the end.
payload.compact({
start: i - 1,
end: i,
fn: (content) => {
return { body: content.body.slice(0, -1) + ' selected>', head: content.head };
}
});
}
return content;
}
});
}
children();
/**
* In the special case where an `option` element has no `value` attribute but
* the children of the `option` element are a single expression, we can simplify
* by running the children and passing the resulting value, which means
* we don't have to do all of the same parsing nonsense. It also means we can avoid
* coercing everything to a string.
* @param {Payload} payload
* @param {(() => unknown)} child
*/
export function simple_valueless_option(payload, child) {
const result = child();
var body = payload.out.slice(i).join('');
/**
* @param {AccumulatedContent} content
* @param {unknown} child_value
* @returns {AccumulatedContent}
*/
const mark_selected = (content, child_value) => {
if (child_value === payload.local.select_value) {
return { body: content.body.slice(0, -1) + ' selected>', head: content.head };
}
return content;
};
if (body.replace(/<!---->/g, '') === payload.select_value) {
// replace '>' with ' selected>' (closing tag will be added later)
var last_item = payload.out[i - 1];
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);
}
payload.compact({
start: payload.length - 1,
fn: (content) => {
if (result instanceof Promise) {
return result.then((child_value) => mark_selected(content, child_value));
}
return mark_selected(content, result);
}
});
payload.child((child_payload) => {
if (result instanceof Promise) {
return result.then((child_value) => {
child_payload.push(escape_html(child_value));
});
}
child_payload.push(escape_html(result));
});
}
/**
* Since your document can only have one `title`, we have to have some sort of algorithm for determining
* which one "wins". To do this, we perform a depth-first comparison of where the title was encountered --
* later ones "win" over earlier ones, regardless of what order the promises resolve in. To accomodate this, we:
* - Figure out where we are in the content tree (`get_path`)
* - Render the title in its own child so that it has a defined "slot" in the payload
* - Compact that spot so that we get the entire rendered contents of the title
* - Attempt to set the global title (this is where the "wins" logic based on the path happens)
*
* TODO we could optimize this by not even rendering the title if the path wouldn't be accepted
*
* @param {Payload} payload
* @param {((payload: Payload) => void | Promise<void>)} children
*/
export function build_title(payload, children) {
const path = payload.get_path();
const i = payload.length;
payload.child(children);
payload.compact({
start: i,
fn: ({ head }) => {
payload.global.set_title(head, path);
// since we can only ever render the title in this chunk, and title rendering is handled specially,
// we can just ditch the results after we've saved them globally
return { head: '', body: '' };
}
});
}

@ -1,80 +1,573 @@
export class HeadPayload {
/** @type {Set<{ hash: string; code: string }>} */
css = new Set();
/** @type {string[]} */
out = [];
uid = () => '';
title = '';
constructor(
/** @type {Set<{ hash: string; code: string }>} */ css = new Set(),
/** @type {string[]} */ out = [],
title = '',
uid = () => ''
) {
this.css = css;
this.out = out;
this.title = title;
this.uid = uid;
/** @import { Component } from 'svelte' */
/** @import { RenderOutput, SSRContext, SyncRenderOutput } from './types.js' */
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 * as e from './errors.js';
import * as w from './warnings.js';
import { BLOCK_CLOSE, BLOCK_OPEN } from './hydration.js';
/** @typedef {'head' | 'body'} PayloadType */
/** @typedef {{ [key in PayloadType]: string }} AccumulatedContent */
/**
* @template T
* @typedef {T | Promise<T>} MaybePromise<T>
*/
/**
* @typedef {string | Payload} PayloadItem
*/
/**
* Payloads are basically a tree of `string | Payload`s, where each `Payload` in the tree represents
* work that may or may not have completed. A payload can be {@link collect}ed to aggregate the
* content from itself and all of its children, but this will throw if any of the children are
* performing asynchronous work. To asynchronously collect a payload, just `await` it.
*
* The `string` values within a payload are always associated with the {@link type} of that payload. To switch types,
* call {@link child} with a different `type` argument.
*/
export class Payload {
/**
* The contents of the payload.
* @type {PayloadItem[]}
*/
#out = [];
/**
* Any `onDestroy` callbacks registered during execution of this payload.
* @type {(() => void)[] | undefined}
*/
#on_destroy = undefined;
/**
* Whether this payload is a component body.
* @type {boolean}
*/
#is_component_body = false;
/**
* The type of string content that this payload is accumulating.
* @type {PayloadType}
*/
type;
/** @type {Payload | undefined} */
#parent;
/**
* Asynchronous work associated with this payload. `initial` is the promise from the function
* this payload was passed to (if that function was async), and `followup` is any any additional
* work from `compact` calls that needs to complete prior to collecting this payload's content.
* @type {{ initial: Promise<void> | undefined, followup: Promise<void>[] }}
*/
promises = { initial: undefined, followup: [] };
/**
* State which is associated with the content tree as a whole.
* It will be re-exposed, uncopied, on all children.
* @type {SSRState}
* @readonly
*/
global;
/**
* State that is local to the branch it is declared in.
* It will be shallow-copied to all children.
*
* @type {{ select_value: string | undefined }}
*/
local;
/**
* @param {SSRState} global
* @param {Payload | undefined} [parent]
* @param {PayloadType} [type]
*/
constructor(global, parent, type) {
this.global = global;
this.local = parent ? { ...parent.local } : { select_value: undefined };
this.#parent = parent;
this.type = type ?? parent?.type ?? 'body';
}
/**
* Create a child payload. The child payload inherits the state from the parent,
* but has its own content.
* @param {(payload: Payload) => MaybePromise<void>} fn
* @param {PayloadType} [type]
*/
child(fn, type) {
const child = new Payload(this.global, this, type);
this.#out.push(child);
const parent = ssr_context;
set_ssr_context({
...ssr_context,
p: parent,
c: null,
r: child
});
const result = fn(child);
set_ssr_context(parent);
if (result instanceof Promise) {
if (child.global.mode === 'sync') {
e.await_invalid();
}
// just to avoid unhandled promise rejections -- we'll end up throwing in `collect_async` if something fails
result.catch(() => {});
child.promises.initial = result;
}
return child;
}
/**
* Create a component payload. The component payload inherits the state from the parent,
* but has its own content. It is treated as an ordering boundary for ondestroy callbacks.
* @param {(payload: Payload) => MaybePromise<void>} fn
* @param {Function} [component_fn]
* @returns {void}
*/
component(fn, component_fn) {
push(component_fn);
const child = this.child(fn);
child.#is_component_body = true;
pop();
}
/**
* @param {string | (() => Promise<string>)} content
*/
push(content) {
if (typeof content === 'function') {
this.child(async (payload) => payload.push(await content()));
} else {
this.#out.push(content);
}
}
/**
* Compact everything between `start` and `end` into a single payload, then call `fn` with the result of that payload.
* The compacted payload will be sync if all of the children are sync and {@link fn} is sync, otherwise it will be async.
* @param {{ start: number, end?: number, fn: (content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent> }} args
*/
compact({ start, end = this.#out.length, fn }) {
const child = new Payload(this.global, this);
const to_compact = this.#out.splice(start, end - start, child);
if (this.global.mode === 'sync') {
Payload.#compact(fn, child, to_compact, this.type);
} else {
this.promises.followup.push(Payload.#compact_async(fn, child, to_compact, this.type));
}
}
/**
* @param {() => void} fn
*/
on_destroy(fn) {
(this.#on_destroy ??= []).push(fn);
}
/**
* @returns {number[]}
*/
get_path() {
return this.#parent ? [...this.#parent.get_path(), this.#parent.#out.indexOf(this)] : [];
}
/**
* @deprecated this is needed for legacy component bindings
*/
copy() {
const copy = new Payload(this.global, this.#parent, this.type);
copy.#out = this.#out.map((item) => (item instanceof Payload ? item.copy() : item));
copy.promises = this.promises;
return copy;
}
/**
* @param {Payload} other
* @deprecated this is needed for legacy component bindings
*/
subsume(other) {
if (this.global.mode !== other.global.mode) {
throw new Error(
"invariant: A payload cannot switch modes. If you're seeing this, there's a compiler bug. File an issue!"
);
}
this.local = other.local;
this.#out = other.#out.map((item) => {
if (item instanceof Payload) {
item.subsume(item);
}
return item;
});
this.promises = other.promises;
this.type = other.type;
}
get length() {
return this.#out.length;
}
/**
* 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<string, any>} Props
* @param {Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options]
* @returns {RenderOutput}
*/
static render(component, options = {}) {
/** @type {AccumulatedContent | undefined} */
let sync;
/** @type {Promise<AccumulatedContent> | undefined} */
let async;
const result = /** @type {RenderOutput} */ ({});
// making these properties non-enumerable so that console.logging
// doesn't trigger a sync render
Object.defineProperties(result, {
html: {
get: () => {
return (sync ??= Payload.#render(component, options)).body;
}
},
head: {
get: () => {
return (sync ??= Payload.#render(component, options)).head;
}
},
body: {
get: () => {
return (sync ??= Payload.#render(component, options)).body;
}
},
then: {
value:
/**
* this is not type-safe, but honestly it's the best I can do right now, and it's a straightforward function.
*
* @template TResult1
* @template [TResult2=never]
* @param { (value: SyncRenderOutput) => TResult1 } onfulfilled
* @param { (reason: unknown) => TResult2 } onrejected
*/
(onfulfilled, onrejected) => {
if (!async_mode_flag) {
w.experimental_async_ssr();
const result = (sync ??= Payload.#render(component, options));
const user_result = onfulfilled({
head: result.head,
body: result.body,
html: result.body
});
return Promise.resolve(user_result);
}
async ??= Payload.#render_async(component, options);
return async.then((result) => {
Object.defineProperty(result, 'html', {
// eslint-disable-next-line getter-return
get: () => {
e.html_deprecated();
}
});
return onfulfilled(/** @type {SyncRenderOutput} */ (result));
}, onrejected);
}
}
});
return result;
}
/**
* Collect all of the `onDestroy` callbacks regsitered during rendering. In an async context, this is only safe to call
* after awaiting `collect_async`.
*
* Child payloads are "porous" and don't affect execution order, but component body payloads
* create ordering boundaries. Within a payload, callbacks run in order until hitting a component boundary.
* @returns {Iterable<() => void>}
*/
*#collect_on_destroy() {
for (const component of this.#traverse_components()) {
yield* component.#collect_ondestroy();
}
}
/**
* Performs a depth-first search of payloads, yielding the deepest components first, then additional components as we backtrack up the tree.
* @returns {Iterable<Payload>}
*/
*#traverse_components() {
for (const child of this.#out) {
if (typeof child !== 'string') {
yield* child.#traverse_components();
}
}
if (this.#is_component_body) {
yield this;
}
}
/**
* @returns {Iterable<() => void>}
*/
*#collect_ondestroy() {
if (this.#on_destroy) {
for (const fn of this.#on_destroy) {
yield fn;
}
}
for (const child of this.#out) {
if (child instanceof Payload && !child.#is_component_body) {
yield* child.#collect_ondestroy();
}
}
}
/**
* Render a component. Throws if any of the children are performing asynchronous work.
*
* @template {Record<string, any>} Props
* @param {Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
* @returns {AccumulatedContent}
*/
static #render(component, options) {
var previous_context = ssr_context;
try {
const payload = Payload.#open_render('sync', component, options);
const content = Payload.#collect_content([payload], payload.type);
return Payload.#close_render(content, payload);
} finally {
abort();
set_ssr_context(previous_context);
}
}
/**
* Render a component.
*
* @template {Record<string, any>} Props
* @param {Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
* @returns {Promise<AccumulatedContent>}
*/
static async #render_async(component, options) {
var previous_context = ssr_context;
try {
const payload = Payload.#open_render('async', component, options);
const content = await Payload.#collect_content_async([payload], payload.type);
return Payload.#close_render(content, payload);
} finally {
abort();
set_ssr_context(previous_context);
}
}
/**
* @param {(content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent>} fn
* @param {Payload} child
* @param {PayloadItem[]} to_compact
* @param {PayloadType} type
*/
static #compact(fn, child, to_compact, type) {
const content = Payload.#collect_content(to_compact, type);
const transformed_content = fn(content);
if (transformed_content instanceof Promise) {
throw new Error(
"invariant: Somehow you've encountered asynchronous work while rendering synchronously. If you're seeing this, there's a compiler bug. File an issue!"
);
} else {
Payload.#push_accumulated_content(child, transformed_content);
}
}
/**
* @param {(content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent>} fn
* @param {Payload} child
* @param {PayloadItem[]} to_compact
* @param {PayloadType} type
*/
static async #compact_async(fn, child, to_compact, type) {
const content = await Payload.#collect_content_async(to_compact, type);
const transformed_content = await fn(content);
Payload.#push_accumulated_content(child, transformed_content);
}
/**
* Collect all of the code from the `out` array and return it as a string, or a promise resolving to a string.
* @param {PayloadItem[]} items
* @param {PayloadType} current_type
* @param {AccumulatedContent} content
* @returns {AccumulatedContent}
*/
static #collect_content(items, current_type, content = { head: '', body: '' }) {
for (const item of items) {
if (typeof item === 'string') {
content[current_type] += item;
} else if (item instanceof Payload) {
Payload.#collect_content(item.#out, item.type, content);
}
}
return content;
}
/**
* Collect all of the code from the `out` array and return it as a string.
* @param {PayloadItem[]} items
* @param {PayloadType} current_type
* @param {AccumulatedContent} content
* @returns {Promise<AccumulatedContent>}
*/
static async #collect_content_async(items, current_type, content = { head: '', body: '' }) {
// no danger to sequentially awaiting stuff in here; all of the work is already kicked off
for (const item of items) {
if (typeof item === 'string') {
content[current_type] += item;
} else {
if (item.promises.initial) {
// this represents the async function that's modifying this payload.
// we can't do anything until it's done and we know our `out` array is complete.
await item.promises.initial;
}
for (const followup of item.promises.followup) {
// this is sequential because `compact` could synchronously queue up additional followup work
await followup;
}
await Payload.#collect_content_async(item.#out, item.type, content);
}
}
return content;
}
/**
* @param {Payload} tree
* @param {AccumulatedContent} accumulated_content
*/
static #push_accumulated_content(tree, accumulated_content) {
for (const [type, content] of Object.entries(accumulated_content)) {
if (!content) continue;
const child = new Payload(tree.global, tree, /** @type {PayloadType} */ (type));
child.push(content);
tree.#out.push(child);
}
}
/**
* @template {Record<string, any>} Props
* @param {'sync' | 'async'} mode
* @param {import('svelte').Component<Props>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} options
* @returns {Payload}
*/
static #open_render(mode, component, options) {
const payload = new Payload(new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : ''));
payload.push(BLOCK_OPEN);
if (options.context) {
push();
/** @type {SSRContext} */ (ssr_context).c = options.context;
/** @type {SSRContext} */ (ssr_context).r = payload;
}
// @ts-expect-error
component(payload, options.props ?? {});
if (options.context) {
pop();
}
payload.push(BLOCK_CLOSE);
return payload;
}
/**
* @param {AccumulatedContent} content
* @param {Payload} payload
*/
static #close_render(content, payload) {
for (const cleanup of payload.#collect_on_destroy()) {
cleanup();
}
let head = content.head + payload.global.get_title();
const body = BLOCK_OPEN + content.body + BLOCK_CLOSE; // this inserts a fake boundary so hydration matches
for (const { hash, code } of payload.global.css) {
head += `<style id="${hash}">${code}</style>`;
}
return {
head,
body
};
}
}
export class Payload {
/** @type {Set<{ hash: string; code: string }>} */
export class SSRState {
/** @readonly @type {'sync' | 'async'} */
mode;
/** @readonly @type {() => string} */
uid;
/** @readonly @type {Set<{ hash: string; code: string }>} */
css = new Set();
/** @type {string[]} */
out = [];
uid = () => '';
select_value = undefined;
head = new HeadPayload();
/** @type {{ path: number[], value: string }} */
#title = { path: [], value: '' };
constructor(id_prefix = '') {
this.uid = props_id_generator(id_prefix);
this.head.uid = this.uid;
/**
* @param {'sync' | 'async'} mode
* @param {string} [id_prefix]
*/
constructor(mode, id_prefix = '') {
this.mode = mode;
let uid = 1;
this.uid = () => `${id_prefix}s${uid++}`;
}
}
/**
* Used in legacy mode to handle bindings
* @param {Payload} to_copy
* @returns {Payload}
*/
export function copy_payload({ out, css, head, uid }) {
const payload = new Payload();
get_title() {
return this.#title.value;
}
payload.out = [...out];
payload.css = new Set(css);
payload.uid = uid;
/**
* Performs a depth-first (lexicographic) comparison using the path. Rejects sets
* from earlier than or equal to the current value.
* @param {string} value
* @param {number[]} path
*/
set_title(value, path) {
const current = this.#title.path;
payload.head = new HeadPayload();
payload.head.out = [...head.out];
payload.head.css = new Set(head.css);
payload.head.title = head.title;
payload.head.uid = head.uid;
let i = 0;
let l = Math.min(path.length, current.length);
return payload;
}
// skip identical prefixes - [1, 2, 3, ...] === [1, 2, 3, ...]
while (i < l && path[i] === current[i]) i += 1;
/**
* Assigns second payload to first
* @param {Payload} p1
* @param {Payload} p2
* @returns {void}
*/
export function assign_payload(p1, p2) {
p1.out = [...p2.out];
p1.css = p2.css;
p1.head = p2.head;
p1.uid = p2.uid;
}
if (path[i] === undefined) return;
/**
* Creates an ID generator
* @param {string} prefix
* @returns {() => string}
*/
function props_id_generator(prefix) {
let uid = 1;
return () => `${prefix}s${uid++}`;
// replace title if
// - incoming path is longer - [7, 8, 9] > [7, 8]
// - incoming path is later - [7, 8, 9] > [7, 8, 8]
if (current[i] === undefined || path[i] > current[i]) {
this.#title.path = path;
this.#title.value = value;
}
}
}

@ -0,0 +1,364 @@
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { Payload, SSRState } from './payload.js';
import type { Component } from 'svelte';
import { disable_async_mode_flag, enable_async_mode_flag } from '../flags/index.js';
test('collects synchronous body content by default', () => {
const component = (payload: Payload) => {
payload.push('a');
payload.child(($$payload) => {
$$payload.push('b');
});
payload.push('c');
};
const { head, body } = Payload.render(component as unknown as Component);
expect(head).toBe('');
expect(body).toBe('<!--[--><!--[-->abc<!--]--><!--]-->');
});
test('child type switches content area (head vs body)', () => {
const component = (payload: Payload) => {
payload.push('a');
payload.child(($$payload) => {
$$payload.push('<title>T</title>');
}, 'head');
payload.push('b');
};
const { head, body } = Payload.render(component as unknown as Component);
expect(head).toBe('<title>T</title>');
expect(body).toBe('<!--[--><!--[-->ab<!--]--><!--]-->');
});
test('child inherits parent type when not specified', () => {
const component = (payload: Payload) => {
payload.child((payload) => {
payload.push('<meta name="x"/>');
payload.child((payload) => {
payload.push('<style>/* css */</style>');
});
}, 'head');
};
const { head, body } = Payload.render(component as unknown as Component);
expect(body).toBe('<!--[--><!--[--><!--]--><!--]-->');
expect(head).toBe('<meta name="x"/><style>/* css */</style>');
});
test('get_path returns the path indexes to a payload', () => {
const root = new Payload(new SSRState('sync'));
let child_a: InstanceType<typeof Payload> | undefined;
let child_b: InstanceType<typeof Payload> | undefined;
let child_b_0: InstanceType<typeof Payload> | undefined;
root.child(($$payload) => {
child_a = $$payload;
$$payload.push('A');
});
root.child(($$payload) => {
child_b = $$payload;
$$payload.child(($$inner) => {
child_b_0 = $$inner;
$$inner.push('B0');
});
$$payload.push('B1');
});
expect(child_a!.get_path()).toEqual([0]);
expect(child_b!.get_path()).toEqual([1]);
expect(child_b_0!.get_path()).toEqual([1, 0]);
});
test('creating an async child in a sync context throws', () => {
const component = (payload: Payload) => {
payload.push('a');
payload.child(async ($$payload) => {
await Promise.resolve();
$$payload.push('x');
});
};
expect(() => Payload.render(component as unknown as Component).head).toThrow('await_invalid');
expect(() => Payload.render(component as unknown as Component).html).toThrow('await_invalid');
expect(() => Payload.render(component as unknown as Component).body).toThrow('await_invalid');
});
test('compact synchronously aggregates a range and can transform into head/body', () => {
const component = (payload: Payload) => {
const start = payload.length;
payload.push('a');
payload.push('b');
payload.push('c');
payload.compact({
start,
end: start + 2,
fn: (content) => {
return { head: '<h>H</h>', body: content.body + 'd' };
}
});
};
const { head, body } = Payload.render(component as unknown as Component);
expect(head).toBe('<h>H</h>');
expect(body).toBe('<!--[--><!--[-->abdc<!--]--><!--]-->');
});
test('local state is shallow-copied to children', () => {
const root = new Payload(new SSRState('sync'));
root.local.select_value = 'A';
let child: InstanceType<typeof Payload> | undefined;
root.child(($$payload) => {
child = $$payload;
});
expect(child!.local.select_value).toBe('A');
child!.local.select_value = 'B';
expect(root.local.select_value).toBe('A');
});
test('subsume replaces tree content and state from other', () => {
const a = new Payload(new SSRState('async'), undefined, 'head');
a.push('<meta />');
a.local.select_value = 'A';
const b = new Payload(new SSRState('async'));
b.child(async ($$payload) => {
await Promise.resolve();
$$payload.push('body');
});
b.global.css.add({ hash: 'h', code: 'c' });
b.global.set_title('Title', [1]);
b.local.select_value = 'B';
b.promises.initial = Promise.resolve();
a.subsume(b);
expect(a.type).toBe('body');
expect(a.local.select_value).toBe('B');
expect(a.promises).toBe(b.promises);
});
test('subsume refuses to switch modes', () => {
const a = new Payload(new SSRState('sync'), undefined, 'head');
a.push('<meta />');
a.local.select_value = 'A';
const b = new Payload(new SSRState('async'));
b.child(async ($$payload) => {
await Promise.resolve();
$$payload.push('body');
});
b.global.css.add({ hash: 'h', code: 'c' });
b.global.set_title('Title', [1]);
b.local.select_value = 'B';
b.promises.initial = Promise.resolve();
expect(() => a.subsume(b)).toThrow(
"invariant: A payload cannot switch modes. If you're seeing this, there's a compiler bug. File an issue!"
);
});
test('SSRState uid generator uses prefix', () => {
const state = new SSRState('sync', 'id-');
expect(state.uid()).toBe('id-s1');
});
test('SSRState title ordering favors later lexicographic paths', () => {
const state = new SSRState('sync');
state.set_title('A', [1]);
expect(state.get_title()).toBe('A');
// equal path -> unchanged
state.set_title('B', [1]);
expect(state.get_title()).toBe('A');
// earlier -> unchanged
state.set_title('C', [0, 9]);
expect(state.get_title()).toBe('A');
// later -> update
state.set_title('D', [2]);
expect(state.get_title()).toBe('D');
// longer but same prefix -> update
state.set_title('E', [2, 0]);
expect(state.get_title()).toBe('E');
// shorter (earlier) than current with same prefix -> unchanged
state.set_title('F', [2]);
expect(state.get_title()).toBe('E');
});
describe('async', () => {
beforeAll(() => {
enable_async_mode_flag();
});
afterAll(() => {
disable_async_mode_flag();
});
test('awaiting payload gets async content', async () => {
const component = (payload: Payload) => {
payload.push('1');
payload.child(async ($$payload) => {
await Promise.resolve();
$$payload.push('2');
});
payload.push('3');
};
const result = await Payload.render(component as unknown as Component);
expect(result.head).toBe('');
expect(result.body).toBe('<!--[--><!--[-->123<!--]--><!--]-->');
expect(() => result.html).toThrow('html_deprecated');
});
test('compact schedules followup when compaction input is async', async () => {
const component = (payload: Payload) => {
payload.push('a');
payload.child(async ($$payload) => {
await Promise.resolve();
$$payload.push('X');
});
payload.push('b');
payload.compact({
start: 0,
fn: async (content) => ({
body: content.body.toLowerCase(),
head: await Promise.resolve('')
})
});
};
const { body, head } = await Payload.render(component as unknown as Component);
expect(head).toBe('');
expect(body).toBe('<!--[--><!--[-->axb<!--]--><!--]-->');
});
test('push accepts async functions in async context', async () => {
const component = (payload: Payload) => {
payload.push('a');
payload.push(async () => {
await Promise.resolve();
return 'b';
});
payload.push('c');
};
const { head, body } = await Payload.render(component as unknown as Component);
expect(head).toBe('');
expect(body).toBe('<!--[--><!--[-->abc<!--]--><!--]-->');
});
test('push handles async functions with different timing', async () => {
const component = (payload: Payload) => {
payload.push(async () => {
await Promise.resolve();
return 'fast';
});
payload.push(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return 'slow';
});
payload.push('sync');
};
const { head, body } = await Payload.render(component as unknown as Component);
expect(head).toBe('');
expect(body).toBe('<!--[--><!--[-->fastslowsync<!--]--><!--]-->');
});
test('push async functions work with head content type', async () => {
const component = (payload: Payload) => {
payload.child(($$payload) => {
$$payload.push(async () => {
await Promise.resolve();
return '<title>Async Title</title>';
});
}, 'head');
};
const { head, body } = await Payload.render(component as unknown as Component);
expect(body).toBe('<!--[--><!--[--><!--]--><!--]-->');
expect(head).toBe('<title>Async Title</title>');
});
test('push async functions can be mixed with child payloads', async () => {
const component = (payload: Payload) => {
payload.push('start-');
payload.push(async () => {
await Promise.resolve();
return 'async-';
});
payload.child(($$payload) => {
$$payload.push('child-');
});
payload.push('-end');
};
const { head, body } = await Payload.render(component as unknown as Component);
expect(head).toBe('');
expect(body).toBe('<!--[--><!--[-->start-async-child--end<!--]--><!--]-->');
});
test('push async functions work with compact operations', async () => {
const component = (payload: Payload) => {
payload.push('a');
payload.push(async () => {
await Promise.resolve();
return 'b';
});
payload.push('c');
payload.compact({
start: 0,
fn: (content) => ({ head: '', body: content.body.toUpperCase() })
});
};
const { head, body } = await Payload.render(component as unknown as Component);
expect(head).toBe('');
expect(body).toBe('<!--[--><!--[-->ABC<!--]--><!--]-->');
});
test('push async functions are not supported in sync context', () => {
const component = (payload: Payload) => {
payload.push('a');
payload.push(() => Promise.resolve('b'));
};
expect(() => Payload.render(component as unknown as Component).body).toThrow('await_invalid');
expect(() => Payload.render(component as unknown as Component).html).toThrow('await_invalid');
expect(() => Payload.render(component as unknown as Component).head).toThrow('await_invalid');
});
test('on_destroy yields callbacks in the correct order', async () => {
const destroyed: string[] = [];
const component = (payload: Payload) => {
payload.component((payload) => {
payload.on_destroy(() => destroyed.push('a'));
// children should not alter relative order
payload.child(async (payload) => {
await Promise.resolve();
payload.on_destroy(() => destroyed.push('b'));
payload.on_destroy(() => destroyed.push('b*'));
});
// but child components should
payload.component((payload) => {
payload.on_destroy(() => destroyed.push('c'));
});
payload.child((payload) => {
payload.on_destroy(() => destroyed.push('d'));
});
payload.component((payload) => {
payload.on_destroy(() => destroyed.push('e'));
});
});
};
await Payload.render(component as unknown as Component);
expect(destroyed).toEqual(['c', 'e', 'a', 'b', 'b*', 'd']);
});
});

@ -1,17 +1,20 @@
export interface Component {
import type { Element } from './dev';
import type { Payload } from './payload';
export interface SSRContext {
/** parent */
p: null | Component;
/** context */
p: null | SSRContext;
/** component context */
c: null | Map<unknown, unknown>;
/** ondestroy */
d: null | Array<() => void>;
/**
* dev mode only: the component function
*/
/** payload (renderer) */
r: null | Payload;
/** dev mode only: the current component function */
function?: any;
/** dev mode only: the current element */
element?: Element;
}
export interface RenderOutput {
export interface SyncRenderOutput {
/** HTML that goes into the `<head>` */
head: string;
/** @deprecated use `body` instead */
@ -19,3 +22,5 @@ export interface RenderOutput {
/** HTML that goes somewhere into the `<body>` */
body: string;
}
export type RenderOutput = SyncRenderOutput & PromiseLike<SyncRenderOutput>;

@ -0,0 +1,17 @@
/* This file is generated by scripts/process-messages/index.js. Do not edit! */
import { DEV } from 'esm-env';
var bold = 'font-weight: bold';
var normal = 'font-weight: normal';
/**
* Attempted to use asynchronous rendering without `experimental.async` enabled
*/
export function experimental_async_ssr() {
if (DEV) {
console.warn(`%c[svelte] experimental_async_ssr\n%cAttempted to use asynchronous rendering without \`experimental.async\` enabled\nhttps://svelte.dev/e/experimental_async_ssr`, bold, normal);
} else {
console.warn(`https://svelte.dev/e/experimental_async_ssr`);
}
}

@ -2,22 +2,6 @@
import { DEV } from 'esm-env';
/**
* Cannot await outside a `<svelte:boundary>` with a `pending` snippet
* @returns {never}
*/
export function await_outside_boundary() {
if (DEV) {
const error = new Error(`await_outside_boundary\nCannot await outside a \`<svelte:boundary>\` with a \`pending\` snippet\nhttps://svelte.dev/e/await_outside_boundary`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/await_outside_boundary`);
}
}
/**
* Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead
* @returns {never}

@ -31,6 +31,7 @@ export function asClassComponent(component) {
html: result.body
};
};
// @ts-expect-error this is present for SSR
component_constructor.render = _render;

@ -1 +1 @@
<!--[--><input> <p>Hello world!</p><!--]-->
<!--[--><!--[--><input> <p>Hello world!</p><!--]--><!--]-->

@ -1 +1 @@
<!--[--> <p>start</p><!--[--><p>cond</p><!--]--><!--]-->
<!--[--><!--[--> <p>start</p><!--[--><p>cond</p><!--]--><!--]--><!--]-->

@ -1 +1 @@
<!--[--><h1>Hello everybody!</h1><!--]-->
<!--[--><!--[--><h1>Hello everybody!</h1><!--]--><!--]-->

@ -1 +1 @@
<!--[--><ul><!--[--><li>a</li><!--]--></ul> <ul><!--[--><li>a</li><!--]--></ul> <ul><!--[--><li>a</li><!--]--></ul> <!--[--><li>a</li> <li>a</li><!--]--> <!--[--><li>a</li> <li>a</li><!--]--> <!--[--><li>a</li> <li>a</li><!--]--><!--]-->
<!--[--><!--[--><ul><!--[--><li>a</li><!--]--></ul> <ul><!--[--><li>a</li><!--]--></ul> <ul><!--[--><li>a</li><!--]--></ul> <!--[--><li>a</li> <li>a</li><!--]--> <!--[--><li>a</li> <li>a</li><!--]--> <!--[--><li>a</li> <li>a</li><!--]--><!--]--><!--]-->

@ -1 +1 @@
<!--[--><!--[!--><p>a</p><!--]--> <!--[--><p>empty</p><!--]--><!--]-->
<!--[--><!--[--><!--[!--><p>a</p><!--]--> <!--[--><p>empty</p><!--]--><!--]--><!--]-->

@ -1 +1 @@
<!--[--><ul><!--[--><li>a</li><!--]--></ul> <ul><!--[--><li>a</li><!--]--></ul> <ul><!--[--><li>a</li><!--]--></ul> <!--[--><li>a</li> <li>a</li><!--]--> <!--[--><li>a</li> <li>a</li><!--]--> <!--[--><li>a</li> <li>a</li><!--]--><!--]-->
<!--[--><!--[--><ul><!--[--><li>a</li><!--]--></ul> <ul><!--[--><li>a</li><!--]--></ul> <ul><!--[--><li>a</li><!--]--></ul> <!--[--><li>a</li> <li>a</li><!--]--> <!--[--><li>a</li> <li>a</li><!--]--> <!--[--><li>a</li> <li>a</li><!--]--><!--]--><!--]-->

@ -1 +1 @@
<!--[--><ul><!--[--><li>a</li><li>b</li><!--]--></ul> <ul><!--[--><li>a</li><li>b</li><!--]--></ul> <ul><!--[--><li>a</li><li>b</li><!--]--></ul> <!--[--><li>a</li> <li>a</li><li>b</li> <li>b</li><!--]--> <!--[--><li>a</li> <li>a</li><li>b</li> <li>b</li><!--]--> <!--[--><li>a</li> <li>a</li><li>b</li> <li>b</li><!--]--><!--]-->
<!--[--><!--[--><ul><!--[--><li>a</li><li>b</li><!--]--></ul> <ul><!--[--><li>a</li><li>b</li><!--]--></ul> <ul><!--[--><li>a</li><li>b</li><!--]--></ul> <!--[--><li>a</li> <li>a</li><li>b</li> <li>b</li><!--]--> <!--[--><li>a</li> <li>a</li><li>b</li> <li>b</li><!--]--> <!--[--><li>a</li> <li>a</li><li>b</li> <li>b</li><!--]--><!--]--><!--]-->

@ -1 +1 @@
<!--[--><div class="bar"></div><!--]-->
<!--[--><!--[--><div class="bar"></div><!--]--><!--]-->

@ -1 +1 @@
<!--[--><div class="bar"></div><!--]-->
<!--[--><!--[--><div class="bar"></div><!--]--><!--]-->

@ -1 +1 @@
<!--[--><div></div> <!--[--><div></div> <div></div><!--]--><!--]-->
<!--[--><!--[--><div></div> <!--[--><div></div> <div></div><!--]--><!--]--><!--]-->

@ -1 +1 @@
<!--[--><!--[--><!--]--> <div><!----><!----></div> hello<!--]-->
<!--[--><!--[--><!--[--><!--]--> <div><!----><!----></div> hello<!--]--><!--]-->

@ -1 +1 @@
<!--[--><!--[!--><p>foo</p><!--]--><!--]-->
<!--[--><!--[--><!--[!--><p>foo</p><!--]--><!--]--><!--]-->

@ -1 +1 @@
<!--[--><!---->.<input><!--]-->
<!--[--><!--[--><!---->.<input><!--]--><!--]-->

@ -1 +1 @@
<!--[--><input type="text"><!--]-->
<!--[--><!--[--><input type="text"><!--]--><!--]-->

@ -1 +1 @@
<!--[--><noscript>JavaScript is required for this site.</noscript> <h1>Hello!</h1><p>Count: 1</p><!--]-->
<!--[--><!--[--><noscript>JavaScript is required for this site.</noscript> <h1>Hello!</h1><p>Count: 1</p><!--]--><!--]-->

@ -1,7 +1,7 @@
<!--[--><pre>static content no line</pre> <pre> static content ignored line
<!--[--><!--[--><pre>static content no line</pre> <pre> static content ignored line
</pre> <pre>
static content relevant line
</pre> <pre><div><span></span></div>
</pre> <pre>
<div><span></span></div>
</pre><!--]-->
</pre><!--]--><!--]-->

@ -1 +1 @@
<!--[--><div></div><!--]-->
<!--[--><!--[--><div></div><!--]--><!--]-->

@ -1 +1 @@
<!--[--><a href="/foo">foo</a> <a href="/foo">foo</a><!--]-->
<!--[--><!--[--><a href="/foo">foo</a> <a href="/foo">foo</a><!--]--><!--]-->

@ -1 +1 @@
<!--[--><h1>call <a href="tel:+636-555-3226">+636-555-3226</a> now<span>!</span></h1><!--]-->
<!--[--><!--[--><h1>call <a href="tel:+636-555-3226">+636-555-3226</a> now<span>!</span></h1><!--]--><!--]-->

@ -1 +1 @@
<!--[--><!----><script>{}<!----></script><!----><!--]-->
<!--[--><!--[--><!----><script>{}<!----></script><!----><!--]--><!--]-->

@ -1 +1 @@
<!--[--><p>hydrated</p><!--]-->
<!--[--><!--[--><p>hydrated</p><!--]--><!--]-->

@ -1 +1 @@
<!--[--><!--[--><p>child</p><!--]--> <!--[--><p>child</p><p>child</p><p>child</p><!--]--><!--]-->
<!--[--><!--[--><!--[--><p>child</p><!--]--> <!--[--><p>child</p><p>child</p><p>child</p><!--]--><!--]--><!--]-->

@ -1 +1 @@
<!--[--><!--[--><p>thing</p><!--]--> <!--[--><p>thing</p><p>thing</p><p>thing</p><!--]--><!--]-->
<!--[--><!--[--><!--[--><p>thing</p><!--]--> <!--[--><p>thing</p><p>thing</p><p>thing</p><!--]--><!--]--><!--]-->

@ -1,2 +1,2 @@
<!-- unrelated comment -->
<!--[--><!--[-->hello<!--]--><!--]-->
<!--[--><!--[--><!--[-->hello<!--]--><!--]--><!--]-->

@ -1,2 +1,2 @@
<!-- unrelated comment -->
<!--[--><!--[-->hello<!--]--><!--]-->
<!--[--><!--[--><!--[-->hello<!--]--><!--]--><!--]-->

@ -1 +1 @@
<!--[--><span><span></span></span><!--]-->
<!--[--><!--[--><span><span></span></span><!--]--><!--]-->

@ -1 +1 @@
<!--[--><!---->x<!--]-->
<!--[--><!--[--><!---->x<!--]--><!--]-->

@ -1,2 +1,2 @@
<!--[-->
<main><p>nested</p><!----></main><!--]-->
<!--[--><!--[-->
<main><p>nested</p><!----></main><!--]--><!--]-->

@ -6,5 +6,9 @@ import config from '__CONFIG__';
import { render } from 'svelte/server';
export default function () {
return render(SvelteComponent, { props: config.props || {}, idPrefix: config?.id_prefix });
const { head, body, html } = render(SvelteComponent, {
props: config.props || {},
idPrefix: config?.id_prefix
});
return { head, body, html };
}

@ -1,7 +1,7 @@
import { test } from '../../test';
export default test({
skip_mode: ['server'],
skip_mode: ['server', 'async-server'],
get props() {
return { value: 'hello!' };

@ -1,7 +1,7 @@
import { test } from '../../test';
export default test({
skip_mode: ['server'],
skip_mode: ['server', 'async-server'],
get props() {
return { value: 'hello!' };

@ -1,7 +1,7 @@
import { test } from '../../test';
export default test({
skip_mode: ['server'],
skip_mode: ['server', 'async-server'],
get props() {
return { value: 'hello!' };

@ -8,9 +8,9 @@ export default test({
ssrHtml: `
<select>
<option value="1">1</option>
<option selected value="2">2</option>
<option value="3">3</option>
<option>1</option>
<option selected>2</option>
<option>3</option>
</select>
<p>foo: 2</p>
@ -21,9 +21,9 @@ export default test({
target.innerHTML,
`
<select>
<option value="1">1</option>
<option ${variant === 'hydrate' ? 'selected ' : ''}value="2">2</option>
<option value="3">3</option>
<option>1</option>
<option ${variant === 'hydrate' ? 'selected ' : ''}>2</option>
<option>3</option>
</select>
<p>foo: 2</p>
@ -47,9 +47,9 @@ export default test({
target.innerHTML,
`
<select>
<option value="1">1</option>
<option ${variant === 'hydrate' ? 'selected ' : ''}value="2">2</option>
<option value="3">3</option>
<option>1</option>
<option ${variant === 'hydrate' ? 'selected ' : ''}>2</option>
<option>3</option>
</select>
<p>foo: 3</p>

@ -8,9 +8,9 @@ export default test({
<select>
<option disabled=''>x</option>
<option value="a">a</option>
<option value="b">b</option>
<option value="c">c</option>
<option>a</option>
<option>b</option>
<option>c</option>
</select>
`,

@ -28,9 +28,9 @@ export default test({
target.innerHTML,
`
<select>
<option value="one">one</option>
<option value="two">two</option>
<option value="three">three</option>
<option>one</option>
<option>two</option>
<option>three</option>
</select>
<p>selected: two</p>
`

@ -28,9 +28,9 @@ export default test({
target.innerHTML,
`
<select>
<option value='one'>one</option>
<option value='two'>two</option>
<option value='three'>three</option>
<option>one</option>
<option>two</option>
<option>three</option>
</select>
<p>selected: two</p>
`

@ -28,9 +28,9 @@ export default test({
target.innerHTML,
`
<select>
<option value="one">one</option>
<option value="two">two</option>
<option value="three">three</option>
<option>one</option>
<option>two</option>
<option>three</option>
</select>
<p>selected: two</p>
`

@ -6,9 +6,9 @@ export default test({
<p>selected: </p>
<select>
<option value="a">a</option>
<option value="b">b</option>
<option value="c">c</option>
<option>a</option>
<option>b</option>
<option>c</option>
</select>
<p>selected: </p>
@ -37,9 +37,9 @@ export default test({
<p>selected: a</p>
<select>
<option value="a">a</option>
<option value="b">b</option>
<option value="c">c</option>
<option>a</option>
<option>b</option>
<option>c</option>
</select>
<p>selected: a</p>
@ -59,9 +59,9 @@ export default test({
<p>selected: d</p>
<select>
<option value="a">a</option>
<option value="b">b</option>
<option value="c">c</option>
<option>a</option>
<option>b</option>
<option>c</option>
</select>
<p>selected: d</p>
@ -78,9 +78,9 @@ export default test({
<p>selected: b</p>
<select>
<option value="a">a</option>
<option value="b">b</option>
<option value="c">c</option>
<option>a</option>
<option>b</option>
<option>c</option>
</select>
<p>selected: b</p>
@ -100,9 +100,9 @@ export default test({
<p>selected: </p>
<select>
<option value="a">a</option>
<option value="b">b</option>
<option value="c">c</option>
<option>a</option>
<option>b</option>
<option>c</option>
</select>
<p>selected: </p>

@ -4,7 +4,7 @@ import { ok, test } from '../../test';
export default test({
mode: ['client', 'hydrate'],
html: `<p>selected: a</p><select><option value="a">a</option><option value="b">b</option><option value="c">c</option></select>`,
html: `<p>selected: a</p><select><option>a</option><option>b</option><option>c</option></select>`,
async test({ assert, component, target }) {
const select = target.querySelector('select');
@ -29,7 +29,7 @@ export default test({
// model of selected value should be kept around, even if it is not in the list
assert.htmlEqual(
target.innerHTML,
`<p>selected: a</p><select><option value="b">b</option><option value="c">c</option></select>`
`<p>selected: a</p><select><option>b</option><option>c</option></select>`
);
}
});

@ -1,7 +1,7 @@
import { test } from '../../test';
export default test({
skip_mode: ['server'],
skip_mode: ['server', 'async-server'],
html: `
<div><div>Value in child component: </div></div>

@ -4,7 +4,7 @@ export default test({
withoutNormalizeHtml: true,
// Unable to test `html` with `<textarea>` content
// as the textarea#value will not show within `innerHtml`
ssrHtml: `<!--[--><textarea id="textarea"> A
ssrHtml: `<!--[--><!--[--><textarea id="textarea"> A
B
</textarea> <div id="div-with-textarea"><textarea> A
B
@ -14,7 +14,7 @@ newline after leading space</textarea></div> <textarea id="textarea-with-multipl
multiple leading newlines</textarea> <div id="div-with-textarea-with-multiple-leading-newlines"><textarea>
multiple leading newlines</textarea></div><!--]-->`,
multiple leading newlines</textarea></div><!--]--><!--]-->`,
test({ assert, target }) {
// Test for <textarea> tag
const elementTextarea = /** @type {HTMLTextAreaElement} */ (target.querySelector('#textarea'));

@ -43,9 +43,9 @@ Promise.withResolvers = () => {
export interface RuntimeTest<Props extends Record<string, any> = Record<string, any>>
extends BaseTest {
/** Use e.g. `mode: ['client']` to indicate that this test should never run in server/hydrate modes */
mode?: Array<'server' | 'client' | 'hydrate'>;
mode?: Array<'server' | 'async-server' | 'client' | 'hydrate'>;
/** Temporarily skip specific modes, without skipping the entire test */
skip_mode?: Array<'server' | 'client' | 'hydrate'>;
skip_mode?: Array<'server' | 'async-server' | 'client' | 'hydrate'>;
/** Skip if running with process.env.NO_ASYNC */
skip_no_async?: boolean;
/** Skip if running without process.env.NO_ASYNC */
@ -83,7 +83,11 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
errors: any[];
hydrate: Function;
}) => void | Promise<void>;
test_ssr?: (args: { logs: any[]; assert: Assert }) => void | Promise<void>;
test_ssr?: (args: {
logs: any[];
assert: Assert;
variant: 'ssr' | 'async-ssr';
}) => void | Promise<void>;
accessors?: boolean;
immutable?: boolean;
intro?: boolean;
@ -124,8 +128,8 @@ let console_warn = console.warn;
let console_error = console.error;
export function runtime_suite(runes: boolean) {
return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'dom', CompileOptions>(
['dom', 'hydrate', 'ssr'],
return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'async-ssr' | 'dom', CompileOptions>(
['dom', 'hydrate', 'ssr', 'async-ssr'],
(variant, config, test_name) => {
if (!async_mode && (config.skip_no_async || test_name.startsWith('async-'))) {
return true;
@ -162,6 +166,22 @@ export function runtime_suite(runes: boolean) {
if (config.skip_mode?.includes('server')) return true;
}
if (variant === 'async-ssr') {
if (!runes || !async_mode) return 'no-test';
if (
(config.mode && !config.mode.includes('async-server')) ||
(!config.test_ssr &&
config.html === undefined &&
config.ssrHtml === undefined &&
config.error === undefined &&
config.runtime_error === undefined &&
!config.mode?.includes('async-server'))
) {
return 'no-test';
}
if (config.skip_mode?.includes('async-server')) return true;
}
return false;
},
(config, cwd) => {
@ -207,7 +227,7 @@ async function common_setup(cwd: string, runes: boolean | undefined, config: Run
async function run_test_variant(
cwd: string,
config: RuntimeTest,
variant: 'dom' | 'hydrate' | 'ssr',
variant: 'dom' | 'hydrate' | 'ssr' | 'async-ssr',
compileOptions: CompileOptions,
runes: boolean
) {
@ -310,20 +330,26 @@ async function run_test_variant(
let snapshot = undefined;
if (variant === 'hydrate' || variant === 'ssr') {
if (variant === 'hydrate' || variant === 'ssr' || variant === 'async-ssr') {
config.before_test?.();
// ssr into target
const SsrSvelteComponent = (await import(`${cwd}/_output/server/main.svelte.js`)).default;
const { html, head } = render(SsrSvelteComponent, {
const render_result = render(SsrSvelteComponent, {
props: config.server_props ?? config.props ?? {},
idPrefix: config.id_prefix
});
const rendered =
variant === 'async-ssr' || (variant === 'hydrate' && compileOptions.experimental?.async)
? await render_result
: render_result;
const { body, head } = rendered;
fs.writeFileSync(`${cwd}/_output/rendered.html`, html);
target.innerHTML = html;
const prefix = variant === 'async-ssr' ? 'async_' : '';
fs.writeFileSync(`${cwd}/_output/${prefix}rendered.html`, body);
target.innerHTML = body;
if (head) {
fs.writeFileSync(`${cwd}/_output/rendered_head.html`, head);
fs.writeFileSync(`${cwd}/_output/${prefix}rendered_head.html`, head);
window.document.head.innerHTML = window.document.head.innerHTML + head;
}
@ -338,7 +364,7 @@ async function run_test_variant(
target.innerHTML = '';
}
if (variant === 'ssr') {
if (variant === 'ssr' || variant === 'async-ssr') {
if (config.ssrHtml) {
assert_html_equal_with_options(target.innerHTML, config.ssrHtml, {
preserveComments:
@ -361,7 +387,8 @@ async function run_test_variant(
...assert,
htmlEqual: assert_html_equal,
htmlEqualWithOptions: assert_html_equal_with_options
}
},
variant
});
}
} else {

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
mode: ['server'],
error: 'await_invalid'
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save