diff --git a/.changeset/khaki-monkeys-cry.md b/.changeset/khaki-monkeys-cry.md new file mode 100644 index 000000000000..8135ae94702e --- /dev/null +++ b/.changeset/khaki-monkeys-cry.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: add $state.is rune diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index 0698530fc3c3..68d881ced09d 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -63,6 +63,27 @@ declare namespace $state { */ export function snapshot(state: T): T; + /** + * Compare two values, one or both of which is a reactive `$state(...)` proxy. + * + * Example: + * ```ts + * + * ``` + * + * https://svelte-5-preview.vercel.app/docs/runes#$state.is + * + */ + export function is(a: any, b: any): boolean; + // prevent intellisense from being unhelpful /** @deprecated */ export const apply: never; diff --git a/packages/svelte/src/compiler/phases/2-analyze/validation.js b/packages/svelte/src/compiler/phases/2-analyze/validation.js index 00985e838121..544b5a8aa7fd 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/validation.js +++ b/packages/svelte/src/compiler/phases/2-analyze/validation.js @@ -865,6 +865,12 @@ function validate_call_expression(node, scope, path) { e.rune_invalid_arguments_length(node, rune, 'exactly one argument'); } } + + if (rune === '$state.is') { + if (node.arguments.length !== 2) { + e.rune_invalid_arguments_length(node, rune, 'exactly two arguments'); + } + } } /** diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js index 9f3192c638c7..e1229a2bedbd 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/javascript-runes.js @@ -209,7 +209,8 @@ export const javascript_visitors_runes = { rune === '$effect.active' || rune === '$effect.root' || rune === '$inspect' || - rune === '$state.snapshot' + rune === '$state.snapshot' || + rune === '$state.is' ) { if (init != null && is_hoistable_function(init)) { const hoistable_function = visit(init); @@ -430,6 +431,14 @@ export const javascript_visitors_runes = { ); } + if (rune === '$state.is') { + return b.call( + '$.is', + /** @type {import('estree').Expression} */ (context.visit(node.arguments[0])), + /** @type {import('estree').Expression} */ (context.visit(node.arguments[1])) + ); + } + if (rune === '$effect.root') { const args = /** @type {import('estree').Expression[]} */ ( node.arguments.map((arg) => context.visit(arg)) diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 12e02eb1d4a5..582cfaf66ae6 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -778,6 +778,13 @@ const javascript_visitors_runes = { return /** @type {import('estree').Expression} */ (context.visit(node.arguments[0])); } + if (rune === '$state.is') { + return b.call( + 'Object.is', + /** @type {import('estree').Expression} */ (context.visit(node.arguments[0])) + ); + } + if (rune === '$inspect' || rune === '$inspect().with') { return transform_inspect_rune(node, context); } diff --git a/packages/svelte/src/compiler/phases/constants.js b/packages/svelte/src/compiler/phases/constants.js index 54f99995c610..8e3c01139526 100644 --- a/packages/svelte/src/compiler/phases/constants.js +++ b/packages/svelte/src/compiler/phases/constants.js @@ -32,6 +32,7 @@ export const Runes = /** @type {const} */ ([ '$state', '$state.frozen', '$state.snapshot', + '$state.is', '$props', '$bindable', '$derived', diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/input.js b/packages/svelte/src/internal/client/dom/elements/bindings/input.js index 464479496db8..1196a603396f 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/input.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/input.js @@ -3,6 +3,7 @@ import { render_effect, effect } from '../../../reactivity/effects.js'; import { stringify } from '../../../render.js'; import { listen_to_event_and_reset_event } from './shared.js'; import * as e from '../../../errors.js'; +import { get_proxied_value, is } from '../../../proxy.js'; /** * @param {HTMLInputElement} input @@ -95,10 +96,10 @@ export function bind_group(inputs, group_index, input, get_value, update) { if (is_checkbox) { value = value || []; // @ts-ignore - input.checked = value.includes(input.__value); + input.checked = get_proxied_value(value).includes(get_proxied_value(input.__value)); } else { // @ts-ignore - input.checked = input.__value === value; + input.checked = is(input.__value, value); } }); diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/select.js b/packages/svelte/src/internal/client/dom/elements/bindings/select.js index 840be3a7af03..533e8e9af91a 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js @@ -1,6 +1,7 @@ import { effect } from '../../../reactivity/effects.js'; import { listen_to_event_and_reset_event } from './shared.js'; import { untrack } from '../../../runtime.js'; +import { is } from '../../../proxy.js'; /** * Selects the correct option(s) (depending on whether this is a multiple select) @@ -16,7 +17,7 @@ export function select_option(select, value, mounting) { for (var option of select.options) { var option_value = get_option_value(option); - if (option_value === value) { + if (is(option_value, value)) { option.selected = true; return; } diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 33eb0c712b1f..8453696e69f1 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -143,7 +143,7 @@ export { validate_prop_bindings } from './validate.js'; export { raf } from './timing.js'; -export { proxy, snapshot } from './proxy.js'; +export { proxy, snapshot, is } 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 e845da3ba2b0..33e5edbe51ad 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -337,3 +337,24 @@ if (DEV) { e.state_prototype_fixed(); }; } + +/** + * @param {any} value + */ +export function get_proxied_value(value) { + if (value !== null && typeof value === 'object' && STATE_SYMBOL in value) { + var metadata = value[STATE_SYMBOL]; + if (metadata) { + return metadata.p; + } + } + return value; +} + +/** + * @param {any} a + * @param {any} b + */ +export function is(a, b) { + return Object.is(get_proxied_value(a), get_proxied_value(b)); +} diff --git a/packages/svelte/tests/runtime-runes/samples/state-is/_config.js b/packages/svelte/tests/runtime-runes/samples/state-is/_config.js new file mode 100644 index 000000000000..27aabe821329 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-is/_config.js @@ -0,0 +1,7 @@ +import { test } from '../../test'; + +export default test({ + async test({ assert, logs }) { + assert.deepEqual(logs, [false, true]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/state-is/main.svelte b/packages/svelte/tests/runtime-runes/samples/state-is/main.svelte new file mode 100644 index 000000000000..ff23de439a3c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/state-is/main.svelte @@ -0,0 +1,10 @@ + diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 3a26e79e91d7..39695c7bade0 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -2624,6 +2624,27 @@ declare namespace $state { */ export function snapshot(state: T): T; + /** + * Compare two values, one or both of which is a reactive `$state(...)` proxy. + * + * Example: + * ```ts + * + * ``` + * + * https://svelte-5-preview.vercel.app/docs/runes#$state.is + * + */ + export function is(a: any, b: any): boolean; + // prevent intellisense from being unhelpful /** @deprecated */ export const apply: never; diff --git a/sites/svelte-5-preview/src/lib/autocomplete.js b/sites/svelte-5-preview/src/lib/autocomplete.js index 8e540e9b32a2..ff0c2f741059 100644 --- a/sites/svelte-5-preview/src/lib/autocomplete.js +++ b/sites/svelte-5-preview/src/lib/autocomplete.js @@ -118,6 +118,7 @@ const runes = [ { snippet: '$bindable()', test: is_bindable }, { snippet: '$effect.root(() => {\n\t${}\n})' }, { snippet: '$state.snapshot(${})' }, + { snippet: '$state.is(${})' }, { snippet: '$effect.active()' }, { snippet: '$inspect(${});', test: is_statement } ]; diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md index ae2ccd510c0b..b9b75d2db1a2 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/02-runes.md @@ -112,6 +112,24 @@ This is handy when you want to pass some state to an external library or API tha > Note that `$state.snapshot` will clone the data when removing reactivity. If the value passed isn't a `$state` proxy, it will be returned as-is. +## `$state.is` + +Sometimes you might need to compare two values, one of which is a reactive `$state(...)` proxy. For this you can use `$state.is(a, b)`: + +```svelte + +``` + +This is handy when you might want to check if the object exists within a deeply reactive object/array. + ## `$derived` Derived state is declared with the `$derived` rune: