Skip to content

Commit 493dd5f

Browse files
trueadmRich-Harris
andcommitted
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 <[email protected]>
1 parent bac8732 commit 493dd5f

File tree

22 files changed

+233
-38
lines changed

22 files changed

+233
-38
lines changed

.changeset/nervous-spoons-relax.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
chore: add $derived.call rune

packages/svelte/src/compiler/errors.js

+3-4
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,9 @@ const runes = {
171171
`$props() assignment must not contain nested properties or computed keys`,
172172
'invalid-props-location': () =>
173173
`$props() can only be used at the top level of components as a variable declaration initializer`,
174-
'invalid-derived-location': () =>
175-
`$derived() can only be used as a variable declaration initializer or a class field`,
176-
'invalid-state-location': () =>
177-
`$state() can only be used as a variable declaration initializer or a class field`,
174+
/** @param {string} rune */
175+
'invalid-state-location': (rune) =>
176+
`${rune}(...) can only be used as a variable declaration initializer or a class field`,
178177
'invalid-effect-location': () => `$effect() can only be used as an expression statement`,
179178
/**
180179
* @param {boolean} is_binding

packages/svelte/src/compiler/phases/2-analyze/index.js

+15-3
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,13 @@ const runes_scope_js_tweaker = {
678678
const callee = node.init.callee;
679679
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return;
680680

681-
if (rune !== '$state' && rune !== '$state.frozen' && rune !== '$derived') return;
681+
if (
682+
rune !== '$state' &&
683+
rune !== '$state.frozen' &&
684+
rune !== '$derived' &&
685+
rune !== '$derived.call'
686+
)
687+
return;
682688

683689
for (const path of extract_paths(node.id)) {
684690
// @ts-ignore this fails in CI for some insane reason
@@ -708,7 +714,13 @@ const runes_scope_tweaker = {
708714
const callee = init.callee;
709715
if (callee.type !== 'Identifier' && callee.type !== 'MemberExpression') return;
710716

711-
if (rune !== '$state' && rune !== '$state.frozen' && rune !== '$derived' && rune !== '$props')
717+
if (
718+
rune !== '$state' &&
719+
rune !== '$state.frozen' &&
720+
rune !== '$derived' &&
721+
rune !== '$derived.call' &&
722+
rune !== '$props'
723+
)
712724
return;
713725

714726
for (const path of extract_paths(node.id)) {
@@ -719,7 +731,7 @@ const runes_scope_tweaker = {
719731
? 'state'
720732
: rune === '$state.frozen'
721733
? 'frozen_state'
722-
: rune === '$derived'
734+
: rune === '$derived' || rune === '$derived.call'
723735
? 'derived'
724736
: path.is_rest
725737
? 'rest_prop'

packages/svelte/src/compiler/phases/2-analyze/validation.js

+22-14
Original file line numberDiff line numberDiff line change
@@ -715,10 +715,10 @@ function validate_call_expression(node, scope, path) {
715715
error(node, 'invalid-props-location');
716716
}
717717

718-
if (rune === '$state' || rune === '$derived') {
718+
if (rune === '$state' || rune === '$derived' || rune === '$derived.call') {
719719
if (parent.type === 'VariableDeclarator') return;
720720
if (parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) return;
721-
error(node, rune === '$derived' ? 'invalid-derived-location' : 'invalid-state-location');
721+
error(node, 'invalid-state-location', rune);
722722
}
723723

724724
if (rune === '$effect' || rune === '$effect.pre') {
@@ -786,10 +786,10 @@ export const validation_runes_js = {
786786

787787
const args = /** @type {import('estree').CallExpression} */ (init).arguments;
788788

789-
if (rune === '$derived' && args.length !== 1) {
790-
error(node, 'invalid-rune-args-length', '$derived', [1]);
789+
if ((rune === '$derived' || rune === '$derived.call') && args.length !== 1) {
790+
error(node, 'invalid-rune-args-length', rune, [1]);
791791
} else if (rune === '$state' && args.length > 1) {
792-
error(node, 'invalid-rune-args-length', '$state', [0, 1]);
792+
error(node, 'invalid-rune-args-length', rune, [0, 1]);
793793
} else if (rune === '$props') {
794794
error(node, 'invalid-props-location');
795795
}
@@ -811,7 +811,7 @@ export const validation_runes_js = {
811811
definition.value?.type === 'CallExpression'
812812
) {
813813
const rune = get_rune(definition.value, context.state.scope);
814-
if (rune === '$derived') {
814+
if (rune === '$derived' || rune === '$derived.call') {
815815
private_derived_state.push(definition.key.name);
816816
}
817817
}
@@ -938,25 +938,23 @@ export const validation_runes = merge(validation, a11y_validators, {
938938
context.type === 'Identifier' &&
939939
(context.name === '$state' || context.name === '$derived')
940940
) {
941-
error(
942-
node,
943-
context.name === '$derived' ? 'invalid-derived-location' : 'invalid-state-location'
944-
);
941+
error(node, 'invalid-state-location', context.name);
945942
}
946943
next({ ...state });
947944
},
948-
VariableDeclarator(node, { state }) {
945+
VariableDeclarator(node, { state, path }) {
949946
const init = unwrap_ts_expression(node.init);
950947
const rune = get_rune(init, state.scope);
951948

952949
if (rune === null) return;
953950

954951
const args = /** @type {import('estree').CallExpression} */ (init).arguments;
955952

956-
if (rune === '$derived' && args.length !== 1) {
957-
error(node, 'invalid-rune-args-length', '$derived', [1]);
953+
// TODO some of this is duplicated with above, seems off
954+
if ((rune === '$derived' || rune === '$derived.call') && args.length !== 1) {
955+
error(node, 'invalid-rune-args-length', rune, [1]);
958956
} else if (rune === '$state' && args.length > 1) {
959-
error(node, 'invalid-rune-args-length', '$state', [0, 1]);
957+
error(node, 'invalid-rune-args-length', rune, [0, 1]);
960958
} else if (rune === '$props') {
961959
if (state.has_props_rune) {
962960
error(node, 'duplicate-props-rune');
@@ -991,6 +989,16 @@ export const validation_runes = merge(validation, a11y_validators, {
991989
}
992990
}
993991
}
992+
993+
if (rune === '$derived') {
994+
const arg = args[0];
995+
if (
996+
arg.type === 'CallExpression' &&
997+
(arg.callee.type === 'ArrowFunctionExpression' || arg.callee.type === 'FunctionExpression')
998+
) {
999+
warn(state.analysis.warnings, node, path, 'derived-iife');
1000+
}
1001+
}
9941002
},
9951003
// TODO this is a code smell. need to refactor this stuff
9961004
ClassBody: validation_runes_js.ClassBody,

packages/svelte/src/compiler/phases/3-transform/client/types.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export interface ComponentClientTransformState extends ClientTransformState {
5858
}
5959

6060
export interface StateField {
61-
kind: 'state' | 'frozen_state' | 'derived';
61+
kind: 'state' | 'frozen_state' | 'derived' | 'derived_call';
6262
id: PrivateIdentifier;
6363
}
6464

packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js

+25-7
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,22 @@ export const javascript_visitors_runes = {
2929

3030
if (definition.value?.type === 'CallExpression') {
3131
const rune = get_rune(definition.value, state.scope);
32-
if (rune === '$state' || rune === '$state.frozen' || rune === '$derived') {
32+
if (
33+
rune === '$state' ||
34+
rune === '$state.frozen' ||
35+
rune === '$derived' ||
36+
rune === '$derived.call'
37+
) {
3338
/** @type {import('../types.js').StateField} */
3439
const field = {
3540
kind:
36-
rune === '$state' ? 'state' : rune === '$state.frozen' ? 'frozen_state' : 'derived',
41+
rune === '$state'
42+
? 'state'
43+
: rune === '$state.frozen'
44+
? 'frozen_state'
45+
: rune === '$derived.call'
46+
? 'derived_call'
47+
: 'derived',
3748
// @ts-expect-error this is set in the next pass
3849
id: is_private ? definition.key : null
3950
};
@@ -94,7 +105,9 @@ export const javascript_visitors_runes = {
94105
'$.source',
95106
should_proxy_or_freeze(init, state.scope) ? b.call('$.freeze', init) : init
96107
)
97-
: b.call('$.derived', b.thunk(init));
108+
: field.kind === 'derived_call'
109+
? b.call('$.derived', init)
110+
: b.call('$.derived', b.thunk(init));
98111
} else {
99112
// if no arguments, we know it's state as `$derived()` is a compile error
100113
value = b.call('$.source');
@@ -136,7 +149,7 @@ export const javascript_visitors_runes = {
136149
);
137150
}
138151

139-
if (field.kind === 'derived' && state.options.dev) {
152+
if ((field.kind === 'derived' || field.kind === 'derived_call') && state.options.dev) {
140153
body.push(
141154
b.method(
142155
'set',
@@ -276,9 +289,14 @@ export const javascript_visitors_runes = {
276289
continue;
277290
}
278291

279-
if (rune === '$derived') {
292+
if (rune === '$derived' || rune === '$derived.call') {
280293
if (declarator.id.type === 'Identifier') {
281-
declarations.push(b.declarator(declarator.id, b.call('$.derived', b.thunk(value))));
294+
declarations.push(
295+
b.declarator(
296+
declarator.id,
297+
b.call('$.derived', rune === '$derived.call' ? value : b.thunk(value))
298+
)
299+
);
282300
} else {
283301
const bindings = state.scope.get_bindings(declarator);
284302
const id = state.scope.generate('derived_value');
@@ -289,7 +307,7 @@ export const javascript_visitors_runes = {
289307
'$.derived',
290308
b.thunk(
291309
b.block([
292-
b.let(declarator.id, value),
310+
b.let(declarator.id, rune === '$derived.call' ? b.call(value) : value),
293311
b.return(b.array(bindings.map((binding) => binding.node)))
294312
])
295313
)

packages/svelte/src/compiler/phases/3-transform/server/transform-server.js

+19
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,15 @@ const javascript_visitors_runes = {
558558
: /** @type {import('estree').Expression} */ (visit(node.value.arguments[0]))
559559
};
560560
}
561+
if (rune === '$derived.call') {
562+
return {
563+
...node,
564+
value:
565+
node.value.arguments.length === 0
566+
? null
567+
: b.call(/** @type {import('estree').Expression} */ (visit(node.value.arguments[0])))
568+
};
569+
}
561570
}
562571
next();
563572
},
@@ -583,6 +592,16 @@ const javascript_visitors_runes = {
583592
? b.id('undefined')
584593
: /** @type {import('estree').Expression} */ (visit(args[0]));
585594

595+
if (rune === '$derived.call') {
596+
declarations.push(
597+
b.declarator(
598+
/** @type {import('estree').Pattern} */ (visit(declarator.id)),
599+
b.call(value)
600+
)
601+
);
602+
continue;
603+
}
604+
586605
if (declarator.id.type === 'Identifier') {
587606
declarations.push(b.declarator(declarator.id, value));
588607
continue;

packages/svelte/src/compiler/phases/constants.js

+1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const Runes = /** @type {const} */ ([
7575
'$state.frozen',
7676
'$props',
7777
'$derived',
78+
'$derived.call',
7879
'$effect',
7980
'$effect.pre',
8081
'$effect.active',

packages/svelte/src/compiler/utils/builders.js

+1
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ export function thunk(expression) {
395395
expression.type === 'CallExpression' &&
396396
expression.callee.type !== 'Super' &&
397397
expression.callee.type !== 'MemberExpression' &&
398+
expression.callee.type !== 'CallExpression' &&
398399
expression.arguments.length === 0
399400
) {
400401
return expression.callee;

packages/svelte/src/compiler/warnings.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ const runes = {
2323
`Referencing a local variable with a $ prefix will create a store subscription. Please rename ${name} to avoid the ambiguity.`,
2424
/** @param {string} name */
2525
'non-state-reference': (name) =>
26-
`${name} is updated, but is not declared with $state(...). Changing its value will not correctly trigger updates.`
26+
`${name} is updated, but is not declared with $state(...). Changing its value will not correctly trigger updates.`,
27+
'derived-iife': () =>
28+
`Use \`$derived.call(() => {...})\` instead of \`$derived((() => {...})());\``
2729
};
2830

2931
/** @satisfies {Warnings} */

packages/svelte/src/main/ambient.d.ts

+21
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ declare namespace $state {
5959
*/
6060
declare function $derived<T>(expression: T): T;
6161

62+
declare namespace $derived {
63+
/**
64+
* Sometimes you need to create complex derivations that don't fit inside a short expression.
65+
* In these cases, you can use `$derived.call` which accepts a function as its argument.
66+
*
67+
* Example:
68+
* ```ts
69+
* let total = $derived.call(() => {
70+
* let result = 0;
71+
* for (const n of numbers) {
72+
* result += n;
73+
* }
74+
* return result;
75+
* });
76+
* ```
77+
*
78+
* https://svelte-5-preview.vercel.app/docs/runes#$derived-call
79+
*/
80+
export function fn<T>(fn: () => T): void;
81+
}
82+
6283
/**
6384
* Runs code when a component is mounted to the DOM, and then whenever its dependencies change, i.e. `$state` or `$derived` values.
6485
* The timing of the execution is after the DOM has been updated.

packages/svelte/tests/compiler-errors/samples/class-state-field-static/_config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { test } from '../../test';
33
export default test({
44
error: {
55
code: 'invalid-state-location',
6-
message: '$state() can only be used as a variable declaration initializer or a class field',
6+
message: '$state(...) can only be used as a variable declaration initializer or a class field',
77
position: [33, 41]
88
}
99
});

packages/svelte/tests/compiler-errors/samples/runes-no-rune-each/_config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ import { test } from '../../test';
33
export default test({
44
error: {
55
code: 'invalid-state-location',
6-
message: '$state() can only be used as a variable declaration initializer or a class field'
6+
message: '$state(...) can only be used as a variable declaration initializer or a class field'
77
}
88
});

packages/svelte/tests/compiler-errors/samples/runes-wrong-derived-placement/_config.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { test } from '../../test';
22

33
export default test({
44
error: {
5-
code: 'invalid-derived-location',
6-
message: '$derived() can only be used as a variable declaration initializer or a class field'
5+
code: 'invalid-state-location',
6+
message: '$derived(...) can only be used as a variable declaration initializer or a class field'
77
}
88
});

packages/svelte/tests/compiler-errors/samples/runes-wrong-state-placement/_config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ import { test } from '../../test';
33
export default test({
44
error: {
55
code: 'invalid-state-location',
6-
message: '$state() can only be used as a variable declaration initializer or a class field'
6+
message: '$state(...) can only be used as a variable declaration initializer or a class field'
77
}
88
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
html: `
5+
<button>0</button>
6+
<p>doubled: 0</p>
7+
`,
8+
9+
async test({ assert, target }) {
10+
const btn = target.querySelector('button');
11+
12+
await btn?.click();
13+
assert.htmlEqual(
14+
target.innerHTML,
15+
`
16+
<button>1</button>
17+
<p>doubled: 2</p>
18+
`
19+
);
20+
21+
await btn?.click();
22+
assert.htmlEqual(
23+
target.innerHTML,
24+
`
25+
<button>2</button>
26+
<p>doubled: 4</p>
27+
`
28+
);
29+
}
30+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<script>
2+
class Counter {
3+
count = $state(0);
4+
doubled = $derived.call(() => this.count * 2);
5+
}
6+
7+
const counter = new Counter();
8+
</script>
9+
10+
<button on:click={() => counter.count++}>{counter.count}</button>
11+
<p>doubled: {counter.doubled}</p>

0 commit comments

Comments
 (0)