today's hard work; few tests left to fix

adjust-boundary-error-message
S. Elliott Johnson 2 weeks ago
parent 4ffb8c90de
commit ba95b56c69

@ -304,6 +304,8 @@ export function analyze_module(source, options) {
options: /** @type {ValidatedCompileOptions} */ (options),
fragment: null,
snippet: null,
title: null,
boundary: null,
parent_element: null,
reactive_statement: null
},
@ -533,7 +535,7 @@ export function analyze_component(root, source, options) {
snippet_renderers: new Map(),
snippets: new Set(),
async_deriveds: new Set(),
suspends: false
has_blocking_await: false
};
state.adjust({
@ -694,6 +696,8 @@ export function analyze_component(root, source, options) {
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',
fragment: ast === template.ast ? ast : null,
snippet: null,
title: null,
boundary: null,
parent_element: null,
has_props_rune: false,
component_slots: new Set(),
@ -761,6 +765,8 @@ export function analyze_component(root, source, options) {
options,
fragment: ast === template.ast ? ast : null,
snippet: null,
title: null,
boundary: null,
parent_element: null,
has_props_rune: false,
ast_type: ast === instance.ast ? 'instance' : ast === template.ast ? 'template' : 'module',

@ -10,6 +10,7 @@ export interface AnalysisState {
ast_type: 'instance' | 'template' | 'module';
fragment: AST.Fragment | null;
snippet: AST.SnippetBlock | null;
title: AST.TitleElement | null;
boundary: AST.SvelteBoundary | null;
/**
* Tag name of the parent element. `null` if the parent is `svelte:element`, `#snippet`, a component or the root.

@ -12,11 +12,7 @@ export function AwaitExpression(node, context) {
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;
}
@ -27,6 +23,10 @@ export function AwaitExpression(node, context) {
context.state.snippet.metadata.has_await = true;
}
if (context.state.title) {
context.state.title.metadata.has_await = true;
}
// disallow top-level `await` or `await` in template expressions
// unless a) in runes mode and b) opted into `experimental.async`
if (suspend) {

@ -17,5 +17,5 @@ export function TitleElement(node, context) {
}
}
context.next();
context.visit(node.fragment, { ...context.state, title: node });
}

@ -195,9 +195,7 @@ 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')))
])
),

@ -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,
wrap_in_child_payload
} from './shared/utils.js';
/**
* @param {AST.RegularElement} node
@ -73,6 +78,7 @@ export function RegularElement(node, context) {
}
let select_with_value = false;
const template_start = state.template.length;
if (node.name === 'select') {
const value = node.attributes.find(
@ -176,6 +182,22 @@ 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.
// TODO is this cast safe?
const elements = state.template.splice(template_start, Infinity);
state.template.push(
wrap_in_child_payload(
b.block(build_template(elements)),
// TODO this will always produce correct results (because it will produce an async function if the surrounding component is async)
// but it will false-positive and create unnecessary async functions (eg. when the component is async but the select element is not)
// we could probably optimize by checking if the select element is async. Might be worth it.
context.state.analysis.has_blocking_await
)
);
}
if (!node_is_void) {

@ -13,5 +13,20 @@ 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'), '='));
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)))
);
}
}

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

@ -1,4 +1,4 @@
/** @import { AssignmentOperator, Expression, Identifier, Node, Statement } from 'estree' */
/** @import { AssignmentOperator, Expression, Identifier, Node, Statement, BlockStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext, ServerTransformState } from '../../types.js' */
@ -257,3 +257,17 @@ export function build_getter(node, state) {
return node;
}
/**
* @param {BlockStatement | Expression} body
* @param {boolean} async
* @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)
)
);
}

@ -325,6 +325,10 @@ export namespace AST {
export interface TitleElement extends BaseElement {
type: 'TitleElement';
name: 'title';
/** @internal */
metadata: {
has_await: boolean;
};
}
export interface SlotElement extends BaseElement {

@ -101,7 +101,14 @@ export function render(component, options = {}) {
for (const cleanup of on_destroy) cleanup();
on_destroy = prev_on_destroy;
let head = payload.head.collect() + payload.head.title;
let head = payload.head.collect();
if (typeof payload.head.title.value !== 'string') {
throw new Error(
'TODO -- should encorporate this into the collect/collect_async logic somewhere'
);
}
head += payload.head.title.value;
for (const { hash, code } of payload.css) {
head += `<style id="${hash}">${code}</style>`;
@ -125,10 +132,9 @@ export function render(component, options = {}) {
* @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.head.out.push(BLOCK_OPEN);
payload.head.child(({ $$payload }) => fn($$payload));
payload.head.out.push(BLOCK_CLOSE);
}
/**
@ -507,7 +513,7 @@ export { push, pop } from './context.js';
export { push_element, pop_element, validate_snippet_args } from './dev.js';
export { assign_payload, copy_payload } from './payload.js';
export { assign_payload } from './payload.js';
export { snapshot } from '../shared/clone.js';
@ -562,36 +568,33 @@ export function maybe_selected(payload, value) {
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();
// TODO this seems really likely to break in async world; we really need to find a better way to do this
// @ts-expect-error
var body = collect_body(payload.out.slice(i));
// 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...
let match = false;
payload.compact({
start: i,
fn: (body) => {
if (body.replace(/<!---->/g, '') === payload.select_value) {
// replace '>' with ' selected>' (closing tag will be added later)
var last_item = payload.out[i - 1];
if (typeof last_item !== 'string') {
throw new Error('TODO something very bad has happened, this should be very impossible');
match = true;
}
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);
return body;
}
}
});
/**
* @param {(string | Payload)[]} out_fragment
* @returns {string}
*/
function collect_body(out_fragment) {
let body = '';
for (const item of out_fragment) {
if (typeof item === 'string') {
body += item;
} else {
body += collect_body(/** @type {(string | Payload)[]} */ (item.out));
if (!match) {
return;
}
// ...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>';
}
return body;
});
}

@ -1,4 +1,5 @@
// TODO I think this will be better using some sort of mixin, eg add_async_tree(Payload, clone)
// 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.
/**
* A base class for payloads. Payloads are basically a tree of `string | Payload`s, where each
@ -8,12 +9,18 @@
* asynchronously with {@link collect_async}, which will wait for all children to complete before
* collecting their contents.
*
* @template {Record<PropertyKey, unknown>} TState
* @template {new (parent: Partial<InstanceType<TSubclass>>) => {}} TSubclass
*/
class BasePayload {
/**
* 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
*/
/**
* The contents of the payload.
* @type {(string | BasePayload<TState>)[]}
* @type {(string | Instance)[]}
*/
out = [];
@ -24,32 +31,15 @@ class BasePayload {
*/
promise;
/**
* Internal state. This is the easiest way to represent the additional state each payload kind
* needs to add to itself while still giving the base payload the ability to copy itself.
* @protected
* @type {TState}
*/
_state;
/**
* Create a new payload, copying the state from the parent payload.
* @param {TState} parent_state
*/
constructor(parent_state) {
this._state = parent_state;
}
/**
* 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: BasePayload<TState> }) => void | Promise<void>} render
* @param {(args: { $$payload: Instance }) => void | Promise<void>} render
* @returns {void}
*/
child(render) {
// @ts-expect-error dynamic constructor invocation for subclass instance creation
const child = new this.constructor(this._state);
const child = this.#create_child_instance();
this.out.push(child);
const result = render({ $$payload: child });
if (result instanceof Promise) {
@ -57,14 +47,44 @@ class BasePayload {
}
}
/**
* 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
*/
compact({ start, end = this.out.length, fn }) {
const child = this.#create_child_instance();
const to_compact = this.out.splice(start, end - start, child);
const promises = BasePayload.#collect_promises(to_compact, []);
/** @param {string | Promise<string>} res */
const push_result = (res) => {
if (typeof res === 'string') {
child.out.push(res);
} else {
child.promise = res.then((resolved) => {
child.out.push(resolved);
});
}
};
if (promises.length > 0) {
child.promise = Promise.all(promises)
.then(() => fn(BasePayload.#collect_content(to_compact)))
.then(push_result);
} else {
push_result(fn(BasePayload.#collect_content(to_compact)));
}
}
/**
* Waits for all child payloads to finish their blocking asynchronous work, then returns the generated content.
* @returns {Promise<string>}
*/
async collect_async() {
// TODO: Should probably use `Promise.allSettled` here just so we can report detailed errors
await Promise.all(this.#collect_promises(this.out));
return this.#collect_content();
await Promise.all(BasePayload.#collect_promises(this.out, this.promise ? [this.promise] : []));
return BasePayload.#collect_content(this.out);
}
/**
@ -72,27 +92,27 @@ class BasePayload {
* @returns {string}
*/
collect() {
const promises = this.#collect_promises(this.out);
const promises = BasePayload.#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 this.#collect_content();
return BasePayload.#collect_content(this.out);
}
/**
* @param {(string | BasePayload<TState>)[]} items
* @param {Promise<void>[]} [promises]
* @param {(string | Instance)[]} items
* @param {Promise<void>[]} promises
* @returns {Promise<void>[]}
*/
#collect_promises(items, promises = this.promise ? [this.promise] : []) {
static #collect_promises(items, promises) {
for (const item of items) {
if (item instanceof BasePayload) {
if (typeof item !== 'string') {
if (item.promise) {
promises.push(item.promise);
}
this.#collect_promises(item.out, promises);
BasePayload.#collect_promises(item.out, promises);
}
}
return promises;
@ -100,84 +120,108 @@ class BasePayload {
/**
* Collect all of the code from the `out` array and return it as a string.
* @param {(string | Instance)[]} items
* @returns {string}
*/
#collect_content() {
static #collect_content(items) {
// TODO throw in `async` mode
let content = '';
for (const item of this.out) {
for (const item of items) {
if (typeof item === 'string') {
content += item;
} else {
content += item.#collect_content();
content += BasePayload.#collect_content(item.out);
}
}
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);
}
}
/**
* @extends {BasePayload<{
* css: Set<{ hash: string; code: string }>,
* title: { value: string },
* uid: () => string
* }>}
* @extends {BasePayload<typeof HeadPayload>}
*/
export class HeadPayload extends BasePayload {
/** @type {Set<{ hash: string; code: string }>} */
#css;
/** @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;
get css() {
return this._state.css;
return this.#css;
}
get uid() {
return this._state.uid;
return this.#uid;
}
// title is boxed so that it gets globally shared between all parent/child heads
get title() {
return this._state.title.value;
}
set title(value) {
this._state.title.value = value;
return this.#title;
}
/**
* @param {{ css?: Set<{ hash: string; code: string }>, title?: { value: string }, uid?: () => string }} args
* @param {{ css?: Set<{ hash: string; code: string }>, title?: { value: string | Promise<string> }, uid?: () => string }} args
*/
constructor({ css = new Set(), title = { value: '' }, uid = () => '' } = {}) {
super({
css,
title,
uid
super();
this.#css = css;
this.#title = title;
this.#uid = 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;
}
}
/**
* @extends {BasePayload<{
* css: Set<{ hash: string; code: string }>,
* uid: () => string,
* select_value: any,
* head: HeadPayload,
* }>}
* @extends {BasePayload<typeof Payload>}
*/
export class Payload extends BasePayload {
/** @type {() => string} */
#uid;
/** @type {Set<{ hash: string; code: string }>} */
#css;
/** @type {HeadPayload} */
#head;
/** @type {string} */
select_value = '';
get css() {
return this._state.css;
return this.#css;
}
get uid() {
return this._state.uid;
return this.#uid;
}
get head() {
return this._state.head;
}
get select_value() {
return this._state.select_value;
}
set select_value(value) {
this._state.select_value = value;
return this.#head;
}
/**
@ -189,62 +233,46 @@ export class Payload extends BasePayload {
uid = props_id_generator(id_prefix),
css = new Set()
} = {}) {
super({
uid,
head,
css,
select_value: undefined
});
super();
this.#uid = uid;
this.#css = css;
this.#head = head;
}
}
/**
* Used in legacy mode to handle bindings
* @param {Payload} to_copy
* @returns {Payload}
*/
export function copy_payload({ promise, out, css, head, uid }) {
copy() {
const payload = new Payload({
css: new Set(css),
uid,
head: new HeadPayload({
css: new Set(head.css),
// @ts-expect-error
title: head._state.title,
uid: head.uid
})
css: new Set(this.#css),
uid: this.#uid,
head: this.#head.copy()
});
payload.promise = promise;
payload.out = [...out];
payload.head.promise = head.promise;
payload.head.out = [...head.out];
payload.promise = this.promise;
payload.out = [...this.out];
return payload;
}
}
/**
* Assigns second payload to first
* Assigns second payload to first -- legacy nonsense
* @param {Payload} p1
* @param {Payload} p2
* @returns {void}
*/
export function assign_payload(p1, p2) {
p1.out = [...p2.out];
// this is all legacy code so typescript can go cry in a corner -- I don't want to write setters for all of these because they really shouldn't be settable
// @ts-expect-error
p1._state.css = p2.css;
// @ts-expect-error
p1._state.head._state.css = p2.head.css;
// @ts-expect-error
p1._state.head._state.title.value = p2.head.title;
// @ts-expect-error
p1._state.head._state.uid = p2.head.uid;
p1.head.promise = p2.head.promise;
p1.head.out = [...p2.head.out];
// @ts-expect-error
p1._state.uid = p2.uid;
p1.promise = p2.promise;
p1.css.clear();
for (const entry of p2.css) {
p1.css.add(entry);
}
p1.head.out = [...p2.head.out];
p1.head.promise = p2.head.promise;
p1.head.css.clear();
for (const entry of p2.head.css) {
p1.head.css.add(entry);
}
p1.head.title.value = p2.head.title.value;
}
/**

Loading…
Cancel
Save