lots of progress, couple of failing tests around selects

adjust-boundary-error-message
S. Elliott Johnson 1 week ago
parent df67afefee
commit e39b4b7546

@ -57,6 +57,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm playwright install chromium
- run: pnpm test runtime-runes
- run: pnpm test server-side-rendering
env:
CI: true
SVELTE_NO_ASYNC: true

@ -240,7 +240,7 @@ export function server_component(analysis, options) {
b.call(
'$$payload.child',
b.arrow(
[b.object_pattern([b.init('$$payload', b.id('$$payload'))])],
[b.id('$$payload')],
b.block([
.../** @type {Statement[]} */ (instance.body),
.../** @type {Statement[]} */ (template.body)
@ -304,7 +304,7 @@ export function server_component(analysis, options) {
const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);
body.push(b.const('$$css', b.object([b.init('hash', hash), b.init('code', code)])));
component_block.body.unshift(b.stmt(b.call('$$payload.css.add', b.id('$$css'))));
component_block.body.unshift(b.stmt(b.call('$$payload.global.css.add', b.id('$$css'))));
}
let should_inject_props =

@ -44,12 +44,12 @@ export function EachBlock(node, context) {
);
if (node.fallback) {
const open = b.stmt(b.call(b.member(b.id('$$payload.out'), b.id('push')), block_open));
const open = b.stmt(b.call(b.member(b.id('$$payload'), b.id('push')), block_open));
const fallback = /** @type {BlockStatement} */ (context.visit(node.fallback));
fallback.body.unshift(
b.stmt(b.call(b.member(b.id('$$payload.out'), b.id('push')), b.literal(BLOCK_OPEN_ELSE)))
b.stmt(b.call(b.member(b.id('$$payload'), b.id('push')), b.literal(BLOCK_OPEN_ELSE)))
);
state.template.push(

@ -17,12 +17,10 @@ export function IfBlock(node, context) {
? /** @type {BlockStatement} */ (context.visit(node.alternate))
: b.block([]);
consequent.body.unshift(
b.stmt(b.call(b.member(b.id('$$payload.out'), b.id('push')), block_open))
);
consequent.body.unshift(b.stmt(b.call(b.member(b.id('$$payload'), b.id('push')), block_open)));
alternate.body.unshift(
b.stmt(b.call(b.member(b.id('$$payload.out'), b.id('push')), b.literal(BLOCK_OPEN_ELSE)))
b.stmt(b.call(b.member(b.id('$$payload'), b.id('push')), b.literal(BLOCK_OPEN_ELSE)))
);
context.state.template.push(b.if(test, consequent, alternate), block_close);

@ -92,7 +92,7 @@ export function RegularElement(node, context) {
b.stmt(
b.assignment(
'=',
b.id('$$payload.select_value'),
b.id('$$payload.local.select_value'),
b.member(
build_spread_object(
node,
@ -113,7 +113,7 @@ export function RegularElement(node, context) {
);
} else if (value) {
select_with_value = true;
const left = b.id('$$payload.select_value');
const left = b.id('$$payload.local.select_value');
if (value.type === 'Attribute') {
state.template.push(
b.stmt(b.assignment('=', left, build_attribute_value(value.value, context)))
@ -151,7 +151,11 @@ export function RegularElement(node, context) {
b.call(
'$.valueless_option',
b.id('$$payload'),
b.thunk(b.block([...inner_state.init, ...build_template(inner_state.template)]))
b.arrow(
[b.id('$$payload')],
b.block([...inner_state.init, ...build_template(inner_state.template)]),
context.state.analysis.has_blocking_await
)
)
)
);

@ -17,7 +17,7 @@ export function SnippetBlock(node, context) {
b.call(
'$$payload.child',
b.arrow(
[b.object_pattern([b.init('$$payload', b.id('$$payload'))])],
[b.id('$$payload')],
/** @type {BlockStatement} */ (context.visit(node.body)),
node.metadata.has_await
)

@ -11,6 +11,14 @@ export function SvelteHead(node, context) {
const block = /** @type {BlockStatement} */ (context.visit(node.fragment));
context.state.template.push(
b.stmt(b.call('$.head', b.id('$$payload'), b.arrow([b.id('$$payload')], block)))
b.stmt(
b.call(
'$.head',
b.id('$$payload'),
// same thing as elsewhere; this will create more async functions than necessary but should never be _wrong_
// because the component rendering this head block will always be async if the head block is async
b.arrow([b.id('$$payload')], block, context.state.analysis.has_blocking_await)
)
)
);
}

@ -13,20 +13,30 @@ export function TitleElement(node, context) {
process_children(node.fragment.nodes, { ...context, state: { ...context.state, template } });
template.push(b.literal('</title>'));
if (!node.metadata.has_await) {
context.state.init.push(...build_template(template, b.id('$$payload.title.value'), '='));
} else {
const async_template = b.thunk(
// TODO I'm sure there is a better way to do this
b.block([
b.let('title'),
...build_template(template, b.id('title'), '='),
b.return(b.id('title'))
]),
true
);
context.state.init.push(
b.stmt(b.assignment('=', b.id('$$payload.title.value'), b.call(async_template)))
);
}
context.state.init.push(
b.stmt(
b.call(
'$$payload.child',
// this nonsense is necessary so that the write to the title is as tightly scoped to a specific location
// in the async tree as possible. This lets us use `get_path` to compare this assignment to other assignments
// so that we can overwrite earlier assignments with later ones.
b.arrow(
[b.id('$$payload')],
b.block([
b.const('path', b.call('$$payload.get_path')),
b.let('title'),
...build_template(template, b.id('title'), '='),
b.stmt(
b.assignment(
'=',
b.id('$$payload.global.head.title'),
b.object([b.init('path', b.id('path')), b.init('value', b.id('title'))])
)
)
]),
node.metadata.has_await
)
)
)
);
}

@ -238,7 +238,7 @@ export function build_inline_component(node, expression, context) {
b.call(
'$$payload.child',
b.arrow(
[b.object_pattern([b.init('$$payload', b.id('$$payload'))])],
[b.id('$$payload')],
b.block(block.body),
context.state.analysis.has_blocking_await
)

@ -99,7 +99,7 @@ function is_statement(node) {
* @param {AssignmentOperator | 'push'} operator
* @returns {Statement[]}
*/
export function build_template(template, out = b.id('$$payload.out'), operator = 'push') {
export function build_template(template, out = b.id('$$payload'), operator = 'push') {
/** @type {string[]} */
let strings = [];
@ -264,10 +264,5 @@ export function build_getter(node, state) {
* @returns {Statement}
*/
export function wrap_in_child_payload(body, async) {
return b.stmt(
b.call(
'$$payload.child',
b.arrow([b.object_pattern([b.init('$$payload', b.id('$$payload'))])], body, async)
)
);
return b.stmt(b.call('$$payload.child', b.arrow([b.id('$$payload')], body, async)));
}

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

@ -6,7 +6,7 @@ import {
} from '../../html-tree-validation.js';
import { current_component } from './context.js';
import * as e from './errors.js';
import { HeadPayload, Payload } from './payload.js';
import { Payload } from './payload.js';
/**
* @typedef {{
@ -40,7 +40,10 @@ function print_error(payload, message) {
// eslint-disable-next-line no-console
console.error(message);
payload.head.out.push(`<script>console.error(${JSON.stringify(message)})</script>`);
payload.out.push({
type: 'head',
content: `<script>console.error(${JSON.stringify(message)})</script>`
});
}
export function reset_elements() {
@ -100,7 +103,7 @@ export function validate_snippet_args(payload) {
if (
typeof payload !== 'object' ||
// for some reason typescript consider the type of payload as never after the first instanceof
!(payload instanceof Payload || /** @type {any} */ (payload) instanceof HeadPayload)
!(payload instanceof Payload)
) {
e.invalid_snippet_arguments();
}

@ -17,7 +17,7 @@ import { EMPTY_COMMENT, BLOCK_CLOSE, BLOCK_OPEN, BLOCK_OPEN_ELSE } from './hydra
import { validate_store } from '../shared/validate.js';
import { is_boolean_attribute, is_raw_text_element, is_void } from '../../utils.js';
import { reset_elements } from './dev.js';
import { Payload } from './payload.js';
import { Payload, TreeState } from './payload.js';
import { abort } from './abort-signal.js';
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2
@ -33,23 +33,23 @@ const INVALID_ATTR_NAME_CHAR_REGEX =
* @returns {void}
*/
export function element(payload, tag, attributes_fn = noop, children_fn = noop) {
payload.out.push('<!---->');
payload.push('<!---->');
if (tag) {
payload.out.push(`<${tag}`);
payload.push(`<${tag}`);
attributes_fn();
payload.out.push(`>`);
payload.push(`>`);
if (!is_void(tag)) {
children_fn();
if (!is_raw_text_element(tag)) {
payload.out.push(EMPTY_COMMENT);
payload.push(EMPTY_COMMENT);
}
payload.out.push(`</${tag}>`);
payload.push(`</${tag}>`);
}
}
payload.out.push('<!---->');
payload.push('<!---->');
}
/**
@ -68,11 +68,11 @@ export let on_destroy = [];
*/
export function render(component, options = {}) {
try {
const payload = new Payload({ id_prefix: options.idPrefix ? options.idPrefix + '-' : '' });
const payload = new Payload(new TreeState(options.idPrefix ? options.idPrefix + '-' : ''));
const prev_on_destroy = on_destroy;
on_destroy = [];
payload.out.push(BLOCK_OPEN);
payload.push(BLOCK_OPEN);
let reset_reset_element;
@ -97,24 +97,83 @@ export function render(component, options = {}) {
reset_reset_element();
}
payload.out.push(BLOCK_CLOSE);
payload.push(BLOCK_CLOSE);
for (const cleanup of on_destroy) cleanup();
on_destroy = prev_on_destroy;
let head = payload.head.collect();
let { head, body } = payload.collect();
head += payload.global.head.title.value;
if (typeof payload.head.title.value !== 'string') {
throw new Error(
'TODO -- should encorporate this into the collect/collect_async logic somewhere'
);
for (const { hash, code } of payload.global.css) {
head += `<style id="${hash}">${code}</style>`;
}
head += payload.head.title.value;
for (const { hash, code } of payload.css) {
head += `<style id="${hash}">${code}</style>`;
return {
head,
html: body,
body: body
};
} finally {
abort();
}
}
/**
* TODO THIS NEEDS TO ACTUALLY BE DONE
* Array of `onDestroy` callbacks that should be called at the end of the server render function
* @type {Function[]}
*/
export let async_on_destroy = [];
/**
* Only available on the server and when compiling with the `server` option.
* Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app.
* @template {Record<string, any>} Props
* @param {import('svelte').Component<Props> | ComponentType<SvelteComponent<Props>>} component
* @param {{ props?: Omit<Props, '$$slots' | '$$events'>; context?: Map<any, any>; idPrefix?: string }} [options]
* @returns {Promise<RenderOutput>}
*/
export async function render_async(component, options = {}) {
try {
const payload = new Payload(new TreeState(options.idPrefix ? options.idPrefix + '-' : ''));
const prev_on_destroy = async_on_destroy;
async_on_destroy = [];
payload.push(BLOCK_OPEN);
let reset_reset_element;
if (DEV) {
// prevent parent/child element state being corrupted by a bad render
reset_reset_element = reset_elements();
}
if (options.context) {
push();
/** @type {Component} */ (current_component).c = options.context;
}
// @ts-expect-error
component(payload, options.props ?? {}, {}, {});
if (options.context) {
pop();
}
const body = payload.collect();
if (reset_reset_element) {
reset_reset_element();
}
payload.push(BLOCK_CLOSE);
for (const cleanup of async_on_destroy) cleanup();
async_on_destroy = prev_on_destroy;
let { head, body } = await payload.collect_async();
head += payload.global.head.title.value;
for (const { hash, code } of payload.global.css) {
head += `<style id="${hash}">${code}</style>`;
}
return {
head,
@ -128,13 +187,13 @@ export function render(component, options = {}) {
/**
* @param {Payload} payload
* @param {(head_payload: Payload['head']) => void} fn
* @param {(payload: Payload) => Promise<void> | void} fn
* @returns {void}
*/
export function head(payload, fn) {
payload.head.out.push(BLOCK_OPEN);
payload.head.child(({ $$payload }) => fn($$payload));
payload.head.out.push(BLOCK_CLOSE);
payload.out.push({ type: 'head', content: BLOCK_OPEN });
payload.child(fn, 'head');
payload.out.push({ type: 'head', content: BLOCK_CLOSE });
}
/**
@ -149,21 +208,21 @@ export function css_props(payload, is_html, props, component, dynamic = false) {
const styles = style_object_to_string(props);
if (is_html) {
payload.out.push(`<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>`);
}
}
@ -448,13 +507,13 @@ export function bind_props(props_parent, props_now) {
*/
function await_block(payload, promise, pending_fn, then_fn) {
if (is_promise(promise)) {
payload.out.push(BLOCK_OPEN);
payload.push(BLOCK_OPEN);
promise.then(null, noop);
if (pending_fn !== null) {
pending_fn();
}
} else if (then_fn !== null) {
payload.out.push(BLOCK_OPEN_ELSE);
payload.push(BLOCK_OPEN_ELSE);
then_fn(promise);
}
}
@ -500,8 +559,8 @@ export function once(get_value) {
* @returns {string}
*/
export function props_id(payload) {
const uid = payload.uid();
payload.out.push('<!--#' + uid + '-->');
const uid = payload.global.uid();
payload.push('<!--#' + uid + '-->');
return uid;
}
@ -555,37 +614,37 @@ export function derived(fn) {
* @param {*} value
*/
export function maybe_selected(payload, value) {
return value === payload.select_value ? ' selected' : '';
return value === payload.local.select_value ? ' selected' : '';
}
/**
* @param {Payload} payload
* @param {() => void} children
* @param {(payload: Payload) => void | Promise<void>} children
* @returns {void}
*/
export function valueless_option(payload, children) {
var i = payload.out.length;
// prior to children, `payload` has some combination of string/unresolved payload that ends in `<option ...>`
children();
payload.child((payload) => children(payload));
// 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: (body) => {
if (body.replace(/<!---->/g, '') === payload.select_value) {
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: (body) => {
return body.slice(0, -1) + ' selected>';
fn: (content) => {
return { body: content.body.slice(0, -1) + ' selected>', head: content.head };
}
});
}
return body;
return content;
}
});
}

@ -1,26 +1,31 @@
// Optimization: Right now, the state from parents is copied into the children. _Technically_ we could save the state on the root
// and simply have the children inherit that state and re-expose it through getters. This could save memory but probably isn't worth it.
/** @typedef {{ type: 'head' | 'body', content: string }} TNode */
/** @typedef {{ [key in TNode['type']]: string }} AccumulatedContent */
/** @typedef {{ start: number, end: number, fn: (content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent> }} Compaction */
// TODO we test for `instanceof AsyncContentTree` in some tight loops -- we might optimize
// by giving the tree a symbol property and checking that instead if we can actually notice any impact
/**
* /**
* A base class for payloads. 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. A payload can also be collected
* asynchronously with {@link collect_async}, which will wait for all children to complete before
* collecting their contents.
*
* @template {new (parent: Partial<InstanceType<TSubclass>>) => {}} TSubclass
*/
class BasePayload {
export class Payload {
/**
* This is the magical type that represents the instance type of a subclass of this type.
* How does it work? idk man but it does
* @typedef {this & InstanceType<TSubclass>} Instance
* @type {TNode['type']}
*/
type;
/** @type {Payload | undefined} */
parent;
/**
* The contents of the payload.
* @type {(string | Instance)[]}
* @type {(TNode | Payload)[]}
*/
out = [];
@ -31,88 +36,158 @@ class BasePayload {
*/
promise;
/**
* State which is associated with the content tree as a whole.
* It will be re-exposed, uncopied, on all children.
* @type {TreeState}
* @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 {TreeState} [global]
* @param {{ select_value: string | undefined }} [local]
* @param {Payload | undefined} [parent]
* @param {TNode['type']} [type]
*/
constructor(global = new TreeState(), local = { select_value: undefined }, parent, type) {
this.global = global;
this.local = { ...local };
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 `out` array and `promise` property. The child payload is automatically
* inserted into the parent payload's `out` array.
* @param {(args: { $$payload: Instance }) => void | Promise<void>} render
* @param {(tree: Payload) => void | Promise<void>} render
* @param {TNode['type']} [type]
* @returns {void}
*/
child(render) {
const child = this.#create_child_instance();
child(render, type) {
const child = new Payload(this.global, this.local, this, type);
this.out.push(child);
const result = render({ $$payload: child });
const result = render(child);
if (result instanceof Promise) {
child.promise = result;
}
}
/**
* This is a convenience function that allows pushing strings, and will automatically use the configured `type` of this
* payload. It's fine to push content of a different type to the `out` array; it's just more annoying to write.
* @param {string} content
*/
push(content) {
this.out.push({ type: this.type, 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: (value: string) => string | Promise<string> }} args
* @param {{ start: number, end?: number, fn: (content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent> }} args
*/
compact({ start, end = this.out.length, fn }) {
const child = this.#create_child_instance();
const child = new Payload(this.global, this.local, this);
const to_compact = this.out.splice(start, end - start, child);
const promises = BasePayload.#collect_promises(to_compact, []);
const promises = Payload.#collect_promises(to_compact, []);
/** @param {string | Promise<string>} res */
/** @param {AccumulatedContent | Promise<AccumulatedContent>} res */
const push_result = (res) => {
if (typeof res === 'string') {
child.out.push(res);
} else {
if (res instanceof Promise) {
child.promise = res.then((resolved) => {
child.out.push(resolved);
Payload.#push_accumulated_content(child, resolved);
});
} else {
Payload.#push_accumulated_content(child, res);
}
};
if (promises.length > 0) {
// we have to wait for the accumulated work associated with all branches to complete,
// then we can accumulate their content to compact it.
child.promise = Promise.all(promises)
.then(() => fn(BasePayload.#collect_content(to_compact)))
.then(() => fn(Payload.#collect_content(to_compact)))
.then(push_result);
} else {
push_result(fn(BasePayload.#collect_content(to_compact)));
push_result(fn(Payload.#collect_content(to_compact)));
}
}
/**
* @returns {number[]}
*/
get_path() {
return this.parent ? [...this.parent.get_path(), this.parent.out.indexOf(this)] : [];
}
/**
* Waits for all child payloads to finish their blocking asynchronous work, then returns the generated content.
* @returns {Promise<string>}
* @returns {Promise<AccumulatedContent>}
*/
async collect_async() {
// TODO: Should probably use `Promise.allSettled` here just so we can report detailed errors
await Promise.all(BasePayload.#collect_promises(this.out, this.promise ? [this.promise] : []));
return BasePayload.#collect_content(this.out);
await Promise.all(Payload.#collect_promises(this.out, this.promise ? [this.promise] : []));
return Payload.#collect_content(this.out);
}
/**
* Collect all of the code from the `out` array and return it as a string.
* @returns {string}
* @returns {AccumulatedContent}
*/
collect() {
const promises = BasePayload.#collect_promises(this.out, this.promise ? [this.promise] : []);
const promises = Payload.#collect_promises(this.out, this.promise ? [this.promise] : []);
if (promises.length > 0) {
// TODO is there a good way to report where this is? Probably by using some sort of loc or stack trace in `child` creation
throw new Error('Encountered an asynchronous component while rendering synchronously');
}
return BasePayload.#collect_content(this.out);
return Payload.#collect_content(this.out);
}
copy() {
const copy = new Payload(this.global, this.local, this.parent, this.type);
copy.out = this.out.map((item) => (item instanceof Payload ? item.copy() : item));
copy.promise = this.promise;
return copy;
}
/**
* @param {Payload} other
*/
subsume(other) {
this.global.subsume(other.global);
this.local = other.local;
this.out = other.out.map((item) => {
if (item instanceof Payload) {
item.subsume(item);
}
return item;
});
this.promise = other.promise;
this.type = other.type;
}
/**
* @param {(string | Instance)[]} items
* @param {(TNode | Payload)[]} items
* @param {Promise<void>[]} promises
* @returns {Promise<void>[]}
*/
static #collect_promises(items, promises) {
for (const item of items) {
if (typeof item !== 'string') {
if (item instanceof Payload) {
if (item.promise) {
promises.push(item.promise);
}
BasePayload.#collect_promises(item.out, promises);
Payload.#collect_promises(item.out, promises);
}
}
return promises;
@ -120,46 +195,41 @@ class BasePayload {
/**
* Collect all of the code from the `out` array and return it as a string.
* @param {(string | Instance)[]} items
* @returns {string}
* @param {(TNode | Payload)[]} items
* @param {AccumulatedContent} content
* @returns {AccumulatedContent}
*/
static #collect_content(items) {
// TODO throw in `async` mode
let content = '';
static #collect_content(items, content = { head: '', body: '' }) {
for (const item of items) {
if (typeof item === 'string') {
content += item;
if (item instanceof Payload) {
Payload.#collect_content(item.out, content);
} else {
content += BasePayload.#collect_content(item.out);
content[item.type] += item.content;
}
}
return content;
}
/** @returns {Instance} */
#create_child_instance() {
// @ts-expect-error - This lets us create an instance of the subclass of this class. Type-danger is constrained by the fact that TSubclass must accept an instance of itself in its constructor.
return new this.constructor(this);
/**
* @param {Payload} tree
* @param {AccumulatedContent} accumulated_content
*/
static #push_accumulated_content(tree, accumulated_content) {
for (const [type, content] of Object.entries(accumulated_content)) {
tree.out.push({ type: /** @type {TNode['type']} */ (type), content });
}
}
}
/**
* @extends {BasePayload<typeof HeadPayload>}
*/
export class HeadPayload extends BasePayload {
/** @type {Set<{ hash: string; code: string }>} */
#css;
export class TreeState {
/** @type {() => string} */
#uid;
/**
* This is a string or a promise so that the last write "wins" synchronously,
* as opposed to writes coming in whenever it happens to during async work.
* It's boxed so that the same value is shared across all children.
* @type {{ value: string | Promise<string>}}
*/
#title;
/** @type {Set<{ hash: string; code: string }>} */
#css;
/** @type {TreeHeadState} */
#head;
get css() {
return this.#css;
@ -169,60 +239,48 @@ export class HeadPayload extends BasePayload {
return this.#uid;
}
get title() {
return this.#title;
get head() {
return this.#head;
}
/**
* @param {{ css?: Set<{ hash: string; code: string }>, title?: { value: string | Promise<string> }, uid?: () => string }} args
* @param {string} [id_prefix]
*/
constructor({ css = new Set(), title = { value: '' }, uid = () => '' } = {}) {
super();
this.#css = css;
this.#title = title;
this.#uid = uid;
constructor(id_prefix = '') {
this.#uid = props_id_generator(id_prefix);
this.#css = new Set();
this.#head = new TreeHeadState(this.#uid);
}
copy() {
const head_payload = new HeadPayload({
css: new Set(this.#css),
title: this.title,
uid: this.#uid
});
head_payload.promise = this.promise;
head_payload.out = [...this.out];
return head_payload;
const state = new TreeState();
state.#css = new Set(this.#css);
state.#head = this.#head.copy();
state.#uid = this.#uid;
return state;
}
/**
* @param {HeadPayload} other
* @param {TreeState} other
*/
subsume(other) {
// @ts-expect-error
this.out = [...other.out];
this.promise = other.promise;
this.#css = other.#css;
this.#title = other.#title;
this.#uid = other.#uid;
this.#head.subsume(other.#head);
}
}
/**
* @extends {BasePayload<typeof Payload>}
*/
export class Payload extends BasePayload {
/** @type {() => string} */
#uid;
export class TreeHeadState {
/** @type {Set<{ hash: string; code: string }>} */
#css;
#css = new Set();
/** @type {HeadPayload} */
#head;
/** @type {() => string} */
#uid = () => '';
/** @type {string | undefined} */
select_value;
/**
* @type {{ path: number[], value: string }}
*/
#title = { path: [], value: '' };
get css() {
return this.#css;
@ -232,51 +290,57 @@ export class Payload extends BasePayload {
return this.#uid;
}
get head() {
return this.#head;
get title() {
return this.#title;
}
set title(value) {
// perform a depth-first (lexicographic) comparison using the path. Reject sets
// from earlier than or equal to the current value.
const contender_path = value.path;
const current_path = this.#title.path;
const max_len = Math.max(contender_path.length, current_path.length);
for (let i = 0; i < max_len; i++) {
const contender_segment = contender_path[i];
const current_segment = current_path[i];
// contender shorter than current and all previous segments equal -> earlier
if (contender_segment === undefined) return;
// current shorter than contender and all previous segments equal -> contender is later
if (current_segment === undefined || contender_segment > current_segment) {
this.#title.path = value.path;
this.#title.value = value.value;
return;
}
if (contender_segment < current_segment) return;
// else equal -> continue
}
// paths are equal -> keep current value (do nothing)
}
/**
* @param {{ id_prefix?: string, head?: HeadPayload, uid?: () => string, css?: Set<{ hash: string; code: string }>, select_value?: string | undefined }} args
* @param {() => string} uid
*/
constructor({
id_prefix = '',
head = new HeadPayload(),
uid = props_id_generator(id_prefix),
css = new Set(),
select_value
} = {}) {
super();
constructor(uid) {
this.#uid = uid;
this.#css = css;
this.#head = head;
this.select_value = select_value;
this.#css = new Set();
this.#title = { path: [], value: '' };
}
copy() {
const payload = new Payload({
css: new Set(this.#css),
uid: this.#uid,
head: this.#head.copy()
});
payload.select_value = this.select_value;
payload.promise = this.promise;
payload.out = [...this.out];
return payload;
const head_state = new TreeHeadState(this.#uid);
head_state.#css = new Set(this.#css);
head_state.#title = this.title;
return head_state;
}
/**
* @param {Payload} other
* @param {TreeHeadState} other
*/
subsume(other) {
// @ts-expect-error
this.out = [...other.out];
this.promise = other.promise;
this.select_value = other.select_value;
this.#css = other.#css;
this.#title = other.#title;
this.#uid = other.#uid;
this.#head.subsume(other.#head);
}
}

@ -1,6 +1,6 @@
/** @import { SvelteComponent } from '../index.js' */
import { asClassComponent as as_class_component, createClassComponent } from './legacy-client.js';
import { render } from '../internal/server/index.js';
import { render, render_async } from '../internal/server/index.js';
// By having this as a separate entry point for server environments, we save the client bundle from having to include the server runtime
@ -31,8 +31,21 @@ export function asClassComponent(component) {
html: result.body
};
};
/** @type {(props?: {}, opts?: { $$slots?: {}; context?: Map<any, any>; }) => Promise<{ html: any; css: { code: string; map: any; }; head: string; }> } */
const _render_async = async (props, { context } = {}) => {
// @ts-expect-error the typings are off, but this will work if the component is compiled in SSR mode
const result = await render_async(component, { props, context });
return {
css: { code: '', map: null },
head: result.head,
html: result.body
};
};
// @ts-expect-error this is present for SSR
component_constructor.render = _render;
// @ts-expect-error this is present for SSR
component_constructor.renderAsync = _render_async;
// @ts-ignore
return component_constructor;

@ -27,3 +27,30 @@ export function render<
}
]
): RenderOutput;
/**
* 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.
*/
export function renderAsync<
Comp extends SvelteComponent<any> | Component<any>,
Props extends ComponentProps<Comp> = ComponentProps<Comp>
>(
...args: {} extends Props
? [
component: Comp extends SvelteComponent<any> ? ComponentType<Comp> : Comp,
options?: {
props?: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
}
]
: [
component: Comp extends SvelteComponent<any> ? ComponentType<Comp> : Comp,
options: {
props: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
}
]
): Promise<RenderOutput>;

@ -1 +1 @@
export { render } from '../internal/server/index.js';
export { render, render_async as renderAsync } from '../internal/server/index.js';

@ -0,0 +1,9 @@
<script>
let { promise } = $props();
</script>
<svelte:head>
{#if await promise}
<title>A</title>
{/if}
</svelte:head>

@ -0,0 +1,7 @@
<script>
let { promise } = $props();
</script>
<svelte:head>
<title>{(await promise) && 'B'}</title>
</svelte:head>

@ -0,0 +1,27 @@
<script>
import { tick } from 'svelte';
import A from './A.svelte';
import B from './B.svelte';
const { promise: main_promise, resolve: main_resolve } = Promise.withResolvers();
const { promise: a_promise, resolve: a_resolve } = Promise.withResolvers();
const { promise: b_promise, resolve: b_resolve } = Promise.withResolvers();
// regardless of resolution order, title should be the result of B, because it's the last-encountered
tick().then(() => {
main_resolve(true);
tick().then(() => {
b_resolve(true);
}).then(() => {
a_resolve(true);
});
})
</script>
<svelte:head>
{#if await main_promise}
<title>Main</title>
{/if}
</svelte:head>
<A promise={a_promise}/>
<B promise={b_promise}/>

@ -0,0 +1,5 @@
<script>
let props = $props();
</script>
<option {...props}>{@render props.children?.()}</option>

@ -0,0 +1 @@
<select><option value="">--Please choose an option--</option><option value="dog" selected>Dog</option><option value="cat">Cat</option></select>

@ -0,0 +1,8 @@
<script lang="ts">
import Option from './Option.svelte';
</script>
<select value={await Promise.resolve('dog')}>
<Option value="">--Please choose an option--</Option>
<Option value={await Promise.resolve('dog')}>Dog</Option>
<Option value="cat">Cat</Option>
</select>

@ -0,0 +1 @@
<select><option>--Please choose an option--</option><option selected>dog</option><option>cat</option></select>

@ -0,0 +1,13 @@
<select value={await Promise.resolve('dog')}>
<option>
{@render option("--Please choose an option--")}
</option>
<option>
{@render option(Promise.resolve('dog'))}
</option>
<option>
{@render option(Promise.resolve('cat'))}
</option>
</select>
{#snippet option(val)}{await val}{/snippet}

@ -0,0 +1 @@
<select><option>--Please choose an option--</option><option selected>dog</option><option>cat</option></select>

@ -0,0 +1,5 @@
<select value={await Promise.resolve('dog')}>
<option>--Please choose an option--</option>
<option>{await Promise.resolve('dog')}</option>
<option>cat</option>
</select>

@ -6,8 +6,13 @@
import * as fs from 'node:fs';
import { assert } from 'vitest';
import { render } from 'svelte/server';
import { compile_directory, should_update_expected, try_read_file } from '../helpers.js';
import { render, renderAsync } from 'svelte/server';
import {
async_mode,
compile_directory,
should_update_expected,
try_read_file
} from '../helpers.js';
import { assert_html_equal_with_options } from '../html_equal.js';
import { suite, type BaseTest } from '../suite.js';
import type { CompileOptions } from '#compiler';
@ -25,8 +30,16 @@ interface SSRTest extends BaseTest {
let console_error = console.error;
const { test, run } = suite<SSRTest>(async (config, test_dir) => {
const compile_options = {
experimental: {
async: async_mode,
...config.compileOptions?.experimental
},
...config.compileOptions
};
if (!config.load_compiled) {
await compile_directory(test_dir, 'server', config.compileOptions);
await compile_directory(test_dir, 'server', compile_options);
}
const errors: string[] = [];
@ -37,7 +50,10 @@ const { test, run } = suite<SSRTest>(async (config, test_dir) => {
const Component = (await import(`${test_dir}/_output/server/main.svelte.js`)).default;
const expected_html = try_read_file(`${test_dir}/_expected.html`);
const rendered = render(Component, { props: config.props || {}, idPrefix: config.id_prefix });
const rendered = await (compile_options.experimental.async ? renderAsync : render)(Component, {
props: config.props || {},
idPrefix: config.id_prefix
});
const { body, head } = rendered;
fs.writeFileSync(`${test_dir}/_output/rendered.html`, body);
@ -48,7 +64,7 @@ const { test, run } = suite<SSRTest>(async (config, test_dir) => {
try {
assert_html_equal_with_options(body, expected_html || '', {
preserveComments: config.compileOptions?.preserveComments,
preserveComments: compile_options.preserveComments,
withoutNormalizeHtml: config.withoutNormalizeHtml
});
} catch (error: any) {

@ -1,7 +1,7 @@
import * as $ from 'svelte/internal/server';
export default function Await_block_scope($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
let counter = { count: 0 };
const promise = Promise.resolve(counter);
@ -9,8 +9,8 @@ export default function Await_block_scope($$payload) {
counter.count += 1;
}
$$payload.out.push(`<button>clicks: ${$.escape(counter.count)}</button> `);
$$payload.push(`<button>clicks: ${$.escape(counter.count)}</button> `);
$.await($$payload, promise, () => {}, (counter) => {});
$$payload.out.push(`<!--]--> ${$.escape(counter.count)}`);
$$payload.push(`<!--]--> ${$.escape(counter.count)}`);
});
}

@ -2,13 +2,13 @@ import * as $ from 'svelte/internal/server';
import TextInput from './Child.svelte';
function snippet($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.out.push(`<!---->Something`);
$$payload.child(($$payload) => {
$$payload.push(`<!---->Something`);
});
}
export default function Bind_component_snippet($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
let value = '';
const _snippet = snippet;
let $$settled = true;
@ -26,7 +26,7 @@ export default function Bind_component_snippet($$payload) {
}
});
$$payload.out.push(`<!----> value: ${$.escape(value)}`);
$$payload.push(`<!----> value: ${$.escape(value)}`);
}
do {

@ -1,7 +1,7 @@
import * as $ from 'svelte/internal/server';
export default function Bind_this($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
Foo($$payload, {});
});
}

@ -3,7 +3,7 @@ import * as $ from 'svelte/internal/server';
export default function Class_state_field_constructor_assignment($$payload, $$props) {
$.push();
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
class Foo {
a = 0;
#b;

@ -1,15 +1,15 @@
import * as $ from 'svelte/internal/server';
export default function Delegated_locally_declared_shadowed($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
const each_array = $.ensure_array_like({ length: 1 });
$$payload.out.push(`<!--[-->`);
$$payload.push(`<!--[-->`);
for (let index = 0, $$length = each_array.length; index < $$length; index++) {
$$payload.out.push(`<button type="button"${$.attr('data-index', index)}>B</button>`);
$$payload.push(`<button type="button"${$.attr('data-index', index)}>B</button>`);
}
$$payload.out.push(`<!--]-->`);
$$payload.push(`<!--]-->`);
});
}

@ -1,10 +1,10 @@
import * as $ from 'svelte/internal/server';
export default function Main($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
let x = 'test';
let y = () => 'test';
$$payload.out.push(`<div${$.attr('foobar', x)}></div> <svg${$.attr('viewBox', x)}></svg> <custom-element${$.attr('foobar', x)}></custom-element> <div${$.attr('foobar', y())}></div> <svg${$.attr('viewBox', y())}></svg> <custom-element${$.attr('foobar', y())}></custom-element>`);
$$payload.push(`<div${$.attr('foobar', x)}></div> <svg${$.attr('viewBox', x)}></svg> <custom-element${$.attr('foobar', x)}></custom-element> <div${$.attr('foobar', y())}></div> <svg${$.attr('viewBox', y())}></svg> <custom-element${$.attr('foobar', y())}></custom-element>`);
});
}

@ -1,15 +1,15 @@
import * as $ from 'svelte/internal/server';
export default function Each_index_non_null($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
const each_array = $.ensure_array_like(Array(10));
$$payload.out.push(`<!--[-->`);
$$payload.push(`<!--[-->`);
for (let i = 0, $$length = each_array.length; i < $$length; i++) {
$$payload.out.push(`<p>index: ${$.escape(i)}</p>`);
$$payload.push(`<p>index: ${$.escape(i)}</p>`);
}
$$payload.out.push(`<!--]-->`);
$$payload.push(`<!--]-->`);
});
}

@ -1,17 +1,17 @@
import * as $ from 'svelte/internal/server';
export default function Each_string_template($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
const each_array = $.ensure_array_like(['foo', 'bar', 'baz']);
$$payload.out.push(`<!--[-->`);
$$payload.push(`<!--[-->`);
for (let $$index = 0, $$length = each_array.length; $$index < $$length; $$index++) {
let thing = each_array[$$index];
$$payload.out.push(`<!---->${$.escape(thing)}, `);
$$payload.push(`<!---->${$.escape(thing)}, `);
}
$$payload.out.push(`<!--]-->`);
$$payload.push(`<!--]-->`);
});
}

@ -1,7 +1,7 @@
import * as $ from 'svelte/internal/server';
export default function Function_prop_no_getter($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
let count = 0;
function onmouseup() {
@ -16,8 +16,8 @@ export default function Function_prop_no_getter($$payload) {
onmouseenter: () => count = plusOne(count),
children: ($$payload) => {
$$payload.child(({ $$payload }) => {
$$payload.out.push(`<!---->clicks: ${$.escape(count)}`);
$$payload.child(($$payload) => {
$$payload.push(`<!---->clicks: ${$.escape(count)}`);
});
},

@ -1,7 +1,7 @@
import * as $ from 'svelte/internal/server';
export default function Functional_templating($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.out.push(`<h1>hello</h1> <div class="potato"><p>child element</p> <p>another child element</p></div>`);
$$payload.child(($$payload) => {
$$payload.push(`<h1>hello</h1> <div class="potato"><p>child element</p> <p>another child element</p></div>`);
});
}

@ -1,7 +1,7 @@
import * as $ from 'svelte/internal/server';
export default function Hello_world($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.out.push(`<h1>hello world</h1>`);
$$payload.child(($$payload) => {
$$payload.push(`<h1>hello world</h1>`);
});
}

@ -1,7 +1,7 @@
import * as $ from 'svelte/internal/server';
export default function Hmr($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.out.push(`<h1>hello world</h1>`);
$$payload.child(($$payload) => {
$$payload.push(`<h1>hello world</h1>`);
});
}

@ -2,5 +2,5 @@ import * as $ from 'svelte/internal/server';
import { random } from './module.svelte';
export default function Imports_in_modules($$payload) {
$$payload.child(({ $$payload }) => {});
$$payload.child(($$payload) => {});
}

@ -1,10 +1,10 @@
import * as $ from 'svelte/internal/server';
export default function Nullish_coallescence_omittance($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
let name = 'world';
let count = 0;
$$payload.out.push(`<h1>Hello, world!</h1> <b>123</b> <button>Count is ${$.escape(count)}</button> <h1>Hello, world</h1>`);
$$payload.push(`<h1>Hello, world!</h1> <b>123</b> <button>Count is ${$.escape(count)}</button> <h1>Hello, world</h1>`);
});
}

@ -3,7 +3,7 @@ import * as $ from 'svelte/internal/server';
export default function Props_identifier($$payload, $$props) {
$.push();
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
let { $$slots, $$events, ...props } = $$props;
props.a;

@ -1,9 +1,9 @@
import * as $ from 'svelte/internal/server';
export default function Purity($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.out.push(`<p>0</p> <p>${$.escape(location.href)}</p> `);
$$payload.child(($$payload) => {
$$payload.push(`<p>0</p> <p>${$.escape(location.href)}</p> `);
Child($$payload, { prop: encodeURIComponent('hello') });
$$payload.out.push(`<!---->`);
$$payload.push(`<!---->`);
});
}

@ -1,9 +1,9 @@
import * as $ from 'svelte/internal/server';
export default function Skip_static_subtree($$payload, $$props) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
let { title, content } = $$props;
$$payload.out.push(`<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1>${$.escape(title)}</h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> ${$.html(content)} <p>these</p> <p>trailing</p> <p>nodes</p> <p>can</p> <p>be</p> <p>completely</p> <p>ignored</p></main> <cant-skip><custom-elements with="attributes"></custom-elements></cant-skip> <div><input autofocus/></div> <div><source muted/></div> <select><option value="a"${$.maybe_selected($$payload, 'a')}>a</option></select> <img src="..." alt="" loading="lazy"/> <div><img src="..." alt="" loading="lazy"/></div>`);
$$payload.push(`<header><nav><a href="/">Home</a> <a href="/away">Away</a></nav></header> <main><h1>${$.escape(title)}</h1> <div class="static"><p>we don't need to traverse these nodes</p></div> <p>or</p> <p>these</p> <p>ones</p> ${$.html(content)} <p>these</p> <p>trailing</p> <p>nodes</p> <p>can</p> <p>be</p> <p>completely</p> <p>ignored</p></main> <cant-skip><custom-elements with="attributes"></custom-elements></cant-skip> <div><input autofocus/></div> <div><source muted/></div> <select><option value="a"${$.maybe_selected($$payload, 'a')}>a</option></select> <img src="..." alt="" loading="lazy"/> <div><img src="..." alt="" loading="lazy"/></div>`);
});
}

@ -1,7 +1,7 @@
import * as $ from 'svelte/internal/server';
export default function State_proxy_literal($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
let str = '';
let tpl = ``;
@ -12,6 +12,6 @@ export default function State_proxy_literal($$payload) {
tpl = ``;
}
$$payload.out.push(`<input${$.attr('value', str)}/> <input${$.attr('value', tpl)}/> <button>reset</button>`);
$$payload.push(`<input${$.attr('value', str)}/> <input${$.attr('value', tpl)}/> <button>reset</button>`);
});
}

@ -1,7 +1,7 @@
import * as $ from 'svelte/internal/server';
export default function Svelte_element($$payload, $$props) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
let { tag = 'hr' } = $$props;
$.element($$payload, tag);

@ -1,7 +1,7 @@
import * as $ from 'svelte/internal/server';
export default function Text_nodes_deriveds($$payload) {
$$payload.child(({ $$payload }) => {
$$payload.child(($$payload) => {
let count1 = 0;
let count2 = 0;
@ -13,6 +13,6 @@ export default function Text_nodes_deriveds($$payload) {
return count2;
}
$$payload.out.push(`<p>${$.escape(text1())}${$.escape(text2())}</p>`);
$$payload.push(`<p>${$.escape(text1())}${$.escape(text2())}</p>`);
});
}

@ -2493,6 +2493,33 @@ declare module 'svelte/server' {
}
]
): RenderOutput;
/**
* 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.
*/
export function renderAsync<
Comp extends SvelteComponent<any> | Component<any>,
Props extends ComponentProps<Comp> = ComponentProps<Comp>
>(
...args: {} extends Props
? [
component: Comp extends SvelteComponent<any> ? ComponentType<Comp> : Comp,
options?: {
props?: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
}
]
: [
component: Comp extends SvelteComponent<any> ? ComponentType<Comp> : Comp,
options: {
props: Omit<Props, '$$slots' | '$$events'>;
context?: Map<any, any>;
idPrefix?: string;
}
]
): Promise<RenderOutput>;
interface RenderOutput {
/** HTML that goes into the `<head>` */
head: string;

Loading…
Cancel
Save