Skip to content
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/beige-flies-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

feat: add unstate utility function
Original file line number Diff line number Diff line change
Expand Up @@ -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)))]
)
);
}

Expand Down
58 changes: 57 additions & 1 deletion packages/svelte/src/internal/client/proxy/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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}
Expand Down
19 changes: 14 additions & 5 deletions packages/svelte/src/internal/client/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/svelte/src/internal/client/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
10 changes: 9 additions & 1 deletion packages/svelte/src/main/main-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
12 changes: 12 additions & 0 deletions packages/svelte/tests/runtime-runes/samples/unstate/_config.js
Original file line number Diff line number Diff line change
@@ -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>`);
}
});
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ export default function Main($$anchor, $$props) {

$.close_frag($$anchor, fragment);
$.pop();
}
}
Original file line number Diff line number Diff line change
@@ -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 });
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ export default function Svelte_element($$anchor, $$props) {
$.element(node, () => $.get(tag));
$.close_frag($$anchor, fragment);
$.pop();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.