chore: add $derived.call rune (#10240)

* chore: add $derived.fn rune

* fix strange bug

* update types

* remove prev stuff

* regenerate types

* $derived.fn -> $derived.call

* docs

* regenerate types

* get rid of $$derived

* tighten up validation etc

* fix tests

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/10324/head
Dominic Gannaway 2 years ago committed by GitHub
parent 76a4bbd5ec
commit d9a6b8b17a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
chore: add $derived.call rune

@ -171,10 +171,9 @@ const runes = {
`$props() assignment must not contain nested properties or computed keys`, `$props() assignment must not contain nested properties or computed keys`,
'invalid-props-location': () => 'invalid-props-location': () =>
`$props() can only be used at the top level of components as a variable declaration initializer`, `$props() can only be used at the top level of components as a variable declaration initializer`,
'invalid-derived-location': () => /** @param {string} rune */
`$derived() can only be used as a variable declaration initializer or a class field`, 'invalid-state-location': (rune) =>
'invalid-state-location': () => `${rune}(...) can only be used as a variable declaration initializer or a class field`,
`$state() 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`, 'invalid-effect-location': () => `$effect() can only be used as an expression statement`,
/** /**
* @param {boolean} is_binding * @param {boolean} is_binding

@ -678,7 +678,13 @@ const runes_scope_js_tweaker = {
const callee = node.init.callee; const callee = node.init.callee;
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return; 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)) { for (const path of extract_paths(node.id)) {
// @ts-ignore this fails in CI for some insane reason // @ts-ignore this fails in CI for some insane reason
@ -708,7 +714,13 @@ const runes_scope_tweaker = {
const callee = init.callee; const callee = init.callee;
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return; 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; return;
for (const path of extract_paths(node.id)) { for (const path of extract_paths(node.id)) {
@ -719,7 +731,7 @@ const runes_scope_tweaker = {
? 'state' ? 'state'
: rune === '$state.frozen' : rune === '$state.frozen'
? 'frozen_state' ? 'frozen_state'
: rune === '$derived' : rune === '$derived' || rune === '$derived.call'
? 'derived' ? 'derived'
: path.is_rest : path.is_rest
? 'rest_prop' ? 'rest_prop'

@ -715,10 +715,10 @@ function validate_call_expression(node, scope, path) {
error(node, 'invalid-props-location'); 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 === 'VariableDeclarator') return;
if (parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) 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') { if (rune === '$effect' || rune === '$effect.pre') {
@ -786,10 +786,10 @@ export const validation_runes_js = {
const args = /** @type {import('estree').CallExpression} */ (init).arguments; const args = /** @type {import('estree').CallExpression} */ (init).arguments;
if (rune === '$derived' && args.length !== 1) { if ((rune === '$derived' || rune === '$derived.call') && args.length !== 1) {
error(node, 'invalid-rune-args-length', '$derived', [1]); error(node, 'invalid-rune-args-length', rune, [1]);
} else if (rune === '$state' && args.length > 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') { } else if (rune === '$props') {
error(node, 'invalid-props-location'); error(node, 'invalid-props-location');
} }
@ -811,7 +811,7 @@ export const validation_runes_js = {
definition.value?.type === 'CallExpression' definition.value?.type === 'CallExpression'
) { ) {
const rune = get_rune(definition.value, context.state.scope); 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); private_derived_state.push(definition.key.name);
} }
} }
@ -938,14 +938,11 @@ export const validation_runes = merge(validation, a11y_validators, {
context.type === 'Identifier' && context.type === 'Identifier' &&
(context.name === '$state' || context.name === '$derived') (context.name === '$state' || context.name === '$derived')
) { ) {
error( error(node, 'invalid-state-location', context.name);
node,
context.name === '$derived' ? 'invalid-derived-location' : 'invalid-state-location'
);
} }
next({ ...state }); next({ ...state });
}, },
VariableDeclarator(node, { state }) { VariableDeclarator(node, { state, path }) {
const init = unwrap_ts_expression(node.init); const init = unwrap_ts_expression(node.init);
const rune = get_rune(init, state.scope); 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; const args = /** @type {import('estree').CallExpression} */ (init).arguments;
if (rune === '$derived' && args.length !== 1) { // TODO some of this is duplicated with above, seems off
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) { } 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') { } else if (rune === '$props') {
if (state.has_props_rune) { if (state.has_props_rune) {
error(node, 'duplicate-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 // TODO this is a code smell. need to refactor this stuff
ClassBody: validation_runes_js.ClassBody, ClassBody: validation_runes_js.ClassBody,

@ -58,7 +58,7 @@ export interface ComponentClientTransformState extends ClientTransformState {
} }
export interface StateField { export interface StateField {
kind: 'state' | 'frozen_state' | 'derived'; kind: 'state' | 'frozen_state' | 'derived' | 'derived_call';
id: PrivateIdentifier; id: PrivateIdentifier;
} }

@ -29,11 +29,22 @@ export const javascript_visitors_runes = {
if (definition.value?.type === 'CallExpression') { if (definition.value?.type === 'CallExpression') {
const rune = get_rune(definition.value, state.scope); 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} */ /** @type {import('../types.js').StateField} */
const field = { const field = {
kind: 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 // @ts-expect-error this is set in the next pass
id: is_private ? definition.key : null id: is_private ? definition.key : null
}; };
@ -94,7 +105,9 @@ export const javascript_visitors_runes = {
'$.source', '$.source',
should_proxy_or_freeze(init, state.scope) ? b.call('$.freeze', init) : init should_proxy_or_freeze(init, state.scope) ? 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 { } else {
// if no arguments, we know it's state as `$derived()` is a compile error // if no arguments, we know it's state as `$derived()` is a compile error
value = b.call('$.source'); value = b.call('$.source');
@ -136,7 +149,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( body.push(
b.method( b.method(
'set', 'set',
@ -276,9 +289,14 @@ export const javascript_visitors_runes = {
continue; continue;
} }
if (rune === '$derived') { if (rune === '$derived' || rune === '$derived.call') {
if (declarator.id.type === 'Identifier') { 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 { } else {
const bindings = state.scope.get_bindings(declarator); const bindings = state.scope.get_bindings(declarator);
const id = state.scope.generate('derived_value'); const id = state.scope.generate('derived_value');
@ -289,7 +307,7 @@ export const javascript_visitors_runes = {
'$.derived', '$.derived',
b.thunk( b.thunk(
b.block([ 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))) b.return(b.array(bindings.map((binding) => binding.node)))
]) ])
) )

@ -558,6 +558,15 @@ const javascript_visitors_runes = {
: /** @type {import('estree').Expression} */ (visit(node.value.arguments[0])) : /** @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(); next();
}, },
@ -583,6 +592,16 @@ const javascript_visitors_runes = {
? b.id('undefined') ? b.id('undefined')
: /** @type {import('estree').Expression} */ (visit(args[0])); : /** @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') { if (declarator.id.type === 'Identifier') {
declarations.push(b.declarator(declarator.id, value)); declarations.push(b.declarator(declarator.id, value));
continue; continue;

@ -75,6 +75,7 @@ export const Runes = /** @type {const} */ ([
'$state.frozen', '$state.frozen',
'$props', '$props',
'$derived', '$derived',
'$derived.call',
'$effect', '$effect',
'$effect.pre', '$effect.pre',
'$effect.active', '$effect.active',

@ -395,6 +395,7 @@ export function thunk(expression) {
expression.type === 'CallExpression' && expression.type === 'CallExpression' &&
expression.callee.type !== 'Super' && expression.callee.type !== 'Super' &&
expression.callee.type !== 'MemberExpression' && expression.callee.type !== 'MemberExpression' &&
expression.callee.type !== 'CallExpression' &&
expression.arguments.length === 0 expression.arguments.length === 0
) { ) {
return expression.callee; return expression.callee;

@ -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.`, `Referencing a local variable with a $ prefix will create a store subscription. Please rename ${name} to avoid the ambiguity.`,
/** @param {string} name */ /** @param {string} name */
'non-state-reference': (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} */ /** @satisfies {Warnings} */

@ -59,6 +59,27 @@ declare namespace $state {
*/ */
declare function $derived<T>(expression: T): T; declare function $derived<T>(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<T>(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. * 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. * The timing of the execution is after the DOM has been updated.

@ -3,7 +3,7 @@ import { test } from '../../test';
export default test({ export default test({
error: { error: {
code: 'invalid-state-location', 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] position: [33, 41]
} }
}); });

@ -3,6 +3,6 @@ import { test } from '../../test';
export default test({ export default test({
error: { error: {
code: 'invalid-state-location', 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'
} }
}); });

@ -2,7 +2,7 @@ import { test } from '../../test';
export default test({ export default test({
error: { error: {
code: 'invalid-derived-location', code: 'invalid-state-location',
message: '$derived() can only be used as a variable declaration initializer or a class field' message: '$derived(...) can only be used as a variable declaration initializer or a class field'
} }
}); });

@ -3,6 +3,6 @@ import { test } from '../../test';
export default test({ export default test({
error: { error: {
code: 'invalid-state-location', 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'
} }
}); });

@ -0,0 +1,30 @@
import { test } from '../../test';
export default test({
html: `
<button>0</button>
<p>doubled: 0</p>
`,
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
assert.htmlEqual(
target.innerHTML,
`
<button>1</button>
<p>doubled: 2</p>
`
);
await btn?.click();
assert.htmlEqual(
target.innerHTML,
`
<button>2</button>
<p>doubled: 4</p>
`
);
}
});

@ -0,0 +1,11 @@
<script>
class Counter {
count = $state(0);
doubled = $derived.call(() => this.count * 2);
}
const counter = new Counter();
</script>
<button on:click={() => counter.count++}>{counter.count}</button>
<p>doubled: {counter.doubled}</p>

@ -0,0 +1,13 @@
import { test } from '../../test';
export default test({
html: `<button>0</button>`,
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, `<button>2</button>`);
}
});

@ -0,0 +1,6 @@
<script>
let count = $state(0);
let double = $derived.call(() => count * 2);
</script>
<button on:click={() => count++}>{double}</button>

@ -2460,6 +2460,27 @@ declare namespace $state {
*/ */
declare function $derived<T>(expression: T): T; declare function $derived<T>(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<T>(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. * 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. * The timing of the execution is after the DOM has been updated.

@ -205,9 +205,14 @@
return { return {
from: word.from - 1, from: word.from - 1,
options: [ options: [
{ label: '$state', type: 'keyword', boost: 9 }, { label: '$state', type: 'keyword', boost: 10 },
{ label: '$props', type: 'keyword', boost: 8 }, { label: '$props', type: 'keyword', boost: 9 },
{ label: '$derived', type: 'keyword', boost: 7 }, { 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(() => {\n\t${}\n});', { label: '$effect', type: 'keyword', boost: 6 }),
snip('$effect.pre(() => {\n\t${}\n});', { snip('$effect.pre(() => {\n\t${}\n});', {
label: '$effect.pre', label: '$effect.pre',

@ -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. ...`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
<script>
let numbers = $state([1, 2, 3]);
let total = $derived.call(() => {
let total = 0;
for (const n of numbers) {
total += n;
}
return total;
});
</script>
<button on:click={() => numbers.push(numbers.length + 1)}>
{numbers.join(' + ')} = {total}
</button>
```
In essence, `$derived(expression)` is equivalent to `$derived.call(() => expression)`.
## `$effect` ## `$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: 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:

Loading…
Cancel
Save