mirror of https://github.com/sveltejs/svelte
commit
a1be776de4
@ -1,5 +0,0 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
fix: flush effects scheduled during boundary's pending phase
|
@ -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.
|
@ -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.
|
@ -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'}`);
|
||||
}
|
||||
}
|
@ -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, create_async_block } 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('$$renderer'),
|
||||
/** @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 = create_async_block(b.block([statement]));
|
||||
}
|
||||
|
||||
context.state.template.push(statement, block_close);
|
||||
}
|
||||
|
@ -1,25 +1,40 @@
|
||||
/** @import { AwaitExpression } from 'estree' */
|
||||
/** @import { Context } from '../types.js' */
|
||||
import * as b from '../../../../utils/builders.js';
|
||||
/** @import { AwaitExpression, Expression } from 'estree' */
|
||||
/** @import { Context } from '../types' */
|
||||
import { save } from '../../../../utils/ast.js';
|
||||
|
||||
/**
|
||||
* @param {AwaitExpression} node
|
||||
* @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 save(argument);
|
||||
}
|
||||
|
||||
// we also need to restore context after block expressions
|
||||
let i = context.path.length;
|
||||
while (i--) {
|
||||
const parent = context.path[i];
|
||||
|
||||
if (
|
||||
parent.type === 'ArrowFunctionExpression' ||
|
||||
parent.type === 'FunctionExpression' ||
|
||||
parent.type === 'FunctionDeclaration'
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (parent.metadata) {
|
||||
if (parent.type !== 'ExpressionTag' && parent.type !== 'Fragment') {
|
||||
return save(argument);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return b.call('$.await_outside_boundary');
|
||||
return argument === node.argument ? node : { ...node, argument };
|
||||
}
|
||||
|
@ -1,80 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export class Payload {
|
||||
/** @type {Set<{ hash: string; code: string }>} */
|
||||
css = new Set();
|
||||
/** @type {string[]} */
|
||||
out = [];
|
||||
uid = () => '';
|
||||
select_value = undefined;
|
||||
|
||||
head = new HeadPayload();
|
||||
|
||||
constructor(id_prefix = '') {
|
||||
this.uid = props_id_generator(id_prefix);
|
||||
this.head.uid = this.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();
|
||||
|
||||
payload.out = [...out];
|
||||
payload.css = new Set(css);
|
||||
payload.uid = uid;
|
||||
|
||||
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;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ID generator
|
||||
* @param {string} prefix
|
||||
* @returns {() => string}
|
||||
*/
|
||||
function props_id_generator(prefix) {
|
||||
let uid = 1;
|
||||
return () => `${prefix}s${uid++}`;
|
||||
}
|
@ -0,0 +1,611 @@
|
||||
/** @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';
|
||||
import { attributes } from './index.js';
|
||||
|
||||
/** @typedef {'head' | 'body'} RendererType */
|
||||
/** @typedef {{ [key in RendererType]: string }} AccumulatedContent */
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {T | Promise<T>} MaybePromise<T>
|
||||
*/
|
||||
/**
|
||||
* @typedef {string | Renderer} RendererItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renderers are basically a tree of `string | Renderer`s, where each `Renderer` in the tree represents
|
||||
* work that may or may not have completed. A renderer 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 renderer, just `await` it.
|
||||
*
|
||||
* The `string` values within a renderer are always associated with the {@link type} of that renderer. To switch types,
|
||||
* call {@link child} with a different `type` argument.
|
||||
*/
|
||||
export class Renderer {
|
||||
/**
|
||||
* The contents of the renderer.
|
||||
* @type {RendererItem[]}
|
||||
*/
|
||||
#out = [];
|
||||
|
||||
/**
|
||||
* Any `onDestroy` callbacks registered during execution of this renderer.
|
||||
* @type {(() => void)[] | undefined}
|
||||
*/
|
||||
#on_destroy = undefined;
|
||||
|
||||
/**
|
||||
* Whether this renderer is a component body.
|
||||
* @type {boolean}
|
||||
*/
|
||||
#is_component_body = false;
|
||||
|
||||
/**
|
||||
* The type of string content that this renderer is accumulating.
|
||||
* @type {RendererType}
|
||||
*/
|
||||
type;
|
||||
|
||||
/** @type {Renderer | undefined} */
|
||||
#parent;
|
||||
|
||||
/**
|
||||
* Asynchronous work associated with this renderer
|
||||
* @type {Promise<void> | undefined}
|
||||
*/
|
||||
promise = undefined;
|
||||
|
||||
/**
|
||||
* 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 {Renderer | undefined} [parent]
|
||||
*/
|
||||
constructor(global, parent) {
|
||||
this.#parent = parent;
|
||||
|
||||
this.global = global;
|
||||
this.local = parent ? { ...parent.local } : { select_value: undefined };
|
||||
this.type = parent ? parent.type : 'body';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(renderer: Renderer) => void} fn
|
||||
*/
|
||||
head(fn) {
|
||||
const head = new Renderer(this.global, this);
|
||||
head.type = 'head';
|
||||
|
||||
this.#out.push(head);
|
||||
head.child(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(renderer: Renderer) => void} fn
|
||||
*/
|
||||
async(fn) {
|
||||
this.#out.push(BLOCK_OPEN);
|
||||
this.child(fn);
|
||||
this.#out.push(BLOCK_CLOSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child renderer. The child renderer inherits the state from the parent,
|
||||
* but has its own content.
|
||||
* @param {(renderer: Renderer) => MaybePromise<void>} fn
|
||||
*/
|
||||
child(fn) {
|
||||
const child = new Renderer(this.global, this);
|
||||
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.promise = result;
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a component renderer. The component renderer inherits the state from the parent,
|
||||
* but has its own content. It is treated as an ordering boundary for ondestroy callbacks.
|
||||
* @param {(renderer: Renderer) => 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 {Record<string, any>} attrs
|
||||
* @param {(renderer: Renderer) => void} fn
|
||||
*/
|
||||
select({ value, ...attrs }, fn) {
|
||||
this.push(`<select${attributes(attrs)}>`);
|
||||
this.child((renderer) => {
|
||||
renderer.local.select_value = value;
|
||||
fn(renderer);
|
||||
});
|
||||
this.push('</select>');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, any>} attrs
|
||||
* @param {string | number | boolean | ((renderer: Renderer) => void)} body
|
||||
*/
|
||||
option(attrs, body) {
|
||||
this.#out.push(`<option${attributes(attrs)}`);
|
||||
|
||||
/**
|
||||
* @param {Renderer} renderer
|
||||
* @param {any} value
|
||||
* @param {{ head?: string, body: any }} content
|
||||
*/
|
||||
const close = (renderer, value, { head, body }) => {
|
||||
if ('value' in attrs) {
|
||||
value = attrs.value;
|
||||
}
|
||||
|
||||
if (value === this.local.select_value) {
|
||||
renderer.#out.push(' selected');
|
||||
}
|
||||
|
||||
renderer.#out.push(`>${body}</option>`);
|
||||
|
||||
// super edge case, but may as well handle it
|
||||
if (head) {
|
||||
renderer.head((child) => child.push(head));
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof body === 'function') {
|
||||
this.child((renderer) => {
|
||||
const r = new Renderer(this.global, this);
|
||||
body(r);
|
||||
|
||||
if (this.global.mode === 'async') {
|
||||
return r.#collect_content_async().then((content) => {
|
||||
close(renderer, content.body.replaceAll('<!---->', ''), content);
|
||||
});
|
||||
} else {
|
||||
const content = r.#collect_content();
|
||||
close(renderer, content.body.replaceAll('<!---->', ''), content);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
close(this, body, { body });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(renderer: Renderer) => void} fn
|
||||
*/
|
||||
title(fn) {
|
||||
const path = this.get_path();
|
||||
|
||||
/** @param {string} head */
|
||||
const close = (head) => {
|
||||
this.global.set_title(head, path);
|
||||
};
|
||||
|
||||
this.child((renderer) => {
|
||||
const r = new Renderer(renderer.global, renderer);
|
||||
fn(r);
|
||||
|
||||
if (renderer.global.mode === 'async') {
|
||||
return r.#collect_content_async().then((content) => {
|
||||
close(content.head);
|
||||
});
|
||||
} else {
|
||||
const content = r.#collect_content();
|
||||
close(content.head);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | (() => Promise<string>)} content
|
||||
*/
|
||||
push(content) {
|
||||
if (typeof content === 'function') {
|
||||
this.child(async (renderer) => renderer.push(await content()));
|
||||
} else {
|
||||
this.#out.push(content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 Renderer(this.global, this.#parent);
|
||||
copy.#out = this.#out.map((item) => (item instanceof Renderer ? item.copy() : item));
|
||||
copy.promise = this.promise;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Renderer} other
|
||||
* @deprecated this is needed for legacy component bindings
|
||||
*/
|
||||
subsume(other) {
|
||||
if (this.global.mode !== other.global.mode) {
|
||||
throw new Error(
|
||||
"invariant: A renderer 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 Renderer) {
|
||||
item.subsume(item);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.promise = other.promise;
|
||||
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 ??= Renderer.#render(component, options)).body;
|
||||
}
|
||||
},
|
||||
head: {
|
||||
get: () => {
|
||||
return (sync ??= Renderer.#render(component, options)).head;
|
||||
}
|
||||
},
|
||||
body: {
|
||||
get: () => {
|
||||
return (sync ??= Renderer.#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 ??= Renderer.#render(component, options));
|
||||
const user_result = onfulfilled({
|
||||
head: result.head,
|
||||
body: result.body,
|
||||
html: result.body
|
||||
});
|
||||
return Promise.resolve(user_result);
|
||||
}
|
||||
async ??= Renderer.#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 renderers are "porous" and don't affect execution order, but component body renderers
|
||||
* create ordering boundaries. Within a renderer, 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 renderers, yielding the deepest components first, then additional components as we backtrack up the tree.
|
||||
* @returns {Iterable<Renderer>}
|
||||
*/
|
||||
*#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 Renderer && !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 renderer = Renderer.#open_render('sync', component, options);
|
||||
|
||||
const content = renderer.#collect_content();
|
||||
return Renderer.#close_render(content, renderer);
|
||||
} 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 renderer = Renderer.#open_render('async', component, options);
|
||||
|
||||
const content = await renderer.#collect_content_async();
|
||||
return Renderer.#close_render(content, renderer);
|
||||
} finally {
|
||||
abort();
|
||||
set_ssr_context(previous_context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all of the code from the `out` array and return it as a string, or a promise resolving to a string.
|
||||
* @param {AccumulatedContent} content
|
||||
* @returns {AccumulatedContent}
|
||||
*/
|
||||
#collect_content(content = { head: '', body: '' }) {
|
||||
for (const item of this.#out) {
|
||||
if (typeof item === 'string') {
|
||||
content[this.type] += item;
|
||||
} else if (item instanceof Renderer) {
|
||||
item.#collect_content(content);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all of the code from the `out` array and return it as a string.
|
||||
* @param {AccumulatedContent} content
|
||||
* @returns {Promise<AccumulatedContent>}
|
||||
*/
|
||||
async #collect_content_async(content = { head: '', body: '' }) {
|
||||
await this.promise;
|
||||
|
||||
// no danger to sequentially awaiting stuff in here; all of the work is already kicked off
|
||||
for (const item of this.#out) {
|
||||
if (typeof item === 'string') {
|
||||
content[this.type] += item;
|
||||
} else if (item instanceof Renderer) {
|
||||
await item.#collect_content_async(content);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {Renderer}
|
||||
*/
|
||||
static #open_render(mode, component, options) {
|
||||
const renderer = new Renderer(
|
||||
new SSRState(mode, options.idPrefix ? options.idPrefix + '-' : '')
|
||||
);
|
||||
|
||||
renderer.push(BLOCK_OPEN);
|
||||
|
||||
if (options.context) {
|
||||
push();
|
||||
/** @type {SSRContext} */ (ssr_context).c = options.context;
|
||||
/** @type {SSRContext} */ (ssr_context).r = renderer;
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
component(renderer, options.props ?? {});
|
||||
|
||||
if (options.context) {
|
||||
pop();
|
||||
}
|
||||
|
||||
renderer.push(BLOCK_CLOSE);
|
||||
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AccumulatedContent} content
|
||||
* @param {Renderer} renderer
|
||||
*/
|
||||
static #close_render(content, renderer) {
|
||||
for (const cleanup of renderer.#collect_on_destroy()) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
let head = content.head + renderer.global.get_title();
|
||||
let body = content.body;
|
||||
|
||||
for (const { hash, code } of renderer.global.css) {
|
||||
head += `<style id="${hash}">${code}</style>`;
|
||||
}
|
||||
|
||||
return {
|
||||
head,
|
||||
body
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SSRState {
|
||||
/** @readonly @type {'sync' | 'async'} */
|
||||
mode;
|
||||
|
||||
/** @readonly @type {() => string} */
|
||||
uid;
|
||||
|
||||
/** @readonly @type {Set<{ hash: string; code: string }>} */
|
||||
css = new Set();
|
||||
|
||||
/** @type {{ path: number[], value: string }} */
|
||||
#title = { path: [], value: '' };
|
||||
|
||||
/**
|
||||
* @param {'sync' | 'async'} mode
|
||||
* @param {string} [id_prefix]
|
||||
*/
|
||||
constructor(mode, id_prefix = '') {
|
||||
this.mode = mode;
|
||||
|
||||
let uid = 1;
|
||||
this.uid = () => `${id_prefix}s${uid++}`;
|
||||
}
|
||||
|
||||
get_title() {
|
||||
return this.#title.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
let i = 0;
|
||||
let l = Math.min(path.length, current.length);
|
||||
|
||||
// skip identical prefixes - [1, 2, 3, ...] === [1, 2, 3, ...]
|
||||
while (i < l && path[i] === current[i]) i += 1;
|
||||
|
||||
if (path[i] === undefined) return;
|
||||
|
||||
// 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,339 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
||||
import { Renderer, SSRState } from './renderer.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 = (renderer: Renderer) => {
|
||||
renderer.push('a');
|
||||
renderer.child(($$renderer) => {
|
||||
$$renderer.push('b');
|
||||
});
|
||||
renderer.push('c');
|
||||
};
|
||||
|
||||
const { head, body } = Renderer.render(component as unknown as Component);
|
||||
expect(head).toBe('');
|
||||
expect(body).toBe('<!--[-->abc<!--]-->');
|
||||
});
|
||||
|
||||
test('child type switches content area (head vs body)', () => {
|
||||
const component = (renderer: Renderer) => {
|
||||
renderer.push('a');
|
||||
renderer.head(($$renderer) => {
|
||||
$$renderer.push('<title>T</title>');
|
||||
});
|
||||
renderer.push('b');
|
||||
};
|
||||
|
||||
const { head, body } = Renderer.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 = (renderer: Renderer) => {
|
||||
renderer.head((renderer) => {
|
||||
renderer.push('<meta name="x"/>');
|
||||
renderer.child((renderer) => {
|
||||
renderer.push('<style>/* css */</style>');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const { head, body } = Renderer.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 renderer', () => {
|
||||
const root = new Renderer(new SSRState('sync'));
|
||||
let child_a: InstanceType<typeof Renderer> | undefined;
|
||||
let child_b: InstanceType<typeof Renderer> | undefined;
|
||||
let child_b_0: InstanceType<typeof Renderer> | undefined;
|
||||
|
||||
root.child(($$renderer) => {
|
||||
child_a = $$renderer;
|
||||
$$renderer.push('A');
|
||||
});
|
||||
root.child(($$renderer) => {
|
||||
child_b = $$renderer;
|
||||
$$renderer.child(($$inner) => {
|
||||
child_b_0 = $$inner;
|
||||
$$inner.push('B0');
|
||||
});
|
||||
$$renderer.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 = (renderer: Renderer) => {
|
||||
renderer.push('a');
|
||||
renderer.child(async ($$renderer) => {
|
||||
await Promise.resolve();
|
||||
$$renderer.push('x');
|
||||
});
|
||||
};
|
||||
|
||||
expect(() => Renderer.render(component as unknown as Component).head).toThrow('await_invalid');
|
||||
expect(() => Renderer.render(component as unknown as Component).html).toThrow('await_invalid');
|
||||
expect(() => Renderer.render(component as unknown as Component).body).toThrow('await_invalid');
|
||||
});
|
||||
|
||||
test('local state is shallow-copied to children', () => {
|
||||
const root = new Renderer(new SSRState('sync'));
|
||||
root.local.select_value = 'A';
|
||||
let child: InstanceType<typeof Renderer> | undefined;
|
||||
root.child(($$renderer) => {
|
||||
child = $$renderer;
|
||||
});
|
||||
|
||||
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 Renderer(new SSRState('async'));
|
||||
a.type = 'head';
|
||||
|
||||
a.push('<meta />');
|
||||
a.local.select_value = 'A';
|
||||
|
||||
const b = new Renderer(new SSRState('async'));
|
||||
b.child(async ($$renderer) => {
|
||||
await Promise.resolve();
|
||||
$$renderer.push('body');
|
||||
});
|
||||
b.global.css.add({ hash: 'h', code: 'c' });
|
||||
b.global.set_title('Title', [1]);
|
||||
b.local.select_value = 'B';
|
||||
b.promise = Promise.resolve();
|
||||
|
||||
a.subsume(b);
|
||||
|
||||
expect(a.type).toBe('body');
|
||||
expect(a.local.select_value).toBe('B');
|
||||
expect(a.promise).toBe(b.promise);
|
||||
});
|
||||
|
||||
test('subsume refuses to switch modes', () => {
|
||||
const a = new Renderer(new SSRState('sync'));
|
||||
a.type = 'head';
|
||||
|
||||
a.push('<meta />');
|
||||
a.local.select_value = 'A';
|
||||
|
||||
const b = new Renderer(new SSRState('async'));
|
||||
b.child(async ($$renderer) => {
|
||||
await Promise.resolve();
|
||||
$$renderer.push('body');
|
||||
});
|
||||
b.global.css.add({ hash: 'h', code: 'c' });
|
||||
b.global.set_title('Title', [1]);
|
||||
b.local.select_value = 'B';
|
||||
b.promise = Promise.resolve();
|
||||
|
||||
expect(() => a.subsume(b)).toThrow(
|
||||
"invariant: A renderer 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');
|
||||
});
|
||||
|
||||
test('selects an option with an explicit value', () => {
|
||||
const component = (renderer: Renderer) => {
|
||||
renderer.select({ value: 2 }, (renderer) => {
|
||||
renderer.option({ value: 1 }, (renderer) => renderer.push('one'));
|
||||
renderer.option({ value: 2 }, (renderer) => renderer.push('two'));
|
||||
renderer.option({ value: 3 }, (renderer) => renderer.push('three'));
|
||||
});
|
||||
};
|
||||
|
||||
const { head, body } = Renderer.render(component as unknown as Component);
|
||||
expect(head).toBe('');
|
||||
expect(body).toBe(
|
||||
'<!--[--><select><option value="1">one</option><option value="2" selected>two</option><option value="3">three</option></select><!--]-->'
|
||||
);
|
||||
});
|
||||
|
||||
test('selects an option with an implicit value', () => {
|
||||
const component = (renderer: Renderer) => {
|
||||
renderer.select({ value: 'two' }, (renderer) => {
|
||||
renderer.option({}, (renderer) => renderer.push('one'));
|
||||
renderer.option({}, (renderer) => renderer.push('two'));
|
||||
renderer.option({}, (renderer) => renderer.push('three'));
|
||||
});
|
||||
};
|
||||
|
||||
const { head, body } = Renderer.render(component as unknown as Component);
|
||||
expect(head).toBe('');
|
||||
expect(body).toBe(
|
||||
'<!--[--><select><option>one</option><option selected>two</option><option>three</option></select><!--]-->'
|
||||
);
|
||||
});
|
||||
|
||||
describe('async', () => {
|
||||
beforeAll(() => {
|
||||
enable_async_mode_flag();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
disable_async_mode_flag();
|
||||
});
|
||||
|
||||
test('awaiting renderer gets async content', async () => {
|
||||
const component = (renderer: Renderer) => {
|
||||
renderer.push('1');
|
||||
renderer.child(async ($$renderer) => {
|
||||
await Promise.resolve();
|
||||
$$renderer.push('2');
|
||||
});
|
||||
renderer.push('3');
|
||||
};
|
||||
|
||||
const result = await Renderer.render(component as unknown as Component);
|
||||
expect(result.head).toBe('');
|
||||
expect(result.body).toBe('<!--[-->123<!--]-->');
|
||||
expect(() => result.html).toThrow('html_deprecated');
|
||||
});
|
||||
|
||||
test('push accepts async functions in async context', async () => {
|
||||
const component = (renderer: Renderer) => {
|
||||
renderer.push('a');
|
||||
renderer.push(async () => {
|
||||
await Promise.resolve();
|
||||
return 'b';
|
||||
});
|
||||
renderer.push('c');
|
||||
};
|
||||
|
||||
const { head, body } = await Renderer.render(component as unknown as Component);
|
||||
expect(head).toBe('');
|
||||
expect(body).toBe('<!--[-->abc<!--]-->');
|
||||
});
|
||||
|
||||
test('push handles async functions with different timing', async () => {
|
||||
const component = (renderer: Renderer) => {
|
||||
renderer.push(async () => {
|
||||
await Promise.resolve();
|
||||
return 'fast';
|
||||
});
|
||||
renderer.push(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return 'slow';
|
||||
});
|
||||
renderer.push('sync');
|
||||
};
|
||||
|
||||
const { head, body } = await Renderer.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 = (renderer: Renderer) => {
|
||||
renderer.head(($$renderer) => {
|
||||
$$renderer.push(async () => {
|
||||
await Promise.resolve();
|
||||
return '<title>Async Title</title>';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const { head, body } = await Renderer.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 renderers', async () => {
|
||||
const component = (renderer: Renderer) => {
|
||||
renderer.push('start-');
|
||||
renderer.push(async () => {
|
||||
await Promise.resolve();
|
||||
return 'async-';
|
||||
});
|
||||
renderer.child(($$renderer) => {
|
||||
$$renderer.push('child-');
|
||||
});
|
||||
renderer.push('-end');
|
||||
};
|
||||
|
||||
const { head, body } = await Renderer.render(component as unknown as Component);
|
||||
expect(head).toBe('');
|
||||
expect(body).toBe('<!--[-->start-async-child--end<!--]-->');
|
||||
});
|
||||
|
||||
test('push async functions are not supported in sync context', () => {
|
||||
const component = (renderer: Renderer) => {
|
||||
renderer.push('a');
|
||||
renderer.push(() => Promise.resolve('b'));
|
||||
};
|
||||
|
||||
expect(() => Renderer.render(component as unknown as Component).body).toThrow('await_invalid');
|
||||
expect(() => Renderer.render(component as unknown as Component).html).toThrow('await_invalid');
|
||||
expect(() => Renderer.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 = (renderer: Renderer) => {
|
||||
renderer.component((renderer) => {
|
||||
renderer.on_destroy(() => destroyed.push('a'));
|
||||
// children should not alter relative order
|
||||
renderer.child(async (renderer) => {
|
||||
await Promise.resolve();
|
||||
renderer.on_destroy(() => destroyed.push('b'));
|
||||
renderer.on_destroy(() => destroyed.push('b*'));
|
||||
});
|
||||
// but child components should
|
||||
renderer.component((renderer) => {
|
||||
renderer.on_destroy(() => destroyed.push('c'));
|
||||
});
|
||||
renderer.child((renderer) => {
|
||||
renderer.on_destroy(() => destroyed.push('d'));
|
||||
});
|
||||
renderer.component((renderer) => {
|
||||
renderer.on_destroy(() => destroyed.push('e'));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await Renderer.render(component as unknown as Component);
|
||||
expect(destroyed).toEqual(['c', 'e', 'a', 'b', 'b*', 'd']);
|
||||
});
|
||||
});
|
@ -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`);
|
||||
}
|
||||
}
|
@ -1 +1 @@
|
||||
<!--[--> <p>start</p><!--[--><p>cond</p><!--]--><!--]-->
|
||||
<!--[--><!--[--> <p>start</p><!--[--><p>cond</p><!--]--><!--]--><!--]-->
|
||||
|
@ -1 +1 @@
|
||||
<!--[--><!--[--><!--]--> <div><!----><!----></div> hello<!--]-->
|
||||
<!--[--><!--[--><!--]--> <div><!--[--><!--]--></div> hello<!--]-->
|
||||
|
@ -1,2 +1,2 @@
|
||||
<!--[-->
|
||||
<main><p>nested</p><!----></main><!--]-->
|
||||
<main><p>nested</p><!----></main><!--]-->
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue