From 700029bd4d459a39142927ecef290342f81614c1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Jan 2025 10:26:47 -0500 Subject: [PATCH] fix: more efficient template effect grouping (#15050) * WIP * drive-by * group attribute updates * fix * more * unused * more * WIP * unused * simplify * simplify * simplify * more * unused * unused * more * tweak * update how class/style directives are handled * more * more * simplify * changeset --- .changeset/cyan-games-cheat.md | 5 + .../phases/2-analyze/visitors/Attribute.js | 3 - .../2-analyze/visitors/shared/function.js | 3 +- .../3-transform/client/transform-client.js | 2 +- .../phases/3-transform/client/types.d.ts | 4 +- .../3-transform/client/visitors/Fragment.js | 21 +-- .../client/visitors/RegularElement.js | 58 +++---- .../client/visitors/SlotElement.js | 7 +- .../client/visitors/SvelteBoundary.js | 2 +- .../client/visitors/SvelteElement.js | 16 +- .../client/visitors/shared/component.js | 16 +- .../client/visitors/shared/element.js | 64 ++++---- .../client/visitors/shared/fragment.js | 8 +- .../client/visitors/shared/utils.js | 154 ++++++++++++------ packages/svelte/src/compiler/phases/nodes.js | 1 - .../svelte/src/compiler/types/template.d.ts | 1 - .../src/internal/client/reactivity/effects.js | 16 +- .../_config.js | 4 +- .../_config.js | 4 +- .../_config.js | 4 +- .../_config.js | 4 +- .../_config.js | 4 +- .../_expected/client/main.svelte.js | 21 +-- .../_expected/client/index.svelte.js | 4 +- 24 files changed, 227 insertions(+), 199 deletions(-) create mode 100644 .changeset/cyan-games-cheat.md diff --git a/.changeset/cyan-games-cheat.md b/.changeset/cyan-games-cheat.md new file mode 100644 index 000000000000..d90901783b17 --- /dev/null +++ b/.changeset/cyan-games-cheat.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: more efficient template effect grouping diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js index 9d801e095e8d..41144fc74c5c 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/Attribute.js @@ -61,9 +61,6 @@ export function Attribute(node, context) { ) { continue; } - - node.metadata.expression.has_state ||= chunk.metadata.expression.has_state; - node.metadata.expression.has_call ||= chunk.metadata.expression.has_call; } if (is_event_attribute(node)) { diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js index c6151992bfd0..c892efd421d1 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/shared/function.js @@ -15,6 +15,7 @@ export function visit_function(node, context) { context.next({ ...context.state, - function_depth: context.state.function_depth + 1 + function_depth: context.state.function_depth + 1, + expression: 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 582c32b534ec..f4a6c9a4147b 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 @@ -169,9 +169,9 @@ export function client_component(analysis, options) { module_level_snippets: [], // these are set inside the `Fragment` visitor, and cannot be used until then - before_init: /** @type {any} */ (null), init: /** @type {any} */ (null), update: /** @type {any} */ (null), + expressions: /** @type {any} */ (null), after_update: /** @type {any} */ (null), template: /** @type {any} */ (null), locations: /** @type {any} */ (null) 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 5c8476de3e3c..db4adf451c2e 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 @@ -46,14 +46,14 @@ export interface ComponentClientTransformState extends ClientTransformState { readonly events: Set; readonly is_instance: boolean; - /** Stuff that happens before the render effect(s) */ - readonly before_init: Statement[]; /** Stuff that happens before the render effect(s) */ readonly init: Statement[]; /** Stuff that happens inside the render effect */ readonly update: Statement[]; /** 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[]; /** The HTML template string */ readonly template: Array; readonly locations: SourceLocation[]; 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 0e6ea29614ff..e9cfd9c50684 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 @@ -61,9 +61,9 @@ export function Fragment(node, context) { /** @type {ComponentClientTransformState} */ const state = { ...context.state, - before_init: [], init: [], update: [], + expressions: [], after_update: [], template: [], locations: [], @@ -124,18 +124,13 @@ export function Fragment(node, context) { add_template(template_name, args); - body.push(b.var(id, b.call(template_name)), ...state.before_init, ...state.init); + body.push(b.var(id, b.call(template_name))); close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); } else if (is_single_child_not_needing_template) { context.visit(trimmed[0], state); - body.push(...state.before_init, ...state.init); } else if (trimmed.length === 1 && trimmed[0].type === 'Text') { const id = b.id(context.state.scope.generate('text')); - body.push( - b.var(id, b.call('$.text', b.literal(trimmed[0].data))), - ...state.before_init, - ...state.init - ); + body.push(b.var(id, b.call('$.text', b.literal(trimmed[0].data)))); close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); } else if (trimmed.length > 0) { const id = b.id(context.state.scope.generate('fragment')); @@ -153,7 +148,7 @@ export function Fragment(node, context) { state }); - body.push(b.var(id, b.call('$.text')), ...state.before_init, ...state.init); + body.push(b.var(id, b.call('$.text'))); close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); } else { if (is_standalone) { @@ -182,15 +177,13 @@ export function Fragment(node, context) { close = b.stmt(b.call('$.append', b.id('$$anchor'), id)); } - - body.push(...state.before_init, ...state.init); } - } else { - body.push(...state.before_init, ...state.init); } + body.push(...state.init); + if (state.update.length > 0) { - body.push(build_render_statement(state.update)); + body.push(build_render_statement(state)); } body.push(...state.after_update); 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 ffd06dfd866f..21a78de032c4 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,4 +1,4 @@ -/** @import { Expression, ExpressionStatement, Identifier, MemberExpression, ObjectExpression, Statement } from 'estree' */ +/** @import { Expression, ExpressionStatement, Identifier, MemberExpression, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { SourceLocation } from '#shared' */ /** @import { ComponentClientTransformState, ComponentContext } from '../types' */ @@ -16,7 +16,7 @@ import { is_event_attribute, is_text_attribute } from '../../../../utils/ast.js' import * as b from '../../../../utils/builders.js'; import { is_custom_element_node } from '../../../nodes.js'; import { clean_nodes, determine_namespace_for_children } from '../../utils.js'; -import { build_getter, create_derived } from '../utils.js'; +import { build_getter } from '../utils.js'; import { get_attribute_name, build_attribute_value, @@ -28,8 +28,8 @@ import { process_children } from './shared/fragment.js'; import { build_render_statement, build_template_chunk, - build_update, - build_update_assignment + build_update_assignment, + get_expression_id } from './shared/utils.js'; import { visit_event_attribute } from './shared/events.js'; @@ -409,7 +409,7 @@ export function RegularElement(node, context) { b.block([ ...child_state.init, ...element_state.init, - child_state.update.length > 0 ? build_render_statement(child_state.update) : b.empty, + child_state.update.length > 0 ? build_render_statement(child_state) : b.empty, ...child_state.after_update, ...element_state.after_update ]) @@ -536,7 +536,10 @@ function build_element_attribute_update_assignment( const name = get_attribute_name(element, attribute); const is_svg = context.state.metadata.namespace === 'svg' || element.name === 'svg'; const is_mathml = context.state.metadata.namespace === 'mathml'; - let { has_call, value } = build_attribute_value(attribute.value, context); + + let { value, has_state } = build_attribute_value(attribute.value, context, (value) => + get_expression_id(state, value) + ); if (name === 'autofocus') { state.init.push(b.stmt(b.call('$.autofocus', node_id, value))); @@ -557,15 +560,6 @@ function build_element_attribute_update_assignment( value = b.call('$.clsx', value); } - if (attribute.metadata.expression.has_state && has_call) { - // ensure we're not creating a separate template effect for this so that - // potential class directives are added to the same effect and therefore always apply - const id = b.id(state.scope.generate('class_derived')); - state.init.push(b.const(id, create_derived(state, b.thunk(value)))); - value = b.call('$.get', id); - has_call = false; - } - update = b.stmt( b.call( is_svg ? '$.set_svg_class' : is_mathml ? '$.set_mathml_class' : '$.set_class', @@ -605,14 +599,6 @@ function build_element_attribute_update_assignment( } else if (is_dom_property(name)) { update = b.stmt(b.assignment('=', b.member(node_id, name), value)); } else { - if (name === 'style' && attribute.metadata.expression.has_state && has_call) { - // ensure we're not creating a separate template effect for this so that - // potential style directives are added to the same effect and therefore always apply - const id = b.id(state.scope.generate('style_derived')); - state.init.push(b.const(id, create_derived(state, b.thunk(value)))); - value = b.call('$.get', id); - has_call = false; - } const callee = name.startsWith('xlink') ? '$.set_xlink_attribute' : '$.set_attribute'; update = b.stmt( b.call( @@ -625,12 +611,8 @@ function build_element_attribute_update_assignment( ); } - if (attribute.metadata.expression.has_state) { - if (has_call) { - state.init.push(build_update(update)); - } else { - state.update.push(update); - } + if (has_state) { + state.update.push(update); return true; } else { state.init.push(update); @@ -648,7 +630,7 @@ function build_element_attribute_update_assignment( function build_custom_element_attribute_update_assignment(node_id, attribute, context) { const state = context.state; const name = attribute.name; // don't lowercase, as we set the element's property, which might be case sensitive - let { has_call, value } = build_attribute_value(attribute.value, context); + let { value, has_state } = build_attribute_value(attribute.value, context); // We assume that noone's going to redefine the semantics of the class attribute on custom elements, i.e. it's still used for CSS classes if (name === 'class' && attribute.metadata.needs_clsx) { @@ -660,12 +642,10 @@ function build_custom_element_attribute_update_assignment(node_id, attribute, co const update = b.stmt(b.call('$.set_custom_element_data', node_id, b.literal(name), value)); - if (attribute.metadata.expression.has_state) { - if (has_call) { - state.init.push(build_update(update)); - } else { - state.update.push(update); - } + if (has_state) { + // this is different from other updates — it doesn't get grouped, + // because set_custom_element_data may not be idempotent + state.init.push(b.stmt(b.call('$.template_effect', b.thunk(update.expression)))); return true; } else { state.init.push(update); @@ -685,7 +665,9 @@ function build_custom_element_attribute_update_assignment(node_id, attribute, co */ function build_element_special_value_attribute(element, node_id, attribute, context) { const state = context.state; - const { value } = build_attribute_value(attribute.value, context); + const { value, has_state } = build_attribute_value(attribute.value, context, (value) => + get_expression_id(state, value) + ); const inner_assignment = b.assignment( '=', @@ -719,7 +701,7 @@ function build_element_special_value_attribute(element, node_id, attribute, cont state.init.push(b.stmt(b.call('$.init_select', node_id, b.thunk(value)))); } - if (attribute.metadata.expression.has_state) { + if (has_state) { const id = state.scope.generate(`${node_id.name}_value`); build_update_assignment( state, diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js index 86734a07ab12..f1b08acbc695 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js @@ -3,6 +3,7 @@ /** @import { ComponentContext } from '../types' */ import * as b from '../../../../utils/builders.js'; import { build_attribute_value } from './shared/element.js'; +import { memoize_expression } from './shared/utils.js'; /** * @param {AST.SlotElement} node @@ -29,13 +30,15 @@ export function SlotElement(node, context) { if (attribute.type === 'SpreadAttribute') { spreads.push(b.thunk(/** @type {Expression} */ (context.visit(attribute)))); } else if (attribute.type === 'Attribute') { - const { value } = build_attribute_value(attribute.value, context); + const { value, has_state } = build_attribute_value(attribute.value, context, (value) => + memoize_expression(context.state, value) + ); if (attribute.name === 'name') { name = /** @type {Literal} */ (value); is_default = false; } else if (attribute.name !== 'slot') { - if (attribute.metadata.expression.has_state) { + if (has_state) { props.push(b.get(attribute.name, [b.return(value)])); } else { props.push(b.init(attribute.name, value)); 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 325485d4c003..6da650a591d9 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 @@ -23,7 +23,7 @@ export function SvelteBoundary(node, context) { const expression = /** @type {Expression} */ (context.visit(chunk.expression, context.state)); - if (attribute.metadata.expression.has_state) { + if (chunk.metadata.expression.has_state) { props.properties.push(b.get(attribute.name, [b.return(expression)])); } else { props.properties.push(b.init(attribute.name, expression)); 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 ba66fe29d691..e27528365518 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 @@ -1,12 +1,8 @@ -/** @import { BlockStatement, Expression, ExpressionStatement, Identifier, ObjectExpression, Statement } from 'estree' */ +/** @import { BlockStatement, Expression, ExpressionStatement, Statement } from 'estree' */ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ -import { dev, is_ignored, locator } from '../../../../state.js'; -import { - get_attribute_expression, - is_event_attribute, - is_text_attribute -} from '../../../../utils/ast.js'; +import { dev, locator } from '../../../../state.js'; +import { is_text_attribute } from '../../../../utils/ast.js'; import * as b from '../../../../utils/builders.js'; import { determine_namespace_for_children } from '../../utils.js'; import { @@ -15,7 +11,7 @@ import { build_set_attributes, build_style_directives } from './shared/element.js'; -import { build_render_statement, build_update } from './shared/utils.js'; +import { build_render_statement } from './shared/utils.js'; /** * @param {AST.SvelteElement} node @@ -49,9 +45,9 @@ export function SvelteElement(node, context) { state: { ...context.state, node: element_id, - before_init: [], init: [], update: [], + expressions: [], after_update: [] } }; @@ -123,7 +119,7 @@ export function SvelteElement(node, context) { /** @type {Statement[]} */ const inner = inner_context.state.init; if (inner_context.state.update.length > 0) { - inner.push(build_render_statement(inner_context.state.update)); + inner.push(build_render_statement(inner_context.state)); } inner.push(...inner_context.state.after_update); inner.push( 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 f509cb41a7d8..9ac0bac12046 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 @@ -5,7 +5,7 @@ import { dev, is_ignored } from '../../../../../state.js'; import { get_attribute_chunks, object } from '../../../../../utils/ast.js'; import * as b from '../../../../../utils/builders.js'; import { create_derived } from '../../utils.js'; -import { build_bind_this, validate_binding } from '../shared/utils.js'; +import { build_bind_this, memoize_expression, 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'; @@ -132,7 +132,13 @@ export function build_component(node, component_name, context, anchor = context. } else if (attribute.type === 'Attribute') { if (attribute.name.startsWith('--')) { custom_css_props.push( - b.init(attribute.name, build_attribute_value(attribute.value, context).value) + b.init( + attribute.name, + build_attribute_value(attribute.value, context, (value) => + // TODO put the derived in the local block + memoize_expression(context.state, value) + ).value + ) ); continue; } @@ -145,9 +151,11 @@ export function build_component(node, component_name, context, anchor = context. has_children_prop = true; } - const { value } = build_attribute_value(attribute.value, context); + const { value, has_state } = build_attribute_value(attribute.value, context, (value) => + memoize_expression(context.state, value) + ); - if (attribute.metadata.expression.has_state) { + if (has_state) { let arg = value; // When we have a non-simple computation, anything other than an Identifier or Member expression, 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 1b0737e31e18..8fb6b8bdde84 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,12 +1,12 @@ /** @import { Expression, Identifier, ObjectExpression } from 'estree' */ -/** @import { AST, Namespace } from '#compiler' */ +/** @import { AST } from '#compiler' */ /** @import { ComponentClientTransformState, ComponentContext } from '../../types' */ import { normalize_attribute } from '../../../../../../utils.js'; import { is_ignored } from '../../../../../state.js'; -import { get_attribute_expression, is_event_attribute } from '../../../../../utils/ast.js'; +import { is_event_attribute } from '../../../../../utils/ast.js'; import * as b from '../../../../../utils/builders.js'; import { build_getter, create_derived } from '../../utils.js'; -import { build_template_chunk, build_update } from './utils.js'; +import { build_template_chunk, get_expression_id } from './utils.js'; /** * @param {Array} attributes @@ -28,14 +28,16 @@ export function build_set_attributes( is_custom_element, state ) { - let has_state = false; + let is_dynamic = false; /** @type {ObjectExpression['properties']} */ const values = []; for (const attribute of attributes) { if (attribute.type === 'Attribute') { - const { value } = build_attribute_value(attribute.value, context); + const { value, has_state } = build_attribute_value(attribute.value, context, (value) => + get_expression_id(context.state, value) + ); if ( is_event_attribute(attribute) && @@ -49,10 +51,10 @@ export function build_set_attributes( values.push(b.init(attribute.name, value)); } - has_state ||= attribute.metadata.expression.has_state; + is_dynamic ||= has_state; } else { // objects could contain reactive getters -> play it safe and always assume spread attributes are reactive - has_state = true; + is_dynamic = true; let value = /** @type {Expression} */ (context.visit(attribute)); @@ -68,7 +70,7 @@ export function build_set_attributes( const call = b.call( '$.set_attributes', element_id, - has_state ? attributes_id : b.literal(null), + is_dynamic ? attributes_id : b.literal(null), b.object(values), context.state.analysis.css.hash !== '' && b.literal(context.state.analysis.css.hash), preserve_attribute_case, @@ -76,7 +78,7 @@ export function build_set_attributes( is_ignored(element, 'hydration_attribute_changed') && b.true ); - if (has_state) { + if (is_dynamic) { context.state.init.push(b.let(attributes_id)); const update = b.stmt(b.assignment('=', attributes_id, call)); context.state.update.push(update); @@ -104,19 +106,14 @@ export function build_style_directives( const state = context.state; for (const directive of style_directives) { - const { has_state, has_call } = directive.metadata.expression; + const { has_state } = directive.metadata.expression; let value = directive.value === true ? build_getter({ name: directive.name, type: 'Identifier' }, context.state) - : build_attribute_value(directive.value, context).value; - - if (has_call) { - const id = b.id(state.scope.generate('style_directive')); - - state.init.push(b.const(id, create_derived(state, b.thunk(value)))); - value = b.call('$.get', id); - } + : build_attribute_value(directive.value, context, (value) => + get_expression_id(context.state, value) + ).value; const update = b.stmt( b.call( @@ -128,9 +125,7 @@ export function build_style_directives( ) ); - if (!is_attributes_reactive && has_call) { - state.init.push(build_update(update)); - } else if (is_attributes_reactive || has_state || has_call) { + if (has_state || is_attributes_reactive) { state.update.push(update); } else { state.init.push(update); @@ -158,17 +153,12 @@ export function build_class_directives( let value = /** @type {Expression} */ (context.visit(directive.expression)); if (has_call) { - const id = b.id(state.scope.generate('class_directive')); - - state.init.push(b.const(id, create_derived(state, b.thunk(value)))); - value = b.call('$.get', id); + value = get_expression_id(state, value); } const update = b.stmt(b.call('$.toggle_class', element_id, b.literal(directive.name), value)); - if (!is_attributes_reactive && has_call) { - state.init.push(build_update(update)); - } else if (is_attributes_reactive || has_state || has_call) { + if (is_attributes_reactive || has_state) { state.update.push(update); } else { state.init.push(update); @@ -179,28 +169,30 @@ export function build_class_directives( /** * @param {AST.Attribute['value']} value * @param {ComponentContext} context - * @returns {{ value: Expression, has_state: boolean, has_call: boolean }} + * @param {(value: Expression) => Expression} memoize + * @returns {{ value: Expression, has_state: boolean }} */ -export function build_attribute_value(value, context) { +export function build_attribute_value(value, context, memoize = (value) => value) { if (value === true) { - return { has_state: false, has_call: false, value: b.literal(true) }; + return { value: b.literal(true), has_state: false }; } if (!Array.isArray(value) || value.length === 1) { const chunk = Array.isArray(value) ? value[0] : value; if (chunk.type === 'Text') { - return { has_state: false, has_call: false, value: b.literal(chunk.data) }; + return { value: b.literal(chunk.data), has_state: false }; } + let expression = /** @type {Expression} */ (context.visit(chunk.expression)); + return { - has_state: chunk.metadata.expression.has_state, - has_call: chunk.metadata.expression.has_call, - value: /** @type {Expression} */ (context.visit(chunk.expression)) + value: chunk.metadata.expression.has_call ? memoize(expression) : expression, + has_state: chunk.metadata.expression.has_state }; } - return build_template_chunk(value, context.visit, context.state); + return build_template_chunk(value, context.visit, context.state, memoize); } /** diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js index 7674fd1eb234..0b4ac873428e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/fragment.js @@ -4,7 +4,7 @@ import { cannot_be_set_statically } from '../../../../../../utils.js'; import { is_event_attribute, is_text_attribute } from '../../../../../utils/ast.js'; import * as b from '../../../../../utils/builders.js'; -import { build_template_chunk, build_update } from './utils.js'; +import { build_template_chunk } from './utils.js'; /** * Processes an array of template nodes, joining sibling text/expression nodes @@ -69,7 +69,7 @@ export function process_children(nodes, initial, is_element, { visit, state }) { state.template.push(' '); - const { has_state, has_call, value } = build_template_chunk(sequence, visit, state); + const { has_state, value } = build_template_chunk(sequence, visit, state); // if this is a standalone `{expression}`, make sure we handle the case where // no text node was created because the expression was empty during SSR @@ -78,9 +78,7 @@ export function process_children(nodes, initial, is_element, { visit, state }) { const update = b.stmt(b.call('$.set_text', id, value)); - if (has_call && !within_bound_contenteditable) { - state.init.push(build_update(update)); - } else if (has_state && !within_bound_contenteditable) { + if (has_state && !within_bound_contenteditable) { state.update.push(update); } else { state.init.push(b.stmt(b.assignment('=', b.member(id, 'nodeValue'), value))); 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 69fd8832143f..9dd29843299b 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 @@ -6,36 +6,95 @@ import { object } from '../../../../../utils/ast.js'; import * as b from '../../../../../utils/builders.js'; import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js'; import { regex_is_valid_identifier } from '../../../../patterns.js'; -import { create_derived } from '../../utils.js'; import is_reference from 'is-reference'; import { locator } from '../../../../../state.js'; +import { create_derived } from '../../utils.js'; + +/** + * @param {ComponentClientTransformState} state + * @param {Expression} value + */ +export function memoize_expression(state, value) { + const id = b.id(state.scope.generate('expression')); + state.init.push(b.const(id, create_derived(state, b.thunk(value)))); + return b.call('$.get', id); +} + +/** + * + * @param {ComponentClientTransformState} state + * @param {Expression} value + */ +export function get_expression_id(state, value) { + for (let i = 0; i < state.expressions.length; i += 1) { + if (compare_expressions(state.expressions[i], value)) { + return b.id(`$${i}`); + } + } + + return b.id(`$${state.expressions.push(value) - 1}`); +} + +/** + * Returns true of two expressions have an identical AST shape + * @param {Expression} a + * @param {Expression} b + */ +function compare_expressions(a, b) { + if (a.type !== b.type) { + return false; + } + + for (const key in a) { + if (key === 'type' || key === 'metadata' || key === 'loc' || key === 'start' || key === 'end') { + continue; + } + + const va = /** @type {any} */ (a)[key]; + const vb = /** @type {any} */ (b)[key]; + + if ((typeof va === 'object') !== (typeof vb === 'object')) { + return false; + } + + if (typeof va !== 'object' || va === null || vb === null) { + if (va !== vb) return false; + } else if (Array.isArray(va)) { + if (va.length !== vb.length) { + return false; + } + + if (va.some((v, i) => !compare_expressions(v, vb[i]))) { + return false; + } + } else if (!compare_expressions(va, vb)) { + return false; + } + } + + return true; +} /** * @param {Array} values * @param {(node: AST.SvelteNode, state: any) => any} visit * @param {ComponentClientTransformState} state - * @returns {{ value: Expression, has_state: boolean, has_call: boolean }} + * @param {(value: Expression) => Expression} memoize + * @returns {{ value: Expression, has_state: boolean }} */ -export function build_template_chunk(values, visit, state) { +export function build_template_chunk( + values, + visit, + state, + memoize = (value) => get_expression_id(state, value) +) { /** @type {Expression[]} */ const expressions = []; let quasi = b.quasi(''); const quasis = [quasi]; - let has_call = false; let has_state = false; - let contains_multiple_call_expression = false; - - for (const node of values) { - if (node.type === 'ExpressionTag') { - const metadata = node.metadata.expression; - - contains_multiple_call_expression ||= has_call && metadata.has_call; - has_call ||= metadata.has_call; - has_state ||= metadata.has_state; - } - } for (let i = 0; i < values.length; i++) { const node = values[i]; @@ -47,30 +106,20 @@ export function build_template_chunk(values, visit, state) { quasi.value.cooked += node.expression.value + ''; } } else { - if (node.metadata.expression.has_call && contains_multiple_call_expression) { - const id = b.id(state.scope.generate('expression')); - state.init.push( - b.const( - id, - create_derived( - state, - b.thunk( - b.logical( - '??', - /** @type {Expression} */ (visit(node.expression, state)), - b.literal('') - ) - ) - ) - ) - ); - expressions.push(b.call('$.get', id)); - } else if (values.length === 1) { + let value = /** @type {Expression} */ (visit(node.expression, state)); + + has_state ||= node.metadata.expression.has_state; + + if (node.metadata.expression.has_call) { + value = memoize(value); + } + + if (values.length === 1) { // If we have a single expression, then pass that in directly to possibly avoid doing // extra work in the template_effect (instead we do the work in set_text). - return { value: visit(node.expression, state), has_state, has_call }; + return { value, has_state }; } else { - expressions.push(b.logical('??', visit(node.expression, state), b.literal(''))); + expressions.push(b.logical('??', value, b.literal(''))); } quasi = b.quasi('', i + 1 === values.length); @@ -84,26 +133,27 @@ export function build_template_chunk(values, visit, state) { const value = b.template(quasis, expressions); - return { value, has_state, has_call }; -} - -/** - * @param {Statement} statement - */ -export function build_update(statement) { - const body = - statement.type === 'ExpressionStatement' ? statement.expression : b.block([statement]); - - return b.stmt(b.call('$.template_effect', b.thunk(body))); + return { value, has_state }; } /** - * @param {Statement[]} update + * @param {ComponentClientTransformState} state */ -export function build_render_statement(update) { - return update.length === 1 - ? build_update(update[0]) - : b.stmt(b.call('$.template_effect', b.thunk(b.block(update)))); +export function build_render_statement(state) { + return b.stmt( + b.call( + '$.template_effect', + b.arrow( + state.expressions.map((_, i) => b.id(`$${i}`)), + 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') + ) + ); } /** diff --git a/packages/svelte/src/compiler/phases/nodes.js b/packages/svelte/src/compiler/phases/nodes.js index 5066833feb8e..5ca4ce3380a3 100644 --- a/packages/svelte/src/compiler/phases/nodes.js +++ b/packages/svelte/src/compiler/phases/nodes.js @@ -44,7 +44,6 @@ export function create_attribute(name, start, end, value) { name, value, metadata: { - expression: create_expression_metadata(), delegated: null, needs_clsx: false } diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 8be9aed17723..fb609668957d 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -479,7 +479,6 @@ export namespace AST { value: true | ExpressionTag | Array; /** @internal */ metadata: { - expression: ExpressionMetadata; /** May be set if this is an event attribute */ delegated: null | DelegatedEvent; /** May be `true` if this is a `class` attribute that needs `clsx` */ diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 428f69281ba3..e8546331501a 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -43,7 +43,8 @@ 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 { destroy_derived } from './deriveds.js'; +import { derived, derived_safe_equal, destroy_derived } from './deriveds.js'; +import { legacy_mode_flag } from '../../flags/index.js'; /** * @param {'$effect' | '$effect.pre' | '$inspect'} rune @@ -343,16 +344,21 @@ export function render_effect(fn) { } /** - * @param {() => void | (() => void)} fn + * @param {(...expressions: any) => void | (() => void)} fn + * @param {Array<() => any>} thunks * @returns {Effect} */ -export function template_effect(fn) { +export function template_effect(fn, thunks = [], d = derived) { + const deriveds = thunks.map(d); + const effect = () => fn(...deriveds.map(get)); + if (DEV) { - define_property(fn, 'name', { + define_property(effect, 'name', { value: '{expression}' }); } - return block(fn); + + return block(effect); } /** diff --git a/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/_config.js b/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/_config.js index 73bfd09ceb70..99f9681c4ba5 100644 --- a/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/_config.js +++ b/packages/svelte/tests/runtime-legacy/samples/lifecycle-render-order-for-children/_config.js @@ -16,13 +16,13 @@ export default test({ test({ assert, compileOptions, component }) { assert.deepEqual(order, [ 'parent: beforeUpdate 0', - 'parent: render 0', '1: beforeUpdate 0', '1: render 0', '2: beforeUpdate 0', '2: render 0', '3: beforeUpdate 0', '3: render 0', + 'parent: render 0', '1: onMount 0', '1: afterUpdate 0', '2: onMount 0', @@ -39,13 +39,13 @@ export default test({ assert.deepEqual(order, [ 'parent: beforeUpdate 1', - 'parent: render 1', '1: beforeUpdate 1', '1: render 1', '2: beforeUpdate 1', '2: render 1', '3: beforeUpdate 1', '3: render 1', + 'parent: render 1', '1: afterUpdate 1', '2: afterUpdate 1', '3: afterUpdate 1', diff --git a/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-2/_config.js b/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-2/_config.js index 1066d9a2df19..cb8e6486457d 100644 --- a/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-2/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-2/_config.js @@ -10,7 +10,6 @@ export default test({ assert.deepEqual(logs, [ 'parent: $effect.pre 0', 'parent: $effect.pre (2) 0', - 'parent: render 0', '1: $effect.pre 0', '1: $effect.pre (2) 0', '1: render 0', @@ -20,6 +19,7 @@ export default test({ '3: $effect.pre 0', '3: $effect.pre (2) 0', '3: render 0', + 'parent: render 0', '1: $effect 0', '2: $effect 0', '3: $effect 0', @@ -33,7 +33,6 @@ export default test({ assert.deepEqual(logs, [ 'parent: $effect.pre 1', 'parent: $effect.pre (2) 1', - 'parent: render 1', '1: $effect.pre 1', '1: $effect.pre (2) 1', '1: render 1', @@ -43,6 +42,7 @@ export default test({ '3: $effect.pre 1', '3: $effect.pre (2) 1', '3: render 1', + 'parent: render 1', '1: $effect 1', '2: $effect 1', '3: $effect 1', diff --git a/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-3/_config.js b/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-3/_config.js index 55847c35a2a3..6c063bcb3e45 100644 --- a/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-3/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-3/_config.js @@ -8,13 +8,13 @@ export default test({ async test({ assert, component, logs }) { assert.deepEqual(logs, [ - 'parent: render 0', '1: $effect.pre 0', '1: render 0', '2: $effect.pre 0', '2: render 0', '3: $effect.pre 0', '3: render 0', + 'parent: render 0', '1: $effect 0', '2: $effect 0', '3: $effect 0', @@ -26,13 +26,13 @@ export default test({ flushSync(() => (component.n += 1)); assert.deepEqual(logs, [ - 'parent: render 1', '1: $effect.pre 1', '1: render 1', '2: $effect.pre 1', '2: render 1', '3: $effect.pre 1', '3: render 1', + 'parent: render 1', '1: $effect 1', '2: $effect 1', '3: $effect 1', diff --git a/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-4/_config.js b/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-4/_config.js index 0cd7e15a3726..29b0b67a5235 100644 --- a/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-4/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children-4/_config.js @@ -10,7 +10,6 @@ export default test({ assert.deepEqual(logs, [ 'parent: $effect.pre 0', 'parent: nested $effect.pre 0', - 'parent: render 0', '1: $effect.pre 0', '1: nested $effect.pre 0', '1: render 0', @@ -20,6 +19,7 @@ export default test({ '3: $effect.pre 0', '3: nested $effect.pre 0', '3: render 0', + 'parent: render 0', '1: $effect 0', '2: $effect 0', '3: $effect 0', @@ -33,7 +33,6 @@ export default test({ assert.deepEqual(logs, [ 'parent: $effect.pre 1', 'parent: nested $effect.pre 1', - 'parent: render 1', '1: $effect.pre 1', '1: nested $effect.pre 1', '1: render 1', @@ -43,6 +42,7 @@ export default test({ '3: $effect.pre 1', '3: nested $effect.pre 1', '3: render 1', + 'parent: render 1', '1: $effect 1', '2: $effect 1', '3: $effect 1', diff --git a/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children/_config.js b/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children/_config.js index 19b8fb39383c..3138ec7231d7 100644 --- a/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/lifecycle-render-order-for-children/_config.js @@ -9,13 +9,13 @@ export default test({ async test({ assert, component, logs }) { assert.deepEqual(logs, [ 'parent: $effect.pre 0', - 'parent: render 0', '1: $effect.pre 0', '1: render 0', '2: $effect.pre 0', '2: render 0', '3: $effect.pre 0', '3: render 0', + 'parent: render 0', '1: $effect 0', '2: $effect 0', '3: $effect 0', @@ -28,13 +28,13 @@ export default test({ assert.deepEqual(logs, [ 'parent: $effect.pre 1', - 'parent: render 1', '1: $effect.pre 1', '1: render 1', '2: $effect.pre 1', '2: render 1', '3: $effect.pre 1', '3: render 1', + 'parent: render 1', '1: $effect 1', '2: $effect 1', '3: $effect 1', diff --git a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js index ce77a27e190c..d97a58bf40d8 100644 --- a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js +++ b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js @@ -11,23 +11,24 @@ export default function Main($$anchor) { var div = $.first_child(fragment); var svg = $.sibling(div, 2); var custom_element = $.sibling(svg, 2); - var div_1 = $.sibling(custom_element, 2); - $.template_effect(() => $.set_attribute(div_1, 'foobar', y())); + $.template_effect(() => $.set_custom_element_data(custom_element, 'fooBar', x)); + var div_1 = $.sibling(custom_element, 2); var svg_1 = $.sibling(div_1, 2); - - $.template_effect(() => $.set_attribute(svg_1, 'viewBox', y())); - var custom_element_1 = $.sibling(svg_1, 2); $.template_effect(() => $.set_custom_element_data(custom_element_1, 'fooBar', y())); - $.template_effect(() => { - $.set_attribute(div, 'foobar', x); - $.set_attribute(svg, 'viewBox', x); - $.set_custom_element_data(custom_element, 'fooBar', x); - }); + $.template_effect( + ($0) => { + $.set_attribute(div, 'foobar', x); + $.set_attribute(svg, 'viewBox', x); + $.set_attribute(div_1, 'foobar', $0); + $.set_attribute(svg_1, 'viewBox', $0); + }, + [y] + ); $.append($$anchor, fragment); } \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js index 2e0bd651da38..d520d1ef2488 100644 --- a/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/text-nodes-deriveds/_expected/client/index.svelte.js @@ -16,11 +16,9 @@ export default function Text_nodes_deriveds($$anchor) { } var p = root(); - const expression = $.derived(() => text1() ?? ''); - const expression_1 = $.derived(() => text2() ?? ''); var text = $.child(p); - $.template_effect(() => $.set_text(text, `${$.get(expression)}${$.get(expression_1)}`)); $.reset(p); + $.template_effect(($0, $1) => $.set_text(text, `${$0 ?? ''}${$1 ?? ''}`), [text1, text2]); $.append($$anchor, p); } \ No newline at end of file