diff --git a/.changeset/sharp-rings-march.md b/.changeset/sharp-rings-march.md new file mode 100644 index 000000000000..bd3a5e531210 --- /dev/null +++ b/.changeset/sharp-rings-march.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: allow `$state` in return statements diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md index 16630a977b62..e98c66f08ffb 100644 --- a/documentation/docs/02-runes/02-$state.md +++ b/documentation/docs/02-runes/02-$state.md @@ -65,6 +65,19 @@ let { done, text } = todos[0]; todos[0].done = !todos[0].done; ``` +You can also use `$state` in return statements to proxy their argument: + +```js +function createCounter() { + return $state({ + count: 0, + increment() { + this.count++; + } + }); +} +``` + ### Classes You can also use `$state` in class fields (whether public or private): diff --git a/documentation/docs/98-reference/.generated/client-warnings.md b/documentation/docs/98-reference/.generated/client-warnings.md index 77d1df4cdde2..7bfc20e58905 100644 --- a/documentation/docs/98-reference/.generated/client-warnings.md +++ b/documentation/docs/98-reference/.generated/client-warnings.md @@ -219,6 +219,12 @@ Reactive `$state(...)` proxies and the values they proxy have different identiti To resolve this, ensure you're comparing values where both values were created with `$state(...)`, or neither were. Note that `$state.raw(...)` will _not_ create a state proxy. +### state_return_not_proxyable + +``` +The argument passed to a `$state` call in a return statement must be a plain object or array. Otherwise, the `$state` call will have no effect +``` + ### transition_slide_display ``` diff --git a/packages/svelte/messages/client-warnings/warnings.md b/packages/svelte/messages/client-warnings/warnings.md index f8e9ebd8a047..7c644409ce43 100644 --- a/packages/svelte/messages/client-warnings/warnings.md +++ b/packages/svelte/messages/client-warnings/warnings.md @@ -185,6 +185,10 @@ To fix it, either create callback props to communicate changes, or mark `person` To resolve this, ensure you're comparing values where both values were created with `$state(...)`, or neither were. Note that `$state.raw(...)` will _not_ create a state proxy. +## state_return_not_proxyable + +> The argument passed to a `$state` call in a return statement must be a plain object or array. Otherwise, the `$state` call will have no effect + ## transition_slide_display > The `slide` transition does not work correctly for elements with `display: %value%` diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js index 904817b014e4..b788e2873735 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -112,6 +112,20 @@ export function CallExpression(node, context) { } case '$state': + if ( + (!(parent.type === 'VariableDeclarator' || parent.type === 'ReturnStatement') || + get_parent(context.path, -3).type === 'ConstTag') && + !(parent.type === 'PropertyDefinition' && !parent.static && !parent.computed) && + !(parent.type === 'ArrowFunctionExpression' && parent.body === node) + ) { + e.state_invalid_placement(node, rune); + } + + if (node.arguments.length > 1) { + e.rune_invalid_arguments_length(node, rune, 'zero or one arguments'); + } + + break; case '$state.raw': case '$derived': case '$derived.by': diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js index b110f8eae82c..5e16a5055060 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js @@ -4,12 +4,14 @@ import { dev, is_ignored } from '../../../../state.js'; import * as b from '#compiler/builders'; import { get_rune } from '../../../scope.js'; import { transform_inspect_rune } from '../../utils.js'; +import { should_proxy } from '../utils.js'; /** * @param {CallExpression} node * @param {Context} context */ export function CallExpression(node, context) { + const parent = context.path.at(-1); switch (get_rune(node, context.state.scope)) { case '$host': return b.id('$$props.$$host'); @@ -33,6 +35,18 @@ export function CallExpression(node, context) { case '$inspect': case '$inspect().with': return transform_inspect_rune(node, context); + case '$state': + if ( + parent?.type === 'ReturnStatement' || + (parent?.type === 'ArrowFunctionExpression' && parent.body === node) + ) { + if (node.arguments[0]) { + return b.call( + '$.return_proxy', + /** @type {Expression} */ (context.visit(node.arguments[0] ?? b.void0)) + ); + } + } } if ( diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js index 5bcbdee9fbfe..d5097578bda0 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js @@ -1,4 +1,4 @@ -/** @import { CallExpression, Expression } from 'estree' */ +/** @import { ArrowFunctionExpression, CallExpression, Expression } from 'estree' */ /** @import { Context } from '../types.js' */ import { is_ignored } from '../../../../state.js'; import * as b from '#compiler/builders'; @@ -37,5 +37,17 @@ export function CallExpression(node, context) { return transform_inspect_rune(node, context); } + if ( + rune === '$state' && + (context.path.at(-1)?.type === 'ReturnStatement' || + (context.path.at(-1)?.type === 'ArrowFunctionExpression' && + /** @type {ArrowFunctionExpression} */ (context.path.at(-1)).body === node)) + ) { + if (node.arguments[0]) { + return context.visit(node.arguments[0]); + } + return b.void0; + } + context.next(); } diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 14d6e29f5bb4..eb5321c9409e 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -141,7 +141,7 @@ export { } from './runtime.js'; export { validate_binding, validate_each_keys } from './validate.js'; export { raf } from './timing.js'; -export { proxy } from './proxy.js'; +export { proxy, return_proxy } from './proxy.js'; export { create_custom_element } from './dom/elements/custom-element.js'; export { child, diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index fd5706eaf270..9499e7f9ddb9 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -12,9 +12,25 @@ import { state as source, set } from './reactivity/sources.js'; import { STATE_SYMBOL } from '#client/constants'; import { UNINITIALIZED } from '../../constants.js'; import * as e from './errors.js'; +import * as w from './warnings.js'; import { get_stack } from './dev/tracing.js'; import { tracing_mode_flag } from '../flags/index.js'; +/** + * @param {unknown} value + * @returns {boolean} + */ +function should_proxy(value) { + if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) { + return false; + } + const prototype = get_prototype_of(value); + if (prototype !== object_prototype && prototype !== array_prototype) { + return false; + } + return true; +} + /** * @template T * @param {T} value @@ -22,13 +38,7 @@ import { tracing_mode_flag } from '../flags/index.js'; */ export function proxy(value) { // if non-proxyable, or is already a proxy, return `value` - if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) { - return value; - } - - const prototype = get_prototype_of(value); - - if (prototype !== object_prototype && prototype !== array_prototype) { + if (!should_proxy(value)) { return value; } @@ -282,6 +292,21 @@ export function proxy(value) { }); } +/** + * @template T + * @param {T} value + * @returns {T | void} + */ +export function return_proxy(value) { + if (should_proxy(value)) { + return proxy(value); + } else if (DEV && !(typeof value === 'object' && value !== null && STATE_SYMBOL in value)) { + // if the argument passed was already a proxy, we don't warn + w.state_return_not_proxyable(); + } + return value; +} + /** * @param {Source} signal * @param {1 | -1} [d] diff --git a/packages/svelte/src/internal/client/warnings.js b/packages/svelte/src/internal/client/warnings.js index c84b487e280d..acb9f82c0cb6 100644 --- a/packages/svelte/src/internal/client/warnings.js +++ b/packages/svelte/src/internal/client/warnings.js @@ -170,6 +170,17 @@ export function state_proxy_equality_mismatch(operator) { } } +/** + * The argument passed to a `$state` call in a return statement must be a plain object or array. Otherwise, the `$state` call will have no effect + */ +export function state_return_not_proxyable() { + if (DEV) { + console.warn(`%c[svelte] state_return_not_proxyable\n%cThe argument passed to a \`$state\` call in a return statement must be a plain object or array. Otherwise, the \`$state\` call will have no effect\nhttps://svelte.dev/e/state_return_not_proxyable`, bold, normal); + } else { + console.warn(`https://svelte.dev/e/state_return_not_proxyable`); + } +} + /** * The `slide` transition does not work correctly for elements with `display: %value%` * @param {string} value diff --git a/packages/svelte/tests/snapshot/samples/state-in-return/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/state-in-return/_expected/client/index.svelte.js new file mode 100644 index 000000000000..de55e87b536a --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/state-in-return/_expected/client/index.svelte.js @@ -0,0 +1,14 @@ +/* index.svelte.js generated by Svelte VERSION */ +import * as $ from 'svelte/internal/client'; + +export default function proxy(object) { + return $.return_proxy(object); +} + +export function createCounter() { + let count = $.state(0); + + $.update(count); +} + +export const proxy_in_arrow = (object) => $.return_proxy(object); \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/state-in-return/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/state-in-return/_expected/server/index.svelte.js new file mode 100644 index 000000000000..de52cde883be --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/state-in-return/_expected/server/index.svelte.js @@ -0,0 +1,14 @@ +/* index.svelte.js generated by Svelte VERSION */ +import * as $ from 'svelte/internal/server'; + +export default function proxy(object) { + return object; +} + +export function createCounter() { + let count = 0; + + count++; +} + +export const proxy_in_arrow = (object) => object; \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/state-in-return/index.svelte.js b/packages/svelte/tests/snapshot/samples/state-in-return/index.svelte.js new file mode 100644 index 000000000000..66d143f4096e --- /dev/null +++ b/packages/svelte/tests/snapshot/samples/state-in-return/index.svelte.js @@ -0,0 +1,8 @@ +export default function proxy(object) { + return $state(object); +} +export function createCounter() { + let count = $state(0); + count++; +} +export const proxy_in_arrow = (object) => $state(object);