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

@ -10,6 +10,7 @@ export interface AnalysisState {
ast_type: 'instance' | 'template' | 'module'; ast_type: 'instance' | 'template' | 'module';
fragment: AST.Fragment | null; fragment: AST.Fragment | null;
snippet: AST.SnippetBlock | null; snippet: AST.SnippetBlock | null;
title: AST.TitleElement | null;
boundary: AST.SvelteBoundary | null; boundary: AST.SvelteBoundary | null;
/** /**
* Tag name of the parent element. `null` if the parent is `svelte:element`, `#snippet`, a component or the root. * 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) { if (context.state.expression) {
context.state.expression.has_await = true; context.state.expression.has_await = true;
if ( if (context.state.fragment && context.path.some((node) => node.type === 'ConstTag')) {
context.state.fragment &&
// TODO there's probably a better way to do this
context.path.some((node) => node.type === 'ConstTag')
) {
context.state.fragment.metadata.has_await = true; context.state.fragment.metadata.has_await = true;
} }
@ -27,6 +23,10 @@ export function AwaitExpression(node, context) {
context.state.snippet.metadata.has_await = true; 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 // disallow top-level `await` or `await` in template expressions
// unless a) in runes mode and b) opted into `experimental.async` // unless a) in runes mode and b) opted into `experimental.async`
if (suspend) { 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.unary('!', b.id('$$settled')),
b.block([ b.block([
b.stmt(b.assignment('=', b.id('$$settled'), b.true)), b.stmt(b.assignment('=', b.id('$$settled'), b.true)),
b.stmt( b.stmt(b.assignment('=', b.id('$$inner_payload'), b.call('$$payload.copy'))),
b.assignment('=', b.id('$$inner_payload'), b.call('$.copy_payload', b.id('$$payload')))
),
b.stmt(b.call('$$render_inner', b.id('$$inner_payload'))) 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 { Location } from 'locate-character' */
/** @import { AST } from '#compiler' */ /** @import { AST } from '#compiler' */
/** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */ /** @import { ComponentContext, ComponentServerTransformState } from '../types.js' */
@ -8,7 +8,12 @@ import { dev, locator } from '../../../../state.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { clean_nodes, determine_namespace_for_children } from '../../utils.js'; import { clean_nodes, determine_namespace_for_children } from '../../utils.js';
import { build_element_attributes, build_spread_object } from './shared/element.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 * @param {AST.RegularElement} node
@ -73,6 +78,7 @@ export function RegularElement(node, context) {
} }
let select_with_value = false; let select_with_value = false;
const template_start = state.template.length;
if (node.name === 'select') { if (node.name === 'select') {
const value = node.attributes.find( const value = node.attributes.find(
@ -176,6 +182,22 @@ export function RegularElement(node, context) {
if (select_with_value) { if (select_with_value) {
state.template.push(b.stmt(b.assignment('=', b.id('$$payload.select_value'), b.void0))); 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) { if (!node_is_void) {

@ -13,5 +13,20 @@ export function TitleElement(node, context) {
process_children(node.fragment.nodes, { ...context, state: { ...context.state, template } }); process_children(node.fragment.nodes, { ...context, state: { ...context.state, template } });
template.push(b.literal('</title>')); 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.arrow(
[b.object_pattern([b.init('$$payload', b.id('$$payload'))])], [b.object_pattern([b.init('$$payload', b.id('$$payload'))])],
b.block(block.body), 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 { AST } from '#compiler' */
/** @import { ComponentContext, ServerTransformState } from '../../types.js' */ /** @import { ComponentContext, ServerTransformState } from '../../types.js' */
@ -257,3 +257,17 @@ export function build_getter(node, state) {
return node; 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 { export interface TitleElement extends BaseElement {
type: 'TitleElement'; type: 'TitleElement';
name: 'title'; name: 'title';
/** @internal */
metadata: {
has_await: boolean;
};
} }
export interface SlotElement extends BaseElement { export interface SlotElement extends BaseElement {

@ -101,7 +101,14 @@ export function render(component, options = {}) {
for (const cleanup of on_destroy) cleanup(); for (const cleanup of on_destroy) cleanup();
on_destroy = prev_on_destroy; 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) { for (const { hash, code } of payload.css) {
head += `<style id="${hash}">${code}</style>`; head += `<style id="${hash}">${code}</style>`;
@ -125,10 +132,9 @@ export function render(component, options = {}) {
* @returns {void} * @returns {void}
*/ */
export function head(payload, fn) { export function head(payload, fn) {
const head_payload = payload.head; payload.head.out.push(BLOCK_OPEN);
head_payload.out.push(BLOCK_OPEN); payload.head.child(({ $$payload }) => fn($$payload));
fn(head_payload); payload.head.out.push(BLOCK_CLOSE);
head_payload.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 { 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'; export { snapshot } from '../shared/clone.js';
@ -562,36 +568,33 @@ export function maybe_selected(payload, value) {
export function valueless_option(payload, children) { export function valueless_option(payload, children) {
var i = payload.out.length; var i = payload.out.length;
// prior to children, `payload` has some combination of string/unresolved payload that ends in `<option ...>`
children(); children();
// TODO this seems really likely to break in async world; we really need to find a better way to do this // post-children, `payload` has child content, possibly also with some number of hydration comments.
// @ts-expect-error // we can compact this last chunk of content to see if it matches the select value...
var body = collect_body(payload.out.slice(i)); let match = false;
payload.compact({
if (body.replace(/<!---->/g, '') === payload.select_value) { start: i,
// replace '>' with ' selected>' (closing tag will be added later) fn: (body) => {
var last_item = payload.out[i - 1]; if (body.replace(/<!---->/g, '') === payload.select_value) {
if (typeof last_item !== 'string') { match = true;
throw new Error('TODO something very bad has happened, this should be very impossible'); }
return body;
} }
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); if (!match) {
return;
} }
}
/** // ...and if it does match the select value, we can compact the part of the payload representing the `<option ...>`
* @param {(string | Payload)[]} out_fragment // to add the `selected` attribute to the end.
* @returns {string} payload.compact({
*/ start: i - 1,
function collect_body(out_fragment) { end: i,
let body = ''; fn: (body) => {
for (const item of out_fragment) { return body.slice(0, -1) + ' selected>';
if (typeof item === 'string') {
body += item;
} else {
body += collect_body(/** @type {(string | Payload)[]} */ (item.out));
} }
} });
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 * 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 * asynchronously with {@link collect_async}, which will wait for all children to complete before
* collecting their contents. * collecting their contents.
* *
* @template {Record<PropertyKey, unknown>} TState * @template {new (parent: Partial<InstanceType<TSubclass>>) => {}} TSubclass
*/ */
class BasePayload { 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. * The contents of the payload.
* @type {(string | BasePayload<TState>)[]} * @type {(string | Instance)[]}
*/ */
out = []; out = [];
@ -24,32 +31,15 @@ class BasePayload {
*/ */
promise; 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, * 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 * but has its own `out` array and `promise` property. The child payload is automatically
* inserted into the parent payload's `out` array. * 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} * @returns {void}
*/ */
child(render) { child(render) {
// @ts-expect-error dynamic constructor invocation for subclass instance creation const child = this.#create_child_instance();
const child = new this.constructor(this._state);
this.out.push(child); this.out.push(child);
const result = render({ $$payload: child }); const result = render({ $$payload: child });
if (result instanceof Promise) { 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. * Waits for all child payloads to finish their blocking asynchronous work, then returns the generated content.
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
async collect_async() { async collect_async() {
// TODO: Should probably use `Promise.allSettled` here just so we can report detailed errors // TODO: Should probably use `Promise.allSettled` here just so we can report detailed errors
await Promise.all(this.#collect_promises(this.out)); await Promise.all(BasePayload.#collect_promises(this.out, this.promise ? [this.promise] : []));
return this.#collect_content(); return BasePayload.#collect_content(this.out);
} }
/** /**
@ -72,27 +92,27 @@ class BasePayload {
* @returns {string} * @returns {string}
*/ */
collect() { collect() {
const promises = this.#collect_promises(this.out); const promises = BasePayload.#collect_promises(this.out, this.promise ? [this.promise] : []);
if (promises.length > 0) { 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 // 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'); 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 {(string | Instance)[]} items
* @param {Promise<void>[]} [promises] * @param {Promise<void>[]} promises
* @returns {Promise<void>[]} * @returns {Promise<void>[]}
*/ */
#collect_promises(items, promises = this.promise ? [this.promise] : []) { static #collect_promises(items, promises) {
for (const item of items) { for (const item of items) {
if (item instanceof BasePayload) { if (typeof item !== 'string') {
if (item.promise) { if (item.promise) {
promises.push(item.promise); promises.push(item.promise);
} }
this.#collect_promises(item.out, promises); BasePayload.#collect_promises(item.out, promises);
} }
} }
return promises; return promises;
@ -100,84 +120,108 @@ class BasePayload {
/** /**
* Collect all of the code from the `out` array and return it as a string. * Collect all of the code from the `out` array and return it as a string.
* @param {(string | Instance)[]} items
* @returns {string} * @returns {string}
*/ */
#collect_content() { static #collect_content(items) {
// TODO throw in `async` mode // TODO throw in `async` mode
let content = ''; let content = '';
for (const item of this.out) { for (const item of items) {
if (typeof item === 'string') { if (typeof item === 'string') {
content += item; content += item;
} else { } else {
content += item.#collect_content(); content += BasePayload.#collect_content(item.out);
} }
} }
return 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);
}
} }
/** /**
* @extends {BasePayload<{ * @extends {BasePayload<typeof HeadPayload>}
* css: Set<{ hash: string; code: string }>,
* title: { value: string },
* uid: () => string
* }>}
*/ */
export class HeadPayload extends BasePayload { 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() { get css() {
return this._state.css; return this.#css;
} }
get uid() { 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() { get title() {
return this._state.title.value; return this.#title;
}
set title(value) {
this._state.title.value = value;
} }
/** /**
* @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 = () => '' } = {}) { constructor({ css = new Set(), title = { value: '' }, uid = () => '' } = {}) {
super({ super();
css, this.#css = css;
title, this.#title = title;
uid 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<{ * @extends {BasePayload<typeof Payload>}
* css: Set<{ hash: string; code: string }>,
* uid: () => string,
* select_value: any,
* head: HeadPayload,
* }>}
*/ */
export class Payload extends BasePayload { export class Payload extends BasePayload {
/** @type {() => string} */
#uid;
/** @type {Set<{ hash: string; code: string }>} */
#css;
/** @type {HeadPayload} */
#head;
/** @type {string} */
select_value = '';
get css() { get css() {
return this._state.css; return this.#css;
} }
get uid() { get uid() {
return this._state.uid; return this.#uid;
} }
get head() { get head() {
return this._state.head; return this.#head;
}
get select_value() {
return this._state.select_value;
}
set select_value(value) {
this._state.select_value = value;
} }
/** /**
@ -189,62 +233,46 @@ export class Payload extends BasePayload {
uid = props_id_generator(id_prefix), uid = props_id_generator(id_prefix),
css = new Set() css = new Set()
} = {}) { } = {}) {
super({ super();
uid, this.#uid = uid;
head, this.#css = css;
css, this.#head = head;
select_value: undefined
});
} }
}
/** copy() {
* Used in legacy mode to handle bindings const payload = new Payload({
* @param {Payload} to_copy css: new Set(this.#css),
* @returns {Payload} uid: this.#uid,
*/ head: this.#head.copy()
export function copy_payload({ promise, out, css, head, uid }) { });
const payload = new Payload({
css: new Set(css), payload.promise = this.promise;
uid, payload.out = [...this.out];
head: new HeadPayload({ return payload;
css: new Set(head.css), }
// @ts-expect-error
title: head._state.title,
uid: head.uid
})
});
payload.promise = promise;
payload.out = [...out];
payload.head.promise = head.promise;
payload.head.out = [...head.out];
return payload;
} }
/** /**
* Assigns second payload to first * Assigns second payload to first -- legacy nonsense
* @param {Payload} p1 * @param {Payload} p1
* @param {Payload} p2 * @param {Payload} p2
* @returns {void} * @returns {void}
*/ */
export function assign_payload(p1, p2) { export function assign_payload(p1, p2) {
p1.out = [...p2.out]; 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.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