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 <richard.a.harris@gmail.com>
pull/9791/head
Dominic Gannaway 2 years ago committed by GitHub
parent f1954d034b
commit c7e626ebbb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: add unstate utility function

@ -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)))]
)
);
}

@ -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<string | symbol, import('../types.js').SourceSignal<any>>; v: import('../types.js').SourceSignal<number>; a: boolean }} Metadata */
@ -42,6 +48,56 @@ export function proxy(value) {
return value;
}
/**
* @template {StateObject} T
* @param {T} value
* @param {Map<T, Record<string | symbol, any>>} already_unwrapped
* @returns {Record<string | symbol, any>}
*/
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<string | symbol, any>} */
const array = [];
already_unwrapped.set(value, array);
for (const element of value) {
array.push(unwrap(element, already_unwrapped));
}
return array;
} else {
/** @type {Record<string | symbol, any>} */
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<string | symbol, any>}
*/
export function unstate(value) {
return unwrap(value);
}
/**
* @param {StateObject} value
* @returns {Metadata}

@ -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<string, unknown>}
*/
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<Props>) => 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

@ -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;

@ -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';

@ -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';

@ -0,0 +1,12 @@
import { test } from '../../test';
export default test({
html: `<button>[{"a":0}]</button>`,
async test({ assert, target }) {
const btn = target.querySelector('button');
await btn?.click();
assert.htmlEqual(target.innerHTML, `<button>[{"a":0},{"a":1}]</button>`);
}
});

@ -0,0 +1,7 @@
<script>
import { unstate } from 'svelte';
let items = $state([{a: 0}]);
</script>
<button on:click={() => items.push({a: items.length})}>{JSON.stringify(structuredClone(unstate(items)))}</button>

@ -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();

@ -45,4 +45,4 @@ export default function Main($$anchor, $$props) {
$.close_frag($$anchor, fragment);
$.pop();
}
}

@ -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 });

@ -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 };

@ -14,4 +14,4 @@ export default function Svelte_element($$anchor, $$props) {
$.element(node, () => $.get(tag));
$.close_frag($$anchor, fragment);
$.pop();
}
}

@ -22,3 +22,24 @@ To prevent something from being treated as an `$effect`/`$derived` dependency, u
});
</script>
```
## `unstate`
To remove reactivity from objects and arrays created with `$state`, use `unstate`:
```svelte
<script>
import { unstate } from 'svelte';
let counter = $state({ count: 0 });
$effect(() => {
// Will log { count: 0 }
console.log(unstate(counter));
});
</script>
```
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.

Loading…
Cancel
Save