diff --git a/.changeset/eleven-weeks-dance.md b/.changeset/eleven-weeks-dance.md new file mode 100644 index 000000000000..eec83c3c2c52 --- /dev/null +++ b/.changeset/eleven-weeks-dance.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: support `await` in components diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 32348bb78182..b9268636b2e9 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -110,6 +110,12 @@ Rest element properties of `$props()` such as `%property%` are readonly The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files ``` +### set_context_after_init + +``` +`setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression +``` + ### state_descriptors_fixed ``` diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md index fe90b0db3815..b49d4aae8db3 100644 --- a/documentation/docs/98-reference/.generated/client-warnings.md +++ b/documentation/docs/98-reference/.generated/client-warnings.md @@ -34,6 +34,22 @@ function add() { } ``` +### await_reactivity_loss + +``` +Detected reactivity loss +``` + +TODO + +### await_waterfall + +``` +An async value (%location%) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app. +``` + +TODO + ### binding_property_non_reactive ``` diff --git a/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index db848a0299ee..20f57770d122 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -480,6 +480,12 @@ Expected token %token% Expected whitespace ``` +### experimental_async + +``` +Cannot use `await` in deriveds and template expressions, or at the top level of a component, unless the `experimental.async` compiler option is `true` +``` + ### export_undefined ``` @@ -534,6 +540,12 @@ The arguments keyword cannot be used within the template or at the top level of %message% ``` +### legacy_await_invalid + +``` +Cannot use `await` in deriveds and template expressions, or at the top level of a component, unless in runes mode +``` + ### legacy_export_invalid ``` diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index 6c31aaafd0df..ac31e22c75c1 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -1,5 +1,13 @@ +### await_outside_boundary + +``` +Cannot await outside a `` with a `pending` snippet +``` + +TODO + ### invalid_default_snippet ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index c4e68f8fee80..8748bf8978a6 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -72,6 +72,10 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long > The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files +## set_context_after_init + +> `setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression + ## state_descriptors_fixed > Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`. diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index f1901271d11c..0a6af616c328 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -30,6 +30,18 @@ function add() { } ``` +## await_reactivity_loss + +> Detected reactivity loss + +TODO + +## await_waterfall + +> An async value (%location%) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app. + +TODO + ## binding_property_non_reactive > `%binding%` is binding to a non-reactive property diff --git a/packages/svelte/messages/compile-errors/script.md b/packages/svelte/messages/compile-errors/script.md index e11975aef26a..2b0c5eafdf86 100644 --- a/packages/svelte/messages/compile-errors/script.md +++ b/packages/svelte/messages/compile-errors/script.md @@ -70,6 +70,10 @@ This turned out to be buggy and unpredictable, particularly when working with de > `$effect()` can only be used as an expression statement +## experimental_async + +> Cannot use `await` in deriveds and template expressions, or at the top level of a component, unless the `experimental.async` compiler option is `true` + ## export_undefined > `%name%` is not defined @@ -98,6 +102,10 @@ This turned out to be buggy and unpredictable, particularly when working with de > The arguments keyword cannot be used within the template or at the top level of a component +## legacy_await_invalid + +> Cannot use `await` in deriveds and template expressions, or at the top level of a component, unless in runes mode + ## legacy_export_invalid > Cannot use `export let` in runes mode — use `$props()` instead diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md index 4b4d3322028d..8ec7694a8d31 100644 --- a/packages/svelte/messages/shared-errors/errors.md +++ b/packages/svelte/messages/shared-errors/errors.md @@ -1,3 +1,9 @@ +## await_outside_boundary + +> Cannot await outside a `` with a `pending` snippet + +TODO + ## invalid_default_snippet > Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead diff --git a/packages/svelte/package.json b/packages/svelte/package.json index f097ef870c71..4d5ca63b1789 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -59,6 +59,9 @@ "./internal/disclose-version": { "default": "./src/internal/disclose-version.js" }, + "./internal/flags/async": { + "default": "./src/internal/flags/async.js" + }, "./internal/flags/legacy": { "default": "./src/internal/flags/legacy.js" }, diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 25e72340c64d..9a22a8941869 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -168,6 +168,15 @@ export function effect_invalid_placement(node) { e(node, 'effect_invalid_placement', `\`$effect()\` can only be used as an expression statement\nhttps://svelte.dev/e/effect_invalid_placement`); } +/** + * Cannot use `await` in deriveds and template expressions, or at the top level of a component, unless the `experimental.async` compiler option is `true` + * @param {null | number | NodeLike} node + * @returns {never} + */ +export function experimental_async(node) { + e(node, 'experimental_async', `Cannot use \`await\` in deriveds and template expressions, or at the top level of a component, unless the \`experimental.async\` compiler option is \`true\`\nhttps://svelte.dev/e/experimental_async`); +} + /** * `%name%` is not defined * @param {null | number | NodeLike} node @@ -233,6 +242,15 @@ export function invalid_arguments_usage(node) { e(node, 'invalid_arguments_usage', `The arguments keyword cannot be used within the template or at the top level of a component\nhttps://svelte.dev/e/invalid_arguments_usage`); } +/** + * Cannot use `await` in deriveds and template expressions, or at the top level of a component, unless in runes mode + * @param {null | number | NodeLike} node + * @returns {never} + */ +export function legacy_await_invalid(node) { + e(node, 'legacy_await_invalid', `Cannot use \`await\` in deriveds and template expressions, or at the top level of a component, unless in runes mode\nhttps://svelte.dev/e/legacy_await_invalid`); +} + /** * Cannot use `export let` in runes mode — use `$props()` instead * @param {null | number | NodeLike} node diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js index 5ca9adb98be3..3ef510edb797 100644 --- a/packages/svelte/src/compiler/migrate/index.js +++ b/packages/svelte/src/compiler/migrate/index.js @@ -146,7 +146,10 @@ export function migrate(source, { filename, use_ts } = {}) { ...validate_component_options({}, ''), ...parsed_options, customElementOptions, - filename: filename ?? '(unknown)' + filename: filename ?? '(unknown)', + experimental: { + async: true + } }; const str = new MagicString(source); diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index 6b6c9160d8d7..87332f647d86 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -295,6 +295,8 @@ export default function element(parser) { } else { element.tag = get_attribute_expression(definition); } + + element.metadata.expression = create_expression_metadata(); } if (is_top_level_script_or_style) { diff --git a/packages/svelte/src/compiler/phases/1-parse/state/tag.js b/packages/svelte/src/compiler/phases/1-parse/state/tag.js index 4153463c8361..fa6e66634398 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/tag.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/tag.js @@ -63,7 +63,10 @@ function open(parser) { end: -1, test: read_expression(parser), consequent: create_fragment(), - alternate: null + alternate: null, + metadata: { + expression: create_expression_metadata() + } }); parser.allow_whitespace(); @@ -326,7 +329,10 @@ function open(parser) { start, end: -1, expression, - fragment: create_fragment() + fragment: create_fragment(), + metadata: { + expression: create_expression_metadata() + } }); parser.stack.push(block); @@ -461,7 +467,10 @@ function next(parser) { elseif: true, test: expression, consequent: create_fragment(), - alternate: null + alternate: null, + metadata: { + expression: create_expression_metadata() + } }); parser.stack.push(child); @@ -624,7 +633,10 @@ function special(parser) { type: 'HtmlTag', start, end: parser.index, - expression + expression, + metadata: { + expression: create_expression_metadata() + } }); return; diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index fded183b86c3..d99f1a84d66c 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -21,6 +21,7 @@ import { AssignmentExpression } from './visitors/AssignmentExpression.js'; import { AttachTag } from './visitors/AttachTag.js'; import { Attribute } from './visitors/Attribute.js'; import { AwaitBlock } from './visitors/AwaitBlock.js'; +import { AwaitExpression } from './visitors/AwaitExpression.js'; import { BindDirective } from './visitors/BindDirective.js'; import { CallExpression } from './visitors/CallExpression.js'; import { ClassBody } from './visitors/ClassBody.js'; @@ -138,6 +139,7 @@ const visitors = { AttachTag, Attribute, AwaitBlock, + AwaitExpression, BindDirective, CallExpression, ClassBody, @@ -209,9 +211,14 @@ function js(script, root, allow_reactive_declarations, parent) { body: [] }; - const { scope, scopes } = create_scopes(ast, root, allow_reactive_declarations, parent); + const { scope, scopes, has_await } = create_scopes( + ast, + root, + allow_reactive_declarations, + parent + ); - return { ast, scope, scopes }; + return { ast, scope, scopes, has_await }; } /** @@ -236,7 +243,7 @@ const RESERVED = ['$$props', '$$restProps', '$$slots']; * @returns {Analysis} */ export function analyze_module(ast, options) { - const { scope, scopes } = create_scopes(ast, new ScopeRoot(), false, null); + const { scope, scopes, has_await } = create_scopes(ast, new ScopeRoot(), false, null); for (const [name, references] of scope.references) { if (name[0] !== '$' || RESERVED.includes(name)) continue; @@ -253,12 +260,14 @@ export function analyze_module(ast, options) { /** @type {Analysis} */ const analysis = { - module: { ast, scope, scopes }, + module: { ast, scope, scopes, has_await }, name: options.filename, accessors: false, runes: true, immutable: true, tracing: false, + async_deriveds: new Set(), + context_preserving_awaits: new Set(), classes: new Map() }; @@ -298,7 +307,12 @@ export function analyze_component(root, source, options) { const module = js(root.module, scope_root, false, null); const instance = js(root.instance, scope_root, true, module.scope); - const { scope, scopes } = create_scopes(root.fragment, scope_root, false, instance.scope); + const { scope, scopes, has_await } = create_scopes( + root.fragment, + scope_root, + false, + instance.scope + ); /** @type {Template} */ const template = { ast: root.fragment, scope, scopes }; @@ -406,7 +420,9 @@ export function analyze_component(root, source, options) { const component_name = get_component_name(options.filename); - const runes = options.runes ?? Array.from(module.scope.references.keys()).some(is_rune); + const runes = + options.runes ?? + (has_await || instance.has_await || Array.from(module.scope.references.keys()).some(is_rune)); if (!runes) { for (let check of synthetic_stores_legacy_check) { @@ -472,7 +488,9 @@ export function analyze_component(root, source, options) { source, undefined_exports: new Map(), snippet_renderers: new Map(), - snippets: new Set() + snippets: new Set(), + async_deriveds: new Set(), + context_preserving_awaits: new Set() }; if (!runes) { diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js new file mode 100644 index 000000000000..4f50d447f7d6 --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/AwaitExpression.js @@ -0,0 +1,50 @@ +/** @import { AwaitExpression } from 'estree' */ +/** @import { Context } from '../types' */ +import * as e from '../../../errors.js'; + +/** + * @param {AwaitExpression} node + * @param {Context} context + */ +export function AwaitExpression(node, context) { + const tla = context.state.ast_type === 'instance' && context.state.function_depth === 1; + let suspend = tla; + let preserve_context = tla; + + if (context.state.expression) { + context.state.expression.has_await = true; + suspend = true; + + // wrap the expression in `(await $.save(...)).restore()` if necessary, + // i.e. whether anything could potentially be read _after_ the await + let i = context.path.length; + while (i--) { + const parent = context.path[i]; + + // stop walking up when we find a node with metadata, because that + // means we've hit the template node containing the expression + // @ts-expect-error we could probably use a neater/more robust mechanism + if (parent.metadata) break; + + // TODO make this more accurate — we don't need to call suspend + // if this is the last thing that could be read + preserve_context = true; + } + } + + if (suspend) { + if (!context.state.options.experimental.async) { + e.experimental_async(node); + } + + if (!context.state.analysis.runes) { + e.legacy_await_invalid(node); + } + } + + if (preserve_context) { + context.state.analysis.context_preserving_awaits.add(node); + } + + context.next(); +} 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 33abb52cac5c..9b6337b9ed9a 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -7,6 +7,7 @@ import { get_parent } from '../../../utils/ast.js'; import { is_pure, is_safe_identifier } from './shared/utils.js'; import { dev, locate_node, source } from '../../../state.js'; import * as b from '#compiler/builders'; +import { create_expression_metadata } from '../../nodes.js'; /** * @param {CallExpression} node @@ -163,6 +164,13 @@ export function CallExpression(node, context) { break; + case '$effect.pending': + if (context.state.expression) { + context.state.expression.has_state = true; + } + + break; + case '$inspect': if (node.arguments.length < 1) { e.rune_invalid_arguments_length(node, rune, 'one or more arguments'); @@ -227,7 +235,19 @@ export function CallExpression(node, context) { } // `$inspect(foo)` or `$derived(foo) should not trigger the `static-state-reference` warning - if (rune === '$inspect' || rune === '$derived') { + if (rune === '$derived') { + const expression = create_expression_metadata(); + + context.next({ + ...context.state, + function_depth: context.state.function_depth + 1, + expression + }); + + if (expression.has_await) { + context.state.analysis.async_deriveds.add(node); + } + } else if (rune === '$inspect') { context.next({ ...context.state, function_depth: context.state.function_depth + 1 }); } else { context.next(); diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js index c89b11ad3695..ccb2c17955d8 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/HtmlTag.js @@ -15,5 +15,8 @@ export function HtmlTag(node, context) { // unfortunately this is necessary in order to fix invalid HTML mark_subtree_dynamic(context.path); - context.next(); + context.next({ + ...context.state, + expression: node.metadata.expression + }); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/IfBlock.js index a65771bcfca9..dcdae3587f63 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/IfBlock.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/IfBlock.js @@ -17,5 +17,11 @@ export function IfBlock(node, context) { mark_subtree_dynamic(context.path); - context.next(); + context.visit(node.test, { + ...context.state, + expression: node.metadata.expression + }); + + context.visit(node.consequent); + if (node.alternate) context.visit(node.alternate); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/KeyBlock.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/KeyBlock.js index 88bb6a98e748..d0dcf8e15c51 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/KeyBlock.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/KeyBlock.js @@ -16,5 +16,10 @@ export function KeyBlock(node, context) { mark_subtree_dynamic(context.path); - context.next(); + context.visit(node.expression, { + ...context.state, + expression: node.metadata.expression + }); + + context.visit(node.fragment); } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/StyleDirective.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/StyleDirective.js index 7d6eb5be99e8..9699d3c03b4a 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/StyleDirective.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/StyleDirective.js @@ -32,6 +32,7 @@ export function StyleDirective(node, context) { node.metadata.expression.has_state ||= chunk.metadata.expression.has_state; node.metadata.expression.has_call ||= chunk.metadata.expression.has_call; + node.metadata.expression.has_await ||= chunk.metadata.expression.has_await; } } } diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteBoundary.js index d50cb80cb83e..35af96ba122e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteBoundary.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteBoundary.js @@ -2,7 +2,7 @@ /** @import { Context } from '../types' */ import * as e from '../../../errors.js'; -const valid = ['onerror', 'failed']; +const valid = ['onerror', 'failed', 'pending']; /** * @param {AST.SvelteBoundary} node diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteElement.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteElement.js index c45859408c4b..5be1f91cbaeb 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteElement.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/SvelteElement.js @@ -62,5 +62,17 @@ export function SvelteElement(node, context) { mark_subtree_dynamic(context.path); - context.next({ ...context.state, parent_element: null }); + context.visit(node.tag, { + ...context.state, + expression: node.metadata.expression + }); + + for (const attribute of node.attributes) { + context.visit(attribute); + } + + context.visit(node.fragment, { + ...context.state, + parent_element: null + }); } 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 e2e006c14bec..0f2b0e2f3311 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 @@ -12,6 +12,7 @@ import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js'; import { AssignmentExpression } from './visitors/AssignmentExpression.js'; import { Attribute } from './visitors/Attribute.js'; import { AwaitBlock } from './visitors/AwaitBlock.js'; +import { AwaitExpression } from './visitors/AwaitExpression.js'; import { BinaryExpression } from './visitors/BinaryExpression.js'; import { BindDirective } from './visitors/BindDirective.js'; import { BlockStatement } from './visitors/BlockStatement.js'; @@ -88,6 +89,7 @@ const visitors = { AssignmentExpression, Attribute, AwaitBlock, + AwaitExpression, BinaryExpression, BindDirective, BlockStatement, @@ -155,7 +157,8 @@ export function client_component(analysis, options) { legacy_reactive_statements: new Map(), metadata: { namespace: options.namespace, - bound_contenteditable: false + bound_contenteditable: false, + async: [] }, events: new Set(), preserve_whitespace: options.preserveWhitespace, @@ -169,6 +172,7 @@ export function client_component(analysis, options) { init: /** @type {any} */ (null), update: /** @type {any} */ (null), expressions: /** @type {any} */ (null), + async_expressions: /** @type {any} */ (null), after_update: /** @type {any} */ (null), template: /** @type {any} */ (null) }; @@ -350,7 +354,7 @@ export function client_component(analysis, options) { const push_args = [b.id('$$props'), b.literal(analysis.runes)]; if (dev) push_args.push(b.id(analysis.name)); - const component_block = b.block([ + let component_block = b.block([ ...store_setup, ...legacy_reactive_declarations, ...group_binding_declarations, @@ -358,10 +362,49 @@ export function client_component(analysis, options) { .../** @type {ESTree.Statement[]} */ (instance.body), analysis.runes || !analysis.needs_context ? b.empty - : b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)), - .../** @type {ESTree.Statement[]} */ (template.body) + : b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)) ]); + const should_inject_context = + dev || + analysis.needs_context || + analysis.reactive_statements.size > 0 || + component_returned_object.length > 0; + + let should_inject_props = + should_inject_context || + analysis.needs_props || + analysis.uses_props || + analysis.uses_rest_props || + analysis.uses_slots || + analysis.slot_names.size > 0; + + if (analysis.instance.has_await) { + const body = b.function_declaration( + b.id('$$body'), + should_inject_props ? [b.id('$$anchor'), b.id('$$props')] : [b.id('$$anchor')], + b.block([ + b.var('$$unsuspend', b.call('$.suspend')), + ...component_block.body, + b.if(b.call('$.aborted'), b.return()), + .../** @type {ESTree.Statement[]} */ (template.body), + b.stmt(b.call('$$unsuspend')) + ]), + true + ); + + state.hoisted.push(body); + + component_block = b.block([ + b.var('fragment', b.call('$.comment')), + b.var('node', b.call('$.first_child', b.id('fragment'))), + b.stmt(b.call(body.id, b.id('node'), should_inject_props && b.id('$$props'))), + b.stmt(b.call('$.append', b.id('$$anchor'), b.id('fragment'))) + ]); + } else { + component_block.body.push(.../** @type {ESTree.Statement[]} */ (template.body)); + } + if (!analysis.runes) { // Bind static exports to props so that people can access them with bind:x for (const { name, alias } of analysis.exports) { @@ -395,12 +438,6 @@ export function client_component(analysis, options) { ); } - const should_inject_context = - dev || - analysis.needs_context || - analysis.reactive_statements.size > 0 || - component_returned_object.length > 0; - // we want the cleanup function for the stores to run as the very last thing // so that it can effectively clean up the store subscription even after the user effects runs if (should_inject_context) { @@ -466,14 +503,6 @@ export function client_component(analysis, options) { component_block.body.unshift(b.const('$$slots', b.call('$.sanitize_slots', b.id('$$props')))); } - let should_inject_props = - should_inject_context || - analysis.needs_props || - analysis.uses_props || - analysis.uses_rest_props || - analysis.uses_slots || - analysis.slot_names.size > 0; - // Merge hoisted statements into module body. // Ensure imports are on top, with the order preserved, then module body, then hoisted statements /** @type {ESTree.ImportDeclaration[]} */ @@ -534,6 +563,10 @@ export function client_component(analysis, options) { ); } + if (options.experimental.async) { + body.unshift(b.imports([], 'svelte/internal/flags/async')); + } + if (!analysis.runes) { body.unshift(b.imports([], 'svelte/internal/flags/legacy')); } 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 2388ee1b00d7..802606bd46bb 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 @@ -50,12 +50,18 @@ export interface ComponentClientTransformState extends ClientTransformState { /** Stuff that happens after the render effect (control blocks, dynamic elements, bindings, actions, etc) */ readonly after_update: Statement[]; /** Expressions used inside the render effect */ - readonly expressions: Expression[]; + readonly expressions: Array<{ id: Identifier; expression: Expression }>; + /** Expressions used inside the render effect */ + readonly async_expressions: Array<{ id: Identifier; expression: Expression }>; /** The HTML template string */ readonly template: Template; readonly metadata: { namespace: Namespace; bound_contenteditable: boolean; + /** + * Synthetic async deriveds belonging to the current fragment + */ + async: Array<{ id: Identifier; expression: Expression }>; }; readonly preserve_whitespace: boolean; @@ -85,3 +91,8 @@ export type ComponentVisitors = import('zimmerframe').Visitors< AST.SvelteNode, ComponentClientTransformState >; + +export interface MemoizedExpression { + id: Identifier; + expression: Expression; +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitExpression.js new file mode 100644 index 000000000000..b69b2fc72573 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AwaitExpression.js @@ -0,0 +1,26 @@ +/** @import { AwaitExpression, Expression } from 'estree' */ +/** @import { Context } from '../types' */ +import { dev } from '../../../../state.js'; +import * as b from '../../../../utils/builders.js'; + +/** + * @param {AwaitExpression} node + * @param {Context} context + */ +export function AwaitExpression(node, context) { + const save = context.state.analysis.context_preserving_awaits.has(node); + + if (dev || save) { + return b.call( + b.await( + b.call( + '$.save', + node.argument && /** @type {Expression} */ (context.visit(node.argument)), + !save && b.false + ) + ) + ); + } + + return context.next(); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js index 665be9e23bf4..532af08fd12c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js @@ -63,6 +63,9 @@ export function CallExpression(node, context) { .../** @type {Expression[]} */ (node.arguments.map((arg) => context.visit(arg))) ); + case '$effect.pending': + return b.call('$.get', b.id('$.pending')); + case '$inspect': case '$inspect().with': return transform_inspect_rune(node, context); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js index 6c651464f1e8..64967dfc96a9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js @@ -311,6 +311,10 @@ export function EachBlock(node, context) { ); } + const { has_await } = node.metadata.expression; + + const thunk = b.thunk(collection, has_await); + const render_args = [b.id('$$anchor'), item]; if (uses_index || collection_id) render_args.push(index); if (collection_id) render_args.push(collection_id); @@ -319,7 +323,7 @@ export function EachBlock(node, context) { const args = [ context.state.node, b.literal(flags), - b.thunk(collection), + has_await ? b.thunk(b.call('$.get', b.id('$$collection'))) : thunk, key_function, b.arrow(render_args, b.block(declarations.concat(block.body))) ]; @@ -330,7 +334,23 @@ export function EachBlock(node, context) { ); } - context.state.init.push(b.stmt(b.call('$.each', ...args))); + if (has_await) { + context.state.init.push( + b.stmt( + b.call( + '$.async', + context.state.node, + b.array([thunk]), + b.arrow( + [context.state.node, b.id('$$collection')], + b.block([b.stmt(b.call('$.each', ...args))]) + ) + ) + ) + ); + } else { + context.state.init.push(b.stmt(b.call('$.each', ...args))); + } } /** diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index 4825184d3147..86f430327b9a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -47,9 +47,7 @@ export function Fragment(node, context) { const is_single_element = trimmed.length === 1 && trimmed[0].type === 'RegularElement'; const is_single_child_not_needing_template = trimmed.length === 1 && - (trimmed[0].type === 'SvelteFragment' || - trimmed[0].type === 'TitleElement' || - (trimmed[0].type === 'IfBlock' && trimmed[0].elseif)); + (trimmed[0].type === 'SvelteFragment' || trimmed[0].type === 'TitleElement'); const template_name = context.state.scope.root.unique('root'); // TODO infer name from parent @@ -65,12 +63,14 @@ export function Fragment(node, context) { init: [], update: [], expressions: [], + async_expressions: [], after_update: [], template: new Template(), transform: { ...context.state.transform }, metadata: { namespace, - bound_contenteditable: context.state.metadata.bound_contenteditable + bound_contenteditable: context.state.metadata.bound_contenteditable, + async: [] } }; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js index 405b400b428d..590b32885b49 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/HtmlTag.js @@ -11,7 +11,10 @@ import * as b from '#compiler/builders'; export function HtmlTag(node, context) { context.state.template.push_comment(); + const { has_await } = node.metadata.expression; + const expression = /** @type {Expression} */ (context.visit(node.expression)); + const html = has_await ? b.call('$.get', b.id('$$html')) : expression; const is_svg = context.state.metadata.namespace === 'svg'; const is_mathml = context.state.metadata.namespace === 'mathml'; @@ -20,7 +23,7 @@ export function HtmlTag(node, context) { b.call( '$.html', context.state.node, - b.thunk(expression), + b.thunk(html), is_svg && b.true, is_mathml && b.true, is_ignored(node, 'hydration_html_changed') && b.true @@ -28,5 +31,18 @@ export function HtmlTag(node, context) { ); // push into init, so that bindings run afterwards, which might trigger another run and override hydration - context.state.init.push(statement); + if (node.metadata.expression.has_await) { + context.state.init.push( + b.stmt( + b.call( + '$.async', + context.state.node, + b.array([b.thunk(expression, true)]), + b.arrow([context.state.node, b.id('$$html')], b.block([statement])) + ) + ) + ); + } else { + context.state.init.push(statement); + } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js index 3702a47bc9e3..e06802f0d547 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js @@ -19,28 +19,34 @@ export function IfBlock(node, context) { let alternate_id; if (node.alternate) { - alternate_id = context.state.scope.generate('alternate'); const alternate = /** @type {BlockStatement} */ (context.visit(node.alternate)); - const nodes = node.alternate.nodes; + alternate_id = context.state.scope.generate('alternate'); + statements.push(b.var(b.id(alternate_id), b.arrow([b.id('$$anchor')], alternate))); + } - let alternate_args = [b.id('$$anchor')]; - if (nodes.length === 1 && nodes[0].type === 'IfBlock' && nodes[0].elseif) { - alternate_args.push(b.id('$$elseif')); - } + const { has_await } = node.metadata.expression; - statements.push(b.var(b.id(alternate_id), b.arrow(alternate_args, alternate))); - } + const expression = /** @type {Expression} */ (context.visit(node.test)); + const test = has_await ? b.call('$.get', b.id('$$condition')) : expression; /** @type {Expression[]} */ const args = [ - node.elseif ? b.id('$$anchor') : context.state.node, + context.state.node, b.arrow( [b.id('$$render')], b.block([ b.if( - /** @type {Expression} */ (context.visit(node.test)), + test, b.stmt(b.call(b.id('$$render'), b.id(consequent_id))), - alternate_id ? b.stmt(b.call(b.id('$$render'), b.id(alternate_id), b.false)) : undefined + alternate_id + ? b.stmt( + b.call( + b.id('$$render'), + b.id(alternate_id), + node.alternate ? b.literal(false) : undefined + ) + ) + : undefined ) ]) ) @@ -68,10 +74,23 @@ export function IfBlock(node, context) { // ...even though they're logically equivalent. In the first case, the // transition will only play when `y` changes, but in the second it // should play when `x` or `y` change — both are considered 'local' - args.push(b.id('$$elseif')); + args.push(b.true); } statements.push(b.stmt(b.call('$.if', ...args))); - context.state.init.push(b.block(statements)); + if (has_await) { + context.state.init.push( + b.stmt( + b.call( + '$.async', + context.state.node, + b.array([b.thunk(expression, true)]), + b.arrow([context.state.node, b.id('$$condition')], b.block(statements)) + ) + ) + ); + } else { + context.state.init.push(b.block(statements)); + } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js index 5e63f7e87200..80b5a232271e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/KeyBlock.js @@ -13,7 +13,32 @@ export function KeyBlock(node, context) { const key = /** @type {Expression} */ (context.visit(node.expression)); const body = /** @type {Expression} */ (context.visit(node.fragment)); - context.state.init.push( - b.stmt(b.call('$.key', context.state.node, b.thunk(key), b.arrow([b.id('$$anchor')], body))) - ); + if (node.metadata.expression.has_await) { + context.state.init.push( + b.stmt( + b.call( + '$.async', + context.state.node, + b.array([b.thunk(key, true)]), + b.arrow( + [context.state.node, b.id('$$key')], + b.block([ + b.stmt( + b.call( + '$.key', + context.state.node, + b.thunk(b.call('$.get', b.id('$$key'))), + b.arrow([b.id('$$anchor')], body) + ) + ) + ]) + ) + ) + ) + ); + } else { + context.state.init.push( + b.stmt(b.call('$.key', context.state.node, b.thunk(key), b.arrow([b.id('$$anchor')], body))) + ); + } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index e82379299315..8024b725ae92 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -1,6 +1,6 @@ /** @import { ArrayExpression, Expression, ExpressionStatement, Identifier, MemberExpression, ObjectExpression } from 'estree' */ /** @import { AST } from '#compiler' */ -/** @import { ComponentClientTransformState, ComponentContext } from '../types' */ +/** @import { ComponentClientTransformState, ComponentContext, MemoizedExpression } from '../types' */ /** @import { Scope } from '../../../scope' */ import { cannot_be_set_statically, @@ -261,7 +261,12 @@ export function RegularElement(node, context) { attribute.value, context, (value, metadata) => - metadata.has_call ? get_expression_id(context.state.expressions, value) : value + metadata.has_call || metadata.has_await + ? get_expression_id( + metadata.has_await ? context.state.async_expressions : context.state.expressions, + value + ) + : value ); const update = build_element_attribute_update(node, node_id, name, value, attributes); @@ -327,7 +332,11 @@ export function RegularElement(node, context) { // (e.g. `{location}`), set `textContent` programmatically const use_text_content = trimmed.every((node) => node.type === 'Text' || node.type === 'ExpressionTag') && - trimmed.every((node) => node.type === 'Text' || !node.metadata.expression.has_state) && + trimmed.every( + (node) => + node.type === 'Text' || + (!node.metadata.expression.has_state && !node.metadata.expression.has_await) + ) && trimmed.some((node) => node.type === 'ExpressionTag'); if (use_text_content) { @@ -453,32 +462,48 @@ function setup_select_synchronization(value_binding, context) { /** * @param {AST.ClassDirective[]} class_directives - * @param {Expression[]} expressions + * @param {MemoizedExpression[]} async_expressions + * @param {MemoizedExpression[]} expressions * @param {ComponentContext} context * @return {ObjectExpression | Identifier} */ -export function build_class_directives_object(class_directives, expressions, context) { +export function build_class_directives_object( + class_directives, + async_expressions, + expressions, + context +) { let properties = []; let has_call_or_state = false; + let has_async = false; for (const d of class_directives) { const expression = /** @type Expression */ (context.visit(d.expression)); properties.push(b.init(d.name, expression)); has_call_or_state ||= d.metadata.expression.has_call || d.metadata.expression.has_state; + has_async ||= d.metadata.expression.has_await; } const directives = b.object(properties); - return has_call_or_state ? get_expression_id(expressions, directives) : directives; + return has_call_or_state || has_async + ? get_expression_id(has_async ? async_expressions : expressions, directives) + : directives; } /** * @param {AST.StyleDirective[]} style_directives - * @param {Expression[]} expressions + * @param {MemoizedExpression[]} async_expressions + * @param {MemoizedExpression[]} expressions * @param {ComponentContext} context * @return {ObjectExpression | ArrayExpression}} */ -export function build_style_directives_object(style_directives, expressions, context) { +export function build_style_directives_object( + style_directives, + async_expressions, + expressions, + context +) { let normal_properties = []; let important_properties = []; @@ -487,7 +512,9 @@ export function build_style_directives_object(style_directives, expressions, con directive.value === true ? build_getter({ name: directive.name, type: 'Identifier' }, context.state) : build_attribute_value(directive.value, context, (value, metadata) => - metadata.has_call ? get_expression_id(expressions, value) : value + metadata.has_call + ? get_expression_id(metadata.has_await ? async_expressions : expressions, value) + : value ).value; const property = b.init(directive.name, expression); @@ -622,11 +649,11 @@ function build_element_special_value_attribute(element, node_id, attribute, cont element === 'select' && attribute.value !== true && !is_text_attribute(attribute); const { value, has_state } = build_attribute_value(attribute.value, context, (value, metadata) => - metadata.has_call + metadata.has_call || metadata.has_await ? // if is a select with value we will also invoke `init_select` which need a reference before the template effect so we memoize separately is_select_with_value - ? memoize_expression(state, value) - : get_expression_id(state.expressions, value) + ? memoize_expression(context.state, value) + : get_expression_id(metadata.has_await ? state.async_expressions : state.expressions, value) : value ); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js index fec7b5762a11..12ec2b432a21 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RenderTag.js @@ -1,8 +1,10 @@ -/** @import { Expression } from 'estree' */ +/** @import { Expression, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ -/** @import { ComponentContext } from '../types' */ +/** @import { ComponentContext, MemoizedExpression } from '../types' */ import { unwrap_optional } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; +import { create_derived } from '../utils.js'; +import { get_expression_id } from './shared/utils.js'; /** * @param {AST.RenderTag} node @@ -18,19 +20,36 @@ export function RenderTag(node, context) { /** @type {Expression[]} */ let args = []; + + /** @type {MemoizedExpression[]} */ + const expressions = []; + + /** @type {MemoizedExpression[]} */ + const async_expressions = []; + for (let i = 0; i < raw_args.length; i++) { - let thunk = b.thunk(/** @type {Expression} */ (context.visit(raw_args[i]))); - const { has_call } = node.metadata.arguments[i]; - - if (has_call) { - const id = b.id(context.state.scope.generate('render_arg')); - context.state.init.push(b.var(id, b.call('$.derived_safe_equal', thunk))); - args.push(b.thunk(b.call('$.get', id))); - } else { - args.push(thunk); + let expression = /** @type {Expression} */ (context.visit(raw_args[i])); + const { has_call, has_await } = node.metadata.arguments[i]; + + if (has_await || has_call) { + expression = b.call( + '$.get', + get_expression_id(has_await ? async_expressions : expressions, expression) + ); } + + args.push(b.thunk(expression)); } + [...async_expressions, ...expressions].forEach((memo, i) => { + memo.id.name = `$${i}`; + }); + + /** @type {Statement[]} */ + const statements = expressions.map((memo, i) => + b.var(memo.id, create_derived(context.state, b.thunk(memo.expression))) + ); + let snippet_function = /** @type {Expression} */ (context.visit(callee)); if (node.metadata.dynamic) { @@ -39,11 +58,11 @@ export function RenderTag(node, context) { snippet_function = b.logical('??', snippet_function, b.id('$.noop')); } - context.state.init.push( + statements.push( b.stmt(b.call('$.snippet', context.state.node, b.thunk(snippet_function), ...args)) ); } else { - context.state.init.push( + statements.push( b.stmt( (node.expression.type === 'CallExpression' ? b.call : b.maybe_call)( snippet_function, @@ -53,4 +72,22 @@ export function RenderTag(node, context) { ) ); } + + if (async_expressions.length > 0) { + context.state.init.push( + b.stmt( + b.call( + '$.async', + context.state.node, + b.array(async_expressions.map((memo) => b.thunk(memo.expression, true))), + b.arrow( + [context.state.node, ...async_expressions.map((memo) => memo.id)], + b.block(statements) + ) + ) + ) + ); + } else { + context.state.init.push(statements.length === 1 ? statements[0] : b.block(statements)); + } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js index ec1c65081496..f2009dd319f9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteBoundary.js @@ -43,7 +43,10 @@ export function SvelteBoundary(node, context) { // Capture the `failed` implicit snippet prop for (const child of node.fragment.nodes) { - if (child.type === 'SnippetBlock' && child.expression.name === 'failed') { + if ( + child.type === 'SnippetBlock' && + (child.expression.name === 'failed' || child.expression.name === 'pending') + ) { // we need to delay the visit of the snippets in case they access a ConstTag that is declared // after the snippets so that the visitor for the const tag can be updated snippets_visits.push(() => { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js index 7381553dbe02..0534895fe19b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteElement.js @@ -32,7 +32,7 @@ export function SvelteElement(node, context) { const style_directives = []; /** @type {ExpressionStatement[]} */ - const lets = []; + const statements = []; // Create a temporary context which picks up the init/update statements. // They'll then be added to the function parameter of $.element @@ -47,6 +47,7 @@ export function SvelteElement(node, context) { init: [], update: [], expressions: [], + async_expressions: [], after_update: [] } }; @@ -64,7 +65,7 @@ export function SvelteElement(node, context) { } else if (attribute.type === 'StyleDirective') { style_directives.push(attribute); } else if (attribute.type === 'LetDirective') { - lets.push(/** @type {ExpressionStatement} */ (context.visit(attribute))); + statements.push(/** @type {ExpressionStatement} */ (context.visit(attribute))); } else if (attribute.type === 'OnDirective') { const handler = /** @type {Expression} */ (context.visit(attribute, inner_context.state)); inner_context.state.after_update.push(b.stmt(handler)); @@ -73,9 +74,6 @@ export function SvelteElement(node, context) { } } - // Let bindings first, they can be used on attributes - context.state.init.push(...lets); // create computeds in the outer context; the dynamic element is the single child of this slot - if ( attributes.length === 1 && attributes[0].type === 'Attribute' && @@ -96,14 +94,10 @@ export function SvelteElement(node, context) { ); } - const get_tag = b.thunk(/** @type {Expression} */ (context.visit(node.tag))); + const { has_await } = node.metadata.expression; - if (dev) { - if (node.fragment.nodes.length > 0) { - context.state.init.push(b.stmt(b.call('$.validate_void_dynamic_element', get_tag))); - } - context.state.init.push(b.stmt(b.call('$.validate_dynamic_element_tag', get_tag))); - } + const expression = /** @type {Expression} */ (context.visit(node.tag)); + const get_tag = b.thunk(has_await ? b.call('$.get', b.id('$$tag')) : expression); /** @type {Statement[]} */ const inner = inner_context.state.init; @@ -123,9 +117,16 @@ export function SvelteElement(node, context) { ).body ); + if (dev) { + if (node.fragment.nodes.length > 0) { + statements.push(b.stmt(b.call('$.validate_void_dynamic_element', get_tag))); + } + statements.push(b.stmt(b.call('$.validate_dynamic_element_tag', get_tag))); + } + const location = dev && locator(node.start); - context.state.init.push( + statements.push( b.stmt( b.call( '$.element', @@ -138,4 +139,19 @@ export function SvelteElement(node, context) { ) ) ); + + if (has_await) { + context.state.init.push( + b.stmt( + b.call( + '$.async', + context.state.node, + b.array([b.thunk(expression, true)]), + b.arrow([context.state.node, b.id('$$tag')], b.block(statements)) + ) + ) + ); + } else { + context.state.init.push(statements.length === 1 ? statements[0] : b.block(statements)); + } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js index e717077917f5..fbe1e5edb7de 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js @@ -1,7 +1,7 @@ /** @import { CallExpression, Expression, Identifier, Literal, VariableDeclaration, VariableDeclarator } from 'estree' */ /** @import { Binding } from '#compiler' */ -/** @import { ComponentClientTransformState, ComponentContext } from '../types' */ -import { dev } from '../../../../state.js'; +/** @import { ComponentContext } from '../types' */ +import { dev, is_ignored, locate_node } from '../../../../state.js'; import { extract_paths } from '../../../../utils/ast.js'; import * as b from '#compiler/builders'; import * as assert from '../../../../utils/assert.js'; @@ -20,7 +20,7 @@ export function VariableDeclaration(node, context) { if (context.state.analysis.runes) { for (const declarator of node.declarations) { - const init = declarator.init; + const init = /** @type {Expression} */ (declarator.init); const rune = get_rune(init, context.state.scope); if ( @@ -173,11 +173,38 @@ export function VariableDeclaration(node, context) { } if (rune === '$derived' || rune === '$derived.by') { + const is_async = context.state.analysis.async_deriveds.has( + /** @type {CallExpression} */ (init) + ); + if (declarator.id.type === 'Identifier') { - let expression = /** @type {Expression} */ (context.visit(value)); - if (rune === '$derived') expression = b.thunk(expression); + if (is_async) { + const location = dev && is_ignored(init, 'await_waterfall') && locate_node(init); + const expression = /** @type {Expression} */ (context.visit(value)); + + declarations.push( + b.declarator( + declarator.id, + b.call( + b.await( + b.call( + '$.save', + b.call( + '$.async_derived', + b.thunk(expression, true), + location ? b.literal(location) : undefined + ) + ) + ) + ) + ) + ); + } else { + let expression = /** @type {Expression} */ (context.visit(value)); + if (rune === '$derived') expression = b.thunk(expression); - declarations.push(b.declarator(declarator.id, b.call('$.derived', expression))); + declarations.push(b.declarator(declarator.id, b.call('$.derived', expression))); + } } else { const init = /** @type {CallExpression} */ (declarator.init); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index ff98d6d3787b..b4db8cb3a8d1 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -1,13 +1,14 @@ /** @import { BlockStatement, Expression, ExpressionStatement, Identifier, MemberExpression, Pattern, Property, SequenceExpression, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ -/** @import { ComponentContext } from '../../types.js' */ +/** @import { ComponentContext, MemoizedExpression } from '../../types.js' */ import { dev, is_ignored } from '../../../../../state.js'; import { get_attribute_chunks, object } from '../../../../../utils/ast.js'; import * as b from '#compiler/builders'; -import { build_bind_this, memoize_expression, validate_binding } from '../shared/utils.js'; +import { build_bind_this, get_expression_id, validate_binding } from '../shared/utils.js'; import { build_attribute_value } from '../shared/element.js'; import { build_event_handler } from './events.js'; import { determine_slot } from '../../../../../utils/slot.js'; +import { create_derived } from '../../utils.js'; /** * @param {AST.Component | AST.SvelteComponent | AST.SvelteSelf} node @@ -40,6 +41,12 @@ export function build_component(node, component_name, context, anchor = context. /** @type {Record} */ const events = {}; + /** @type {MemoizedExpression[]} */ + const expressions = []; + + /** @type {MemoizedExpression[]} */ + const async_expressions = []; + /** @type {Property[]} */ const custom_css_props = []; @@ -115,16 +122,21 @@ export function build_component(node, component_name, context, anchor = context. (events[attribute.name] ||= []).push(handler); } else if (attribute.type === 'SpreadAttribute') { const expression = /** @type {Expression} */ (context.visit(attribute)); - if (attribute.metadata.expression.has_state) { - let value = expression; - - if (attribute.metadata.expression.has_call) { - const id = b.id(context.state.scope.generate('spread_element')); - context.state.init.push(b.var(id, b.call('$.derived', b.thunk(value)))); - value = b.call('$.get', id); - } - props_and_spreads.push(b.thunk(value)); + if (attribute.metadata.expression.has_state) { + props_and_spreads.push( + b.thunk( + attribute.metadata.expression.has_await || attribute.metadata.expression.has_call + ? b.call( + '$.get', + get_expression_id( + attribute.metadata.expression.has_await ? async_expressions : expressions, + expression + ) + ) + : expression + ) + ); } else { props_and_spreads.push(expression); } @@ -133,10 +145,15 @@ export function build_component(node, component_name, context, anchor = context. custom_css_props.push( b.init( attribute.name, - build_attribute_value(attribute.value, context, (value, metadata) => + build_attribute_value(attribute.value, context, (value, metadata) => { // TODO put the derived in the local block - metadata.has_call ? memoize_expression(context.state, value) : value - ).value + return metadata.has_call || metadata.has_await + ? b.call( + '$.get', + get_expression_id(metadata.has_await ? async_expressions : expressions, value) + ) + : value; + }).value ) ); continue; @@ -154,20 +171,27 @@ export function build_component(node, component_name, context, anchor = context. attribute.value, context, (value, metadata) => { - if (!metadata.has_state) return value; + if (!metadata.has_state && !metadata.has_await) return value; // When we have a non-simple computation, anything other than an Identifier or Member expression, // then there's a good chance it needs to be memoized to avoid over-firing when read within the // child component (e.g. `active={i === index}`) - const should_wrap_in_derived = get_attribute_chunks(attribute.value).some((n) => { - return ( - n.type === 'ExpressionTag' && - n.expression.type !== 'Identifier' && - n.expression.type !== 'MemberExpression' - ); - }); - - return should_wrap_in_derived ? memoize_expression(context.state, value) : value; + const should_wrap_in_derived = + metadata.has_await || + get_attribute_chunks(attribute.value).some((n) => { + return ( + n.type === 'ExpressionTag' && + n.expression.type !== 'Identifier' && + n.expression.type !== 'MemberExpression' + ); + }); + + return should_wrap_in_derived + ? b.call( + '$.get', + get_expression_id(metadata.has_await ? async_expressions : expressions, value) + ) + : value; } ); @@ -427,7 +451,12 @@ export function build_component(node, component_name, context, anchor = context. }; } - const statements = [...snippet_declarations]; + const statements = [ + ...snippet_declarations, + ...expressions.map((memo) => + b.let(memo.id, create_derived(context.state, b.thunk(memo.expression))) + ) + ]; if (node.type === 'SvelteComponent') { const prev = fn; @@ -470,5 +499,20 @@ export function build_component(node, component_name, context, anchor = context. statements.push(b.stmt(fn(anchor))); } + [...async_expressions, ...expressions].forEach((memo, i) => { + memo.id.name = `$${i}`; + }); + + if (async_expressions.length > 0) { + return b.stmt( + b.call( + '$.async', + anchor, + b.array(async_expressions.map(({ expression }) => b.thunk(expression, true))), + b.arrow([b.id('$$anchor'), ...async_expressions.map(({ id }) => id)], b.block(statements)) + ) + ); + } + return statements.length > 1 ? b.block(statements) : statements[0]; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js index 67de25b77041..6733b6932f6c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/element.js @@ -1,6 +1,6 @@ /** @import { ArrayExpression, Expression, Identifier, ObjectExpression } from 'estree' */ /** @import { AST, ExpressionMetadata } from '#compiler' */ -/** @import { ComponentContext } from '../../types' */ +/** @import { ComponentContext, MemoizedExpression } from '../../types' */ import { escape_html } from '../../../../../../escaping.js'; import { normalize_attribute } from '../../../../../../utils.js'; import { is_ignored } from '../../../../../state.js'; @@ -28,18 +28,18 @@ export function build_attribute_effect( /** @type {ObjectExpression['properties']} */ const values = []; - /** @type {Expression[]} */ - const expressions = []; + /** @type {MemoizedExpression[]} */ + const async_expressions = []; - /** @param {Expression} value */ - function memoize(value) { - return b.id(`$${expressions.push(value) - 1}`); - } + /** @type {MemoizedExpression[]} */ + const expressions = []; for (const attribute of attributes) { if (attribute.type === 'Attribute') { const { value } = build_attribute_value(attribute.value, context, (value, metadata) => - metadata.has_call ? memoize(value) : value + metadata.has_call || metadata.has_await + ? get_expression_id(metadata.has_await ? async_expressions : expressions, value) + : value ); if ( @@ -56,8 +56,11 @@ export function build_attribute_effect( } else { let value = /** @type {Expression} */ (context.visit(attribute)); - if (attribute.metadata.expression.has_call) { - value = memoize(value); + if (attribute.metadata.expression.has_call || attribute.metadata.expression.has_await) { + value = get_expression_id( + attribute.metadata.expression.has_await ? async_expressions : expressions, + value + ); } values.push(b.spread(value)); @@ -69,7 +72,7 @@ export function build_attribute_effect( b.prop( 'init', b.array([b.id('$.CLASS')]), - build_class_directives_object(class_directives, expressions, context) + build_class_directives_object(class_directives, async_expressions, expressions, context) ) ); } @@ -79,7 +82,7 @@ export function build_attribute_effect( b.prop( 'init', b.array([b.id('$.STYLE')]), - build_style_directives_object(style_directives, expressions, context) + build_style_directives_object(style_directives, async_expressions, expressions, context) ) ); } @@ -90,10 +93,11 @@ export function build_attribute_effect( '$.attribute_effect', element_id, b.arrow( - expressions.map((_, i) => b.id(`$${i}`)), + expressions.map(({ id }) => id), b.object(values) ), - expressions.length > 0 && b.array(expressions.map((expression) => b.thunk(expression))), + // TODO need to handle async expressions too + expressions.length > 0 && b.array(expressions.map(({ expression }) => b.thunk(expression))), element.metadata.scoped && context.state.analysis.css.hash !== '' && b.literal(context.state.analysis.css.hash), @@ -125,7 +129,7 @@ export function build_attribute_value(value, context, memoize = (value) => value return { value: memoize(expression, chunk.metadata.expression), - has_state: chunk.metadata.expression.has_state + has_state: chunk.metadata.expression.has_state || chunk.metadata.expression.has_await }; } @@ -158,7 +162,12 @@ export function build_set_class(element, node_id, attribute, class_directives, c value = b.call('$.clsx', value); } - return metadata.has_call ? get_expression_id(context.state.expressions, value) : value; + return metadata.has_call || metadata.has_await + ? get_expression_id( + metadata.has_await ? context.state.async_expressions : context.state.expressions, + value + ) + : value; }); /** @type {Identifier | undefined} */ @@ -171,7 +180,12 @@ export function build_set_class(element, node_id, attribute, class_directives, c let next; if (class_directives.length) { - next = build_class_directives_object(class_directives, context.state.expressions, context); + next = build_class_directives_object( + class_directives, + context.state.async_expressions, + context.state.expressions, + context + ); has_state ||= class_directives.some((d) => d.metadata.expression.has_state); if (has_state) { @@ -226,7 +240,12 @@ export function build_set_class(element, node_id, attribute, class_directives, c */ export function build_set_style(node_id, attribute, style_directives, context) { let { value, has_state } = build_attribute_value(attribute.value, context, (value, metadata) => - metadata.has_call ? get_expression_id(context.state.expressions, value) : value + metadata.has_call + ? get_expression_id( + metadata.has_await ? context.state.async_expressions : context.state.expressions, + value + ) + : value ); /** @type {Identifier | undefined} */ @@ -239,7 +258,12 @@ export function build_set_style(node_id, attribute, style_directives, context) { let next; if (style_directives.length) { - next = build_style_directives_object(style_directives, context.state.expressions, context); + next = build_style_directives_object( + style_directives, + context.state.async_expressions, + context.state.expressions, + context + ); has_state ||= style_directives.some((d) => d.metadata.expression.has_state); if (has_state) { diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js index ebf88e878f8d..fa67bfe3e151 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js @@ -1,6 +1,6 @@ /** @import { AssignmentExpression, Expression, ExpressionStatement, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression } from 'estree' */ /** @import { AST, ExpressionMetadata } from '#compiler' */ -/** @import { ComponentClientTransformState, Context } from '../../types' */ +/** @import { ComponentClientTransformState, Context, MemoizedExpression } from '../../types' */ import { walk } from 'zimmerframe'; import { object } from '../../../../../utils/ast.js'; import * as b from '#compiler/builders'; @@ -21,12 +21,16 @@ export function memoize_expression(state, value) { } /** - * Pushes `value` into `expressions` and returns a new id - * @param {Expression[]} expressions - * @param {Expression} value + * + * @param {MemoizedExpression[]} expressions + * @param {Expression} expression */ -export function get_expression_id(expressions, value) { - return b.id(`$${expressions.push(value) - 1}`); +export function get_expression_id(expressions, expression) { + // TODO tidy this up + const id = b.id(`$${expressions.length}`); + expressions.push({ id, expression }); + + return id; } /** @@ -41,7 +45,9 @@ export function build_template_chunk( visit, state, memoize = (value, metadata) => - metadata.has_call ? get_expression_id(state.expressions, value) : value + metadata.has_call || metadata.has_await + ? get_expression_id(metadata.has_await ? state.async_expressions : state.expressions, value) + : value ) { /** @type {Expression[]} */ const expressions = []; @@ -50,6 +56,7 @@ export function build_template_chunk( const quasis = [quasi]; let has_state = false; + let has_await = false; for (let i = 0; i < values.length; i++) { const node = values[i]; @@ -72,7 +79,8 @@ export function build_template_chunk( const evaluated = state.scope.evaluate(value); - has_state ||= node.metadata.expression.has_state && !evaluated.is_known; + has_await ||= node.metadata.expression.has_await; + has_state ||= has_await || (node.metadata.expression.has_state && !evaluated.is_known); if (values.length === 1) { // If we have a single expression, then pass that in directly to possibly avoid doing @@ -128,18 +136,27 @@ export function build_template_chunk( * @param {ComponentClientTransformState} state */ export function build_render_statement(state) { + const sync = state.expressions; + const async = state.async_expressions; + + const all = [...sync, ...async]; + + for (let i = 0; i < all.length; i += 1) { + all[i].id.name = `$${i}`; + } + return b.stmt( b.call( '$.template_effect', b.arrow( - state.expressions.map((_, i) => b.id(`$${i}`)), + all.map(({ id }) => id), state.update.length === 1 && state.update[0].type === 'ExpressionStatement' ? state.update[0].expression : b.block(state.update) ), - state.expressions.length > 0 && - b.array(state.expressions.map((expression) => b.thunk(expression))), - state.expressions.length > 0 && !state.analysis.runes && b.id('$.derived_safe_equal') + all.length > 0 && b.array(sync.map(({ expression }) => b.thunk(expression))), + async.length > 0 && b.array(async.map(({ expression }) => b.thunk(expression, true))), + !state.analysis.runes && sync.length > 0 && b.id('$.derived_safe_equal') ) ); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 7a3d6bef6c31..a0e53653fbca 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -10,6 +10,7 @@ import { dev, filename } from '../../../state.js'; import { render_stylesheet } from '../css/index.js'; import { AssignmentExpression } from './visitors/AssignmentExpression.js'; import { AwaitBlock } from './visitors/AwaitBlock.js'; +import { AwaitExpression } from './visitors/AwaitExpression.js'; import { CallExpression } from './visitors/CallExpression.js'; import { ClassBody } from './visitors/ClassBody.js'; import { Component } from './visitors/Component.js'; @@ -44,6 +45,7 @@ import { SvelteBoundary } from './visitors/SvelteBoundary.js'; const global_visitors = { _: set_scope, AssignmentExpression, + AwaitExpression, CallExpression, ClassBody, ExpressionStatement, diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitExpression.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitExpression.js new file mode 100644 index 000000000000..9135892dbd60 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/AwaitExpression.js @@ -0,0 +1,25 @@ +/** @import { AwaitExpression } from 'estree' */ +/** @import { Context } from '../types.js' */ +import * as b from '../../../../utils/builders.js'; + +/** + * @param {AwaitExpression} node + * @param {Context} context + */ +export function AwaitExpression(node, context) { + // if `await` is inside a function, or inside ` + * ``` + */ +export function getAbortSignal() { + if (active_reaction === null) { + throw new Error('TODO getAbortSignal can only be called inside a reaction'); + } + + return (active_reaction.ac ??= new AbortController()).signal; +} + /** * `onMount`, like [`$effect`](https://svelte.dev/docs/svelte/$effect), schedules a function to run as soon as the component has been mounted to the DOM. * Unlike `$effect`, the provided function only runs once. @@ -210,5 +241,5 @@ function init_update_callbacks(context) { export { flushSync } from './internal/client/runtime.js'; export { getContext, getAllContexts, hasContext, setContext } from './internal/client/context.js'; export { hydrate, mount, unmount } from './internal/client/render.js'; -export { tick, untrack } from './internal/client/runtime.js'; +export { tick, untrack, settled } from './internal/client/runtime.js'; export { createRawSnippet } from './internal/client/dom/blocks/snippet.js'; diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index 0f1aff8f5aa7..219bcfb3605d 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -35,6 +35,23 @@ export function unmount() { export async function tick() {} +export async function settled() {} + +/** @type {AbortController | null} */ +let controller = null; + +export function getAbortSignal() { + if (controller === null) { + const c = (controller = new AbortController()); + queueMicrotask(() => { + c.abort(); + controller = null; + }); + } + + return controller.signal; +} + export { getAllContexts, getContext, hasContext, setContext } from './internal/server/context.js'; export { createRawSnippet } from './internal/server/blocks/snippet.js'; diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 7e5196c606b4..79b98e357730 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -5,23 +5,30 @@ export const BLOCK_EFFECT = 1 << 4; export const BRANCH_EFFECT = 1 << 5; export const ROOT_EFFECT = 1 << 6; export const BOUNDARY_EFFECT = 1 << 7; -export const UNOWNED = 1 << 8; -export const DISCONNECTED = 1 << 9; -export const CLEAN = 1 << 10; -export const DIRTY = 1 << 11; -export const MAYBE_DIRTY = 1 << 12; -export const INERT = 1 << 13; -export const DESTROYED = 1 << 14; -export const EFFECT_RAN = 1 << 15; +export const UNOWNED = 1 << 9; +export const DISCONNECTED = 1 << 10; +export const CLEAN = 1 << 11; +export const DIRTY = 1 << 12; +export const MAYBE_DIRTY = 1 << 13; +export const INERT = 1 << 14; +export const DESTROYED = 1 << 15; +export const EFFECT_RAN = 1 << 16; /** 'Transparent' effects do not create a transition boundary */ -export const EFFECT_TRANSPARENT = 1 << 16; +export const EFFECT_TRANSPARENT = 1 << 17; /** Svelte 4 legacy mode props need to be handled with deriveds and be recognized elsewhere, hence the dedicated flag */ -export const LEGACY_DERIVED_PROP = 1 << 17; -export const INSPECT_EFFECT = 1 << 18; -export const HEAD_EFFECT = 1 << 19; -export const EFFECT_HAS_DERIVED = 1 << 20; -export const EFFECT_IS_UPDATING = 1 << 21; +export const LEGACY_DERIVED_PROP = 1 << 18; +export const INSPECT_EFFECT = 1 << 19; +export const HEAD_EFFECT = 1 << 20; +export const EFFECT_HAS_DERIVED = 1 << 21; +export const EFFECT_IS_UPDATING = 1 << 22; +export const EFFECT_PRESERVED = 1 << 23; // effects with this flag should not be pruned + +// Flags used for async +export const REACTION_IS_UPDATING = 1 << 24; +export const EFFECT_ASYNC = 1 << 25; export const STATE_SYMBOL = Symbol('$state'); export const LEGACY_PROPS = Symbol('legacy props'); export const LOADING_ATTR_SYMBOL = Symbol(''); + +export const STALE_REACTION = Symbol('stale reaction'); diff --git a/packages/svelte/src/internal/client/context.js b/packages/svelte/src/internal/client/context.js index 7c7213b7a2de..f326f3a0b714 100644 --- a/packages/svelte/src/internal/client/context.js +++ b/packages/svelte/src/internal/client/context.js @@ -2,6 +2,7 @@ import { DEV } from 'esm-env'; import { lifecycle_outside_component } from '../shared/errors.js'; +import * as e from './errors.js'; import { source } from './reactivity/sources.js'; import { active_effect, @@ -10,7 +11,7 @@ import { set_active_reaction } from './runtime.js'; import { effect, teardown } from './reactivity/effects.js'; -import { legacy_mode_flag } from '../flags/index.js'; +import { async_mode_flag, legacy_mode_flag } from '../flags/index.js'; /** @type {ComponentContext | null} */ export let component_context = null; @@ -65,6 +66,13 @@ export function getContext(key) { */ export function setContext(key, context) { const context_map = get_or_init_context_map('setContext'); + + if (async_mode_flag) { + if (/** @type {ComponentContext} */ (component_context).m) { + e.set_context_after_init(); + } + } + context_map.set(key, context); return context; } @@ -109,18 +117,9 @@ export function push(props, runes = false, fn) { m: false, s: props, x: null, - l: null + l: legacy_mode_flag && !runes ? { s: null, u: null, $: [] } : null }); - if (legacy_mode_flag && !runes) { - component_context.l = { - s: null, - u: null, - r1: [], - r2: source(false) - }; - } - teardown(() => { /** @type {ComponentContext} */ (ctx).d = true; }); diff --git a/packages/svelte/src/internal/client/dev/debug.js b/packages/svelte/src/internal/client/dev/debug.js index fbde87a2d764..fdaa02350c01 100644 --- a/packages/svelte/src/internal/client/dev/debug.js +++ b/packages/svelte/src/internal/client/dev/debug.js @@ -7,6 +7,7 @@ import { CLEAN, DERIVED, EFFECT, + EFFECT_ASYNC, MAYBE_DIRTY, RENDER_EFFECT, ROOT_EFFECT @@ -39,6 +40,8 @@ export function log_effect_tree(effect, depth = 0) { label = 'boundary'; } else if ((flags & BLOCK_EFFECT) !== 0) { label = 'block'; + } else if ((flags & EFFECT_ASYNC) !== 0) { + label = 'async'; } else if ((flags & BRANCH_EFFECT) !== 0) { label = 'branch'; } else if ((flags & RENDER_EFFECT) !== 0) { diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js new file mode 100644 index 000000000000..c3828fdb2517 --- /dev/null +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -0,0 +1,30 @@ +/** @import { Effect, TemplateNode, Value } from '#client' */ +import { DESTROYED } from '#client/constants'; +import { async_derived } from '../../reactivity/deriveds.js'; +import { active_effect } from '../../runtime.js'; +import { capture, get_pending_boundary } from './boundary.js'; + +/** + * @param {TemplateNode} node + * @param {Array<() => Promise>} expressions + * @param {(anchor: TemplateNode, ...deriveds: Value[]) => void} fn + */ +export function async(node, expressions, fn) { + // TODO handle hydration + + var parent = /** @type {Effect} */ (active_effect); + + var restore = capture(); + var boundary = get_pending_boundary(); + + boundary.increment(); + + Promise.all(expressions.map((fn) => async_derived(fn))).then((result) => { + if ((parent.f & DESTROYED) !== 0) return; + + restore(); + fn(node, ...result); + + boundary.decrement(); + }); +} diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 53060017b935..cb9fc6ef6b2b 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -1,6 +1,6 @@ /** @import { Effect, TemplateNode, } from '#client' */ -import { BOUNDARY_EFFECT, EFFECT_TRANSPARENT } from '#client/constants'; +import { BOUNDARY_EFFECT, EFFECT_PRESERVED, EFFECT_TRANSPARENT } from '#client/constants'; import { component_context, set_component_context } from '../../context.js'; import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js'; import { @@ -19,115 +19,380 @@ import { remove_nodes, set_hydrate_node } from '../hydration.js'; -import { queue_micro_task } from '../task.js'; +import { get_next_sibling } from '../operations.js'; +import { queue_boundary_micro_task } from '../task.js'; +import * as e from '../../../shared/errors.js'; +import { DEV } from 'esm-env'; +import { from_async_derived, set_from_async_derived } from '../../reactivity/deriveds.js'; +import { Batch } from '../../reactivity/batch.js'; /** - * @param {Effect} boundary - * @param {() => void} fn + * @typedef {{ + * onerror?: (error: unknown, reset: () => void) => void; + * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void; + * pending?: (anchor: Node) => void; + * }} BoundaryProps */ -function with_boundary(boundary, fn) { - var previous_effect = active_effect; - var previous_reaction = active_reaction; - var previous_ctx = component_context; - - set_active_effect(boundary); - set_active_reaction(boundary); - set_component_context(boundary.ctx); - - try { - fn(); - } finally { - set_active_effect(previous_effect); - set_active_reaction(previous_reaction); - set_component_context(previous_ctx); - } -} + +var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT; /** * @param {TemplateNode} node - * @param {{ - * onerror?: (error: unknown, reset: () => void) => void, - * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void - * }} props - * @param {((anchor: Node) => void)} boundary_fn + * @param {BoundaryProps} props + * @param {((anchor: Node) => void)} children * @returns {void} */ -export function boundary(node, props, boundary_fn) { - var anchor = node; +export function boundary(node, props, children) { + new Boundary(node, props, children); +} + +export class Boundary { + inert = false; + ran = false; + + /** @type {Boundary | null} */ + parent; + + /** @type {TemplateNode} */ + #anchor; + + /** @type {TemplateNode} */ + #hydrate_open; + + /** @type {BoundaryProps} */ + #props; + + /** @type {((anchor: Node) => void)} */ + #children; /** @type {Effect} */ - var boundary_effect; - - block(() => { - var boundary = /** @type {Effect} */ (active_effect); - var hydrate_open = hydrate_node; - var is_creating_fallback = false; - - // We re-use the effect's fn property to avoid allocation of an additional field - boundary.fn = (/** @type {unknown}} */ error) => { - var onerror = props.onerror; - let failed = props.failed; - - // If we have nothing to capture the error, or if we hit an error while - // rendering the fallback, re-throw for another boundary to handle - if ((!onerror && !failed) || is_creating_fallback) { - throw error; + #effect; + + /** @type {Effect | null} */ + #main_effect = null; + + /** @type {Effect | null} */ + #pending_effect = null; + + /** @type {Effect | null} */ + #failed_effect = null; + + /** @type {DocumentFragment | null} */ + #offscreen_fragment = null; + + #pending_count = 0; + #is_creating_fallback = false; + + /** + * @param {TemplateNode} node + * @param {BoundaryProps} props + * @param {((anchor: Node) => void)} children + */ + constructor(node, props, children) { + this.#anchor = node; + this.#props = props; + this.#children = children; + + this.#hydrate_open = hydrate_node; + + this.parent = /** @type {Effect} */ (active_effect).b; + + this.#effect = block(() => { + /** @type {Effect} */ (active_effect).b = this; + + if (hydrating) { + hydrate_next(); } - var reset = () => { - pause_effect(boundary_effect); + const pending = this.#props.pending; - with_boundary(boundary, () => { - is_creating_fallback = false; - boundary_effect = branch(() => boundary_fn(anchor)); - reset_is_throwing_error(); + if (hydrating && pending) { + this.#pending_effect = branch(() => pending(this.#anchor)); + + // future work: when we have some form of async SSR, we will + // need to use hydration boundary comments to report whether + // the pending or main block was rendered for a given + // boundary, and hydrate accordingly + queueMicrotask(() => { + this.#main_effect = this.#run(() => { + Batch.ensure(); + return branch(() => this.#children(this.#anchor)); + }); + + if (this.#pending_count > 0) { + this.#show_pending_snippet(); + } else { + pause_effect(/** @type {Effect} */ (this.#pending_effect), () => { + this.#pending_effect = null; + }); + this.ran = true; + } }); - }; + } else { + this.#main_effect = branch(() => children(this.#anchor)); + + if (this.#pending_count > 0) { + this.#show_pending_snippet(); + } else { + this.ran = true; + } + } - onerror?.(error, reset); + reset_is_throwing_error(); + }, flags); - if (boundary_effect) { - destroy_effect(boundary_effect); - } else if (hydrating) { - set_hydrate_node(hydrate_open); - next(); - set_hydrate_node(remove_nodes()); + if (hydrating) { + this.#anchor = hydrate_node; + } + } + + has_pending_snippet() { + return !!this.#props.pending; + } + + /** + * @param {() => Effect | null} fn + */ + #run(fn) { + var previous_effect = active_effect; + var previous_reaction = active_reaction; + var previous_ctx = component_context; + + set_active_effect(this.#effect); + set_active_reaction(this.#effect); + set_component_context(this.#effect.ctx); + + try { + return fn(); + } finally { + set_active_effect(previous_effect); + set_active_reaction(previous_reaction); + set_component_context(previous_ctx); + } + } + + #show_pending_snippet() { + const pending = this.#props.pending; + + if (pending !== undefined) { + // TODO can this be false? + if (this.#main_effect !== null) { + this.#offscreen_fragment = document.createDocumentFragment(); + move_effect(this.#main_effect, this.#offscreen_fragment); + } + + if (this.#pending_effect === null) { + this.#pending_effect = branch(() => pending(this.#anchor)); } + } else if (this.parent) { + throw new Error('TODO show pending snippet on parent'); + } else { + throw new Error('no pending snippet to show'); + } + } - if (failed) { - // Render the `failed` snippet in a microtask - queue_micro_task(() => { - with_boundary(boundary, () => { - is_creating_fallback = true; - - try { - boundary_effect = branch(() => { - failed( - anchor, - () => error, - () => reset - ); - }); - } catch (error) { - handle_error(error, boundary, null, boundary.ctx); - } + commit() { + this.ran = true; - reset_is_throwing_error(); - is_creating_fallback = false; - }); + if (this.#pending_effect) { + pause_effect(this.#pending_effect, () => { + this.#pending_effect = null; + }); + } + + if (this.#offscreen_fragment) { + this.#anchor.before(this.#offscreen_fragment); + this.#offscreen_fragment = null; + } + } + + increment() { + this.#pending_count++; + } + + decrement() { + if (--this.#pending_count === 0) { + this.commit(); + + if (this.#main_effect !== null) { + // TODO do we also need to `resume_effect` here? + // schedule_effect(this.#main_effect); + } + } + } + + /** @param {unknown} error */ + error(error) { + var onerror = this.#props.onerror; + let failed = this.#props.failed; + + const reset = () => { + this.#pending_count = 0; + + if (this.#failed_effect !== null) { + pause_effect(this.#failed_effect, () => { + this.#failed_effect = null; }); } + + this.ran = false; + + this.#main_effect = this.#run(() => { + this.#is_creating_fallback = false; + + try { + return branch(() => this.#children(this.#anchor)); + } finally { + reset_is_throwing_error(); + } + }); + + if (this.#pending_count > 0) { + this.#show_pending_snippet(); + } else { + this.ran = true; + } }; + // If we have nothing to capture the error, or if we hit an error while + // rendering the fallback, re-throw for another boundary to handle + if (this.#is_creating_fallback || (!onerror && !failed)) { + throw error; + } + + onerror?.(error, reset); + + if (this.#main_effect) { + destroy_effect(this.#main_effect); + this.#main_effect = null; + } + + if (this.#pending_effect) { + destroy_effect(this.#pending_effect); + this.#pending_effect = null; + } + + if (this.#failed_effect) { + destroy_effect(this.#failed_effect); + this.#failed_effect = null; + } + if (hydrating) { - hydrate_next(); + set_hydrate_node(this.#hydrate_open); + next(); + set_hydrate_node(remove_nodes()); + } + + if (failed) { + queue_boundary_micro_task(() => { + this.#failed_effect = this.#run(() => { + this.#is_creating_fallback = true; + + try { + return branch(() => { + failed( + this.#anchor, + () => error, + () => reset + ); + }); + } catch (error) { + handle_error(error, this.#effect, null, this.#effect.ctx); + return null; + } finally { + reset_is_throwing_error(); + this.#is_creating_fallback = false; + } + }); + }); } + } +} + +/** + * + * @param {Effect} effect + * @param {DocumentFragment} fragment + */ +function move_effect(effect, fragment) { + var node = effect.nodes_start; + var end = effect.nodes_end; + + while (node !== null) { + /** @type {TemplateNode | null} */ + var next = node === end ? null : /** @type {TemplateNode} */ (get_next_sibling(node)); + + fragment.append(node); + node = next; + } +} + +export function get_pending_boundary() { + var boundary = /** @type {Effect} */ (active_effect).b; + + while (boundary !== null && !boundary.has_pending_snippet()) { + boundary = boundary.parent; + } + + if (boundary === null) { + e.await_outside_boundary(); + } - boundary_effect = branch(() => boundary_fn(anchor)); - reset_is_throwing_error(); - }, EFFECT_TRANSPARENT | BOUNDARY_EFFECT); + return boundary; +} + +export function capture(track = true) { + var previous_effect = active_effect; + var previous_reaction = active_reaction; + var previous_component_context = component_context; - if (hydrating) { - anchor = hydrate_node; + if (DEV && !track) { + var was_from_async_derived = from_async_derived; } + + return function restore() { + if (track) { + set_active_effect(previous_effect); + set_active_reaction(previous_reaction); + set_component_context(previous_component_context); + } else if (DEV) { + set_from_async_derived(was_from_async_derived); + } + + // prevent the active effect from outstaying its welcome + queue_boundary_micro_task(exit); + }; +} + +// TODO we should probably be incrementing the current batch, not the boundary? +export function suspend() { + let boundary = get_pending_boundary(); + + boundary.increment(); + + return function unsuspend() { + boundary.decrement(); + }; +} + +/** + * @template T + * @param {Promise} promise + * @param {boolean} [track] + * @returns {Promise<() => T>} + */ +export async function save(promise, track = true) { + var restore = capture(track); + var value = await promise; + + return () => { + restore(); + return value; + }; +} + +function exit() { + set_active_effect(null); + set_active_reaction(null); + set_component_context(null); } diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 2997664fa259..b5590a85531d 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -1,4 +1,5 @@ /** @import { EachItem, EachState, Effect, MaybeSource, Source, TemplateNode, TransitionManager, Value } from '#client' */ +/** @import { Batch } from '../../reactivity/batch.js'; */ import { EACH_INDEX_REACTIVE, EACH_IS_ANIMATED, @@ -21,7 +22,8 @@ import { clear_text_content, create_text, get_first_child, - get_next_sibling + get_next_sibling, + should_defer_append } from '../operations.js'; import { block, @@ -39,6 +41,7 @@ import { queue_micro_task } from '../task.js'; import { active_effect, get } from '../../runtime.js'; import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; +import { current_batch } from '../../reactivity/batch.js'; /** * The row of a keyed each block that is currently updating. We track this @@ -64,17 +67,18 @@ export function index(_, i) { * Pause multiple effects simultaneously, and coordinate their * subsequent destruction. Used in each blocks * @param {EachState} state - * @param {EachItem[]} items + * @param {EachItem[]} to_destroy * @param {null | Node} controlled_anchor - * @param {Map} items_map */ -function pause_effects(state, items, controlled_anchor, items_map) { +function pause_effects(state, to_destroy, controlled_anchor) { + var items_map = state.items; + /** @type {TransitionManager[]} */ var transitions = []; - var length = items.length; + var length = to_destroy.length; for (var i = 0; i < length; i++) { - pause_children(items[i].e, transitions, true); + pause_children(to_destroy[i].e, transitions, true); } var is_controlled = length > 0 && transitions.length === 0 && controlled_anchor !== null; @@ -87,12 +91,12 @@ function pause_effects(state, items, controlled_anchor, items_map) { clear_text_content(parent_node); parent_node.append(/** @type {Element} */ (controlled_anchor)); items_map.clear(); - link(state, items[0].prev, items[length - 1].next); + link(state, to_destroy[0].prev, to_destroy[length - 1].next); } run_out_transitions(transitions, () => { for (var i = 0; i < length; i++) { - var item = items[i]; + var item = to_destroy[i]; if (!is_controlled) { items_map.delete(item.k); link(state, item.prev, item.next); @@ -137,6 +141,9 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f var was_empty = false; + /** @type {Map} */ + var offscreen_items = new Map(); + // TODO: ideally we could use derived for runes mode but because of the ability // to use a store which can be mutated, we can't do that here as mutating a store // will still result in the collection array being the same from the store @@ -146,8 +153,45 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f return is_array(collection) ? collection : collection == null ? [] : array_from(collection); }); + /** @type {V[]} */ + var array; + + /** @type {Effect} */ + var each_effect; + + function commit() { + reconcile( + each_effect, + array, + state, + offscreen_items, + anchor, + render_fn, + flags, + get_key, + get_collection + ); + + if (fallback_fn !== null) { + if (array.length === 0) { + if (fallback) { + resume_effect(fallback); + } else { + fallback = branch(() => fallback_fn(anchor)); + } + } else if (fallback !== null) { + pause_effect(fallback, () => { + fallback = null; + }); + } + } + } + block(() => { - var array = get(each_array); + // store a reference to the effect so that we can update the start/end nodes in reconciliation + each_effect ??= /** @type {Effect} */ (active_effect); + + array = get(each_array); var length = array.length; if (was_empty && length === 0) { @@ -219,21 +263,56 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } - if (!hydrating) { - reconcile(array, state, anchor, render_fn, flags, get_key, get_collection); - } + if (hydrating) { + if (length === 0 && fallback_fn) { + fallback = branch(() => fallback_fn(anchor)); + } + } else { + if (should_defer_append()) { + var keys = new Set(); + var batch = /** @type {Batch} */ (current_batch); + + for (i = 0; i < length; i += 1) { + value = array[i]; + key = get_key(value, i); + + var existing = state.items.get(key) ?? offscreen_items.get(key); + + if (existing) { + // update before reconciliation, to trigger any async updates + if ((flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0) { + update_item(existing, value, i, flags); + } + } else { + item = create_item( + null, + state, + null, + null, + value, + key, + i, + render_fn, + flags, + get_collection, + true + ); + + offscreen_items.set(key, item); + } - if (fallback_fn !== null) { - if (length === 0) { - if (fallback) { - resume_effect(fallback); - } else { - fallback = branch(() => fallback_fn(anchor)); + keys.add(key); } - } else if (fallback !== null) { - pause_effect(fallback, () => { - fallback = null; - }); + + for (const [key, item] of state.items) { + if (!keys.has(key)) { + batch.skipped_effects.add(item.e); + } + } + + batch.add_callback(commit); + } else { + commit(); } } @@ -259,8 +338,10 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f /** * Add, remove, or reorder items output by an each block as its input changes * @template V + * @param {Effect} each_effect * @param {Array} array * @param {EachState} state + * @param {Map} offscreen_items * @param {Element | Comment | Text} anchor * @param {(anchor: Node, item: MaybeSource, index: number | Source, collection: () => V[]) => void} render_fn * @param {number} flags @@ -268,7 +349,17 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f * @param {() => V[]} get_collection * @returns {void} */ -function reconcile(array, state, anchor, render_fn, flags, get_key, get_collection) { +function reconcile( + each_effect, + array, + state, + offscreen_items, + anchor, + render_fn, + flags, + get_key, + get_collection +) { var is_animated = (flags & EACH_IS_ANIMATED) !== 0; var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0; @@ -320,23 +411,39 @@ function reconcile(array, state, anchor, render_fn, flags, get_key, get_collecti for (i = 0; i < length; i += 1) { value = array[i]; key = get_key(value, i); + item = items.get(key); if (item === undefined) { - var child_anchor = current ? /** @type {TemplateNode} */ (current.e.nodes_start) : anchor; - - prev = create_item( - child_anchor, - state, - prev, - prev === null ? state.first : prev.next, - value, - key, - i, - render_fn, - flags, - get_collection - ); + var pending = offscreen_items.get(key); + + if (pending !== undefined) { + offscreen_items.delete(key); + items.set(key, pending); + + var next = prev ? prev.next : current; + + link(state, prev, pending); + link(state, pending, next); + + move(pending, next, anchor); + prev = pending; + } else { + var child_anchor = current ? /** @type {TemplateNode} */ (current.e.nodes_start) : anchor; + + prev = create_item( + child_anchor, + state, + prev, + prev === null ? state.first : prev.next, + value, + key, + i, + render_fn, + flags, + get_collection + ); + } items.set(key, prev); @@ -455,7 +562,7 @@ function reconcile(array, state, anchor, render_fn, flags, get_key, get_collecti } } - pause_effects(state, to_destroy, controlled_anchor, items); + pause_effects(state, to_destroy, controlled_anchor); } } @@ -468,8 +575,14 @@ function reconcile(array, state, anchor, render_fn, flags, get_key, get_collecti }); } - /** @type {Effect} */ (active_effect).first = state.first && state.first.e; - /** @type {Effect} */ (active_effect).last = prev && prev.e; + each_effect.first = state.first && state.first.e; + each_effect.last = prev && prev.e; + + for (var unused of offscreen_items.values()) { + destroy_effect(unused.e); + } + + offscreen_items.clear(); } /** @@ -493,7 +606,7 @@ function update_item(item, value, index, type) { /** * @template V - * @param {Node} anchor + * @param {Node | null} anchor * @param {EachState} state * @param {EachItem | null} prev * @param {EachItem | null} next @@ -503,6 +616,7 @@ function update_item(item, value, index, type) { * @param {(anchor: Node, item: V | Source, index: number | Value, collection: () => V[]) => void} render_fn * @param {number} flags * @param {() => V[]} get_collection + * @param {boolean} [deferred] * @returns {EachItem} */ function create_item( @@ -515,7 +629,8 @@ function create_item( index, render_fn, flags, - get_collection + get_collection, + deferred ) { var previous_each_item = current_each_item; var reactive = (flags & EACH_ITEM_REACTIVE) !== 0; @@ -549,13 +664,20 @@ function create_item( current_each_item = item; try { - item.e = branch(() => render_fn(anchor, v, i, get_collection), hydrating); + if (anchor === null) { + var fragment = document.createDocumentFragment(); + fragment.append((anchor = create_text())); + } + + item.e = branch(() => render_fn(/** @type {Node} */ (anchor), v, i, get_collection), hydrating); item.e.prev = prev && prev.e; item.e.next = next && next.e; if (prev === null) { - state.first = item; + if (!deferred) { + state.first = item; + } } else { prev.next = item; prev.e.next = item.e; @@ -583,7 +705,7 @@ function move(item, next, anchor) { var dest = next ? /** @type {TemplateNode} */ (next.e.nodes_start) : anchor; var node = /** @type {TemplateNode} */ (item.e.nodes_start); - while (node !== end) { + while (node !== null && node !== end) { var next_node = /** @type {TemplateNode} */ (get_next_sibling(node)); dest.before(node); node = next_node; diff --git a/packages/svelte/src/internal/client/dom/blocks/if.js b/packages/svelte/src/internal/client/dom/blocks/if.js index bf1098c3f465..a4a5b68b576f 100644 --- a/packages/svelte/src/internal/client/dom/blocks/if.js +++ b/packages/svelte/src/internal/client/dom/blocks/if.js @@ -1,4 +1,5 @@ /** @import { Effect, TemplateNode } from '#client' */ +/** @import { Batch } from '../../reactivity/batch.js'; */ import { EFFECT_TRANSPARENT } from '#client/constants'; import { hydrate_next, @@ -10,16 +11,20 @@ import { set_hydrating } from '../hydration.js'; import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js'; -import { HYDRATION_START, HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; +import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; +import { create_text, should_defer_append } from '../operations.js'; +import { current_batch } from '../../reactivity/batch.js'; + +// TODO reinstate https://github.com/sveltejs/svelte/pull/15250 /** * @param {TemplateNode} node - * @param {(branch: (fn: (anchor: Node, elseif?: [number,number]) => void, flag?: boolean) => void) => void} fn - * @param {[number,number]} [elseif] + * @param {(branch: (fn: (anchor: Node) => void, flag?: boolean) => void) => void} fn + * @param {boolean} [elseif] True if this is an `{:else if ...}` block rather than an `{#if ...}`, as that affects which transitions are considered 'local' * @returns {void} */ -export function if_block(node, fn, [root_index, hydrate_index] = [0, 0]) { - if (hydrating && root_index === 0) { +export function if_block(node, fn, elseif = false) { + if (hydrating) { hydrate_next(); } @@ -34,45 +39,69 @@ export function if_block(node, fn, [root_index, hydrate_index] = [0, 0]) { /** @type {UNINITIALIZED | boolean | null} */ var condition = UNINITIALIZED; - var flags = root_index > 0 ? EFFECT_TRANSPARENT : 0; + var flags = elseif ? EFFECT_TRANSPARENT : 0; var has_branch = false; - const set_branch = ( - /** @type {(anchor: Node, elseif?: [number,number]) => void} */ fn, - flag = true - ) => { + const set_branch = (/** @type {(anchor: Node) => void} */ fn, flag = true) => { has_branch = true; update_branch(flag, fn); }; + /** @type {DocumentFragment | null} */ + var offscreen_fragment = null; + + /** @type {Effect | null} */ + var pending_effect = null; + + function commit() { + if (offscreen_fragment !== null) { + // remove the anchor + /** @type {Text} */ (offscreen_fragment.lastChild).remove(); + + anchor.before(offscreen_fragment); + offscreen_fragment = null; + } + + if (pending_effect) { + if (condition) { + consequent_effect = pending_effect; + } else { + alternate_effect = pending_effect; + } + } + + var current_effect = condition ? consequent_effect : alternate_effect; + var previous_effect = condition ? alternate_effect : consequent_effect; + + if (current_effect !== null) { + resume_effect(current_effect); + } + + if (previous_effect !== null) { + pause_effect(previous_effect, () => { + if (condition) { + alternate_effect = null; + } else { + consequent_effect = null; + } + }); + } + + pending_effect = null; + } + const update_branch = ( /** @type {boolean | null} */ new_condition, - /** @type {null | ((anchor: Node, elseif?: [number,number]) => void)} */ fn + /** @type {null | ((anchor: Node) => void)} */ fn ) => { if (condition === (condition = new_condition)) return; /** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ let mismatch = false; - if (hydrating && hydrate_index !== -1) { - if (root_index === 0) { - const data = read_hydration_instruction(anchor); - - if (data === HYDRATION_START) { - hydrate_index = 0; - } else if (data === HYDRATION_START_ELSE) { - hydrate_index = Infinity; - } else { - hydrate_index = parseInt(data.substring(1)); - if (hydrate_index !== hydrate_index) { - // if hydrate_index is NaN - // we set an invalid index to force mismatch - hydrate_index = condition ? Infinity : -1; - } - } - } - const is_else = hydrate_index > root_index; + if (hydrating) { + const is_else = read_hydration_instruction(anchor) === HYDRATION_START_ELSE; if (!!condition === is_else) { // Hydration mismatch: remove everything inside the anchor and start fresh. @@ -82,34 +111,33 @@ export function if_block(node, fn, [root_index, hydrate_index] = [0, 0]) { set_hydrate_node(anchor); set_hydrating(false); mismatch = true; - hydrate_index = -1; // ignore hydration in next else if } } - if (condition) { - if (consequent_effect) { - resume_effect(consequent_effect); - } else if (fn) { - consequent_effect = branch(() => fn(anchor)); - } + var defer = should_defer_append(); + var target = anchor; - if (alternate_effect) { - pause_effect(alternate_effect, () => { - alternate_effect = null; - }); - } - } else { - if (alternate_effect) { - resume_effect(alternate_effect); - } else if (fn) { - alternate_effect = branch(() => fn(anchor, [root_index + 1, hydrate_index])); - } + if (defer) { + offscreen_fragment = document.createDocumentFragment(); + offscreen_fragment.append((target = create_text())); + } - if (consequent_effect) { - pause_effect(consequent_effect, () => { - consequent_effect = null; - }); + if (condition ? !consequent_effect : !alternate_effect) { + pending_effect = fn && branch(() => fn(target)); + } + + if (defer) { + var batch = /** @type {Batch} */ (current_batch); + + const skipped = condition ? alternate_effect : consequent_effect; + if (skipped !== null) { + // TODO need to do this for other kinds of blocks + batch.skipped_effects.add(skipped); } + + batch.add_callback(commit); + } else { + commit(); } if (mismatch) { diff --git a/packages/svelte/src/internal/client/dom/blocks/key.js b/packages/svelte/src/internal/client/dom/blocks/key.js index a69716354819..0023764e1bd9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/key.js +++ b/packages/svelte/src/internal/client/dom/blocks/key.js @@ -1,9 +1,12 @@ /** @import { Effect, TemplateNode } from '#client' */ +/** @import { Batch } from '../../reactivity/batch.js'; */ import { UNINITIALIZED } from '../../../../constants.js'; import { block, branch, pause_effect } from '../../reactivity/effects.js'; import { not_equal, safe_not_equal } from '../../reactivity/equality.js'; import { is_runes } from '../../context.js'; import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; +import { create_text, should_defer_append } from '../operations.js'; +import { current_batch } from '../../reactivity/batch.js'; /** * @template V @@ -25,15 +28,48 @@ export function key_block(node, get_key, render_fn) { /** @type {Effect} */ var effect; + /** @type {Effect} */ + var pending_effect; + + /** @type {DocumentFragment | null} */ + var offscreen_fragment = null; + var changed = is_runes() ? not_equal : safe_not_equal; + function commit() { + if (effect) { + pause_effect(effect); + } + + if (offscreen_fragment !== null) { + // remove the anchor + /** @type {Text} */ (offscreen_fragment.lastChild).remove(); + + anchor.before(offscreen_fragment); + offscreen_fragment = null; + } + + effect = pending_effect; + } + block(() => { if (changed(key, (key = get_key()))) { - if (effect) { - pause_effect(effect); + var target = anchor; + + var defer = should_defer_append(); + + if (defer) { + offscreen_fragment = document.createDocumentFragment(); + offscreen_fragment.append((target = create_text())); } - effect = branch(() => render_fn(anchor)); + pending_effect = branch(() => render_fn(target)); + + if (defer) { + /** @type {Batch} */ (current_batch).add_callback(commit); + } else { + commit(); + } } }); diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js index ad21436505d0..f16da9c42703 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js @@ -1,7 +1,10 @@ /** @import { TemplateNode, Dom, Effect } from '#client' */ +/** @import { Batch } from '../../reactivity/batch.js'; */ import { EFFECT_TRANSPARENT } from '#client/constants'; import { block, branch, pause_effect } from '../../reactivity/effects.js'; +import { current_batch } from '../../reactivity/batch.js'; import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; +import { create_text, should_defer_append } from '../operations.js'; /** * @template P @@ -24,16 +27,50 @@ export function component(node, get_component, render_fn) { /** @type {Effect | null} */ var effect; - block(() => { - if (component === (component = get_component())) return; + /** @type {DocumentFragment | null} */ + var offscreen_fragment = null; + /** @type {Effect | null} */ + var pending_effect = null; + + function commit() { if (effect) { pause_effect(effect); effect = null; } + if (offscreen_fragment) { + // remove the anchor + /** @type {Text} */ (offscreen_fragment.lastChild).remove(); + + anchor.before(offscreen_fragment); + offscreen_fragment = null; + } + + effect = pending_effect; + pending_effect = null; + } + + block(() => { + if (component === (component = get_component())) return; + + var defer = should_defer_append(); + if (component) { - effect = branch(() => render_fn(anchor, component)); + var target = anchor; + + if (defer) { + offscreen_fragment = document.createDocumentFragment(); + offscreen_fragment.append((target = create_text())); + } + + pending_effect = branch(() => render_fn(target, component)); + } + + if (defer) { + /** @type {Batch} */ (current_batch).add_callback(commit); + } else { + commit(); } }, EFFECT_TRANSPARENT); diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js index f1992007ed7d..4fd2ee0a4b02 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -64,6 +64,10 @@ export function bind_value(input, get, set = get) { var value = get(); + if (input === document.activeElement) { + return; + } + if (is_numberlike_input(input) && value === to_number(input.value)) { // handles 0 vs 00 case (see https://github.com/sveltejs/svelte/issues/9959) return; diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 97062f04e38d..a4325fce5ab3 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -1,8 +1,11 @@ -/** @import { TemplateNode } from '#client' */ +/** @import { Effect, TemplateNode } from '#client' */ import { hydrate_node, hydrating, set_hydrate_node } from './hydration.js'; import { DEV } from 'esm-env'; import { init_array_prototype_warnings } from '../dev/equality.js'; import { get_descriptor, is_extensible } from '../../shared/utils.js'; +import { active_effect } from '../runtime.js'; +import { EFFECT_RAN } from '../constants.js'; +import { async_mode_flag } from '../../flags/index.js'; // export these for reference in the compiled code, making global name deduplication unnecessary /** @type {Window} */ @@ -205,6 +208,19 @@ export function clear_text_content(node) { node.textContent = ''; } +/** + * Returns `true` if we're updating the current block, for example `condition` in + * an `{#if condition}` block just changed. In this case, the branch should be + * appended (or removed) at the same time as other updates within the + * current `` + */ +export function should_defer_append() { + if (!async_mode_flag) return false; + + var flags = /** @type {Effect} */ (active_effect).f; + return (flags & EFFECT_RAN) !== 0; +} + /** * * @param {string} tag diff --git a/packages/svelte/src/internal/client/dom/task.js b/packages/svelte/src/internal/client/dom/task.js index 48a2fbe660eb..3d58d2215ee9 100644 --- a/packages/svelte/src/internal/client/dom/task.js +++ b/packages/svelte/src/internal/client/dom/task.js @@ -6,13 +6,22 @@ const request_idle_callback = ? (/** @type {() => void} */ cb) => setTimeout(cb, 1) : requestIdleCallback; +/** @type {Array<() => void>} */ +let boundary_micro_tasks = []; + /** @type {Array<() => void>} */ let micro_tasks = []; /** @type {Array<() => void>} */ let idle_tasks = []; -function run_micro_tasks() { +function run_boundary_micro_tasks() { + var tasks = boundary_micro_tasks; + boundary_micro_tasks = []; + run_all(tasks); +} + +function run_post_micro_tasks() { var tasks = micro_tasks; micro_tasks = []; run_all(tasks); @@ -24,11 +33,29 @@ function run_idle_tasks() { run_all(tasks); } +function run_micro_tasks() { + run_boundary_micro_tasks(); + run_post_micro_tasks(); +} + +/** + * @param {() => void} fn + */ +export function queue_boundary_micro_task(fn) { + if (boundary_micro_tasks.length === 0 && micro_tasks.length === 0) { + queueMicrotask(run_micro_tasks); + } + + // TODO do we need to differentiate between `boundary_micro_tasks` and `micro_tasks`? + // nothing breaks if we push everything to `micro_tasks` + boundary_micro_tasks.push(fn); +} + /** * @param {() => void} fn */ export function queue_micro_task(fn) { - if (micro_tasks.length === 0) { + if (boundary_micro_tasks.length === 0 && micro_tasks.length === 0) { queueMicrotask(run_micro_tasks); } @@ -50,7 +77,7 @@ export function queue_idle_task(fn) { * Synchronously run any queued tasks. */ export function flush_tasks() { - if (micro_tasks.length > 0) { + if (boundary_micro_tasks.length > 0 || micro_tasks.length > 0) { run_micro_tasks(); } diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 429dd99da9b9..5beae00aa1d8 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -276,6 +276,21 @@ export function rune_outside_svelte(rune) { } } +/** + * `setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression + * @returns {never} + */ +export function set_context_after_init() { + if (DEV) { + const error = new Error(`set_context_after_init\n\`setContext\` must be called when a component first initializes, not in a subsequent effect or after an \`await\` expression\nhttps://svelte.dev/e/set_context_after_init`); + + error.name = 'Svelte error'; + throw error; + } else { + throw new Error(`https://svelte.dev/e/set_context_after_init`); + } +} + /** * Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`. * @returns {never} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 71c06d7b1b8b..7becf49e21b4 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -9,6 +9,7 @@ export { create_ownership_validator } from './dev/ownership.js'; export { check_target, legacy_api } from './dev/legacy.js'; export { trace } from './dev/tracing.js'; export { inspect } from './dev/inspect.js'; +export { async } from './dom/blocks/async.js'; export { validate_snippet_args } from './dev/validation.js'; export { await_block as await } from './dom/blocks/await.js'; export { if_block as if } from './dom/blocks/if.js'; @@ -97,8 +98,13 @@ export { props_id, with_script } from './dom/template.js'; -export { user_derived as derived, derived_safe_equal } from './reactivity/deriveds.js'; export { + async_derived, + user_derived as derived, + derived_safe_equal +} from './reactivity/deriveds.js'; +export { + aborted, effect_tracking, effect_root, legacy_pre_effect, @@ -109,7 +115,15 @@ export { user_effect, user_pre_effect } from './reactivity/effects.js'; -export { mutable_source, mutate, set, state, update, update_pre } from './reactivity/sources.js'; +export { + mutable_source, + mutate, + pending, + set, + state, + update, + update_pre +} from './reactivity/sources.js'; export { prop, rest_props, @@ -129,7 +143,7 @@ export { update_store, mark_store_binding } from './reactivity/store.js'; -export { boundary } from './dom/blocks/boundary.js'; +export { boundary, save, suspend } from './dom/blocks/boundary.js'; export { set_text } from './render.js'; export { get, diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js new file mode 100644 index 000000000000..e7fa61f483c4 --- /dev/null +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -0,0 +1,283 @@ +/** @import { Derived, Effect, Source } from '#client' */ +import { CLEAN, DIRTY } from '#client/constants'; +import { + flush_queued_effects, + flush_queued_root_effects, + process_effects, + schedule_effect, + set_queued_root_effects, + set_signal_status, + update_effect +} from '../runtime.js'; +import { raf } from '../timing.js'; +import { internal_set, pending } from './sources.js'; + +/** @type {Set} */ +const batches = new Set(); + +/** @type {Batch | null} */ +export let current_batch = null; + +/** Update `$effect.pending()` */ +function update_pending() { + internal_set(pending, batches.size > 0); +} + +/** @type {Map | null} */ +export let batch_deriveds = null; + +export class Batch { + /** @type {Map} */ + #previous = new Map(); + + /** @type {Map} */ + #current = new Map(); + + /** @type {Set<() => void>} */ + #callbacks = new Set(); + + #pending = 0; + + /** @type {{ promise: Promise, resolve: (value?: any) => void, reject: (reason: unknown) => void } | null} */ + // TODO replace with Promise.withResolvers once supported widely enough + deferred = null; + + /** @type {Effect[]} */ + async_effects = []; + + /** @type {Effect[]} */ + render_effects = []; + + /** @type {Effect[]} */ + effects = []; + + /** @type {Set} */ + skipped_effects = new Set(); + + /** + * + * @param {Effect[]} root_effects + */ + process(root_effects) { + set_queued_root_effects([]); + + /** @type {Map | null} */ + var current_values = null; + var time_travelling = false; + + for (const batch of batches) { + if (batch !== this) { + time_travelling = true; + break; + } + } + + if (time_travelling) { + current_values = new Map(); + batch_deriveds = new Map(); + + for (const [source, current] of this.#current) { + current_values.set(source, { v: source.v, wv: source.wv }); + source.v = current; + } + + for (const batch of batches) { + if (batch === this) continue; + + for (const [source, previous] of batch.#previous) { + if (!current_values.has(source)) { + current_values.set(source, { v: source.v, wv: source.wv }); + source.v = previous; + } + } + } + } + + for (const root of root_effects) { + process_effects(this, root); + } + + if (this.async_effects.length === 0 && this.#pending === 0) { + var merged = false; + + // if there are older batches with overlapping + // state, we can't commit this batch. instead, + // we merge it into the older batches + for (const batch of batches) { + if (batch === this) break; + + for (const [source] of batch.#current) { + if (this.#current.has(source)) { + merged = true; + + for (const [source, value] of this.#current) { + batch.#current.set(source, value); + // TODO what about batch.#previous? + } + + for (const e of this.render_effects) { + set_signal_status(e, CLEAN); + // TODO use sets instead of arrays + if (!batch.render_effects.includes(e)) { + batch.render_effects.push(e); + } + } + + for (const e of this.effects) { + set_signal_status(e, CLEAN); + // TODO use sets instead of arrays + if (!batch.effects.includes(e)) { + batch.effects.push(e); + } + } + + for (const e of this.skipped_effects) { + batch.skipped_effects.add(e); + } + + for (const fn of this.#callbacks) { + batch.#callbacks.add(fn); + } + + this.remove(); + break; + } + } + } + + if (merged) { + this.remove(); + } else { + var render_effects = this.render_effects; + var effects = this.effects; + + this.render_effects = []; + this.effects = []; + + this.commit(); + + flush_queued_effects(render_effects); + flush_queued_effects(effects); + + this.deferred?.resolve(); + } + } else { + for (const e of this.render_effects) set_signal_status(e, CLEAN); + for (const e of this.effects) set_signal_status(e, CLEAN); + } + + if (current_values) { + for (const [source, { v, wv }] of current_values) { + // reset the source to the current value (unless + // it got a newer value as a result of effects running) + if (source.wv <= wv) { + source.v = v; + } + } + + batch_deriveds = null; + } + + for (const effect of this.async_effects) { + update_effect(effect); + } + + this.async_effects = []; + } + + /** + * @param {Source} source + * @param {any} value + */ + capture(source, value) { + if (!this.#previous.has(source)) { + this.#previous.set(source, value); + } + + this.#current.set(source, source.v); + } + + remove() { + batches.delete(this); + } + + restore() { + current_batch = this; + } + + flush() { + flush_queued_root_effects(); + + if (current_batch !== this) { + return; + } + + if (this.#pending === 0) { + this.remove(); + } + + current_batch = null; + } + + commit() { + for (const fn of this.#callbacks) { + fn(); + } + + this.#callbacks.clear(); + + raf.tick(update_pending); + } + + increment() { + this.#pending += 1; + } + + decrement() { + this.#pending -= 1; + + if (this.#pending === 0) { + for (const e of this.render_effects) { + set_signal_status(e, DIRTY); + schedule_effect(e); + } + + for (const e of this.effects) { + set_signal_status(e, DIRTY); + schedule_effect(e); + } + + this.render_effects = []; + this.effects = []; + + this.commit(); + } + } + + /** @param {() => void} fn */ + add_callback(fn) { + this.#callbacks.add(fn); + } + + static ensure() { + if (current_batch === null) { + if (batches.size === 0) { + raf.tick(update_pending); + } + + const batch = (current_batch = new Batch()); + batches.add(current_batch); + + queueMicrotask(() => { + if (current_batch !== batch) { + // a flushSync happened in the meantime + return; + } + + batch.flush(); + }); + } + + return current_batch; + } +} diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index e9cea0df3e64..543b711a790f 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -1,6 +1,17 @@ -/** @import { Derived, Effect } from '#client' */ +/** @import { Derived, Effect, Source } from '#client' */ +/** @import { Batch } from './batch.js'; */ import { DEV } from 'esm-env'; -import { CLEAN, DERIVED, DIRTY, EFFECT_HAS_DERIVED, MAYBE_DIRTY, UNOWNED } from '#client/constants'; +import { + CLEAN, + DERIVED, + DESTROYED, + DIRTY, + EFFECT_ASYNC, + EFFECT_PRESERVED, + MAYBE_DIRTY, + STALE_REACTION, + UNOWNED +} from '#client/constants'; import { active_reaction, active_effect, @@ -9,16 +20,31 @@ import { update_reaction, increment_write_version, set_active_effect, + handle_error, push_reaction_value, is_destroying_effect } from '../runtime.js'; import { equals, safe_equals } from './equality.js'; import * as e from '../errors.js'; -import { destroy_effect } from './effects.js'; -import { inspect_effects, set_inspect_effects } from './sources.js'; +import * as w from '../warnings.js'; +import { destroy_effect, render_effect } from './effects.js'; +import { inspect_effects, internal_set, set_inspect_effects, source } from './sources.js'; import { get_stack } from '../dev/tracing.js'; import { tracing_mode_flag } from '../../flags/index.js'; +import { get_pending_boundary } from '../dom/blocks/boundary.js'; import { component_context } from '../context.js'; +import { UNINITIALIZED } from '../../../constants.js'; +import { current_batch } from './batch.js'; + +/** @type {Effect | null} */ +export let from_async_derived = null; + +/** @param {Effect | null} v */ +export function set_from_async_derived(v) { + from_async_derived = v; +} + +export const recent_async_deriveds = new Set(); /** * @template V @@ -38,7 +64,7 @@ export function derived(fn) { } else { // Since deriveds are evaluated lazily, any effects created inside them are // created too late to ensure that the parent effect is added to the tree - active_effect.f |= EFFECT_HAS_DERIVED; + active_effect.f |= EFFECT_PRESERVED; } /** @type {Derived} */ @@ -53,7 +79,8 @@ export function derived(fn) { rv: 0, v: /** @type {V} */ (null), wv: 0, - parent: parent_derived ?? active_effect + parent: parent_derived ?? active_effect, + ac: null }; if (DEV && tracing_mode_flag) { @@ -63,6 +90,117 @@ export function derived(fn) { return signal; } +/** + * @template V + * @param {() => V | Promise} fn + * @param {string} [location] If provided, print a warning if the value is not read immediately after update + * @returns {Promise>} + */ +/*#__NO_SIDE_EFFECTS__*/ +export function async_derived(fn, location) { + let parent = /** @type {Effect | null} */ (active_effect); + + if (parent === null) { + throw new Error('TODO cannot create unowned async derived'); + } + + let boundary = get_pending_boundary(); + + var promise = /** @type {Promise} */ (/** @type {unknown} */ (undefined)); + var signal = source(/** @type {V} */ (UNINITIALIZED)); + + /** @type {Promise | null} */ + var prev = null; + + // only suspend in async deriveds created on initialisation + var should_suspend = !active_reaction; + + render_effect(() => { + if (DEV) from_async_derived = active_effect; + var p = fn(); + if (DEV) from_async_derived = null; + + promise = + prev === null + ? Promise.resolve(p) + : prev.then( + () => p, + () => p + ); + + prev = promise; + + var batch = /** @type {Batch} */ (current_batch); + var ran = boundary.ran; + + if (should_suspend) { + (ran ? batch : boundary).increment(); + } + + /** + * @param {any} value + * @param {unknown} error + */ + const handler = (value, error = undefined) => { + prev = null; + + if ((parent.f & DESTROYED) !== 0) { + return; + } + + from_async_derived = null; + + if (should_suspend) { + (ran ? batch : boundary).decrement(); + } + + if (ran) batch.restore(); + + if (error) { + if (error !== STALE_REACTION) { + handle_error(error, parent, null, parent.ctx); + } + } else { + internal_set(signal, value); + + if (DEV && location !== undefined) { + recent_async_deriveds.add(signal); + + setTimeout(() => { + if (recent_async_deriveds.has(signal)) { + w.await_waterfall(location); + recent_async_deriveds.delete(signal); + } + }); + } + } + + if (ran) batch.flush(); + }; + + promise.then(handler, (e) => handler(null, e || 'unknown')); + }, EFFECT_ASYNC | EFFECT_PRESERVED); + + return new Promise((fulfil) => { + /** @param {Promise} p */ + function next(p) { + function go() { + if (p === promise) { + fulfil(signal); + } else { + // if the effect re-runs before the initial promise + // resolves, delay resolution until we have a value + next(promise); + } + } + + p.then(go, go); + } + + next(promise); + }); +} + /** * @template V * @param {() => V} fn diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 36be1ecd0427..07b648c4439a 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -1,4 +1,4 @@ -/** @import { ComponentContext, ComponentContextLegacy, Derived, Effect, TemplateNode, TransitionManager } from '#client' */ +/** @import { ComponentContext, ComponentContextLegacy, Derived, Effect, TemplateNode, TransitionManager, Value } from '#client' */ import { check_dirtiness, active_effect, @@ -31,16 +31,17 @@ import { INSPECT_EFFECT, HEAD_EFFECT, MAYBE_DIRTY, - EFFECT_HAS_DERIVED, - BOUNDARY_EFFECT + EFFECT_PRESERVED, + STALE_REACTION } from '#client/constants'; -import { set } from './sources.js'; import * as e from '../errors.js'; import { DEV } from 'esm-env'; import { define_property } from '../../shared/utils.js'; import { get_next_sibling } from '../dom/operations.js'; -import { derived } from './deriveds.js'; +import { async_derived, derived } from './deriveds.js'; +import { capture } from '../dom/blocks/boundary.js'; import { component_context, dev_current_component_function } from '../context.js'; +import { Batch } from './batch.js'; /** * @param {'$effect' | '$effect.pre' | '$inspect'} rune @@ -91,6 +92,10 @@ function create_effect(type, fn, sync, push = true) { } } + if (parent !== null && (parent.f & INERT) !== 0) { + type |= INERT; + } + /** @type {Effect} */ var effect = { ctx: component_context, @@ -103,10 +108,12 @@ function create_effect(type, fn, sync, push = true) { last: null, next: null, parent, + b: parent && parent.b, prev: null, teardown: null, transitions: null, - wv: 0 + wv: 0, + ac: null }; if (DEV) { @@ -133,7 +140,7 @@ function create_effect(type, fn, sync, push = true) { effect.first === null && effect.nodes_start === null && effect.teardown === null && - (effect.f & (EFFECT_HAS_DERIVED | BOUNDARY_EFFECT)) === 0; + (effect.f & EFFECT_PRESERVED) === 0; if (!inert && push) { if (parent !== null) { @@ -228,6 +235,7 @@ export function inspect_effect(fn) { * @returns {() => void} */ export function effect_root(fn) { + Batch.ensure(); const effect = create_effect(ROOT_EFFECT, fn, true); return () => { @@ -241,6 +249,7 @@ export function effect_root(fn) { * @returns {(options?: { outro?: boolean }) => Promise} */ export function component_root(fn) { + Batch.ensure(); const effect = create_effect(ROOT_EFFECT, fn, true); return (options = {}) => { @@ -274,9 +283,10 @@ export function effect(fn) { export function legacy_pre_effect(deps, fn) { var context = /** @type {ComponentContextLegacy} */ (component_context); - /** @type {{ effect: null | Effect, ran: boolean }} */ - var token = { effect: null, ran: false }; - context.l.r1.push(token); + /** @type {{ effect: null | Effect, ran: boolean, deps: () => any }} */ + var token = { effect: null, ran: false, deps }; + + context.l.$.push(token); token.effect = render_effect(() => { deps(); @@ -286,7 +296,6 @@ export function legacy_pre_effect(deps, fn) { if (token.ran) return; token.ran = true; - set(context.l.r2, true); untrack(fn); }); } @@ -295,10 +304,10 @@ export function legacy_pre_effect_reset() { var context = /** @type {ComponentContextLegacy} */ (component_context); render_effect(() => { - if (!get(context.l.r2)) return; - // Run dirty `$:` statements - for (var token of context.l.r1) { + for (var token of context.l.$) { + token.deps(); + var effect = token.effect; // If the effect is CLEAN, then make it MAYBE_DIRTY. This ensures we traverse through @@ -313,8 +322,6 @@ export function legacy_pre_effect_reset() { token.ran = false; } - - context.l.r2.v = false; // set directly to avoid rerunning this effect }); } @@ -322,18 +329,38 @@ export function legacy_pre_effect_reset() { * @param {() => void | (() => void)} fn * @returns {Effect} */ -export function render_effect(fn) { - return create_effect(RENDER_EFFECT, fn, true); +export function render_effect(fn, flags = 0) { + return create_effect(RENDER_EFFECT | flags, fn, true); } /** * @param {(...expressions: any) => void | (() => void)} fn - * @param {Array<() => any>} thunks - * @returns {Effect} + * @param {Array<() => any>} sync + * @param {Array<() => Promise>} async */ -export function template_effect(fn, thunks = [], d = derived) { - const deriveds = thunks.map(d); - const effect = () => fn(...deriveds.map(get)); +export function template_effect(fn, sync = [], async = [], d = derived) { + var parent = /** @type {Effect} */ (active_effect); + + if (async.length > 0) { + var restore = capture(); + + Promise.all(async.map((expression) => async_derived(expression))).then((result) => { + if ((parent.f & DESTROYED) !== 0) return; + + restore(); + create_template_effect(fn, [...sync.map(d), ...result]); + }); + } else { + create_template_effect(fn, sync.map(d)); + } +} + +/** + * @param {(...expressions: any) => void | (() => void)} fn + * @param {Value[]} deriveds + */ +function create_template_effect(fn, deriveds) { + var effect = () => fn(...deriveds.map(get)); if (DEV) { define_property(effect, 'name', { @@ -341,7 +368,7 @@ export function template_effect(fn, thunks = [], d = derived) { }); } - return block(effect); + create_effect(RENDER_EFFECT, effect, true); } /** @@ -389,6 +416,8 @@ export function destroy_effect_children(signal, remove_dom = false) { signal.first = signal.last = null; while (effect !== null) { + effect.ac?.abort(STALE_REACTION); + var next = effect.next; if ((effect.f & ROOT_EFFECT) !== 0) { @@ -466,6 +495,7 @@ export function destroy_effect(effect, remove_dom = true) { effect.fn = effect.nodes_start = effect.nodes_end = + effect.ac = null; } @@ -620,3 +650,8 @@ function resume_children(effect, local) { } } } + +export function aborted() { + var effect = /** @type {Effect} */ (active_effect); + return (effect.f & DESTROYED) !== 0; +} diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 9d2ad2baee4e..69967ab3b937 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -27,18 +27,23 @@ import { UNOWNED, MAYBE_DIRTY, BLOCK_EFFECT, - ROOT_EFFECT + ROOT_EFFECT, + EFFECT_ASYNC } from '#client/constants'; import * as e from '../errors.js'; import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { get_stack } from '../dev/tracing.js'; import { component_context, is_runes } from '../context.js'; +import { Batch } from './batch.js'; import { proxy } from '../proxy.js'; import { execute_derived } from './deriveds.js'; export let inspect_effects = new Set(); export const old_values = new Map(); +/** Internal representation of `$effect.pending()` */ +export let pending = source(false); + /** * @param {Set} v */ @@ -133,7 +138,7 @@ export function set(source, value, should_proxy = false) { active_reaction !== null && !untracking && is_runes() && - (active_reaction.f & (DERIVED | BLOCK_EFFECT)) !== 0 && + (active_reaction.f & (DERIVED | BLOCK_EFFECT | EFFECT_ASYNC)) !== 0 && !reaction_sources?.includes(source) ) { e.state_unsafe_mutation(); @@ -151,6 +156,8 @@ export function set(source, value, should_proxy = false) { * @returns {V} */ export function internal_set(source, value) { + // console.trace('internal_set', source.v, value); + if (!source.equals(value)) { var old_value = source.v; @@ -162,6 +169,9 @@ export function internal_set(source, value) { source.v = value; + const batch = Batch.ensure(); + batch.capture(source, old_value); + if (DEV && tracing_mode_flag) { source.updated = get_stack('UpdatedAt'); if (active_effect != null) { @@ -252,9 +262,10 @@ export function update_pre(source, d = 1) { /** * @param {Value} signal * @param {number} status should be DIRTY or MAYBE_DIRTY + * @param {boolean} partial should skip async/block effects * @returns {void} */ -function mark_reactions(signal, status) { +export function mark_reactions(signal, status, partial = false) { var reactions = signal.reactions; if (reactions === null) return; @@ -265,9 +276,6 @@ function mark_reactions(signal, status) { var reaction = reactions[i]; var flags = reaction.f; - // Skip any effects that are already dirty - if ((flags & DIRTY) !== 0) continue; - // In legacy mode, skip the current effect to prevent infinite loops if (!runes && reaction === active_effect) continue; @@ -277,15 +285,19 @@ function mark_reactions(signal, status) { continue; } - set_signal_status(reaction, status); + if (partial && (flags & (EFFECT_ASYNC | BLOCK_EFFECT)) !== 0) { + continue; + } - // If the signal a) was previously clean or b) is an unowned derived, then mark it - if ((flags & (CLEAN | UNOWNED)) !== 0) { - if ((flags & DERIVED) !== 0) { - mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY); - } else { - schedule_effect(/** @type {Effect} */ (reaction)); - } + if (status === DIRTY || (flags & DIRTY) === 0) { + // don't make a DIRTY signal MAYBE_DIRTY + set_signal_status(reaction, status); + } + + if ((flags & DERIVED) !== 0) { + mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY, partial); + } else { + schedule_effect(/** @type {Effect} */ (reaction)); } } } diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 5ef0097649a4..5af392c7915d 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -1,4 +1,5 @@ import type { ComponentContext, Dom, Equals, TemplateNode, TransitionManager } from '#client'; +import type { Boundary } from '../dom/blocks/boundary'; export interface Signal { /** Flags bitmask */ @@ -31,6 +32,8 @@ export interface Reaction extends Signal { fn: null | Function; /** Signals that this signal reads from */ deps: null | Value[]; + /** An AbortController that aborts when the signal is destroyed */ + ac: null | AbortController; } export interface Derived extends Value, Reaction { @@ -67,6 +70,8 @@ export interface Effect extends Reaction { last: null | Effect; /** Parent effect */ parent: Effect | null; + /** THe boundary this effect belongs to */ + b: Boundary | null; /** Dev only */ component_function?: any; } diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index 3256fe827410..222b971bdf7c 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -30,6 +30,7 @@ import * as w from './warnings.js'; import * as e from './errors.js'; import { assign_nodes } from './dom/template.js'; import { is_passive_event } from '../../utils.js'; +import { async_mode_flag } from '../flags/index.js'; /** * This is normally true — block effects should run their intro transitions — diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index d790c0ad145a..00051cbc2348 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -1,6 +1,12 @@ /** @import { ComponentContext, Derived, Effect, Reaction, Signal, Source, Value } from '#client' */ import { DEV } from 'esm-env'; -import { define_property, get_descriptors, get_prototype_of, index_of } from '../shared/utils.js'; +import { + deferred, + define_property, + get_descriptors, + get_prototype_of, + index_of +} from '../shared/utils.js'; import { destroy_block_effect_children, destroy_effect_children, @@ -23,14 +29,24 @@ import { LEGACY_DERIVED_PROP, DISCONNECTED, BOUNDARY_EFFECT, - EFFECT_IS_UPDATING + REACTION_IS_UPDATING, + EFFECT_IS_UPDATING, + EFFECT_ASYNC, + RENDER_EFFECT, + STALE_REACTION } from './constants.js'; import { flush_tasks } from './dom/task.js'; import { internal_set, old_values } from './reactivity/sources.js'; -import { destroy_derived_effects, update_derived } from './reactivity/deriveds.js'; +import { + destroy_derived_effects, + execute_derived, + from_async_derived, + recent_async_deriveds, + update_derived +} from './reactivity/deriveds.js'; import * as e from './errors.js'; import { FILENAME } from '../../constants.js'; -import { tracing_mode_flag } from '../flags/index.js'; +import { async_mode_flag, tracing_mode_flag } from '../flags/index.js'; import { tracing_expressions, get_stack } from './dev/tracing.js'; import { component_context, @@ -39,7 +55,11 @@ import { set_component_context, set_dev_current_component_function } from './context.js'; +import { Boundary } from './dom/blocks/boundary.js'; +import * as w from './warnings.js'; import { is_firefox } from './dom/operations.js'; +import { current_batch, Batch, batch_deriveds } from './reactivity/batch.js'; +import { log_effect_tree, root } from './dev/debug.js'; // Used for DEV time error handling /** @param {WeakSet} value */ @@ -65,6 +85,11 @@ export function set_is_destroying_effect(value) { /** @type {Effect[]} */ let queued_root_effects = []; +/** @param {Effect[]} v */ +export function set_queued_root_effects(v) { + queued_root_effects = v; +} + /** @type {Effect[]} Stack of effects, dev only */ let dev_effect_stack = []; // Handle signal reactivity tree dependencies and reactions @@ -177,8 +202,12 @@ export function check_dirtiness(reaction) { var length = dependencies.length; // If we are working with a disconnected or an unowned signal that is now connected (due to an active effect) - // then we need to re-connect the reaction to the dependency - if (is_disconnected || is_unowned_connected) { + // then we need to re-connect the reaction to the dependency, unless the effect has already been destroyed + // (which can happen if the derived is read by an async derived) + if ( + (is_disconnected || is_unowned_connected) && + (active_effect === null || (active_effect.f & DESTROYED) === 0) + ) { var derived = /** @type {Derived} */ (reaction); var parent = derived.parent; @@ -238,8 +267,7 @@ function propagate_error(error, effect) { while (current !== null) { if ((current.f & BOUNDARY_EFFECT) !== 0) { try { - // @ts-expect-error - current.fn(error); + /** @type {Boundary} */ (current.b).error(error); return; } catch { // Remove boundary flag from effect @@ -410,7 +438,13 @@ export function update_reaction(reaction) { reaction.f |= EFFECT_IS_UPDATING; + if (reaction.ac !== null) { + reaction.ac?.abort(STALE_REACTION); + reaction.ac = null; + } + try { + reaction.f |= REACTION_IS_UPDATING; var result = /** @type {Function} */ (0, reaction.fn)(); var deps = reaction.deps; @@ -474,6 +508,7 @@ export function update_reaction(reaction) { return result; } finally { + reaction.f ^= REACTION_IS_UPDATING; new_deps = previous_deps; skipped_deps = previous_skipped_deps; untracked_writes = previous_untracked_writes; @@ -508,6 +543,7 @@ function remove_reaction(signal, dependency) { } } } + // If the derived has no reactions, then we can disconnect it from the graph, // allowing it to either reconnect in the future, or be GC'd by the VM. if ( @@ -655,8 +691,9 @@ function infinite_loop_guard() { } } -function flush_queued_root_effects() { +export function flush_queued_root_effects() { var was_updating_effect = is_updating_effect; + var batch = /** @type {Batch} */ (current_batch); try { var flush_count = 0; @@ -667,15 +704,8 @@ function flush_queued_root_effects() { infinite_loop_guard(); } - var root_effects = queued_root_effects; - var length = root_effects.length; - - queued_root_effects = []; + batch.process(queued_root_effects); - for (var i = 0; i < length; i++) { - var collected_effects = process_effects(root_effects[i]); - flush_queued_effects(collected_effects); - } old_values.clear(); } } finally { @@ -693,7 +723,7 @@ function flush_queued_root_effects() { * @param {Array} effects * @returns {void} */ -function flush_queued_effects(effects) { +export function flush_queued_effects(effects) { var length = effects.length; if (length === 0) return; @@ -732,11 +762,6 @@ function flush_queued_effects(effects) { * @returns {void} */ export function schedule_effect(signal) { - if (!is_flushing) { - is_flushing = true; - queueMicrotask(flush_queued_root_effects); - } - var effect = (last_scheduled_effect = signal); while (effect.parent !== null) { @@ -759,27 +784,27 @@ export function schedule_effect(signal) { * bitwise flag passed in only. The collected effects array will be populated with all the user * effects to be flushed. * + * @param {Batch} batch * @param {Effect} root - * @returns {Effect[]} */ -function process_effects(root) { - /** @type {Effect[]} */ - var effects = []; +export function process_effects(batch, root) { + root.f ^= CLEAN; - /** @type {Effect | null} */ - var effect = root; + var effect = root.first; while (effect !== null) { var flags = effect.f; var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0; var is_skippable_branch = is_branch && (flags & CLEAN) !== 0; - if (!is_skippable_branch && (flags & INERT) === 0) { - if ((flags & EFFECT) !== 0) { - effects.push(effect); - } else if (is_branch) { - effect.f ^= CLEAN; - } else { + var skip = is_skippable_branch || (flags & INERT) !== 0 || batch.skipped_effects.has(effect); + + if (!skip && effect.fn !== null) { + if ((flags & EFFECT_ASYNC) !== 0) { + if (check_dirtiness(effect)) { + batch.async_effects.push(effect); + } + } else if ((flags & BLOCK_EFFECT) !== 0) { try { if (check_dirtiness(effect)) { update_effect(effect); @@ -787,9 +812,26 @@ function process_effects(root) { } catch (error) { handle_error(error, effect, null, effect.ctx); } + } else if (is_branch) { + effect.f ^= CLEAN; + } else if ((flags & RENDER_EFFECT) !== 0) { + // we need to branch here because in legacy mode we run render effects + // before running block effects + if (async_mode_flag) { + batch.render_effects.push(effect); + } else { + try { + if (check_dirtiness(effect)) { + update_effect(effect); + } + } catch (error) { + handle_error(error, effect, null, effect.ctx); + } + } + } else if ((flags & EFFECT) !== 0) { + batch.effects.push(effect); } - /** @type {Effect | null} */ var child = effect.first; if (child !== null) { @@ -806,8 +848,6 @@ function process_effects(root) { parent = parent.parent; } } - - return effects; } /** @@ -820,6 +860,8 @@ function process_effects(root) { export function flushSync(fn) { var result; + const batch = Batch.ensure(); + if (fn) { is_flushing = true; flush_queued_root_effects(); @@ -832,6 +874,10 @@ export function flushSync(fn) { flush_tasks(); if (queued_root_effects.length === 0) { + if (batch === current_batch) { + batch.flush(); + } + return /** @type {T} */ (result); } @@ -851,6 +897,15 @@ export async function tick() { flushSync(); } +/** + * Returns a promise that resolves once any state changes, and asynchronous work resulting from them, + * have resolved and the DOM has been updated + * @returns {Promise} + */ +export function settled() { + return (Batch.ensure().deferred ??= deferred()).promise; +} + /** * @template V * @param {Value} signal @@ -866,22 +921,44 @@ export function get(signal) { // Register the dependency on the current reaction signal. if (active_reaction !== null && !untracking) { - if (!reaction_sources?.includes(signal)) { + // if we're in a derived that is being read inside an _async_ derived, + // it's possible that the effect was already destroyed. In this case, + // we don't add the dependency, because that would create a memory leak + var destroyed = active_effect !== null && (active_effect.f & DESTROYED) !== 0; + + if (!destroyed && !reaction_sources?.includes(signal)) { var deps = active_reaction.deps; - if (signal.rv < read_version) { - signal.rv = read_version; - // If the signal is accessing the same dependencies in the same - // order as it did last time, increment `skipped_deps` - // rather than updating `new_deps`, which creates GC cost - if (new_deps === null && deps !== null && deps[skipped_deps] === signal) { - skipped_deps++; - } else if (new_deps === null) { - new_deps = [signal]; - } else if (!skip_reaction || !new_deps.includes(signal)) { - // Normally we can push duplicated dependencies to `new_deps`, but if we're inside - // an unowned derived because skip_reaction is true, then we need to ensure that - // we don't have duplicates - new_deps.push(signal); + + if ((active_reaction.f & REACTION_IS_UPDATING) !== 0) { + // we're in the effect init/update cycle + if (signal.rv < read_version) { + signal.rv = read_version; + + // If the signal is accessing the same dependencies in the same + // order as it did last time, increment `skipped_deps` + // rather than updating `new_deps`, which creates GC cost + if (new_deps === null && deps !== null && deps[skipped_deps] === signal) { + skipped_deps++; + } else if (new_deps === null) { + new_deps = [signal]; + } else if (!skip_reaction || !new_deps.includes(signal)) { + // Normally we can push duplicated dependencies to `new_deps`, but if we're inside + // an unowned derived because skip_reaction is true, then we need to ensure that + // we don't have duplicates + new_deps.push(signal); + } + } + } else { + // we're adding a dependency outside the init/update cycle + // (i.e. after an `await`) + (active_reaction.deps ??= []).push(signal); + + var reactions = signal.reactions; + + if (reactions === null) { + signal.reactions = [active_reaction]; + } else if (!reactions.includes(active_reaction)) { + reactions.push(active_reaction); } } } @@ -901,7 +978,10 @@ export function get(signal) { } } - if (is_derived) { + // if this is a derived, we may need to update it, but + // not if `batch_deriveds` is not null (meaning we're + // currently time travelling)) + if (is_derived && batch_deriveds === null) { derived = /** @type {Derived} */ (signal); if (check_dirtiness(derived)) { @@ -909,32 +989,57 @@ export function get(signal) { } } - if ( - DEV && - tracing_mode_flag && - tracing_expressions !== null && - active_reaction !== null && - tracing_expressions.reaction === active_reaction - ) { - // Used when mapping state between special blocks like `each` - if (signal.debug) { - signal.debug(); - } else if (signal.created) { - var entry = tracing_expressions.entries.get(signal); - - if (entry === undefined) { - entry = { read: [] }; - tracing_expressions.entries.set(signal, entry); + if (DEV) { + if (from_async_derived) { + var tracking = (from_async_derived.f & REACTION_IS_UPDATING) !== 0; + var was_read = from_async_derived.deps !== null && from_async_derived.deps.includes(signal); + + if (!tracking && !was_read) { + w.await_reactivity_loss(); } + } - entry.read.push(get_stack('TracedAt')); + if ( + tracing_mode_flag && + tracing_expressions !== null && + active_reaction !== null && + tracing_expressions.reaction === active_reaction + ) { + // Used when mapping state between special blocks like `each` + if (signal.debug) { + signal.debug(); + } else if (signal.created) { + var entry = tracing_expressions.entries.get(signal); + + if (entry === undefined) { + entry = { read: [] }; + tracing_expressions.entries.set(signal, entry); + } + + entry.read.push(get_stack('TracedAt')); + } } + + recent_async_deriveds.delete(signal); } if (is_destroying_effect && old_values.has(signal)) { return old_values.get(signal); } + // if we're time travelling, we don't want to update the + // intrinsic value of the derived — we want to compute it + // once and stash it for the duration of batch processing + if (is_derived && batch_deriveds !== null) { + derived = /** @type {Derived} */ (signal); + + if (!batch_deriveds.has(derived)) { + batch_deriveds.set(derived, execute_derived(derived)); + } + + return batch_deriveds.get(derived); + } + return signal.v; } diff --git a/packages/svelte/src/internal/client/types.d.ts b/packages/svelte/src/internal/client/types.d.ts index 9703c2aac198..01baee04676d 100644 --- a/packages/svelte/src/internal/client/types.d.ts +++ b/packages/svelte/src/internal/client/types.d.ts @@ -51,9 +51,7 @@ export type ComponentContext = { m: Array<() => any>; }; /** `$:` statements */ - r1: any[]; - /** This tracks whether `$:` statements have run in the current cycle, to ensure they only run once */ - r2: Source; + $: any[]; }; /** * dev mode only: the component function diff --git a/packages/svelte/src/internal/client/warnings.js b/packages/svelte/src/internal/client/warnings.js index e07892a4b064..dd086dd278f1 100644 --- a/packages/svelte/src/internal/client/warnings.js +++ b/packages/svelte/src/internal/client/warnings.js @@ -18,6 +18,29 @@ export function assignment_value_stale(property, location) { } } +/** + * Detected reactivity loss + */ +export function await_reactivity_loss() { + if (DEV) { + console.warn(`%c[svelte] await_reactivity_loss\n%cDetected reactivity loss\nhttps://svelte.dev/e/await_reactivity_loss`, bold, normal); + } else { + console.warn(`https://svelte.dev/e/await_reactivity_loss`); + } +} + +/** + * An async value (%location%) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app. + * @param {string} location + */ +export function await_waterfall(location) { + if (DEV) { + console.warn(`%c[svelte] await_waterfall\n%cAn async value (${location}) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app.\nhttps://svelte.dev/e/await_waterfall`, bold, normal); + } else { + console.warn(`https://svelte.dev/e/await_waterfall`); + } +} + /** * `%binding%` (%location%) is binding to a non-reactive property * @param {string} binding diff --git a/packages/svelte/src/internal/flags/async.js b/packages/svelte/src/internal/flags/async.js new file mode 100644 index 000000000000..ca4ff9286a4a --- /dev/null +++ b/packages/svelte/src/internal/flags/async.js @@ -0,0 +1,3 @@ +import { enable_async_mode_flag } from './index.js'; + +enable_async_mode_flag(); diff --git a/packages/svelte/src/internal/flags/index.js b/packages/svelte/src/internal/flags/index.js index 017840f2d967..6920f6b8eeda 100644 --- a/packages/svelte/src/internal/flags/index.js +++ b/packages/svelte/src/internal/flags/index.js @@ -1,6 +1,11 @@ +export let async_mode_flag = false; export let legacy_mode_flag = false; export let tracing_mode_flag = false; +export function enable_async_mode_flag() { + async_mode_flag = true; +} + export function enable_legacy_mode_flag() { legacy_mode_flag = true; } diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index 2ca85fff44c2..5eaedc8781a8 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -515,6 +515,8 @@ export { export { escape_html as escape }; +export { await_outside_boundary } from '../shared/errors.js'; + /** * @template T * @param {()=>T} fn diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index b8606fbf6f7d..16449f8117e5 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -2,6 +2,21 @@ import { DEV } from 'esm-env'; +/** + * Cannot await outside a `` with a `pending` snippet + * @returns {never} + */ +export function await_outside_boundary() { + if (DEV) { + const error = new Error(`await_outside_boundary\nCannot await outside a \`\` with a \`pending\` snippet\nhttps://svelte.dev/e/await_outside_boundary`); + + error.name = 'Svelte error'; + throw error; + } else { + throw new Error(`https://svelte.dev/e/await_outside_boundary`); + } +} + /** * Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead * @returns {never} diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index 921eaec57cf5..cd3a9ac61ae4 100644 --- a/packages/svelte/src/utils.js +++ b/packages/svelte/src/utils.js @@ -445,6 +445,7 @@ const RUNES = /** @type {const} */ ([ '$effect.pre', '$effect.tracking', '$effect.root', + '$effect.pending', '$inspect', '$inspect().with', '$inspect.trace', diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js index e62b662372da..9e094044f73d 100644 --- a/packages/svelte/tests/helpers.js +++ b/packages/svelte/tests/helpers.js @@ -86,7 +86,8 @@ export async function compile_directory( const compiled = compileModule(text, { filename: opts.filename, generate: opts.generate, - dev: opts.dev + dev: opts.dev, + experimental: opts.experimental }); write(out, compiled.js.code.replace(`v${VERSION}`, 'VERSION')); } else { diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-abort/_config.js b/packages/svelte/tests/runtime-legacy/samples/transition-abort/_config.js index e35b08f2a8ca..dccf5ca669db 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-abort/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/transition-abort/_config.js @@ -27,6 +27,8 @@ export default test({ array: ['a', 'b', 'c'] }); + raf.tick(25); + raf.tick(50); assert.htmlEqual( target.innerHTML, diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts index c0d1177a823e..49f3a919e548 100644 --- a/packages/svelte/tests/runtime-legacy/shared.ts +++ b/packages/svelte/tests/runtime-legacy/shared.ts @@ -25,6 +25,20 @@ type Assert = typeof import('vitest').assert & { ): void; }; +// TODO remove this shim when we can +// @ts-expect-error +Promise.withResolvers = () => { + let resolve; + let reject; + + const promise = new Promise((f, r) => { + resolve = f; + reject = r; + }); + + return { promise, resolve, reject }; +}; + export interface RuntimeTest = Record> extends BaseTest { /** Use e.g. `mode: ['client']` to indicate that this test should never run in server/hydrate modes */ @@ -154,6 +168,9 @@ async function common_setup(cwd: string, runes: boolean | undefined, config: Run rootDir: cwd, dev: force_hmr ? true : undefined, hmr: force_hmr ? true : undefined, + experimental: { + async: runes + }, fragments, ...config.compileOptions, immutable: config.immutable, diff --git a/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js b/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js new file mode 100644 index 000000000000..1405ee6e9f73 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js @@ -0,0 +1,37 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs, variant }) { + if (variant === 'hydrate') { + await Promise.resolve(); + } + + const [reset, resolve] = target.querySelectorAll('button'); + + flushSync(() => reset.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + assert.deepEqual(logs, ['aborted']); + + flushSync(() => resolve.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

hello

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-abort-signal/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-abort-signal/main.svelte new file mode 100644 index 000000000000..d8d77bf0e9f7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-abort-signal/main.svelte @@ -0,0 +1,29 @@ + + + + + + +

{await load(deferred)}

+ + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-attribute-without-state/_config.js b/packages/svelte/tests/runtime-runes/samples/async-attribute-without-state/_config.js new file mode 100644 index 000000000000..3de81a507b59 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-attribute-without-state/_config.js @@ -0,0 +1,18 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ` +

pending

+ `, + + async test({ assert, target }) { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flushSync(); + + assert.htmlEqual(target.innerHTML, '

hello

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-attribute-without-state/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-attribute-without-state/main.svelte new file mode 100644 index 000000000000..00a11cac438a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-attribute-without-state/main.svelte @@ -0,0 +1,7 @@ + +

hello

+ + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-attribute/_config.js b/packages/svelte/tests/runtime-runes/samples/async-attribute/_config.js new file mode 100644 index 000000000000..f256e6a43c28 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-attribute/_config.js @@ -0,0 +1,34 @@ +import { flushSync, tick } from 'svelte'; +import { ok, test } from '../../test'; + +export default test({ + html: ` + + + +

pending

+ `, + + async test({ assert, target, component }) { + const [cool, neat, reset] = target.querySelectorAll('button'); + + flushSync(() => cool.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + + const p = target.querySelector('p'); + ok(p); + assert.htmlEqual(p.outerHTML, '

hello

'); + + flushSync(() => reset.click()); + assert.htmlEqual(p.outerHTML, '

hello

'); + + flushSync(() => neat.click()); + await Promise.resolve(); + await tick(); + assert.htmlEqual(p.outerHTML, '

hello

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-attribute/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-attribute/main.svelte new file mode 100644 index 000000000000..6332a9802d5c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-attribute/main.svelte @@ -0,0 +1,15 @@ + + + + + + + +

hello

+ + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-child-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-child-effect/_config.js new file mode 100644 index 000000000000..41d4130470d6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-child-effect/_config.js @@ -0,0 +1,74 @@ +import { flushSync, tick } from 'svelte'; +import { ok, test } from '../../test'; + +export default test({ + html: ` + +

loading

+ `, + + async test({ assert, target, variant }) { + if (variant === 'hydrate') { + await Promise.resolve(); + } + + flushSync(() => { + target.querySelector('button')?.click(); + }); + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + + const [button1, button2] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + ` + + +

A

+

a

+ ` + ); + + flushSync(() => button2.click()); + flushSync(() => button2.click()); + + flushSync(() => button1.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

AA

+

aa

+ ` + ); + + flushSync(() => button1.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

AAA

+

aaa

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-child-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-child-effect/main.svelte new file mode 100644 index 000000000000..edb0eaea44fd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-child-effect/main.svelte @@ -0,0 +1,26 @@ + + + + + + +

{await push(input.toUpperCase())}

+ + {#if true} +

{input}

+ {/if} + + {#snippet pending()} +

loading

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-in-if/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-in-if/Child.svelte new file mode 100644 index 000000000000..fb47377513a7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-in-if/Child.svelte @@ -0,0 +1,5 @@ + + +

{n}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-in-if/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-in-if/_config.js new file mode 100644 index 000000000000..ab020d85f749 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-in-if/_config.js @@ -0,0 +1,25 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const button = target.querySelector('button'); + + flushSync(() => button?.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flushSync(); + + assert.htmlEqual( + target.innerHTML, + ` + +

1

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-in-if/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-in-if/main.svelte new file mode 100644 index 000000000000..a53381c2d5f3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-in-if/main.svelte @@ -0,0 +1,17 @@ + + + + + + {#if show} + + {/if} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-invalidation-during-init/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-invalidation-during-init/Child.svelte new file mode 100644 index 000000000000..ffcd8b46b408 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-invalidation-during-init/Child.svelte @@ -0,0 +1,9 @@ + + +

{(await d).value}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-invalidation-during-init/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-invalidation-during-init/_config.js new file mode 100644 index 000000000000..df3fbe65cd34 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-invalidation-during-init/_config.js @@ -0,0 +1,49 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ` + + + +

pending

+ `, + + async test({ assert, target, component, errors, variant }) { + if (variant === 'hydrate') { + await Promise.resolve(); + } + + const [toggle, resolve1, resolve2] = target.querySelectorAll('button'); + + flushSync(() => toggle.click()); + + flushSync(() => resolve1.click()); + await Promise.resolve(); + await Promise.resolve(); + + flushSync(() => resolve2.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

two

+ ` + ); + + assert.deepEqual(errors, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-invalidation-during-init/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-invalidation-during-init/main.svelte new file mode 100644 index 000000000000..9babdb2fe274 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-invalidation-during-init/main.svelte @@ -0,0 +1,20 @@ + + + + + + + + + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-module/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-module/Child.svelte new file mode 100644 index 000000000000..f803a30c37f9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-module/Child.svelte @@ -0,0 +1,20 @@ + + +

{derived.value}{console.log(`template ${derived.value} ${num}`)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-module/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-module/_config.js new file mode 100644 index 000000000000..30adf19581ac --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-module/_config.js @@ -0,0 +1,66 @@ +import { flushSync, tick } from 'svelte'; +import { deferred } from '../../../../src/internal/shared/utils.js'; +import { test } from '../../test'; + +/** @type {ReturnType} */ +let d; + +export default test({ + html: `

pending

`, + + get props() { + d = deferred(); + + return { + promise: d.promise, + num: 1 + }; + }, + + async test({ assert, target, component, logs }) { + d.resolve(42); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flushSync(); + await tick(); + assert.htmlEqual(target.innerHTML, '

42

'); + + component.num = 2; + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + assert.htmlEqual(target.innerHTML, '

84

'); + + d = deferred(); + component.promise = d.promise; + await tick(); + assert.htmlEqual(target.innerHTML, '

84

'); + + d.resolve(43); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + assert.htmlEqual(target.innerHTML, '

86

'); + + assert.deepEqual(logs, [ + 'outside boundary 1', + '$effect.pre 42 1', + 'template 42 1', + '$effect 42 1', + '$effect.pre 84 2', + 'template 84 2', + 'outside boundary 2', + '$effect 84 2', + '$effect.pre 86 2', + 'template 86 2', + '$effect 86 2' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-module/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-module/main.svelte new file mode 100644 index 000000000000..e90bbf720ed3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-module/main.svelte @@ -0,0 +1,15 @@ + + + + + + {#snippet pending()} +

pending

+ {/snippet} +
+ +{console.log(`outside boundary ${num}`)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-module/state.svelte.js b/packages/svelte/tests/runtime-runes/samples/async-derived-module/state.svelte.js new file mode 100644 index 000000000000..a53fbb8c6fc5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-module/state.svelte.js @@ -0,0 +1,9 @@ +export async function create_derived(get_promise, get_num) { + let value = $derived((await get_promise()) * get_num()); + + return { + get value() { + return value; + } + }; +} diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/Component.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/Component.svelte new file mode 100644 index 000000000000..a90a9dedf724 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/Component.svelte @@ -0,0 +1,29 @@ + + + + + +

{n}: {Math.min(current, 3)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/_config.js new file mode 100644 index 000000000000..423213696477 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/_config.js @@ -0,0 +1,41 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `

pending...

`, + + async test({ assert, target }) { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

0: 0

+ ` + ); + + const [shift, increment] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + for (let i = 1; i < 5; i += 1) { + flushSync(() => increment.click()); + } + + for (let i = 1; i < 5; i += 1) { + shift.click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(p.innerHTML, `${i}: ${Math.min(i, 3)}`); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/main.svelte new file mode 100644 index 000000000000..2d5ddca4dbfc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived-unchanging/main.svelte @@ -0,0 +1,11 @@ + + + + + + {#snippet pending()} +

pending...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived/Child.svelte new file mode 100644 index 000000000000..b59fd7c08fc3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived/Child.svelte @@ -0,0 +1,15 @@ + + +

{value}{console.log(`template ${value} ${num}`)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived/_config.js new file mode 100644 index 000000000000..d573cf624672 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived/_config.js @@ -0,0 +1,58 @@ +import { flushSync, tick } from 'svelte'; +import { ok, test } from '../../test'; + +export default test({ + html: ` + + + + +

pending

+ `, + + async test({ assert, target, logs }) { + const [resolve_a, resolve_b, reset, increment] = target.querySelectorAll('button'); + + flushSync(() => resolve_a.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flushSync(); + + const p = target.querySelector('p'); + ok(p); + assert.htmlEqual(p.innerHTML, '1a'); + + flushSync(() => increment.click()); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + assert.htmlEqual(p.innerHTML, '2a'); + + flushSync(() => reset.click()); + assert.htmlEqual(p.innerHTML, '2a'); + + flushSync(() => resolve_b.click()); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + assert.htmlEqual(p.innerHTML, '2b'); + + assert.deepEqual(logs, [ + 'outside boundary 1', + '$effect.pre 1a 1', + 'template 1a 1', + '$effect 1a 1', + '$effect.pre 2a 2', + 'template 2a 2', + 'outside boundary 2', + '$effect 2a 2', + '$effect.pre 2b 2', + 'template 2b 2', + '$effect 2b 2' + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived/main.svelte new file mode 100644 index 000000000000..1404ae0299d0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-derived/main.svelte @@ -0,0 +1,21 @@ + + + + + + + + + + + {#snippet pending()} +

pending

+ {/snippet} +
+ +{console.log(`outside boundary ${num}`)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-await-item/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-await-item/_config.js new file mode 100644 index 000000000000..52df1275a9de --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-await-item/_config.js @@ -0,0 +1,35 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `

pending

`, + + async test({ assert, target }) { + const [button1, button2, button3] = target.querySelectorAll('button'); + + flushSync(() => button1.click()); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + '

a

b

c

' + ); + + flushSync(() => button2.click()); + await tick(); + assert.htmlEqual( + target.innerHTML, + '

a

b

c

' + ); + + flushSync(() => button3.click()); + await Promise.resolve(); + await tick(); + assert.htmlEqual( + target.innerHTML, + '

b

c

d

e

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-await-item/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-await-item/main.svelte new file mode 100644 index 000000000000..eddcf2b749d7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-await-item/main.svelte @@ -0,0 +1,39 @@ + + + + + + + + + + {#each items as deferred} +

{await deferred.promise}

+ {/each} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each/_config.js new file mode 100644 index 000000000000..b28d310565f3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each/_config.js @@ -0,0 +1,36 @@ +import { flushSync, tick } from 'svelte'; +import { deferred } from '../../../../src/internal/shared/utils.js'; +import { test } from '../../test'; + +/** @type {ReturnType} */ +let d; + +export default test({ + html: `

pending

`, + + get props() { + d = deferred(); + + return { + promise: d.promise + }; + }, + + async test({ assert, target, component }) { + d.resolve(['a', 'b', 'c']); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual(target.innerHTML, '

a

b

c

'); + + d = deferred(); + component.promise = d.promise; + await tick(); + assert.htmlEqual(target.innerHTML, '

a

b

c

'); + + d.resolve(['d', 'e', 'f', 'g']); + await tick(); + assert.htmlEqual(target.innerHTML, '

d

e

f

g

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each/main.svelte new file mode 100644 index 000000000000..9b59d57b055a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each/main.svelte @@ -0,0 +1,13 @@ + + + + {#each await promise as item} +

{item}

+ {/each} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-error-recovery/_config.js b/packages/svelte/tests/runtime-runes/samples/async-error-recovery/_config.js new file mode 100644 index 000000000000..91784f67472d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error-recovery/_config.js @@ -0,0 +1,104 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ` + +

pending...

+ `, + + compileOptions: { + // this tests some behaviour that was broken in dev + dev: true + }, + + async test({ assert, target }) { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.htmlEqual( + target.innerHTML, + ` + +

0

+ ` + ); + + let [button] = target.querySelectorAll('button'); + let [p] = target.querySelectorAll('p'); + + flushSync(() => button.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + assert.htmlEqual( + target.innerHTML, + ` + +

1

+ ` + ); + + flushSync(() => button.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + assert.htmlEqual( + target.innerHTML, + ` + +

2

+ ` + ); + + flushSync(() => button.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + assert.htmlEqual( + target.innerHTML, + ` + + + ` + ); + + const [button1, button2] = target.querySelectorAll('button'); + + flushSync(() => button1.click()); + await Promise.resolve(); + + flushSync(() => button2.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + [p] = target.querySelectorAll('p'); + + assert.htmlEqual( + target.innerHTML, + ` + +

4

+ ` + ); + + flushSync(() => button1.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + assert.htmlEqual( + target.innerHTML, + ` + +

5

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-error-recovery/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-error-recovery/main.svelte new file mode 100644 index 000000000000..d5246d330e25 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error-recovery/main.svelte @@ -0,0 +1,24 @@ + + + + + +

{await process(count)}

+ + {#snippet pending()} +

pending...

+ {/snippet} + + {#snippet failed(error, reset)} + + {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-error/_config.js b/packages/svelte/tests/runtime-runes/samples/async-error/_config.js new file mode 100644 index 000000000000..8f6975f6fb53 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error/_config.js @@ -0,0 +1,37 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `

pending

`, + + async test({ assert, target }) { + let [button1, button2, button3] = target.querySelectorAll('button'); + + flushSync(() => button1.click()); + await Promise.resolve(); + await Promise.resolve(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + '

oops!

' + ); + + flushSync(() => button2.click()); + + const reset = /** @type {HTMLButtonElement} */ (target.querySelector('[data-id="reset"]')); + flushSync(() => reset.click()); + + assert.htmlEqual( + target.innerHTML, + '

pending

' + ); + + flushSync(() => button3.click()); + await Promise.resolve(); + await tick(); + assert.htmlEqual( + target.innerHTML, + '

wheee

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-error/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-error/main.svelte new file mode 100644 index 000000000000..9af5bbaa16a5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-error/main.svelte @@ -0,0 +1,20 @@ + + + + + + + +

{await deferred.promise}

+ + {#snippet pending()} +

pending

+ {/snippet} + + {#snippet failed(error, reset)} +

{error.message}

+ + {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-expression/_config.js b/packages/svelte/tests/runtime-runes/samples/async-expression/_config.js new file mode 100644 index 000000000000..c44d112625fa --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-expression/_config.js @@ -0,0 +1,59 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ` + + + +

pending

+ `, + + async test({ assert, target, raf }) { + const [reset, hello, goodbye] = target.querySelectorAll('button'); + + flushSync(() => hello.click()); + raf.tick(0); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

hello

+ ` + ); + + flushSync(() => reset.click()); + raf.tick(0); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

hello

+

updating...

+ ` + ); + + flushSync(() => goodbye.click()); + await Promise.resolve(); + raf.tick(0); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

goodbye

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-expression/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-expression/main.svelte new file mode 100644 index 000000000000..42536ab02a82 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-expression/main.svelte @@ -0,0 +1,19 @@ + + + + + + + +

{await deferred.promise}

+ + {#if $effect.pending()} +

updating...

+ {/if} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-html-tag/_config.js b/packages/svelte/tests/runtime-runes/samples/async-html-tag/_config.js new file mode 100644 index 000000000000..6cded1a1d1ba --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-html-tag/_config.js @@ -0,0 +1,35 @@ +import { flushSync, tick } from 'svelte'; +import { deferred } from '../../../../src/internal/shared/utils.js'; +import { test } from '../../test'; + +/** @type {ReturnType} */ +let d; + +export default test({ + html: `

pending

`, + + get props() { + d = deferred(); + + return { + promise: d.promise + }; + }, + + async test({ assert, target, component }) { + d.resolve('hello'); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + component.promise = (d = deferred()).promise; + await tick(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + d.resolve('wheee'); + await tick(); + assert.htmlEqual(target.innerHTML, '

wheee

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-html-tag/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-html-tag/main.svelte new file mode 100644 index 000000000000..f5aa363731c2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-html-tag/main.svelte @@ -0,0 +1,11 @@ + + + +

{@html await promise}

+ + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-if/_config.js b/packages/svelte/tests/runtime-runes/samples/async-if/_config.js new file mode 100644 index 000000000000..0bf9152dca01 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if/_config.js @@ -0,0 +1,46 @@ +import { flushSync, tick } from 'svelte'; +import { deferred } from '../../../../src/internal/shared/utils.js'; +import { test } from '../../test'; + +/** @type {ReturnType} */ +let d; + +export default test({ + html: `

pending

`, + + get props() { + d = deferred(); + + return { + promise: d.promise + }; + }, + + async test({ assert, target }) { + const [reset, t, f] = target.querySelectorAll('button'); + + flushSync(() => t.click()); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + '

yes

' + ); + + flushSync(() => reset.click()); + await tick(); + assert.htmlEqual( + target.innerHTML, + '

yes

' + ); + + flushSync(() => f.click()); + await tick(); + assert.htmlEqual( + target.innerHTML, + '

no

' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-if/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-if/main.svelte new file mode 100644 index 000000000000..21a4cbef97f2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-if/main.svelte @@ -0,0 +1,19 @@ + + + + + + + + {#if await deferred.promise} +

yes

+ {:else} +

no

+ {/if} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-key/_config.js b/packages/svelte/tests/runtime-runes/samples/async-key/_config.js new file mode 100644 index 000000000000..293ac9357a2f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-key/_config.js @@ -0,0 +1,49 @@ +import { flushSync, tick } from 'svelte'; +import { deferred } from '../../../../src/internal/shared/utils.js'; +import { test } from '../../test'; + +/** @type {ReturnType} */ +let d; + +export default test({ + html: `

pending

`, + + get props() { + d = deferred(); + + return { + promise: d.promise + }; + }, + + async test({ assert, target, component }) { + d.resolve(1); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + const h1 = target.querySelector('h1'); + + d = deferred(); + component.promise = d.promise; + await tick(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + d.resolve(1); + await tick(); + assert.htmlEqual(target.innerHTML, '

hello

'); + assert.equal(target.querySelector('h1'), h1); + + d = deferred(); + component.promise = d.promise; + await tick(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + d.resolve(2); + await tick(); + assert.htmlEqual(target.innerHTML, '

hello

'); + assert.notEqual(target.querySelector('h1'), h1); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-key/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-key/main.svelte new file mode 100644 index 000000000000..7cac0f854240 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-key/main.svelte @@ -0,0 +1,13 @@ + + + + {#key await promise} +

hello

+ {/key} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-linear-order-different-deriveds/_config.js b/packages/svelte/tests/runtime-runes/samples/async-linear-order-different-deriveds/_config.js new file mode 100644 index 000000000000..e4d6979acf57 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-linear-order-different-deriveds/_config.js @@ -0,0 +1,40 @@ +import { flushSync, settled, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `

loading...

`, + + async test({ assert, target }) { + const [both, a, b] = target.querySelectorAll('button'); + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.htmlEqual( + target.innerHTML, + ` + +

1 * 2 = 2

+

2 * 2 = 4

+ ` + ); + + flushSync(() => both.click()); + flushSync(() => b.click()); + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.htmlEqual( + target.innerHTML, + ` + +

2 * 2 = 4

+

4 * 2 = 8

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-linear-order-different-deriveds/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-linear-order-different-deriveds/main.svelte new file mode 100644 index 000000000000..432eed976c47 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-linear-order-different-deriveds/main.svelte @@ -0,0 +1,17 @@ + + + + + + + +

{a} * 2 = {await (a * 2)}

+

{b} * 2 = {b * 2}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-linear-order-same-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-linear-order-same-derived/_config.js new file mode 100644 index 000000000000..76bfbe56d633 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-linear-order-same-derived/_config.js @@ -0,0 +1,42 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [a, b, reset1, reset2, resolve1, resolve2] = target.querySelectorAll('button'); + + flushSync(() => resolve1.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + + const p = /** @type {HTMLElement} */ (target.querySelector('#test')); + + assert.htmlEqual(p.innerHTML, '1 + 2 = 3'); + + flushSync(() => reset1.click()); + flushSync(() => a.click()); + flushSync(() => reset2.click()); + flushSync(() => b.click()); + + flushSync(() => resolve2.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + + assert.htmlEqual(p.innerHTML, '1 + 2 = 3'); + + flushSync(() => resolve1.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + + assert.htmlEqual(p.innerHTML, '2 + 3 = 5'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-linear-order-same-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-linear-order-same-derived/main.svelte new file mode 100644 index 000000000000..cc82db0d7559 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-linear-order-same-derived/main.svelte @@ -0,0 +1,31 @@ + + + + + + + + + + + + +

{a} + {b} = {await add(a, b)}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-derived/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-nested-derived/Child.svelte new file mode 100644 index 000000000000..546494f4c3d6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-nested-derived/Child.svelte @@ -0,0 +1,11 @@ + + +

{indirect}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-nested-derived/_config.js new file mode 100644 index 000000000000..172b44e6e322 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-nested-derived/_config.js @@ -0,0 +1,14 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + assert.htmlEqual(target.innerHTML, '

0

'); + + const button = target.querySelector('button'); + + flushSync(() => button?.click()); + assert.htmlEqual(target.innerHTML, '

1

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-nested-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-nested-derived/main.svelte new file mode 100644 index 000000000000..f6b0afe98cba --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-nested-derived/main.svelte @@ -0,0 +1,15 @@ + + + + + + + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-prop/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-prop/Child.svelte new file mode 100644 index 000000000000..85d212b1a835 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-prop/Child.svelte @@ -0,0 +1,5 @@ + + +

{value}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-prop/_config.js b/packages/svelte/tests/runtime-runes/samples/async-prop/_config.js new file mode 100644 index 000000000000..570b22abd4c4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-prop/_config.js @@ -0,0 +1,36 @@ +import { flushSync, tick } from 'svelte'; +import { deferred } from '../../../../src/internal/shared/utils.js'; +import { test } from '../../test'; + +/** @type {ReturnType} */ +let d; + +export default test({ + html: `

pending

`, + + get props() { + d = deferred(); + + return { + promise: d.promise + }; + }, + + async test({ assert, target, component }) { + d.resolve('hello'); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + d = deferred(); + component.promise = d.promise; + await tick(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + d.resolve('hello again'); + await tick(); + assert.htmlEqual(target.innerHTML, '

hello again

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-prop/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-prop/main.svelte new file mode 100644 index 000000000000..cb5d00b3d374 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-prop/main.svelte @@ -0,0 +1,13 @@ + + + + + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js new file mode 100644 index 000000000000..4ed40d015b49 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js @@ -0,0 +1,26 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + + html: `

pending

`, + + async test({ assert, target, warnings }) { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual(target.innerHTML, '

3

'); + + assert.deepEqual(warnings, ['Detected reactivity loss']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/main.svelte new file mode 100644 index 000000000000..488fc25f324d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/main.svelte @@ -0,0 +1,19 @@ + + + + + + +

{await a_plus_b()}

+ + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-render-tag/_config.js b/packages/svelte/tests/runtime-runes/samples/async-render-tag/_config.js new file mode 100644 index 000000000000..6cded1a1d1ba --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-render-tag/_config.js @@ -0,0 +1,35 @@ +import { flushSync, tick } from 'svelte'; +import { deferred } from '../../../../src/internal/shared/utils.js'; +import { test } from '../../test'; + +/** @type {ReturnType} */ +let d; + +export default test({ + html: `

pending

`, + + get props() { + d = deferred(); + + return { + promise: d.promise + }; + }, + + async test({ assert, target, component }) { + d.resolve('hello'); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + component.promise = (d = deferred()).promise; + await tick(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + d.resolve('wheee'); + await tick(); + assert.htmlEqual(target.innerHTML, '

wheee

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-render-tag/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-render-tag/main.svelte new file mode 100644 index 000000000000..e98738567112 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-render-tag/main.svelte @@ -0,0 +1,15 @@ + + +{#snippet hello(message)} +

{message}

+{/snippet} + + + {@render hello(await promise)} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-svelte-element/_config.js b/packages/svelte/tests/runtime-runes/samples/async-svelte-element/_config.js new file mode 100644 index 000000000000..ea3b91b2a40b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-svelte-element/_config.js @@ -0,0 +1,35 @@ +import { flushSync, tick } from 'svelte'; +import { deferred } from '../../../../src/internal/shared/utils.js'; +import { test } from '../../test'; + +/** @type {ReturnType} */ +let d; + +export default test({ + html: `

pending

`, + + get props() { + d = deferred(); + + return { + promise: d.promise + }; + }, + + async test({ assert, target, component }) { + d.resolve('h1'); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + component.promise = (d = deferred()).promise; + await tick(); + assert.htmlEqual(target.innerHTML, '

hello

'); + + d.resolve('h2'); + await tick(); + assert.htmlEqual(target.innerHTML, '

hello

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-svelte-element/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-svelte-element/main.svelte new file mode 100644 index 000000000000..52852b549c8e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-svelte-element/main.svelte @@ -0,0 +1,11 @@ + + + + hello + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level/Child.svelte new file mode 100644 index 000000000000..7ad618f13003 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level/Child.svelte @@ -0,0 +1,7 @@ + + +

{value}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level/_config.js b/packages/svelte/tests/runtime-runes/samples/async-top-level/_config.js new file mode 100644 index 000000000000..b5931559460b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level/_config.js @@ -0,0 +1,28 @@ +import { flushSync, tick } from 'svelte'; +import { deferred } from '../../../../src/internal/shared/utils.js'; +import { test } from '../../test'; + +/** @type {ReturnType} */ +let d; + +export default test({ + html: `

pending

`, + + get props() { + d = deferred(); + + return { + promise: d.promise + }; + }, + + async test({ assert, target }) { + d.resolve('hello'); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + assert.htmlEqual(target.innerHTML, '

hello

'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-top-level/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-top-level/main.svelte new file mode 100644 index 000000000000..718a256b8676 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-top-level/main.svelte @@ -0,0 +1,13 @@ + + + + + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-waterfall-on-init/_config.js b/packages/svelte/tests/runtime-runes/samples/async-waterfall-on-init/_config.js new file mode 100644 index 000000000000..91c388e0ca92 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-waterfall-on-init/_config.js @@ -0,0 +1,50 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ` + + +
+

pending

+ `, + + async test({ assert, target }) { + const [button1, button2] = target.querySelectorAll('button'); + + flushSync(() => button1.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + + assert.htmlEqual( + target.innerHTML, + ` + + +
+

pending

+ ` + ); + + flushSync(() => button2.click()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await tick(); + flushSync(); + + assert.htmlEqual( + target.innerHTML, + ` + + +
+ +

true

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-waterfall-on-init/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-waterfall-on-init/main.svelte new file mode 100644 index 000000000000..86af9bb07eab --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-waterfall-on-init/main.svelte @@ -0,0 +1,22 @@ + + + + + +
+ + + {#if await d1.promise} + +

{await d2.promise}

+ {/if} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-with-sync-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-with-sync-derived/_config.js new file mode 100644 index 000000000000..c09d448f9cd7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-with-sync-derived/_config.js @@ -0,0 +1,59 @@ +import { flushSync, settled, tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: `

loading...

`, + + async test({ assert, target }) { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

1

+

1

+

1

+ ` + ); + + const [log, x, other] = target.querySelectorAll('button'); + + flushSync(() => x.click()); + flushSync(() => other.click()); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

1

+

1

+

1

+ ` + ); + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

2

+

2

+

2

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-with-sync-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-with-sync-derived/main.svelte new file mode 100644 index 000000000000..764007e082a3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-with-sync-derived/main.svelte @@ -0,0 +1,19 @@ + + + + + + + +

{x}

+

{await x}

+

{y}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/set-context-after-await/Child.svelte b/packages/svelte/tests/runtime-runes/samples/set-context-after-await/Child.svelte new file mode 100644 index 000000000000..122a31672661 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/set-context-after-await/Child.svelte @@ -0,0 +1,11 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/set-context-after-await/_config.js b/packages/svelte/tests/runtime-runes/samples/set-context-after-await/_config.js new file mode 100644 index 000000000000..0f0edc208b87 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/set-context-after-await/_config.js @@ -0,0 +1,11 @@ +import { test } from '../../test'; + +export default test({ + async test({ assert, logs }) { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + assert.ok(logs[0].startsWith('set_context_after_init')); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/set-context-after-await/main.svelte b/packages/svelte/tests/runtime-runes/samples/set-context-after-await/main.svelte new file mode 100644 index 000000000000..65d0e623cf38 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/set-context-after-await/main.svelte @@ -0,0 +1,11 @@ + + + + + + {#snippet pending()} + ... + {/snippet} + diff --git a/packages/svelte/tests/runtime-runes/samples/set-context-after-mount/_config.js b/packages/svelte/tests/runtime-runes/samples/set-context-after-mount/_config.js new file mode 100644 index 000000000000..cc7c483667cd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/set-context-after-mount/_config.js @@ -0,0 +1,11 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ target, assert, logs }) { + const button = target.querySelector('button'); + + flushSync(() => button?.click()); + assert.ok(logs[0].startsWith('set_context_after_init')); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/set-context-after-mount/main.svelte b/packages/svelte/tests/runtime-runes/samples/set-context-after-mount/main.svelte new file mode 100644 index 000000000000..40145c28daa8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/set-context-after-mount/main.svelte @@ -0,0 +1,19 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/tick-timing/_config.js b/packages/svelte/tests/runtime-runes/samples/tick-timing/_config.js index 339cec55c5a2..25414d4b4710 100644 --- a/packages/svelte/tests/runtime-runes/samples/tick-timing/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/tick-timing/_config.js @@ -3,6 +3,8 @@ import { test, ok } from '../../test'; // Tests that tick only resolves after all pending effects have been cleared export default test({ + skip: true, // weirdly, this works if you run it by itself + async test({ assert, target }) { const btn = target.querySelector('button'); ok(btn); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 1a83e0d0f100..ed382295a60d 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -348,6 +348,30 @@ declare module 'svelte' { */ props: Props; }); + /** + * Returns an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that aborts when the current [derived](https://svelte.dev/docs/svelte/$derived) or [effect](https://svelte.dev/docs/svelte/$effect) re-runs or is destroyed. + * + * Must be called while a derived or effect is running. + * + * ```svelte + * + * ``` + */ + export function getAbortSignal(): AbortSignal; /** * `onMount`, like [`$effect`](https://svelte.dev/docs/svelte/$effect), schedules a function to run as soon as the component has been mounted to the DOM. * Unlike `$effect`, the provided function only runs once. @@ -428,6 +452,11 @@ declare module 'svelte' { * Returns a promise that resolves once any pending state changes have been applied. * */ export function tick(): Promise; + /** + * Returns a promise that resolves once any state changes, and asynchronous work resulting from them, + * have resolved and the DOM has been updated + * */ + export function settled(): Promise; /** * When used inside a [`$derived`](https://svelte.dev/docs/svelte/$derived) or [`$effect`](https://svelte.dev/docs/svelte/$effect), * any state read inside `fn` will not be treated as a dependency. @@ -1087,6 +1116,11 @@ declare module 'svelte/compiler' { * Use this to filter out warnings. Return `true` to keep the warning, `false` to discard it. */ warningFilter?: (warning: Warning) => boolean; + /** Experimental options */ + experimental?: { + /** Allow `await` keyword in deriveds, template expressions, and the top level of components */ + async?: boolean; + }; } /** * - `html` — the default, for e.g. `
` or `` @@ -2984,6 +3018,11 @@ declare module 'svelte/types/compiler/interfaces' { * Use this to filter out warnings. Return `true` to keep the warning, `false` to discard it. */ warningFilter?: (warning: Warning_1) => boolean; + /** Experimental options */ + experimental?: { + /** Allow `await` keyword in deriveds, template expressions, and the top level of components */ + async?: boolean; + }; } /** * - `html` — the default, for e.g. `
` or `` diff --git a/playgrounds/sandbox/index.html b/playgrounds/sandbox/index.html index 845538abf073..d70409ffb63a 100644 --- a/playgrounds/sandbox/index.html +++ b/playgrounds/sandbox/index.html @@ -14,6 +14,12 @@ import { mount, hydrate, unmount } from 'svelte'; import App from '/src/App.svelte'; + globalThis.delayed = (v, ms = 1000) => { + return new Promise((f) => { + setTimeout(() => f(v), ms); + }); + }; + const root = document.getElementById('root'); const render = root.firstChild?.nextSibling ? hydrate : mount; diff --git a/playgrounds/sandbox/run.js b/playgrounds/sandbox/run.js index 2029937f52dc..b24f70c8b51c 100644 --- a/playgrounds/sandbox/run.js +++ b/playgrounds/sandbox/run.js @@ -76,7 +76,10 @@ for (const generate of /** @type {const} */ (['client', 'server'])) { dev: false, filename: input, generate, - runes: argv.values.runes + runes: argv.values.runes, + experimental: { + async: true + } }); for (const warning of compiled.warnings) { @@ -116,7 +119,10 @@ for (const generate of /** @type {const} */ (['client', 'server'])) { const compiled = compileModule(source, { dev: false, filename: input, - generate + generate, + experimental: { + async: true + } }); const output_js = `${cwd}/output/${generate}/${file}`; diff --git a/playgrounds/sandbox/ssr-common.js b/playgrounds/sandbox/ssr-common.js new file mode 100644 index 000000000000..db3e08550868 --- /dev/null +++ b/playgrounds/sandbox/ssr-common.js @@ -0,0 +1,17 @@ +Promise.withResolvers ??= () => { + let resolve; + let reject; + + const promise = new Promise((f, r) => { + resolve = f; + reject = r; + }); + + return { promise, resolve, reject }; +}; + +globalThis.delayed = (v, ms = 1000) => { + return new Promise((f) => { + setTimeout(() => f(v), ms); + }); +}; diff --git a/playgrounds/sandbox/ssr-dev.js b/playgrounds/sandbox/ssr-dev.js index 01ce14e2664d..e019b234a613 100644 --- a/playgrounds/sandbox/ssr-dev.js +++ b/playgrounds/sandbox/ssr-dev.js @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import polka from 'polka'; import { createServer as createViteServer } from 'vite'; import { render } from 'svelte/server'; +import './ssr-common.js'; const PORT = process.env.PORT || '5173'; diff --git a/playgrounds/sandbox/ssr-prod.js b/playgrounds/sandbox/ssr-prod.js index 1ed9435249ea..e8f74ee93ae7 100644 --- a/playgrounds/sandbox/ssr-prod.js +++ b/playgrounds/sandbox/ssr-prod.js @@ -3,6 +3,7 @@ import path from 'node:path'; import polka from 'polka'; import { render } from 'svelte/server'; import App from './src/App.svelte'; +import './ssr-common.js'; const { head, body } = render(App); diff --git a/playgrounds/sandbox/svelte.config.js b/playgrounds/sandbox/svelte.config.js new file mode 100644 index 000000000000..68ac605385aa --- /dev/null +++ b/playgrounds/sandbox/svelte.config.js @@ -0,0 +1,9 @@ +export default { + compilerOptions: { + hmr: false, + + experimental: { + async: true + } + } +}; diff --git a/playgrounds/sandbox/vite.config.js b/playgrounds/sandbox/vite.config.js index 51bfd0a2122e..5ce020421709 100644 --- a/playgrounds/sandbox/vite.config.js +++ b/playgrounds/sandbox/vite.config.js @@ -7,14 +7,7 @@ export default defineConfig({ minify: false }, - plugins: [ - inspect(), - svelte({ - compilerOptions: { - hmr: true - } - }) - ], + plugins: [inspect(), svelte()], optimizeDeps: { // svelte is a local workspace package, optimizing it would require dev server restarts with --force for every change