pull/16762/head
S. Elliott Johnson 5 days ago
parent 49241764bd
commit 0e5eeb7e45

@ -161,7 +161,10 @@ export function RegularElement(node, context) {
b.call( b.call(
'$.simple_valueless_option', '$.simple_valueless_option',
b.id('$$payload'), b.id('$$payload'),
node.metadata.synthetic_value_node.expression b.thunk(
node.metadata.synthetic_value_node.expression,
node.metadata.synthetic_value_node.metadata.expression.has_await
)
) )
) )
); );
@ -216,16 +219,9 @@ export function RegularElement(node, context) {
// in an async world, we could technically have two adjacent select elements with async children, in which case // 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 // 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. // element hadn't resolved prior to hitting the second element.
// TODO is this cast safe?
const elements = state.template.splice(template_start, Infinity); const elements = state.template.splice(template_start, Infinity);
state.template.push( state.template.push(
call_child_payload( call_child_payload(b.block(build_template(elements)), select_with_value_async)
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.
select_with_value_async
)
); );
} }

@ -11,7 +11,7 @@ export function TitleElement(node, context) {
if (node.fragment.metadata.hoisted_promises.promises.length > 0) { if (node.fragment.metadata.hoisted_promises.promises.length > 0) {
context.state.init.push( context.state.init.push(
b.const( b.const(
node.fragment.metadata.hoisted_promises.name, node.fragment.metadata.hoisted_promises.id,
b.array(node.fragment.metadata.hoisted_promises.promises) b.array(node.fragment.metadata.hoisted_promises.promises)
) )
); );
@ -23,20 +23,12 @@ export function TitleElement(node, context) {
template.push(b.literal('</title>')); template.push(b.literal('</title>'));
context.state.init.push( context.state.init.push(
call_child_payload( b.stmt(
b.block([ b.call(
b.const('path', b.call('$$payload.get_path')), '$.build_title',
b.let('title'), b.id('$$payload'),
...build_template(template, b.id('title'), '='), b.thunk(b.block(build_template(template)), node.metadata.has_await)
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
) )
); );
} }

@ -32,6 +32,10 @@ export function process_children(nodes, { visit, state }) {
let sequence = []; let sequence = [];
function flush() { function flush() {
if (sequence.length === 0) {
return;
}
let quasi = b.quasi('', false); let quasi = b.quasi('', false);
const quasis = [quasi]; const quasis = [quasi];
@ -63,26 +67,25 @@ export function process_children(nodes, { visit, state }) {
} }
state.template.push(b.template(quasis, expressions)); state.template.push(b.template(quasis, expressions));
sequence = [];
} }
for (let i = 0; i < nodes.length; i += 1) { for (const node of nodes) {
const node = nodes[i]; if (node.type === 'ExpressionTag' && node.metadata.expression.has_await) {
flush();
if (node.type === 'Text' || node.type === 'Comment' || node.type === 'ExpressionTag') { const visited = /** @type {Expression} */ (visit(node.expression));
state.template.push(
b.stmt(b.call('$$payload.push', b.thunk(b.call('$.escape', visited), true)))
);
} else if (node.type === 'Text' || node.type === 'Comment' || node.type === 'ExpressionTag') {
sequence.push(node); sequence.push(node);
} else { } else {
if (sequence.length > 0) { flush();
flush();
sequence = [];
}
visit(node, { ...state }); visit(node, { ...state });
} }
} }
if (sequence.length > 0) { flush();
flush();
}
} }
/** /**

@ -57,6 +57,8 @@ export namespace AST {
*/ */
dynamic: boolean; dynamic: boolean;
has_await: boolean; has_await: boolean;
/** TODO document */
is_async: boolean;
hoisted_promises: { id: Identifier; promises: Expression[] }; hoisted_promises: { id: Identifier; promises: Expression[] };
}; };
} }

@ -1,6 +1,7 @@
/** @import { ComponentType, SvelteComponent } from 'svelte' */ /** @import { ComponentType, SvelteComponent } from 'svelte' */
/** @import { Component, RenderOutput } from '#server' */ /** @import { Component, RenderOutput } from '#server' */
/** @import { Store } from '#shared' */ /** @import { Store } from '#shared' */
/** @import { AccumulatedContent } from './payload.js' */
export { FILENAME, HMR } from '../../constants.js'; export { FILENAME, HMR } from '../../constants.js';
import { attr, clsx, to_class, to_style } from '../shared/attributes.js'; import { attr, clsx, to_class, to_style } from '../shared/attributes.js';
import { is_promise, noop } from '../shared/utils.js'; import { is_promise, noop } from '../shared/utils.js';
@ -679,15 +680,68 @@ export function valueless_option(payload, children) {
* we don't have to do all of the same parsing nonsense. It also means we can avoid * we don't have to do all of the same parsing nonsense. It also means we can avoid
* coercing everything to a string. * coercing everything to a string.
* @param {Payload} payload * @param {Payload} payload
* @param {unknown} child_value * @param {(() => unknown)} child
*/ */
export function simple_valueless_option(payload, child_value) { export function simple_valueless_option(payload, child) {
if (child_value === payload.local.select_value) { const result = child();
payload.compact({
start: payload.length - 1, /**
fn: (content) => ({ body: content.body.slice(0, -1) + ' selected>', head: content.head }) * @param {AccumulatedContent} content
}); * @param {unknown} child_value
} * @returns {AccumulatedContent}
*/
const mark_selected = (content, child_value) => {
if (child_value === payload.local.select_value) {
return { body: content.body.slice(0, -1) + ' selected>', head: content.head };
}
return content;
};
payload.compact({
start: payload.length - 1,
fn: (content) => {
if (result instanceof Promise) {
return result.then((child_value) => mark_selected(content, child_value));
}
return mark_selected(content, result);
}
});
payload.child((child_payload) => {
if (result instanceof Promise) {
return result.then((child_value) => {
child_payload.push(escape_html(child_value));
});
}
child_payload.push(escape_html(result));
});
}
payload.push(escape_html(child_value)); /**
* Since your document can only have one `title`, we have to have some sort of algorithm for determining
* which one "wins". To do this, we perform a depth-first comparison of where the title was encountered --
* later ones "win" over earlier ones, regardless of what order the promises resolve in. To accomodate this, we:
* - Figure out where we are in the content tree (`get_path`)
* - Render the title in its own child so that it has a defined "slot" in the payload
* - Compact that spot so that we get the entire rendered contents of the title
* - Attempt to set the global title (this is where the "wins" logic based on the path happens)
*
* TODO we could optimize this by not even rendering the title if the path wouldn't be accepted
*
* @param {Payload} payload
* @param {((payload: Payload) => void | Promise<void>)} children
*/
export function build_title(payload, children) {
const path = payload.get_path();
const i = payload.length;
payload.child(children);
payload.compact({
start: i,
fn: ({ head }) => {
payload.global.head.title = { path, value: head };
// since we can only ever render the title in this chunk, and title rendering is handled specially,
// we can just ditch the results after we've saved them globally
return { head: '', body: '' };
}
});
} }

@ -5,6 +5,9 @@
* @template T * @template T
* @typedef {T | Promise<T>} MaybePromise<T> * @typedef {T | Promise<T>} MaybePromise<T>
*/ */
/**
* @typedef {string | Payload | Promise<string>} PayloadItem
*/
/** /**
* Payloads are basically a tree of `string | Payload`s, where each `Payload` in the tree represents * Payloads are basically a tree of `string | Payload`s, where each `Payload` in the tree represents
@ -18,7 +21,7 @@
export class Payload { export class Payload {
/** /**
* The contents of the payload. * The contents of the payload.
* @type {(string | Payload)[]} * @type {PayloadItem[]}
*/ */
#out = []; #out = [];
@ -50,6 +53,9 @@ export class Payload {
/** /**
* State that is local to the branch it is declared in. * State that is local to the branch it is declared in.
* It will be shallow-copied to all children. * It will be shallow-copied to all children.
*
* TODO I think this needs to be async-compatible if we don't want waterfall-y options but I'm willing
* to live with it for now
* @type {{ select_value: string | undefined }} * @type {{ select_value: string | undefined }}
*/ */
local; local;
@ -98,16 +104,20 @@ export class Payload {
} }
/** /**
* @param {string} content * @param {string | (() => Promise<string>)} content
*/ */
push(content) { push(content) {
this.#out.push(content); if (typeof content === 'function') {
this.#out.push(content());
} else {
this.#out.push(content);
}
} }
/** /**
* Compact everything between `start` and `end` into a single payload, then call `fn` with the result of that payload. * 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. * The compacted payload will be sync if all of the children are sync and {@link fn} is sync, otherwise it will be async.
* @param {{ start: number, end?: number, fn: (content: AccumulatedContent) => AccumulatedContent }} args * @param {{ start: number, end?: number, fn: (content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent> }} args
*/ */
compact({ start, end = this.#out.length, fn }) { compact({ start, end = this.#out.length, fn }) {
const child = new Payload(this.global, this.local, this); const child = new Payload(this.global, this.local, this);
@ -122,7 +132,15 @@ export class Payload {
); );
this.promises.followup.push(followup); this.promises.followup.push(followup);
} else { } else {
Payload.#push_accumulated_content(child, fn(content)); const transformed_content = fn(content);
if (transformed_content instanceof Promise) {
const followup = transformed_content.then((content) =>
Payload.#push_accumulated_content(child, content)
);
this.promises.followup.push(followup);
} else {
Payload.#push_accumulated_content(child, transformed_content);
}
} }
} }
@ -153,7 +171,7 @@ export class Payload {
copy() { copy() {
const copy = new Payload(this.global, this.local, this.parent, this.type); const copy = new Payload(this.global, this.local, this.parent, this.type);
copy.#out = this.#out.map((item) => (typeof item === 'string' ? item : item.copy())); copy.#out = this.#out.map((item) => (item instanceof Payload ? item.copy() : item));
copy.promises = this.promises; copy.promises = this.promises;
return copy; return copy;
} }
@ -170,7 +188,7 @@ export class Payload {
this.global.subsume(other.global); this.global.subsume(other.global);
this.local = other.local; this.local = other.local;
this.#out = other.#out.map((item) => { this.#out = other.#out.map((item) => {
if (typeof item !== 'string') { if (item instanceof Payload) {
item.subsume(item); item.subsume(item);
} }
return item; return item;
@ -185,7 +203,7 @@ export class Payload {
/** /**
* Collect all of the code from the `out` array and return it as a string, or a promise resolving to a string. * Collect all of the code from the `out` array and return it as a string, or a promise resolving to a string.
* @param {(string | Payload)[]} items * @param {PayloadItem[]} items
* @param {PayloadType} current_type * @param {PayloadType} current_type
* @param {AccumulatedContent} content * @param {AccumulatedContent} content
* @returns {MaybePromise<AccumulatedContent>} * @returns {MaybePromise<AccumulatedContent>}
@ -208,13 +226,20 @@ export class Payload {
} else { } else {
flush(); flush();
if (item.promises.initial || item.promises.followup.length) { if (item instanceof Promise) {
has_async = true; has_async = true;
segments.push( segments.push(
Payload.#collect_content_async([item], current_type, { head: '', body: '' }) item.then((resolved) => {
const content = { head: '', body: '' };
content[current_type] = resolved;
return content;
})
); );
} else if (item.promises.initial || item.promises.followup.length) {
has_async = true;
segments.push(Payload.#collect_content_async([item], current_type));
} else { } else {
const sub = Payload.#collect_content(item.#out, item.type, { head: '', body: '' }); const sub = Payload.#collect_content(item.#out, item.type);
if (sub instanceof Promise) { if (sub instanceof Promise) {
has_async = true; has_async = true;
} }
@ -237,16 +262,15 @@ export class Payload {
/** /**
* 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 | Payload)[]} items * @param {PayloadItem[]} items
* @param {PayloadType} current_type * @param {PayloadType} current_type
* @param {AccumulatedContent} content * @param {AccumulatedContent} content
* @returns {Promise<AccumulatedContent>} * @returns {Promise<AccumulatedContent>}
*/ */
static async #collect_content_async(items, current_type, content = { head: '', body: '' }) { static async #collect_content_async(items, current_type, content = { head: '', body: '' }) {
// no danger to sequentially awaiting stuff in here; all of the work is already kicked off
for (const item of items) { for (const item of items) {
if (typeof item === 'string') { if (item instanceof Payload) {
content[current_type] += item;
} else {
if (item.promises.initial) { if (item.promises.initial) {
// this represents the async function that's modifying this payload. // this represents the async function that's modifying this payload.
// we can't do anything until it's done and we know our `out` array is complete. // we can't do anything until it's done and we know our `out` array is complete.
@ -257,6 +281,8 @@ export class Payload {
await followup; await followup;
} }
await Payload.#collect_content_async(item.#out, item.type, content); await Payload.#collect_content_async(item.#out, item.type, content);
} else {
content[current_type] += await item;
} }
} }
return content; return content;

@ -251,3 +251,101 @@ test('TreeHeadState title ordering favors later lexicographic paths', () => {
head.title = { path: [2], value: 'F' }; head.title = { path: [2], value: 'F' };
assert.equal(head.title.value, 'E'); assert.equal(head.title.value, 'E');
}); });
test('push accepts async functions in async context', async () => {
const payload = new Payload(new TreeState('async'));
payload.push('a');
payload.push(async () => {
await Promise.resolve();
return 'b';
});
payload.push('c');
const { head, body } = await payload;
assert.equal(head, '');
assert.equal(body, 'abc');
});
test('push handles async functions with different timing', async () => {
const payload = new Payload(new TreeState('async'));
// Fast async function
payload.push(async () => {
await Promise.resolve();
return 'fast';
});
// Slow async function
payload.push(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return 'slow';
});
// Regular string
payload.push('sync');
const { head, body } = await payload;
assert.equal(head, '');
assert.equal(body, 'fastslowsync');
});
test('push async functions work with head content type', async () => {
const payload = new Payload(new TreeState('async'), undefined, undefined, 'head');
payload.push(async () => {
await Promise.resolve();
return '<title>Async Title</title>';
});
const { head, body } = await payload;
assert.equal(body, '');
assert.equal(head, '<title>Async Title</title>');
});
test('push async functions can be mixed with child payloads', async () => {
const payload = new Payload(new TreeState('async'));
payload.push('start-');
payload.push(async () => {
await Promise.resolve();
return 'async-';
});
payload.child(($$payload) => {
$$payload.push('child-');
});
payload.push('-end');
const { head, body } = await payload;
assert.equal(head, '');
assert.equal(body, 'start-async-child--end');
});
test('push async functions work with compact operations', async () => {
const payload = new Payload(new TreeState('async'));
payload.push('a');
payload.push(async () => {
await Promise.resolve();
return 'b';
});
payload.push('c');
payload.compact({
start: 0,
fn: (content) => ({ head: '', body: content.body.toUpperCase() })
});
const { head, body } = await payload;
assert.equal(head, '');
assert.equal(body, 'ABC');
});
test('push async functions are not supported in sync context', () => {
const payload = new Payload(new TreeState('sync'));
payload.push('a');
expect(() => {
payload.push(() => Promise.resolve('b'));
payload.collect();
}).toThrow();
});

@ -0,0 +1,3 @@
<select value="421">
<option>{await Promise.resolve(42)}1</option>
</select>

@ -0,0 +1,3 @@
<select value={42}>
<option>{await Promise.resolve(42)}</option>
</select>

@ -11,14 +11,16 @@ export default function Async_each_fallback_hoisting($$payload) {
let item = each_array[$$index]; let item = each_array[$$index];
$$payload.child(async ($$payload) => { $$payload.child(async ($$payload) => {
$$payload.push(`<!---->${$.escape(await Promise.reject('This should never be reached'))}`); $$payload.push(`<!---->`);
$$payload.push(async () => $.escape(await Promise.reject('This should never be reached')));
}); });
} }
} else { } else {
$$payload.push('<!--[!-->'); $$payload.push('<!--[!-->');
$$payload.child(async ($$payload) => { $$payload.child(async ($$payload) => {
$$payload.push(`<!---->${$.escape(await Promise.resolve(4))}`); $$payload.push(`<!---->`);
$$payload.push(async () => $.escape(await Promise.resolve(4)));
}); });
} }

@ -14,7 +14,8 @@ export default function Async_each_hoisting($$payload) {
let item = each_array[$$index]; let item = each_array[$$index];
$$payload.child(async ($$payload) => { $$payload.child(async ($$payload) => {
$$payload.push(`<!---->${$.escape(await item)}`); $$payload.push(`<!---->`);
$$payload.push(async () => $.escape(await item));
}); });
} }

@ -6,13 +6,13 @@ export default function Async_if_alternate_hoisting($$payload) {
$$payload.push('<!--[-->'); $$payload.push('<!--[-->');
$$payload.child(async ($$payload) => { $$payload.child(async ($$payload) => {
$$payload.push(`${$.escape(await Promise.reject('no no no'))}`); $$payload.push(async () => $.escape(await Promise.reject('no no no')));
}); });
} else { } else {
$$payload.push('<!--[!-->'); $$payload.push('<!--[!-->');
$$payload.child(async ($$payload) => { $$payload.child(async ($$payload) => {
$$payload.push(`${$.escape(await Promise.resolve('yes yes yes'))}`); $$payload.push(async () => $.escape(await Promise.resolve('yes yes yes')));
}); });
} }

@ -6,13 +6,13 @@ export default function Async_if_hoisting($$payload) {
$$payload.push('<!--[-->'); $$payload.push('<!--[-->');
$$payload.child(async ($$payload) => { $$payload.child(async ($$payload) => {
$$payload.push(`${$.escape(await Promise.resolve('yes yes yes'))}`); $$payload.push(async () => $.escape(await Promise.resolve('yes yes yes')));
}); });
} else { } else {
$$payload.push('<!--[!-->'); $$payload.push('<!--[!-->');
$$payload.child(async ($$payload) => { $$payload.child(async ($$payload) => {
$$payload.push(`${$.escape(await Promise.reject('no no no'))}`); $$payload.push(async () => $.escape(await Promise.reject('no no no')));
}); });
} }

@ -12,7 +12,8 @@
"preview": "vite preview", "preview": "vite preview",
"download": "node scripts/download.js", "download": "node scripts/download.js",
"hash": "node scripts/hash.js", "hash": "node scripts/hash.js",
"create-test": "node scripts/create-test.js" "create-test": "node scripts/create-test.js",
"start": "node run.js"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",

Loading…
Cancel
Save