From c7e626ebbbe04416f8a3ec7dca0f920c7e1497c0 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 5 Dec 2023 19:40:52 +0000 Subject: [PATCH] feat: add unstate utility function (#9776) * feat: add unstate utility function * Update packages/svelte/src/internal/client/proxy/proxy.js Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> * update docs * add class support * oops * lint * fix docs * remove symbol and class support --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Rich Harris --- .changeset/beige-flies-wash.md | 5 ++ .../client/visitors/javascript-runes.js | 7 ++- .../svelte/src/internal/client/proxy/proxy.js | 58 ++++++++++++++++++- packages/svelte/src/internal/client/render.js | 19 ++++-- packages/svelte/src/internal/client/utils.js | 3 + packages/svelte/src/internal/index.js | 2 +- packages/svelte/src/main/main-client.js | 10 +++- .../runtime-runes/samples/unstate/_config.js | 12 ++++ .../runtime-runes/samples/unstate/main.svelte | 7 +++ .../_expected/client/index.svelte.js | 2 +- .../_expected/client/main.svelte.js | 2 +- .../_expected/client/index.svelte.js | 2 +- .../_expected/server/index.svelte.js | 3 +- .../_expected/client/index.svelte.js | 2 +- .../docs/content/01-api/05-functions.md | 21 +++++++ 15 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 .changeset/beige-flies-wash.md create mode 100644 packages/svelte/tests/runtime-runes/samples/unstate/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/unstate/main.svelte diff --git a/.changeset/beige-flies-wash.md b/.changeset/beige-flies-wash.md new file mode 100644 index 0000000000..2ccce23dfe --- /dev/null +++ b/.changeset/beige-flies-wash.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +feat: add unstate utility function 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 01a6ec7df3..e84efe1582 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 @@ -105,7 +105,12 @@ export const javascript_visitors_runes = { // set foo(value) { this.#foo = value; } const value = b.id('value'); body.push( - b.method('set', definition.key, [value], [b.stmt(b.call('$.set', member, value))]) + b.method( + 'set', + definition.key, + [value], + [b.stmt(b.call('$.set', member, b.call('$.proxy', value)))] + ) ); } diff --git a/packages/svelte/src/internal/client/proxy/proxy.js b/packages/svelte/src/internal/client/proxy/proxy.js index f4a3e0c6d5..4e6db687d6 100644 --- a/packages/svelte/src/internal/client/proxy/proxy.js +++ b/packages/svelte/src/internal/client/proxy/proxy.js @@ -8,7 +8,13 @@ import { updating_derived, UNINITIALIZED } from '../runtime.js'; -import { define_property, get_descriptor, is_array } from '../utils.js'; +import { + define_property, + get_descriptor, + get_descriptors, + is_array, + object_keys +} from '../utils.js'; import { READONLY_SYMBOL } from './readonly.js'; /** @typedef {{ s: Map>; v: import('../types.js').SourceSignal; a: boolean }} Metadata */ @@ -42,6 +48,56 @@ export function proxy(value) { return value; } +/** + * @template {StateObject} T + * @param {T} value + * @param {Map>} already_unwrapped + * @returns {Record} + */ +function unwrap(value, already_unwrapped = new Map()) { + if (typeof value === 'object' && value != null && !is_frozen(value) && STATE_SYMBOL in value) { + const unwrapped = already_unwrapped.get(value); + if (unwrapped !== undefined) { + return unwrapped; + } + if (is_array(value)) { + /** @type {Record} */ + const array = []; + already_unwrapped.set(value, array); + for (const element of value) { + array.push(unwrap(element, already_unwrapped)); + } + return array; + } else { + /** @type {Record} */ + const obj = {}; + const keys = object_keys(value); + const descriptors = get_descriptors(value); + already_unwrapped.set(value, obj); + for (const key of keys) { + if (descriptors[key].get) { + define_property(obj, key, descriptors[key]); + } else { + /** @type {T} */ + const property = value[key]; + obj[key] = unwrap(property, already_unwrapped); + } + } + return obj; + } + } + return value; +} + +/** + * @template {StateObject} T + * @param {T} value + * @returns {Record} + */ +export function unstate(value) { + return unwrap(value); +} + /** * @param {StateObject} value * @returns {Metadata} diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index bb9faa2707..d1fa227c6d 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -50,7 +50,16 @@ import { hydrate_block_anchor, set_current_hydration_fragment } from './hydration.js'; -import { array_from, define_property, get_descriptor, get_descriptors, is_array } from './utils.js'; +import { + array_from, + define_property, + get_descriptor, + get_descriptors, + is_array, + object_assign, + object_entries, + object_keys +} from './utils.js'; import { is_promise } from '../common.js'; import { bind_transition, trigger_transitions } from './transitions.js'; @@ -2402,7 +2411,7 @@ function get_setters(element) { * @returns {Record} */ export function spread_attributes(dom, prev, attrs, lowercase_attributes, css_hash) { - const next = Object.assign({}, ...attrs); + const next = object_assign({}, ...attrs); const has_hash = css_hash.length !== 0; for (const key in prev) { if (!(key in next)) { @@ -2498,7 +2507,7 @@ export function spread_attributes(dom, prev, attrs, lowercase_attributes, css_ha */ export function spread_dynamic_element_attributes(node, prev, attrs, css_hash) { if (node.tagName.includes('-')) { - const next = Object.assign({}, ...attrs); + const next = object_assign({}, ...attrs); if (prev !== null) { for (const key in prev) { if (!(key in next)) { @@ -2666,7 +2675,7 @@ export function createRoot(component, options) { const result = /** @type {Exports & { $destroy: () => void; $set: (props: Partial) => void; }} */ ({ $set: (props) => { - for (const [prop, value] of Object.entries(props)) { + for (const [prop, value] of object_entries(props)) { if (prop in _sources) { set(_sources[prop], value); } else { @@ -2678,7 +2687,7 @@ export function createRoot(component, options) { $destroy }); - for (const key of Object.keys(accessors || {})) { + for (const key of object_keys(accessors || {})) { define_property(result, key, { get() { // @ts-expect-error TS doesn't know key exists on accessor diff --git a/packages/svelte/src/internal/client/utils.js b/packages/svelte/src/internal/client/utils.js index 6e43daefd6..952bc9db8a 100644 --- a/packages/svelte/src/internal/client/utils.js +++ b/packages/svelte/src/internal/client/utils.js @@ -2,6 +2,9 @@ // to de-opt (this occurs often when using popular extensions). export var is_array = Array.isArray; export var array_from = Array.from; +export var object_keys = Object.keys; +export var object_entries = Object.entries; +export var object_assign = Object.assign; export var define_property = Object.defineProperty; export var get_descriptor = Object.getOwnPropertyDescriptor; export var get_descriptors = Object.getOwnPropertyDescriptors; diff --git a/packages/svelte/src/internal/index.js b/packages/svelte/src/internal/index.js index b66715e286..4d3205b824 100644 --- a/packages/svelte/src/internal/index.js +++ b/packages/svelte/src/internal/index.js @@ -45,7 +45,7 @@ export * from './client/each.js'; export * from './client/render.js'; export * from './client/validate.js'; export { raf } from './client/timing.js'; -export { proxy, readonly } from './client/proxy/proxy.js'; +export { proxy, readonly, unstate } from './client/proxy/proxy.js'; export { create_custom_element } from './client/custom-element.js'; diff --git a/packages/svelte/src/main/main-client.js b/packages/svelte/src/main/main-client.js index 9da264fc35..f53173bb66 100644 --- a/packages/svelte/src/main/main-client.js +++ b/packages/svelte/src/main/main-client.js @@ -255,4 +255,12 @@ export function afterUpdate(fn) { // TODO bring implementations in here // (except probably untrack — do we want to expose that, if there's also a rune?) -export { flushSync, createRoot, mount, tick, untrack, onDestroy } from '../internal/index.js'; +export { + flushSync, + createRoot, + mount, + tick, + untrack, + unstate, + onDestroy +} from '../internal/index.js'; diff --git a/packages/svelte/tests/runtime-runes/samples/unstate/_config.js b/packages/svelte/tests/runtime-runes/samples/unstate/_config.js new file mode 100644 index 0000000000..c0d6357f14 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/unstate/_config.js @@ -0,0 +1,12 @@ +import { test } from '../../test'; + +export default test({ + html: ``, + + async test({ assert, target }) { + const btn = target.querySelector('button'); + + await btn?.click(); + assert.htmlEqual(target.innerHTML, ``); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/unstate/main.svelte b/packages/svelte/tests/runtime-runes/samples/unstate/main.svelte new file mode 100644 index 0000000000..cb014acff3 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/unstate/main.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js index 4add64c66f..38a74ef9bb 100644 --- a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js @@ -14,7 +14,7 @@ export default function Class_state_field_constructor_assignment($$anchor, $$pro } set a(value) { - $.set(this.#a, value); + $.set(this.#a, $.proxy(value)); } #b = $.source(); diff --git a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js index 713b4e1337..75d495c0a7 100644 --- a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js +++ b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js @@ -45,4 +45,4 @@ export default function Main($$anchor, $$props) { $.close_frag($$anchor, fragment); $.pop(); -} +} \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/export-state/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/export-state/_expected/client/index.svelte.js index c9ba1e7c73..4cc594e9d0 100644 --- a/packages/svelte/tests/snapshot/samples/export-state/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/export-state/_expected/client/index.svelte.js @@ -1,4 +1,4 @@ /* index.svelte.js generated by Svelte VERSION */ import * as $ from "svelte/internal"; -export const object = $.proxy({ ok: true }); +export const object = $.proxy({ ok: true }); \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/export-state/_expected/server/index.svelte.js b/packages/svelte/tests/snapshot/samples/export-state/_expected/server/index.svelte.js index 770f2e5e4f..a3b619df6e 100644 --- a/packages/svelte/tests/snapshot/samples/export-state/_expected/server/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/export-state/_expected/server/index.svelte.js @@ -1,5 +1,4 @@ /* index.svelte.js generated by Svelte VERSION */ import * as $ from "svelte/internal/server"; -export const object = { ok: true }; - +export const object = { ok: true }; \ No newline at end of file diff --git a/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js index 5ed73f0f29..55ccb739aa 100644 --- a/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js @@ -14,4 +14,4 @@ export default function Svelte_element($$anchor, $$props) { $.element(node, () => $.get(tag)); $.close_frag($$anchor, fragment); $.pop(); -} +} \ No newline at end of file diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md index b529d05cfb..26410e1774 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md @@ -22,3 +22,24 @@ To prevent something from being treated as an `$effect`/`$derived` dependency, u }); ``` + +## `unstate` + +To remove reactivity from objects and arrays created with `$state`, use `unstate`: + +```svelte + +``` + +This is handy when you want to pass some state to an external library or API that doesn't expect a reactive object – such as `structuredClone`. + +> Note that `unstate` will return a new object from the input when removing reactivity. If the object passed isn't reactive, it will be returned as is.