diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index de27c4623b..47fe37c44d 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -278,7 +278,8 @@ export function analyze_module(source, options) { tracing: false, async_deriveds: new Set(), comments, - classes: new Map() + classes: new Map(), + pickled_awaits: new Set() }; state.adjust({ @@ -304,7 +305,8 @@ export function analyze_module(source, options) { options: /** @type {ValidatedCompileOptions} */ (options), fragment: null, parent_element: null, - reactive_statement: null + reactive_statement: null, + in_derived: false }, visitors ); @@ -540,7 +542,8 @@ export function analyze_component(root, source, options) { source, snippet_renderers: new Map(), snippets: new Set(), - async_deriveds: new Set() + async_deriveds: new Set(), + pickled_awaits: new Set() }; if (!runes) { @@ -699,7 +702,8 @@ export function analyze_component(root, source, options) { expression: null, state_fields: new Map(), function_depth: scope.function_depth, - reactive_statement: null + reactive_statement: null, + in_derived: false }; walk(/** @type {AST.SvelteNode} */ (ast), state, visitors); @@ -766,7 +770,8 @@ export function analyze_component(root, source, options) { component_slots: new Set(), expression: null, state_fields: new Map(), - function_depth: scope.function_depth + function_depth: scope.function_depth, + in_derived: false }; walk(/** @type {AST.SvelteNode} */ (ast), state, visitors); diff --git a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts index 2d99a2e155..ae9c5911f6 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/types.d.ts +++ b/packages/svelte/src/compiler/phases/2-analyze/types.d.ts @@ -27,6 +27,11 @@ export interface AnalysisState { // legacy stuff reactive_statement: null | ReactiveStatement; + + /** + * True if we're directly inside a `$derived(...)` expression (but not `$derived.by(...)`) + */ + in_derived: boolean; } export type Context = import('zimmerframe').Context< diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js index 6b8e3a37d9..9018623570 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js @@ -1,14 +1,27 @@ -/** @import { AwaitExpression } from 'estree' */ +/** @import { AwaitExpression, Expression, SpreadElement, Property } from 'estree' */ /** @import { Context } from '../types' */ +/** @import { AST } from '#compiler' */ import * as e from '../../../errors.js'; -import * as b from '#compiler/builders'; /** * @param {AwaitExpression} node * @param {Context} context */ export function AwaitExpression(node, context) { - let suspend = context.state.ast_type === 'instance' && context.state.function_depth === 1; + const tla = context.state.ast_type === 'instance' && context.state.function_depth === 1; + + // preserve context for + // a) top-level await and + // b) awaits that precede other expressions in template or `$derived(...)` + if ( + tla || + (is_reactive_expression(context.path, context.state.in_derived) && + !is_last_evaluated_expression(context.path, node)) + ) { + context.state.analysis.pickled_awaits.add(node); + } + + let suspend = tla; if (context.state.expression) { context.state.expression.has_await = true; @@ -34,3 +47,101 @@ export function AwaitExpression(node, context) { context.next(); } + +/** + * @param {AST.SvelteNode[]} path + * @param {boolean} in_derived + */ +export function is_reactive_expression(path, in_derived) { + if (in_derived) { + return true; + } + + let i = path.length; + + while (i--) { + const parent = path[i]; + + if ( + parent.type === 'ArrowFunctionExpression' || + parent.type === 'FunctionExpression' || + parent.type === 'FunctionDeclaration' + ) { + return false; + } + + // @ts-expect-error we could probably use a neater/more robust mechanism + if (parent.metadata) { + return true; + } + } + + return false; +} + +/** + * @param {AST.SvelteNode[]} path + * @param {Expression | SpreadElement | Property} node + */ +export function is_last_evaluated_expression(path, node) { + let i = path.length; + + while (i--) { + const parent = /** @type {Expression | Property | SpreadElement} */ (path[i]); + + // @ts-expect-error we could probably use a neater/more robust mechanism + if (parent.metadata) { + return true; + } + + switch (parent.type) { + case 'ArrayExpression': + if (node !== parent.elements.at(-1)) return false; + break; + + case 'AssignmentExpression': + case 'BinaryExpression': + case 'LogicalExpression': + if (node === parent.left) return false; + break; + + case 'CallExpression': + case 'NewExpression': + if (node !== parent.arguments.at(-1)) return false; + break; + + case 'ConditionalExpression': + if (node === parent.test) return false; + break; + + case 'MemberExpression': + if (parent.computed && node === parent.object) return false; + break; + + case 'ObjectExpression': + if (node !== parent.properties.at(-1)) return false; + break; + + case 'Property': + if (node === parent.key) return false; + break; + + case 'SequenceExpression': + if (node !== parent.expressions.at(-1)) return false; + break; + + case 'TaggedTemplateExpression': + if (node !== parent.quasi.expressions.at(-1)) return false; + break; + + case 'TemplateLiteral': + if (node !== parent.expressions.at(-1)) return false; + break; + + default: + return false; + } + + node = parent; + } +} diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 9b6337b9ed..53a89125a2 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -241,6 +241,7 @@ export function CallExpression(node, context) { context.next({ ...context.state, function_depth: context.state.function_depth + 1, + in_derived: true, expression }); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js index d5f5f7b2e0..5849d828a3 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/ConstTag.js @@ -35,5 +35,9 @@ export function ConstTag(node, context) { const declaration = node.declaration.declarations[0]; context.visit(declaration.id); - context.visit(declaration.init, { ...context.state, expression: node.metadata.expression }); + context.visit(declaration.init, { + ...context.state, + expression: node.metadata.expression, + in_derived: true + }); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js index 89320f3962..f56a665de8 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/VariableDeclarator.js @@ -49,6 +49,12 @@ export function VariableDeclarator(node, context) { } } + if (rune === '$derived') { + context.visit(node.id); + context.visit(/** @type {Expression} */ (node.init), { ...context.state, in_derived: true }); + return; + } + if (rune === '$props') { if (node.id.type !== 'ObjectPattern' && node.id.type !== 'Identifier') { e.props_invalid_identifier(node); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index 706d2b4e10..2629379f63 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -166,7 +166,6 @@ export function client_component(analysis, options) { state_fields: new Map(), transform: {}, in_constructor: false, - in_derived: false, instance_level_snippets: [], module_level_snippets: [], @@ -712,7 +711,6 @@ export function client_module(analysis, options) { state_fields: new Map(), transform: {}, in_constructor: false, - in_derived: false, is_instance: false }; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index 59c024dfb7..932d353671 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -6,8 +6,7 @@ import type { Expression, AssignmentExpression, UpdateExpression, - VariableDeclaration, - Declaration + VariableDeclaration } from 'estree'; import type { AST, Namespace, ValidatedCompileOptions } from '#compiler'; import type { TransformState } from '../types.js'; @@ -22,11 +21,6 @@ export interface ClientTransformState extends TransformState { */ readonly in_constructor: boolean; - /** - * True if we're directly inside a `$derived(...)` expression (but not `$derived.by(...)`) - */ - readonly in_derived: boolean; - /** `true` if we're transforming the contents of ` diff --git a/packages/svelte/tests/server-side-rendering/samples/context/Child.svelte b/packages/svelte/tests/server-side-rendering/samples/context/Child.svelte new file mode 100644 index 0000000000..fab18ea195 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/context/Child.svelte @@ -0,0 +1,8 @@ + + +
{getContext('key')}
diff --git a/packages/svelte/tests/server-side-rendering/samples/context/_config.js b/packages/svelte/tests/server-side-rendering/samples/context/_config.js new file mode 100644 index 0000000000..05de37a8bd --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/context/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'] +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/context/_expected.html b/packages/svelte/tests/server-side-rendering/samples/context/_expected.html new file mode 100644 index 0000000000..554133d8d9 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/context/_expected.html @@ -0,0 +1,3 @@ +
value
+
value
+
child value
\ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/context/main.svelte b/packages/svelte/tests/server-side-rendering/samples/context/main.svelte new file mode 100644 index 0000000000..fd75a6cbae --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/context/main.svelte @@ -0,0 +1,11 @@ + + +
{getContext('key')}
+
{(await Promise.resolve(true)) && getContext('key')}
+ diff --git a/packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/_config.js b/packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/_config.js index 71edff6a68..6325ea7d0e 100644 --- a/packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/_config.js +++ b/packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/_config.js @@ -4,8 +4,6 @@ export default test({ compileOptions: { dev: true }, - - errors: [ + error: 'node_invalid_placement_ssr: `

` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:2:1) cannot be a child of `

` (packages/svelte/tests/server-side-rendering/samples/invalid-nested-svelte-element/main.svelte:1:0)\n\nThis can cause content to shift around as the browser repairs the HTML, and will likely result in a `hydration_mismatch` warning.' - ] }); diff --git a/packages/svelte/tests/server-side-rendering/test.ts b/packages/svelte/tests/server-side-rendering/test.ts index f1326ae6b4..09a8aebb53 100644 --- a/packages/svelte/tests/server-side-rendering/test.ts +++ b/packages/svelte/tests/server-side-rendering/test.ts @@ -7,23 +7,19 @@ import * as fs from 'node:fs'; import { assert } from 'vitest'; import { render, renderAsync } from 'svelte/server'; -import { - async_mode, - compile_directory, - should_update_expected, - try_read_file -} from '../helpers.js'; +import { compile_directory, should_update_expected, try_read_file } from '../helpers.js'; import { assert_html_equal_with_options } from '../html_equal.js'; -import { suite, type BaseTest } from '../suite.js'; +import { suite_with_variants, type BaseTest } from '../suite.js'; import type { CompileOptions } from '#compiler'; interface SSRTest extends BaseTest { + mode?: ('sync' | 'async')[]; compileOptions?: Partial; load_compiled?: boolean; props?: Record; id_prefix?: string; withoutNormalizeHtml?: boolean; - errors?: string[]; + error?: string; } // TODO remove this shim when we can @@ -40,86 +36,101 @@ Promise.withResolvers = () => { return { promise, resolve, reject }; }; -// eslint-disable-next-line no-console -let console_error = console.error; +const { test, run } = suite_with_variants( + ['sync', 'async'], + (variant, config, test_name) => { + if (config.mode && !config.mode.includes(variant)) { + return 'no-test'; + } -const { test, run } = suite(async (config, test_dir) => { - const compile_options = { - experimental: { - async: async_mode, - ...config.compileOptions?.experimental - }, - ...config.compileOptions - }; + if (test_name.startsWith('async') && variant === 'sync') { + return 'no-test'; + } - if (!config.load_compiled) { - await compile_directory(test_dir, 'server', compile_options); - } + return false; + }, + async (config, test_dir) => { + const compile_options = { + experimental: { + async: true, + ...config.compileOptions?.experimental + }, + ...config.compileOptions + }; + + if (!config.load_compiled) { + await compile_directory(test_dir, 'server', compile_options); + } - const errors: string[] = []; - - console.error = (...args) => { - errors.push(...args); - }; - - const Component = (await import(`${test_dir}/_output/server/main.svelte.js`)).default; - const expected_html = try_read_file(`${test_dir}/_expected.html`); - const rendered = async_mode - ? await renderAsync(Component, { - props: config.props || {}, - idPrefix: config.id_prefix - }) - : render(Component, { - props: config.props || {}, - idPrefix: config.id_prefix - }); - const { body, head } = rendered; + return compile_options; + }, + async (config, test_dir, variant, compile_options) => { + const Component = (await import(`${test_dir}/_output/server/main.svelte.js`)).default; + const expected_html = try_read_file(`${test_dir}/_expected.html`); + let rendered; + try { + rendered = + variant === 'async' + ? await renderAsync(Component, { + props: config.props || {}, + idPrefix: config.id_prefix + }) + : render(Component, { + props: config.props || {}, + idPrefix: config.id_prefix + }); + } catch (error) { + if (config.error) { + assert.deepEqual((error as Error).message, config.error); + return; + } else { + throw error; + } + } - fs.writeFileSync(`${test_dir}/_output/rendered.html`, body); + const { body, head } = rendered; - if (head) { - fs.writeFileSync(`${test_dir}/_output/rendered_head.html`, head); - } + const prefix = variant === 'async' ? 'async_' : ''; + fs.writeFileSync(`${test_dir}/_output/${prefix}rendered.html`, body); - try { - assert_html_equal_with_options(body, expected_html || '', { - preserveComments: compile_options.preserveComments, - withoutNormalizeHtml: config.withoutNormalizeHtml - }); - } catch (error: any) { - if (should_update_expected()) { - fs.writeFileSync(`${test_dir}/_expected.html`, body); - console.log(`Updated ${test_dir}/_expected.html.`); - } else { - error.message += '\n' + `${test_dir}/main.svelte`; - throw error; + if (head) { + fs.writeFileSync(`${test_dir}/_output/${prefix}rendered_head.html`, head); } - } - if (fs.existsSync(`${test_dir}/_expected_head.html`)) { try { - assert_html_equal_with_options( - head, - fs.readFileSync(`${test_dir}/_expected_head.html`, 'utf-8'), - {} - ); + assert_html_equal_with_options(body, expected_html || '', { + preserveComments: compile_options.preserveComments, + withoutNormalizeHtml: config.withoutNormalizeHtml + }); } catch (error: any) { if (should_update_expected()) { - fs.writeFileSync(`${test_dir}/_expected_head.html`, head); - console.log(`Updated ${test_dir}/_expected_head.html.`); - error.message += '\n' + `${test_dir}/main.svelte`; + fs.writeFileSync(`${test_dir}/_expected.html`, body); + console.log(`Updated ${test_dir}/_expected.html.`); } else { + error.message += '\n' + `${test_dir}/main.svelte`; throw error; } } - } - if (errors.length > 0) { - assert.deepEqual(config.errors, errors); + if (fs.existsSync(`${test_dir}/_expected_head.html`)) { + try { + assert_html_equal_with_options( + head, + fs.readFileSync(`${test_dir}/_expected_head.html`, 'utf-8'), + {} + ); + } catch (error: any) { + if (should_update_expected()) { + fs.writeFileSync(`${test_dir}/_expected_head.html`, head); + console.log(`Updated ${test_dir}/_expected_head.html.`); + error.message += '\n' + `${test_dir}/main.svelte`; + } else { + throw error; + } + } + } } - - console.error = console_error; -}); +); export { test };