Skip to content

Commit b32d600

Browse files
Rich-Harristrueadm
authored andcommitted
breaking: use structuredClone inside $state.snapshot (#12413)
* move cloning logic into new file, use structuredClone, add tests * changeset * breaking * tweak * use same cloning approach between server and client * get types mostly working * fix type error that popped up * cheeky hack * we no longer need deep_snapshot * shallow copy state when freezing * throw if argument is a state proxy * docs * regenerate
1 parent bde98bb commit b32d600

File tree

34 files changed

+433
-187
lines changed

34 files changed

+433
-187
lines changed

.changeset/perfect-actors-bake.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
breaking: use structuredClone inside `$state.snapshot`

documentation/docs/03-runes/01-state.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ State declared with `$state.frozen` cannot be mutated; it can only be _reassigne
9191

9292
This can improve performance with large arrays and objects that you weren't planning to mutate anyway, since it avoids the cost of making them reactive. Note that frozen state can _contain_ reactive state (for example, a frozen array of reactive objects).
9393

94-
> Objects and arrays passed to `$state.frozen` will be shallowly frozen using `Object.freeze()`. If you don't want this, pass in a clone of the object or array instead.
94+
> Objects and arrays passed to `$state.frozen` will be shallowly frozen using `Object.freeze()`. If you don't want this, pass in a clone of the object or array instead. The argument cannot be an existing state proxy created with `$state(...)`.
9595
9696
## `$state.snapshot`
9797

packages/svelte/messages/client-errors/errors.md

+4
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@
6464

6565
> Snippets created with `createRawSnippet(...)` and used on the client must specify a `mount` function
6666
67+
## state_frozen_invalid_argument
68+
69+
> The argument to `$state.frozen(...)` cannot be an object created with `$state(...)`. You should create a copy of it first, for example with `$state.snapshot`
70+
6771
## state_prototype_fixed
6872

6973
> Cannot set prototype of `$state` object

packages/svelte/src/ambient.d.ts

+70-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,75 @@ declare function $state<T>(initial: T): T;
3232
declare function $state<T>(): T | undefined;
3333

3434
declare namespace $state {
35+
type Primitive = string | number | boolean | null | undefined;
36+
37+
type TypedArray =
38+
| Int8Array
39+
| Uint8Array
40+
| Uint8ClampedArray
41+
| Int16Array
42+
| Uint16Array
43+
| Int32Array
44+
| Uint32Array
45+
| Float32Array
46+
| Float64Array
47+
| BigInt64Array
48+
| BigUint64Array;
49+
50+
/** The things that `structuredClone` can handle — https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm */
51+
export type Cloneable =
52+
| ArrayBuffer
53+
| DataView
54+
| Date
55+
| Error
56+
| Map<any, any>
57+
| RegExp
58+
| Set<any>
59+
| TypedArray
60+
// web APIs
61+
| Blob
62+
| CryptoKey
63+
| DOMException
64+
| DOMMatrix
65+
| DOMMatrixReadOnly
66+
| DOMPoint
67+
| DOMPointReadOnly
68+
| DOMQuad
69+
| DOMRect
70+
| DOMRectReadOnly
71+
| File
72+
| FileList
73+
| FileSystemDirectoryHandle
74+
| FileSystemFileHandle
75+
| FileSystemHandle
76+
| ImageBitmap
77+
| ImageData
78+
| RTCCertificate
79+
| VideoFrame;
80+
81+
/** Turn `SvelteDate`, `SvelteMap` and `SvelteSet` into their non-reactive counterparts. (`URL` is uncloneable.) */
82+
type NonReactive<T> = T extends Date
83+
? Date
84+
: T extends Map<infer K, infer V>
85+
? Map<K, V>
86+
: T extends Set<infer K>
87+
? Set<K>
88+
: T;
89+
90+
type Snapshot<T> = T extends Primitive
91+
? T
92+
: T extends Cloneable
93+
? NonReactive<T>
94+
: T extends { toJSON(): infer R }
95+
? R
96+
: T extends Array<infer U>
97+
? Array<Snapshot<U>>
98+
: T extends object
99+
? T extends { [key: string]: any }
100+
? { [K in keyof T]: Snapshot<T[K]> }
101+
: never
102+
: never;
103+
35104
/**
36105
* Declares reactive read-only state that is shallowly immutable.
37106
*
@@ -75,7 +144,7 @@ declare namespace $state {
75144
*
76145
* @param state The value to snapshot
77146
*/
78-
export function snapshot<T>(state: T): T;
147+
export function snapshot<T>(state: T): Snapshot<T>;
79148

80149
/**
81150
* Compare two values, one or both of which is a reactive `$state(...)` proxy.

packages/svelte/src/compiler/phases/3-transform/server/transform-server.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,10 @@ const global_visitors = {
423423
}
424424

425425
if (rune === '$state.snapshot') {
426-
return /** @type {import('estree').Expression} */ (context.visit(node.arguments[0]));
426+
return b.call(
427+
'$.snapshot',
428+
/** @type {import('estree').Expression} */ (context.visit(node.arguments[0]))
429+
);
427430
}
428431

429432
if (rune === '$state.is') {

packages/svelte/src/index-client.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { current_component_context, flush_sync, untrack } from './internal/client/runtime.js';
2-
import { is_array } from './internal/client/utils.js';
2+
import { is_array } from './internal/shared/utils.js';
33
import { user_effect } from './internal/client/index.js';
44
import * as e from './internal/client/errors.js';
55
import { lifecycle_outside_component } from './internal/shared/errors.js';
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { snapshot } from '../proxy.js';
1+
import { snapshot } from '../../shared/clone.js';
22
import { inspect_effect, validate_effect } from '../reactivity/effects.js';
3-
import { array_prototype, get_prototype_of, object_prototype } from '../utils.js';
43

54
/**
65
* @param {() => any[]} get_value
@@ -13,47 +12,7 @@ export function inspect(get_value, inspector = console.log) {
1312
let initial = true;
1413

1514
inspect_effect(() => {
16-
inspector(initial ? 'init' : 'update', ...deep_snapshot(get_value()));
15+
inspector(initial ? 'init' : 'update', ...snapshot(get_value()));
1716
initial = false;
1817
});
1918
}
20-
21-
/**
22-
* Like `snapshot`, but recursively traverses into normal arrays/objects to find potential states in them.
23-
* @param {any} value
24-
* @param {Map<any, any>} visited
25-
* @returns {any}
26-
*/
27-
function deep_snapshot(value, visited = new Map()) {
28-
if (typeof value === 'object' && value !== null && !visited.has(value)) {
29-
const unstated = snapshot(value);
30-
31-
if (unstated !== value) {
32-
visited.set(value, unstated);
33-
return unstated;
34-
}
35-
36-
const prototype = get_prototype_of(value);
37-
38-
// Only deeply snapshot plain objects and arrays
39-
if (prototype === object_prototype || prototype === array_prototype) {
40-
let contains_unstated = false;
41-
/** @type {any} */
42-
const nested_unstated = Array.isArray(value) ? [] : {};
43-
44-
for (let key in value) {
45-
const result = deep_snapshot(value[key], visited);
46-
nested_unstated[key] = result;
47-
if (result !== value[key]) {
48-
contains_unstated = true;
49-
}
50-
}
51-
52-
visited.set(value, contains_unstated ? nested_unstated : value);
53-
} else {
54-
visited.set(value, value);
55-
}
56-
}
57-
58-
return visited.get(value) ?? value;
59-
}

packages/svelte/src/internal/client/dev/ownership.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { STATE_SYMBOL } from '../constants.js';
55
import { render_effect, user_pre_effect } from '../reactivity/effects.js';
66
import { dev_current_component_function } from '../runtime.js';
7-
import { get_prototype_of } from '../utils.js';
7+
import { get_prototype_of } from '../../shared/utils.js';
88
import * as w from '../warnings.js';
99

1010
/** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */

packages/svelte/src/internal/client/dom/blocks/each.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
resume_effect
2929
} from '../../reactivity/effects.js';
3030
import { source, mutable_source, set } from '../../reactivity/sources.js';
31-
import { is_array, is_frozen } from '../../utils.js';
31+
import { is_array, is_frozen } from '../../../shared/utils.js';
3232
import { INERT, STATE_FROZEN_SYMBOL, STATE_SYMBOL } from '../../constants.js';
3333
import { queue_micro_task } from '../task.js';
3434
import { current_effect } from '../../runtime.js';

packages/svelte/src/internal/client/dom/css.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function append_styles(anchor, css) {
2020

2121
var target = /** @type {ShadowRoot} */ (root).host
2222
? /** @type {ShadowRoot} */ (root)
23-
: /** @type {Document} */ (root).head;
23+
: /** @type {Document} */ (root).head ?? /** @type {Document} */ (root.ownerDocument).head;
2424

2525
if (!target.querySelector('#' + css.hash)) {
2626
const style = document.createElement('style');

packages/svelte/src/internal/client/dom/elements/attributes.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { DEV } from 'esm-env';
22
import { hydrating } from '../hydration.js';
3-
import { get_descriptors, get_prototype_of } from '../../utils.js';
3+
import { get_descriptors, get_prototype_of } from '../../../shared/utils.js';
44
import {
55
AttributeAliases,
66
DelegatedEvents,

packages/svelte/src/internal/client/dom/elements/bindings/props.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { teardown } from '../../../reactivity/effects.js';
2-
import { get_descriptor } from '../../../utils.js';
2+
import { get_descriptor } from '../../../../shared/utils.js';
33

44
/**
55
* Makes an `export`ed (non-prop) variable available on the `$$props` object

packages/svelte/src/internal/client/dom/elements/custom-element.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createClassComponent } from '../../../../legacy/legacy-client.js';
22
import { destroy_effect, render_effect } from '../../reactivity/effects.js';
33
import { append } from '../template.js';
4-
import { define_property, object_keys } from '../../utils.js';
4+
import { define_property, object_keys } from '../../../shared/utils.js';
55

66
/**
77
* @typedef {Object} CustomElementPropDefinition

packages/svelte/src/internal/client/dom/elements/events.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { teardown } from '../../reactivity/effects.js';
2-
import { define_property, is_array } from '../../utils.js';
2+
import { define_property, is_array } from '../../../shared/utils.js';
33
import { hydrating } from '../hydration.js';
44
import { queue_micro_task } from '../task.js';
55

packages/svelte/src/internal/client/dom/elements/transitions.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { noop } from '../../../shared/utils.js';
1+
import { noop, is_function } from '../../../shared/utils.js';
22
import { effect } from '../../reactivity/effects.js';
33
import { current_effect, untrack } from '../../runtime.js';
44
import { raf } from '../../timing.js';
55
import { loop } from '../../loop.js';
66
import { should_intro } from '../../render.js';
7-
import { is_function } from '../../utils.js';
87
import { current_each_item } from '../blocks/each.js';
98
import { TRANSITION_GLOBAL, TRANSITION_IN, TRANSITION_OUT } from '../../../../constants.js';
109
import { BLOCK_EFFECT, EFFECT_RAN, EFFECT_TRANSPARENT } from '../../constants.js';

packages/svelte/src/internal/client/dom/legacy/misc.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { set, source } from '../../reactivity/sources.js';
22
import { get } from '../../runtime.js';
3-
import { is_array } from '../../utils.js';
3+
import { is_array } from '../../../shared/utils.js';
44

55
/**
66
* Under some circumstances, imports may be reactive in legacy mode. In that case,

packages/svelte/src/internal/client/errors.js

+16
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,22 @@ export function snippet_missing_mount() {
278278
}
279279
}
280280

281+
/**
282+
* The argument to `$state.frozen(...)` cannot be an object created with `$state(...)`. You should create a copy of it first, for example with `$state.snapshot`
283+
* @returns {never}
284+
*/
285+
export function state_frozen_invalid_argument() {
286+
if (DEV) {
287+
const error = new Error(`state_frozen_invalid_argument\nThe argument to \`$state.frozen(...)\` cannot be an object created with \`$state(...)\`. You should create a copy of it first, for example with \`$state.snapshot\``);
288+
289+
error.name = 'Svelte error';
290+
throw error;
291+
} else {
292+
// TODO print a link to the documentation
293+
throw new Error("state_frozen_invalid_argument");
294+
}
295+
}
296+
281297
/**
282298
* Cannot set prototype of `$state` object
283299
* @returns {never}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { DEV } from 'esm-env';
2+
import { define_property, is_array, is_frozen, object_freeze } from '../shared/utils.js';
3+
import { STATE_FROZEN_SYMBOL, STATE_SYMBOL } from './constants.js';
4+
import * as e from './errors.js';
5+
6+
/**
7+
* Expects a value that was wrapped with `freeze` and makes it frozen in DEV.
8+
* @template T
9+
* @param {T} value
10+
* @returns {Readonly<T>}
11+
*/
12+
export function freeze(value) {
13+
if (
14+
typeof value === 'object' &&
15+
value !== null &&
16+
!is_frozen(value) &&
17+
!(STATE_FROZEN_SYMBOL in value)
18+
) {
19+
var copy = /** @type {T} */ (value);
20+
21+
if (STATE_SYMBOL in value) {
22+
e.state_frozen_invalid_argument();
23+
}
24+
25+
define_property(copy, STATE_FROZEN_SYMBOL, {
26+
value: true,
27+
writable: true,
28+
enumerable: false
29+
});
30+
31+
// Freeze the object in DEV
32+
if (DEV) {
33+
object_freeze(copy);
34+
}
35+
36+
return /** @type {Readonly<T>} */ (copy);
37+
}
38+
39+
return value;
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { freeze } from './freeze.js';
2+
import { assert, test } from 'vitest';
3+
import { proxy } from './proxy.js';
4+
5+
test('freezes an object', () => {
6+
const frozen = freeze({ a: 1 });
7+
8+
assert.throws(() => {
9+
// @ts-expect-error
10+
frozen.a += 1;
11+
}, /Cannot assign to read only property/);
12+
});
13+
14+
test('throws if argument is a state proxy', () => {
15+
assert.throws(() => freeze(proxy({})), /state_frozen_invalid_argument/);
16+
});

packages/svelte/src/internal/client/index.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export {
9191
template_with_script,
9292
text
9393
} from './dom/template.js';
94+
export { freeze } from './freeze.js';
9495
export { derived, derived_safe_equal } from './reactivity/deriveds.js';
9596
export {
9697
effect_tracking,
@@ -136,7 +137,6 @@ export {
136137
pop,
137138
push,
138139
unwrap,
139-
freeze,
140140
deep_read,
141141
deep_read_state,
142142
getAllContexts,
@@ -150,7 +150,7 @@ export {
150150
validate_prop_bindings
151151
} from './validate.js';
152152
export { raf } from './timing.js';
153-
export { proxy, snapshot, is } from './proxy.js';
153+
export { proxy, is } from './proxy.js';
154154
export { create_custom_element } from './dom/elements/custom-element.js';
155155
export {
156156
child,
@@ -159,6 +159,7 @@ export {
159159
$window as window,
160160
$document as document
161161
} from './dom/operations.js';
162+
export { snapshot } from '../shared/clone.js';
162163
export { noop } from '../shared/utils.js';
163164
export {
164165
validate_component,

0 commit comments

Comments
 (0)