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`,
'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

@ -678,7 +678,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
@ -708,7 +714,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)) {
@ -719,7 +731,7 @@ const runes_scope_tweaker = {
? 'state'
: rune === '$state.frozen'
? 'frozen_state'
: rune === '$derived'
: rune === '$derived' || rune === '$derived.call'
? 'derived'
: path.is_rest
? 'rest_prop'

@ -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,

@ -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;
}

@ -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
};
@ -94,6 +105,8 @@ export const javascript_visitors_runes = {
'$.source',
should_proxy_or_freeze(init, state.scope) ? b.call('$.freeze', init) : 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
@ -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(
b.method(
'set',
@ -276,9 +289,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');
@ -289,7 +307,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)))
])
)

@ -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;

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

@ -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;

@ -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} */

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

@ -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]
}
});

@ -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'
}
});

@ -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'
}
});

@ -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'
}
});

@ -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 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.
* The timing of the execution is after the DOM has been updated.

@ -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',

@ -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
<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`
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