Skip to content

chore: add $derived.call rune #10240

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nervous-spoons-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

chore: add $derived.call rune
7 changes: 3 additions & 4 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -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'
Expand Down
36 changes: 22 additions & 14 deletions packages/svelte/src/compiler/phases/2-analyze/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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');
}
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -938,25 +938,23 @@ 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);

if (rune === null) return;

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');
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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');
Expand All @@ -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)))
])
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/compiler/phases/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const Runes = /** @type {const} */ ([
'$state.frozen',
'$props',
'$derived',
'$derived.call',
'$effect',
'$effect.pre',
'$effect.active',
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/compiler/utils/builders.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion packages/svelte/src/compiler/warnings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand Down
21 changes: 21 additions & 0 deletions packages/svelte/src/main/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
});
Original file line number Diff line number Diff line change
@@ -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>
`
);
}
});
Original file line number Diff line number Diff line change
@@ -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>
Loading