at least passing

pull/16748/head
S. Elliott Johnson 1 month ago
parent f1ca5517db
commit 903f1f1c08

@ -6,7 +6,7 @@ import { walk } from 'zimmerframe';
import { set_scope } from '../../scope.js'; import { set_scope } from '../../scope.js';
import { extract_identifiers } from '../../../utils/ast.js'; import { extract_identifiers } from '../../../utils/ast.js';
import * as b from '#compiler/builders'; import * as b from '#compiler/builders';
import { dev, filename } from '../../../state.js'; import { component_name, dev, filename } from '../../../state.js';
import { render_stylesheet } from '../css/index.js'; import { render_stylesheet } from '../css/index.js';
import { AssignmentExpression } from './visitors/AssignmentExpression.js'; import { AssignmentExpression } from './visitors/AssignmentExpression.js';
import { AwaitBlock } from './visitors/AwaitBlock.js'; import { AwaitBlock } from './visitors/AwaitBlock.js';
@ -40,7 +40,7 @@ import { TitleElement } from './visitors/TitleElement.js';
import { UpdateExpression } from './visitors/UpdateExpression.js'; import { UpdateExpression } from './visitors/UpdateExpression.js';
import { VariableDeclaration } from './visitors/VariableDeclaration.js'; import { VariableDeclaration } from './visitors/VariableDeclaration.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js'; import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { call_child_payload } from './visitors/shared/utils.js'; import { call_child_payload, call_component_payload } from './visitors/shared/utils.js';
/** @type {Visitors} */ /** @type {Visitors} */
const global_visitors = { const global_visitors = {
@ -260,8 +260,9 @@ export function server_component(analysis, options) {
let should_inject_context = dev || analysis.needs_context; let should_inject_context = dev || analysis.needs_context;
if (should_inject_context) { if (should_inject_context) {
component_block.body.unshift(b.stmt(b.call('$.push', dev && b.id(analysis.name)))); component_block = b.block([
component_block.body.push(b.stmt(b.call('$.pop'))); call_component_payload(component_block, dev && b.id(component_name))
]);
} }
if (analysis.uses_rest_props) { if (analysis.uses_rest_props) {

@ -270,6 +270,17 @@ export function call_child_payload(body, async) {
return b.stmt(b.call('$$payload.child', b.arrow([b.id('$$payload')], body, async))); return b.stmt(b.call('$$payload.child', b.arrow([b.id('$$payload')], body, async)));
} }
/**
* @param {BlockStatement | Expression} body
* @param {Identifier | false} component_fn_id
* @returns {Statement}
*/
export function call_component_payload(body, component_fn_id) {
return b.stmt(
b.call('$$payload.component', b.arrow([b.id('$$payload')], body, false), component_fn_id)
);
}
export class PromiseOptimiser { export class PromiseOptimiser {
/** @type {Expression[]} */ /** @type {Expression[]} */
expressions = []; expressions = [];

@ -5,8 +5,7 @@ import * as e from './internal/server/errors.js';
/** @param {() => void} fn */ /** @param {() => void} fn */
export function onDestroy(fn) { export function onDestroy(fn) {
var context = /** @type {SSRContext} */ (ssr_context); /** @type {SSRContext} */ (ssr_context).r.on_destroy(fn);
(context.d ??= []).push(fn);
} }
export { export {

@ -1,6 +1,5 @@
/** @import { SSRContext } from '#server' */ /** @import { SSRContext } from '#server' */
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { async_on_destroy, on_destroy } from './index.js';
import * as e from './errors.js'; import * as e from './errors.js';
/** @type {SSRContext | null} */ /** @type {SSRContext | null} */
@ -63,7 +62,7 @@ function get_or_init_context_map(name) {
* @param {Function} [fn] * @param {Function} [fn]
*/ */
export function push(fn) { export function push(fn) {
ssr_context = { p: ssr_context, c: null, d: null }; ssr_context = { p: ssr_context, c: null, r: null };
if (DEV) { if (DEV) {
ssr_context.function = fn; ssr_context.function = fn;
@ -72,17 +71,7 @@ export function push(fn) {
} }
export function pop() { export function pop() {
var context = /** @type {SSRContext} */ (ssr_context); ssr_context = /** @type {SSRContext} */ (ssr_context).p;
var ondestroy = context.d;
if (ondestroy) {
on_destroy.push(...ondestroy);
// TODO this is probably actually broken
async_on_destroy.push(...ondestroy);
}
ssr_context = context.p;
} }
/** /**

@ -53,12 +53,6 @@ export function element(payload, tag, attributes_fn = noop, children_fn = noop)
payload.push('<!---->'); payload.push('<!---->');
} }
/**
* Array of `onDestroy` callbacks that should be called at the end of the server render function
* @type {Function[]}
*/
export let on_destroy = [];
/** /**
* Only available on the server and when compiling with the `server` option. * 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. * 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.
@ -75,8 +69,6 @@ export function render(component, options = {}) {
new TreeState('sync', options.idPrefix ? options.idPrefix + '-' : '') new TreeState('sync', options.idPrefix ? options.idPrefix + '-' : '')
); );
const prev_on_destroy = on_destroy;
on_destroy = [];
payload.push(BLOCK_OPEN); payload.push(BLOCK_OPEN);
if (options.context) { if (options.context) {
@ -92,10 +84,9 @@ export function render(component, options = {}) {
} }
payload.push(BLOCK_CLOSE); payload.push(BLOCK_CLOSE);
for (const cleanup of on_destroy) cleanup();
on_destroy = prev_on_destroy;
let { head, body } = payload.collect(); let { head, body } = payload.collect();
for (const cleanup of payload.collect_on_destroy()) cleanup();
head += payload.global.head.title.value; head += payload.global.head.title.value;
body = BLOCK_OPEN + body + BLOCK_CLOSE; // this inserts a fake boundary so hydration matches body = BLOCK_OPEN + body + BLOCK_CLOSE; // this inserts a fake boundary so hydration matches
@ -115,13 +106,6 @@ export function render(component, options = {}) {
} }
} }
/**
* TODO THIS NEEDS TO ACTUALLY BE DONE
* Array of `onDestroy` callbacks that should be called at the end of the server render function
* @type {Function[]}
*/
export let async_on_destroy = [];
/** /**
* Only available on the server and when compiling with the `server` option. * 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. * 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.
@ -138,8 +122,6 @@ export async function render_async(component, options = {}) {
new TreeState('async', options.idPrefix ? options.idPrefix + '-' : '') new TreeState('async', options.idPrefix ? options.idPrefix + '-' : '')
); );
const prev_on_destroy = async_on_destroy;
async_on_destroy = [];
payload.push(BLOCK_OPEN); payload.push(BLOCK_OPEN);
if (options.context) { if (options.context) {
@ -155,10 +137,9 @@ export async function render_async(component, options = {}) {
} }
payload.push(BLOCK_CLOSE); payload.push(BLOCK_CLOSE);
for (const cleanup of async_on_destroy) cleanup();
async_on_destroy = prev_on_destroy;
let { head, body } = await payload.collect_async(); let { head, body } = await payload.collect_async();
for (const cleanup of payload.collect_on_destroy()) cleanup();
head += payload.global.head.title.value; head += payload.global.head.title.value;
body = BLOCK_OPEN + body + BLOCK_CLOSE; // this inserts a fake boundary so hydration matches body = BLOCK_OPEN + body + BLOCK_CLOSE; // this inserts a fake boundary so hydration matches

@ -6,9 +6,11 @@
* @typedef {T | Promise<T>} MaybePromise<T> * @typedef {T | Promise<T>} MaybePromise<T>
*/ */
/** /**
* @typedef {string | Payload | Promise<string>} PayloadItem * @typedef {string | Payload} PayloadItem
*/ */
import { pop, push, set_ssr_context, ssr_context } from './context';
/** /**
* 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
* work that may or may not have completed. A payload can be {@link collect}ed to aggregate the * work that may or may not have completed. A payload can be {@link collect}ed to aggregate the
@ -25,6 +27,18 @@ export class Payload {
*/ */
#out = []; #out = [];
/**
* Any `onDestroy` callbacks registered during execution of this payload.
* @type {(() => void)[] | undefined}
*/
#on_destroy = undefined;
/**
* Whether this payload is a component body.
* @type {boolean}
*/
#is_component_body = false;
/** /**
* The type of string content that this payload is accumulating. * The type of string content that this payload is accumulating.
* @type {PayloadType} * @type {PayloadType}
@ -82,17 +96,22 @@ export class Payload {
*/ */
child(render, type) { child(render, type) {
const child = new Payload(this.global, this.local, this, type); const child = new Payload(this.global, this.local, this, type);
this.#out.push(child); this.#run_child(child, render);
const result = render(child); }
if (result instanceof Promise) {
if (this.global.mode === 'sync') { /**
// TODO more-proper error * Create a component payload. The component payload inherits the state from the parent,
throw new Error('Encountered an asynchronous component while rendering synchronously'); * but has its own content. It is treated as an ordering boundary for ondestroy callbacks.
} * @param {(tree: Payload) => MaybePromise<void>} render
// just to avoid unhandled promise rejections -- we'll end up throwing in `then` if something fails * @param {Function} [fn]
result.catch(() => {}); * @returns {void}
child.promises.initial = result; */
} component(render, fn) {
push(fn);
const child = new Payload(this.global, this.local, this);
child.#is_component_body = true;
this.#run_child(child, render);
pop();
} }
/** /**
@ -100,11 +119,7 @@ export class Payload {
*/ */
push(content) { push(content) {
if (typeof content === 'function') { if (typeof content === 'function') {
if (this.global.mode === 'sync') { this.child(async (payload) => payload.push(await content()));
// TODO more-proper error
throw new Error('Encountered an asynchronous component while rendering synchronously');
}
this.#out.push(content());
} else { } else {
this.#out.push(content); this.#out.push(content);
} }
@ -126,6 +141,13 @@ export class Payload {
} }
} }
/**
* @param {() => void} fn
*/
on_destroy(fn) {
(this.#on_destroy ??= []).push(fn);
}
/** /**
* @returns {number[]} * @returns {number[]}
*/ */
@ -150,6 +172,84 @@ export class Payload {
return Payload.#collect_content_async([this], this.type); return Payload.#collect_content_async([this], this.type);
} }
/**
* Collect all of the `onDestroy` callbacks regsitered during rendering. In an async context, this is only safe to call
* after awaiting `collect_async`.
*
* Child payloads are "porous" and don't affect execution order, but component body payloads
* create ordering boundaries. Within a payload, callbacks run in order until hitting a component boundary.
* @returns {Iterable<() => void>}
*/
*collect_on_destroy() {
const payload_children = this.#out.filter((child) => child instanceof Payload);
// First, do a depth-first search to find and process all component bodies
// This includes components nested inside regular payloads
for (const child of payload_children) {
yield* this.#collect_component_callbacks(child);
}
// Then, if this is a component body, yield its own callbacks
if (this.#is_component_body && this.#on_destroy) {
for (const fn of this.#on_destroy) {
yield fn;
}
}
// Finally, collect callbacks from regular (porous) payloads
for (const child of payload_children) {
if (!child.#is_component_body) {
yield* this.#collect_regular_callbacks(child);
}
}
// If this is NOT a component body, yield callbacks after all processing
if (!this.#is_component_body && this.#on_destroy) {
for (const fn of this.#on_destroy) {
yield fn;
}
}
}
/**
* Helper method to collect only component body callbacks in depth-first order
* @param {Payload} payload
* @returns {Iterable<() => void>}
*/
*#collect_component_callbacks(payload) {
if (payload.#is_component_body) {
// This is a component body - process it
yield* payload.collect_on_destroy();
} else {
// This is a regular payload - look for nested components
const children = payload.#out.filter((child) => child instanceof Payload);
for (const child of children) {
yield* this.#collect_component_callbacks(child);
}
}
}
/**
* Helper method to collect callbacks from regular (porous) payloads
* @param {Payload} payload
* @returns {Iterable<() => void>}
*/
*#collect_regular_callbacks(payload) {
if (payload.#on_destroy) {
for (const fn of payload.#on_destroy) {
yield fn;
}
}
// Recursively collect from regular payload children
const children = payload.#out
.filter((child) => child instanceof Payload)
.filter((child) => !child.#is_component_body);
for (const child of children) {
yield* this.#collect_regular_callbacks(child);
}
}
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) => (item instanceof Payload ? item.copy() : item)); copy.#out = this.#out.map((item) => (item instanceof Payload ? item.copy() : item));
@ -198,6 +298,31 @@ export class Payload {
} }
} }
/**
* @param {Payload} child_payload
* @param {(tree: Payload) => MaybePromise<void>} render
* @returns {void}
*/
#run_child(child_payload, render) {
this.#out.push(child_payload);
set_ssr_context({
...ssr_context,
p: ssr_context?.p ?? null,
c: ssr_context?.c ?? null,
r: child_payload
});
const result = render(child_payload);
if (result instanceof Promise) {
if (this.global.mode === 'sync') {
// TODO more-proper error
throw new Error('Encountered an asynchronous component while rendering synchronously');
}
// just to avoid unhandled promise rejections -- we'll end up throwing in `collect_async` if something fails
result.catch(() => {});
child_payload.promises.initial = result;
}
}
/** /**
* @param {(content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent>} fn * @param {(content: AccumulatedContent) => AccumulatedContent | Promise<AccumulatedContent>} fn
* @param {Payload} child * @param {Payload} child
@ -223,8 +348,6 @@ export class Payload {
content[current_type] += item; content[current_type] += item;
} else if (item instanceof Payload) { } else if (item instanceof Payload) {
Payload.#collect_content(item.#out, item.type, content); Payload.#collect_content(item.#out, item.type, content);
} else {
throw new Error('invariant: should never reach this');
} }
} }
return content; return content;
@ -240,7 +363,9 @@ export class Payload {
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 // 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 (item instanceof Payload) { if (typeof item === 'string') {
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.
@ -251,8 +376,6 @@ 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;

@ -335,3 +335,34 @@ test('push async functions are not supported in sync context', () => {
payload.collect(); payload.collect();
}).toThrow(); }).toThrow();
}); });
test('collect_on_destroy yields callbacks in the correct order', async () => {
const payload = new Payload(new TreeState('async'));
const destroyed: string[] = [];
payload.component((payload) => {
payload.on_destroy(() => destroyed.push('a'));
// children should not alter relative order
payload.child(async ($$payload) => {
await Promise.resolve();
$$payload.on_destroy(() => destroyed.push('b'));
$$payload.on_destroy(() => destroyed.push('b*'));
// but child components should
$$payload.component(($$inner) => {
$$inner.on_destroy(() => destroyed.push('c'));
});
});
payload.child((payload) => {
payload.on_destroy(() => destroyed.push('d'));
});
payload.component((payload) => {
payload.on_destroy(() => destroyed.push('e'));
});
});
await payload.collect_async();
for (const callback of payload.collect_on_destroy()) {
callback();
}
assert.deepEqual(destroyed, ['c', 'e', 'a', 'b', 'b*', 'd']);
});

@ -1,12 +1,13 @@
import type { Element } from './dev'; import type { Element } from './dev';
import type { Payload } from './payload';
export interface SSRContext { export interface SSRContext {
/** parent */ /** parent */
p: null | SSRContext; p: null | SSRContext;
/** component context */ /** component context */
c: null | Map<unknown, unknown>; c: null | Map<unknown, unknown>;
/** ondestroy */ /** payload (renderer) */
d: null | Array<() => void>; r: null | Payload;
/** dev mode only: the current component function */ /** dev mode only: the current component function */
function?: any; function?: any;
/** dev mode only: the current element */ /** dev mode only: the current element */

@ -1,7 +1,7 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
skip_mode: ['server'], skip_mode: ['server', 'async-server'],
get props() { get props() {
return { value: 'hello!' }; return { value: 'hello!' };

@ -1,7 +1,7 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
skip_mode: ['server'], skip_mode: ['server', 'async-server'],
get props() { get props() {
return { value: 'hello!' }; return { value: 'hello!' };

@ -1,7 +1,7 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
skip_mode: ['server'], skip_mode: ['server', 'async-server'],
get props() { get props() {
return { value: 'hello!' }; return { value: 'hello!' };

@ -1,7 +1,7 @@
import { test } from '../../test'; import { test } from '../../test';
export default test({ export default test({
skip_mode: ['server'], skip_mode: ['server', 'async-server'],
html: ` html: `
<div><div>Value in child component: </div></div> <div><div>Value in child component: </div></div>

@ -129,7 +129,7 @@ let console_error = console.error;
export function runtime_suite(runes: boolean) { export function runtime_suite(runes: boolean) {
return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'async-ssr' | 'dom', CompileOptions>( return suite_with_variants<RuntimeTest, 'hydrate' | 'ssr' | 'async-ssr' | 'dom', CompileOptions>(
['dom', 'hydrate', 'ssr'], ['dom', 'hydrate', 'ssr', 'async-ssr'],
(variant, config, test_name) => { (variant, config, test_name) => {
if (!async_mode && (config.skip_no_async || test_name.startsWith('async-'))) { if (!async_mode && (config.skip_no_async || test_name.startsWith('async-'))) {
return true; return true;

@ -0,0 +1,9 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { destroyed } from './destroyed.js';
import C from './C.svelte';
onDestroy(() => destroyed.push('A'));
</script>
<div>A</div>
<C/>

@ -0,0 +1,9 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { destroyed } from './destroyed.js';
onDestroy(() => destroyed.push('B'));
onDestroy(() => destroyed.push('B*'))
</script>
<div>B</div>

@ -0,0 +1,5 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { destroyed } from './destroyed.js';
onDestroy(() => destroyed.push('C'));
</script>

@ -0,0 +1,14 @@
import { test } from '../../test';
import { destroyed, reset } from './destroyed.js';
export default test({
mode: ['async-server'],
before_test() {
reset();
},
test_ssr({ assert }) {
assert.deepEqual(destroyed, ['C', 'A', 'B', 'B*', 'root']);
}
});

@ -0,0 +1,4 @@
/** @type {string[]} */
export const destroyed = [];
export const reset = () => (destroyed.length = 0);

@ -0,0 +1,13 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { destroyed } from './destroyed.js';
import A from './A.svelte';
import B from './B.svelte';
onDestroy(() => destroyed.push('root'));
</script>
{#if await Promise.resolve(true)}
<A/>
{/if}
<B/>

@ -1,38 +1,36 @@
import * as $ from 'svelte/internal/server'; import * as $ from 'svelte/internal/server';
export default function Class_state_field_constructor_assignment($$payload, $$props) { export default function Class_state_field_constructor_assignment($$payload, $$props) {
$.push(); $$payload.component(($$payload) => {
class Foo {
class Foo { a = 0;
a = 0; #b;
#b; #foo = $.derived(() => ({ bar: this.a * 2 }));
#foo = $.derived(() => ({ bar: this.a * 2 }));
get foo() {
get foo() { return this.#foo();
return this.#foo(); }
}
set foo($$value) {
set foo($$value) { return this.#foo($$value);
return this.#foo($$value); }
}
#bar = $.derived(() => ({ baz: this.foo }));
#bar = $.derived(() => ({ baz: this.foo }));
get bar() {
get bar() { return this.#bar();
return this.#bar(); }
}
set bar($$value) {
set bar($$value) { return this.#bar($$value);
return this.#bar($$value); }
constructor() {
this.a = 1;
this.#b = 2;
this.foo.bar = 3;
this.bar = 4;
}
} }
});
constructor() {
this.a = 1;
this.#b = 2;
this.foo.bar = 3;
this.bar = 4;
}
}
$.pop();
} }

@ -1,16 +1,15 @@
import * as $ from 'svelte/internal/server'; import * as $ from 'svelte/internal/server';
export default function Props_identifier($$payload, $$props) { export default function Props_identifier($$payload, $$props) {
$.push(); $$payload.component(($$payload) => {
let { $$slots, $$events, ...props } = $$props;
let { $$slots, $$events, ...props } = $$props; props.a;
props[a];
props.a; props.a.b;
props[a]; props.a.b = true;
props.a.b; props.a = true;
props.a.b = true; props[a] = true;
props.a = true; props;
props[a] = true; });
props;
$.pop();
} }
Loading…
Cancel
Save