diff --git a/.changeset/nervous-spoons-relax.md b/.changeset/nervous-spoons-relax.md new file mode 100644 index 000000000000..9480a8ac472e --- /dev/null +++ b/.changeset/nervous-spoons-relax.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +chore: add $derived.call rune diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index ba617ead2d01..f748721e67fe 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -171,10 +171,9 @@ const runes = { `$props() assignment must not contain nested properties or computed keys`, 'invalid-props-location': () => `$props() can only be used at the top level of components as a variable declaration initializer`, - 'invalid-derived-location': () => - `$derived() can only be used as a variable declaration initializer or a class field`, - 'invalid-state-location': () => - `$state() can only be used as a variable declaration initializer or a class field`, + /** @param {string} rune */ + 'invalid-state-location': (rune) => + `${rune}(...) can only be used as a variable declaration initializer or a class field`, 'invalid-effect-location': () => `$effect() can only be used as an expression statement`, /** * @param {boolean} is_binding diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 0c207f95080b..e74d2ee9ab9e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -670,7 +670,13 @@ const runes_scope_js_tweaker = { const callee = node.init.callee; if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return; - if (rune !== '$state' && rune !== '$state.frozen' && rune !== '$derived') return; + if ( + rune !== '$state' && + rune !== '$state.frozen' && + rune !== '$derived' && + rune !== '$derived.call' + ) + return; for (const path of extract_paths(node.id)) { // @ts-ignore this fails in CI for some insane reason @@ -700,7 +706,13 @@ const runes_scope_tweaker = { const callee = init.callee; if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return; - if (rune !== '$state' && rune !== '$state.frozen' && rune !== '$derived' && rune !== '$props') + if ( + rune !== '$state' && + rune !== '$state.frozen' && + rune !== '$derived' && + rune !== '$derived.call' && + rune !== '$props' + ) return; for (const path of extract_paths(node.id)) { @@ -711,7 +723,7 @@ const runes_scope_tweaker = { ? 'state' : rune === '$state.frozen' ? 'frozen_state' - : rune === '$derived' + : rune === '$derived' || rune === '$derived.call' ? 'derived' : path.is_rest ? 'rest_prop' diff --git a/packages/svelte/src/compiler/phases/2-analyze/validation.js b/packages/svelte/src/compiler/phases/2-analyze/validation.js index c07afd7484f1..6ba5149fbffc 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/validation.js +++ b/packages/svelte/src/compiler/phases/2-analyze/validation.js @@ -715,10 +715,10 @@ function validate_call_expression(node, scope, path) { error(node, 'invalid-props-location'); } - if (rune === '$state' || rune === '$derived') { + if (rune === '$state' || rune === '$derived' || rune === '$derived.call') { if (parent.type === 'VariableDeclarator') return; if (parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) return; - error(node, rune === '$derived' ? 'invalid-derived-location' : 'invalid-state-location'); + error(node, 'invalid-state-location', rune); } if (rune === '$effect' || rune === '$effect.pre') { @@ -786,10 +786,10 @@ export const validation_runes_js = { const args = /** @type {import('estree').CallExpression} */ (init).arguments; - if (rune === '$derived' && args.length !== 1) { - error(node, 'invalid-rune-args-length', '$derived', [1]); + if ((rune === '$derived' || rune === '$derived.call') && args.length !== 1) { + error(node, 'invalid-rune-args-length', rune, [1]); } else if (rune === '$state' && args.length > 1) { - error(node, 'invalid-rune-args-length', '$state', [0, 1]); + error(node, 'invalid-rune-args-length', rune, [0, 1]); } else if (rune === '$props') { error(node, 'invalid-props-location'); } @@ -811,7 +811,7 @@ export const validation_runes_js = { definition.value?.type === 'CallExpression' ) { const rune = get_rune(definition.value, context.state.scope); - if (rune === '$derived') { + if (rune === '$derived' || rune === '$derived.call') { private_derived_state.push(definition.key.name); } } @@ -938,14 +938,11 @@ export const validation_runes = merge(validation, a11y_validators, { context.type === 'Identifier' && (context.name === '$state' || context.name === '$derived') ) { - error( - node, - context.name === '$derived' ? 'invalid-derived-location' : 'invalid-state-location' - ); + error(node, 'invalid-state-location', context.name); } next({ ...state }); }, - VariableDeclarator(node, { state }) { + VariableDeclarator(node, { state, path }) { const init = unwrap_ts_expression(node.init); const rune = get_rune(init, state.scope); @@ -953,10 +950,11 @@ export const validation_runes = merge(validation, a11y_validators, { const args = /** @type {import('estree').CallExpression} */ (init).arguments; - if (rune === '$derived' && args.length !== 1) { - error(node, 'invalid-rune-args-length', '$derived', [1]); + // TODO some of this is duplicated with above, seems off + if ((rune === '$derived' || rune === '$derived.call') && args.length !== 1) { + error(node, 'invalid-rune-args-length', rune, [1]); } else if (rune === '$state' && args.length > 1) { - error(node, 'invalid-rune-args-length', '$state', [0, 1]); + error(node, 'invalid-rune-args-length', rune, [0, 1]); } else if (rune === '$props') { if (state.has_props_rune) { error(node, 'duplicate-props-rune'); @@ -991,6 +989,16 @@ export const validation_runes = merge(validation, a11y_validators, { } } } + + if (rune === '$derived') { + const arg = args[0]; + if ( + arg.type === 'CallExpression' && + (arg.callee.type === 'ArrowFunctionExpression' || arg.callee.type === 'FunctionExpression') + ) { + warn(state.analysis.warnings, node, path, 'derived-iife'); + } + } }, // TODO this is a code smell. need to refactor this stuff ClassBody: validation_runes_js.ClassBody, 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 85bf8dd278e0..14b628a89d54 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 @@ -58,7 +58,7 @@ export interface ComponentClientTransformState extends ClientTransformState { } export interface StateField { - kind: 'state' | 'frozen_state' | 'derived'; + kind: 'state' | 'frozen_state' | 'derived' | 'derived_call'; id: PrivateIdentifier; } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js index 0150c30293ad..542280a78585 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js @@ -29,11 +29,22 @@ export const javascript_visitors_runes = { if (definition.value?.type === 'CallExpression') { const rune = get_rune(definition.value, state.scope); - if (rune === '$state' || rune === '$state.frozen' || rune === '$derived') { + if ( + rune === '$state' || + rune === '$state.frozen' || + rune === '$derived' || + rune === '$derived.call' + ) { /** @type {import('../types.js').StateField} */ const field = { kind: - rune === '$state' ? 'state' : rune === '$state.frozen' ? 'frozen_state' : 'derived', + rune === '$state' + ? 'state' + : rune === '$state.frozen' + ? 'frozen_state' + : rune === '$derived.call' + ? 'derived_call' + : 'derived', // @ts-expect-error this is set in the next pass id: is_private ? definition.key : null }; @@ -91,7 +102,9 @@ export const javascript_visitors_runes = { '$.source', should_proxy_or_freeze(init) ? b.call('$.freeze', init) : init ) - : b.call('$.derived', b.thunk(init)); + : field.kind === 'derived_call' + ? b.call('$.derived', init) + : b.call('$.derived', b.thunk(init)); } else { // if no arguments, we know it's state as `$derived()` is a compile error value = b.call('$.source'); @@ -133,7 +146,7 @@ export const javascript_visitors_runes = { ); } - if (field.kind === 'derived' && state.options.dev) { + if ((field.kind === 'derived' || field.kind === 'derived_call') && state.options.dev) { body.push( b.method( 'set', @@ -273,9 +286,14 @@ export const javascript_visitors_runes = { continue; } - if (rune === '$derived') { + if (rune === '$derived' || rune === '$derived.call') { if (declarator.id.type === 'Identifier') { - declarations.push(b.declarator(declarator.id, b.call('$.derived', b.thunk(value)))); + declarations.push( + b.declarator( + declarator.id, + b.call('$.derived', rune === '$derived.call' ? value : b.thunk(value)) + ) + ); } else { const bindings = state.scope.get_bindings(declarator); const id = state.scope.generate('derived_value'); @@ -286,7 +304,7 @@ export const javascript_visitors_runes = { '$.derived', b.thunk( b.block([ - b.let(declarator.id, value), + b.let(declarator.id, rune === '$derived.call' ? b.call(value) : value), b.return(b.array(bindings.map((binding) => binding.node))) ]) ) 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 cc1649c3702f..14b8cd21059e 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 @@ -558,6 +558,15 @@ const javascript_visitors_runes = { : /** @type {import('estree').Expression} */ (visit(node.value.arguments[0])) }; } + if (rune === '$derived.call') { + return { + ...node, + value: + node.value.arguments.length === 0 + ? null + : b.call(/** @type {import('estree').Expression} */ (visit(node.value.arguments[0]))) + }; + } } next(); }, @@ -583,6 +592,16 @@ const javascript_visitors_runes = { ? b.id('undefined') : /** @type {import('estree').Expression} */ (visit(args[0])); + if (rune === '$derived.call') { + declarations.push( + b.declarator( + /** @type {import('estree').Pattern} */ (visit(declarator.id)), + b.call(value) + ) + ); + continue; + } + if (declarator.id.type === 'Identifier') { declarations.push(b.declarator(declarator.id, value)); continue; diff --git a/packages/svelte/src/compiler/phases/constants.js b/packages/svelte/src/compiler/phases/constants.js index b8663e97f013..cb8aaef1c8e8 100644 --- a/packages/svelte/src/compiler/phases/constants.js +++ b/packages/svelte/src/compiler/phases/constants.js @@ -75,6 +75,7 @@ export const Runes = /** @type {const} */ ([ '$state.frozen', '$props', '$derived', + '$derived.call', '$effect', '$effect.pre', '$effect.active', diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index 1595f798792b..e926dce2bd87 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -395,6 +395,7 @@ export function thunk(expression) { expression.type === 'CallExpression' && expression.callee.type !== 'Super' && expression.callee.type !== 'MemberExpression' && + expression.callee.type !== 'CallExpression' && expression.arguments.length === 0 ) { return expression.callee; diff --git a/packages/svelte/src/compiler/warnings.js b/packages/svelte/src/compiler/warnings.js index 67c2dc333bbc..5c810664ba85 100644 --- a/packages/svelte/src/compiler/warnings.js +++ b/packages/svelte/src/compiler/warnings.js @@ -23,7 +23,9 @@ const runes = { `Referencing a local variable with a $ prefix will create a store subscription. Please rename ${name} to avoid the ambiguity.`, /** @param {string} name */ 'non-state-reference': (name) => - `${name} is updated, but is not declared with $state(...). Changing its value will not correctly trigger updates.` + `${name} is updated, but is not declared with $state(...). Changing its value will not correctly trigger updates.`, + 'derived-iife': () => + `Use \`$derived.call(() => {...})\` instead of \`$derived((() => {...})());\`` }; /** @satisfies {Warnings} */ diff --git a/packages/svelte/src/main/ambient.d.ts b/packages/svelte/src/main/ambient.d.ts index 63b949de7372..af902a9d80ea 100644 --- a/packages/svelte/src/main/ambient.d.ts +++ b/packages/svelte/src/main/ambient.d.ts @@ -59,6 +59,27 @@ declare namespace $state { */ declare function $derived(expression: T): T; +declare namespace $derived { + /** + * Sometimes you need to create complex derivations that don't fit inside a short expression. + * In these cases, you can use `$derived.call` which accepts a function as its argument. + * + * Example: + * ```ts + * let total = $derived.call(() => { + * let result = 0; + * for (const n of numbers) { + * result += n; + * } + * return result; + * }); + * ``` + * + * https://svelte-5-preview.vercel.app/docs/runes#$derived-call + */ + export function fn(fn: () => T): void; +} + /** * Runs code when a component is mounted to the DOM, and then whenever its dependencies change, i.e. `$state` or `$derived` values. * The timing of the execution is after the DOM has been updated. diff --git a/packages/svelte/tests/compiler-errors/samples/class-state-field-static/_config.js b/packages/svelte/tests/compiler-errors/samples/class-state-field-static/_config.js index 27991ba42715..dca19b460619 100644 --- a/packages/svelte/tests/compiler-errors/samples/class-state-field-static/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/class-state-field-static/_config.js @@ -3,7 +3,7 @@ import { test } from '../../test'; export default test({ error: { code: 'invalid-state-location', - message: '$state() can only be used as a variable declaration initializer or a class field', + message: '$state(...) can only be used as a variable declaration initializer or a class field', position: [33, 41] } }); diff --git a/packages/svelte/tests/compiler-errors/samples/runes-no-rune-each/_config.js b/packages/svelte/tests/compiler-errors/samples/runes-no-rune-each/_config.js index 86c28248a010..9089b6accab9 100644 --- a/packages/svelte/tests/compiler-errors/samples/runes-no-rune-each/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/runes-no-rune-each/_config.js @@ -3,6 +3,6 @@ import { test } from '../../test'; export default test({ error: { code: 'invalid-state-location', - message: '$state() can only be used as a variable declaration initializer or a class field' + message: '$state(...) can only be used as a variable declaration initializer or a class field' } }); diff --git a/packages/svelte/tests/compiler-errors/samples/runes-wrong-derived-placement/_config.js b/packages/svelte/tests/compiler-errors/samples/runes-wrong-derived-placement/_config.js index a11c307ece90..73c8bf12999c 100644 --- a/packages/svelte/tests/compiler-errors/samples/runes-wrong-derived-placement/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-derived-placement/_config.js @@ -2,7 +2,7 @@ import { test } from '../../test'; export default test({ error: { - code: 'invalid-derived-location', - message: '$derived() can only be used as a variable declaration initializer or a class field' + code: 'invalid-state-location', + message: '$derived(...) can only be used as a variable declaration initializer or a class field' } }); diff --git a/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-placement/_config.js b/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-placement/_config.js index 86c28248a010..9089b6accab9 100644 --- a/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-placement/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/runes-wrong-state-placement/_config.js @@ -3,6 +3,6 @@ import { test } from '../../test'; export default test({ error: { code: 'invalid-state-location', - message: '$state() can only be used as a variable declaration initializer or a class field' + message: '$state(...) can only be used as a variable declaration initializer or a class field' } }); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-derived-fn/_config.js b/packages/svelte/tests/runtime-runes/samples/class-state-derived-fn/_config.js new file mode 100644 index 000000000000..54e9e296fc96 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-derived-fn/_config.js @@ -0,0 +1,30 @@ +import { test } from '../../test'; + +export default test({ + html: ` + +

doubled: 0

+ `, + + async test({ assert, target }) { + const btn = target.querySelector('button'); + + await btn?.click(); + assert.htmlEqual( + target.innerHTML, + ` + +

doubled: 2

+ ` + ); + + await btn?.click(); + assert.htmlEqual( + target.innerHTML, + ` + +

doubled: 4

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/class-state-derived-fn/main.svelte b/packages/svelte/tests/runtime-runes/samples/class-state-derived-fn/main.svelte new file mode 100644 index 000000000000..2816780c25ac --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/class-state-derived-fn/main.svelte @@ -0,0 +1,11 @@ + + + +

doubled: {counter.doubled}

diff --git a/packages/svelte/tests/runtime-runes/samples/derived-fn/_config.js b/packages/svelte/tests/runtime-runes/samples/derived-fn/_config.js new file mode 100644 index 000000000000..09bd0a9aad8d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/derived-fn/_config.js @@ -0,0 +1,13 @@ +import { test } from '../../test'; + +export default test({ + html: ``, + + async test({ assert, target, window }) { + const btn = target.querySelector('button'); + const clickEvent = new window.Event('click', { bubbles: true }); + await btn?.dispatchEvent(clickEvent); + + assert.htmlEqual(target.innerHTML, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/derived-fn/main.svelte b/packages/svelte/tests/runtime-runes/samples/derived-fn/main.svelte new file mode 100644 index 000000000000..59b1a64701e1 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/derived-fn/main.svelte @@ -0,0 +1,6 @@ + + + diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index a70f6e79927a..1b0b5c221b69 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2461,6 +2461,27 @@ declare namespace $state { */ declare function $derived(expression: T): T; +declare namespace $derived { + /** + * Sometimes you need to create complex derivations that don't fit inside a short expression. + * In these cases, you can use `$derived.call` which accepts a function as its argument. + * + * Example: + * ```ts + * let total = $derived.call(() => { + * let result = 0; + * for (const n of numbers) { + * result += n; + * } + * return result; + * }); + * ``` + * + * https://svelte-5-preview.vercel.app/docs/runes#$derived-call + */ + export function fn(fn: () => T): void; +} + /** * Runs code when a component is mounted to the DOM, and then whenever its dependencies change, i.e. `$state` or `$derived` values. * The timing of the execution is after the DOM has been updated. diff --git a/sites/svelte-5-preview/src/lib/CodeMirror.svelte b/sites/svelte-5-preview/src/lib/CodeMirror.svelte index e8997aa9f29e..9a2438be02b2 100644 --- a/sites/svelte-5-preview/src/lib/CodeMirror.svelte +++ b/sites/svelte-5-preview/src/lib/CodeMirror.svelte @@ -205,9 +205,14 @@ return { from: word.from - 1, options: [ - { label: '$state', type: 'keyword', boost: 9 }, - { label: '$props', type: 'keyword', boost: 8 }, - { label: '$derived', type: 'keyword', boost: 7 }, + { label: '$state', type: 'keyword', boost: 10 }, + { label: '$props', type: 'keyword', boost: 9 }, + { label: '$derived', type: 'keyword', boost: 8 }, + snip('$derived.call(() => {\n\t${}\n});', { + label: '$derived.call', + type: 'keyword', + boost: 7 + }), snip('$effect(() => {\n\t${}\n});', { label: '$effect', type: 'keyword', boost: 6 }), snip('$effect.pre(() => {\n\t${}\n});', { label: '$effect.pre', diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md index b597307dd2a5..3cea5062acc7 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md @@ -134,6 +134,29 @@ If the value of a reactive variable is being computed it should be replaced with ``` ...`double` will be calculated first despite the source order. In runes mode, `triple` cannot reference `double` before it has been declared. +## `$derived.call` + +Sometimes you need to create complex derivations that don't fit inside a short expression. In these cases, you can use `$derived.call` which accepts a function as its argument. + +```svelte + + + +``` + +In essence, `$derived(expression)` is equivalent to `$derived.call(() => expression)`. + ## `$effect` To run side-effects like logging or analytics whenever some specific values change, or when a component is mounted to the DOM, we can use the `$effect` rune: