Skip to content

Commit 9c507a0

Browse files
fix: ensure $state.snapshot never errors (#12445)
* fix: ensure `$state.snapshot` never errors Snapshotting can error on un-cloneable objects. It's not practical to error in this case; often there's no way out of this for users, so it makes sense to return the original value in that case, and warn in dev mode about it. closes #12438 * lint * single warning per snapshot call, list affected properties * lint * lint --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 07b0db8 commit 9c507a0

File tree

5 files changed

+174
-7
lines changed

5 files changed

+174
-7
lines changed

.changeset/brave-gorillas-fold.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: ensure `$state.snapshot` never errors
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
## dynamic_void_element_content
22

33
> `<svelte:element this="%tag%">` is a void element — it cannot have content
4+
5+
## state_snapshot_uncloneable
6+
7+
> Value cannot be cloned with `$state.snapshot` — the original value was returned
8+
9+
> The following properties cannot be cloned with `$state.snapshot` — the return value contains the originals:
10+
>
11+
> %properties%
Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,56 @@
11
/** @import { Snapshot } from './types' */
2+
import { DEV } from 'esm-env';
3+
import * as w from './warnings.js';
24
import { get_prototype_of, is_array, object_prototype } from './utils.js';
35

6+
/**
7+
* In dev, we keep track of which properties could not be cloned. In prod
8+
* we don't bother, but we keep a dummy array around so that the
9+
* signature stays the same
10+
* @type {string[]}
11+
*/
12+
const empty = [];
13+
414
/**
515
* @template T
616
* @param {T} value
717
* @returns {Snapshot<T>}
818
*/
919
export function snapshot(value) {
10-
return clone(value, new Map());
20+
if (DEV) {
21+
/** @type {string[]} */
22+
const paths = [];
23+
24+
const copy = clone(value, new Map(), '', paths);
25+
if (paths.length === 1 && paths[0] === '') {
26+
// value could not be cloned
27+
w.state_snapshot_uncloneable();
28+
} else if (paths.length > 0) {
29+
// some properties could not be cloned
30+
const slice = paths.length > 10 ? paths.slice(0, 7) : paths.slice(0, 10);
31+
const excess = paths.length - slice.length;
32+
33+
let uncloned = slice.map((path) => `- <value>${path}`).join('\n');
34+
if (excess > 0) uncloned += `\n- ...and ${excess} more`;
35+
36+
w.state_snapshot_uncloneable(uncloned);
37+
}
38+
39+
return copy;
40+
}
41+
42+
return clone(value, new Map(), '', empty);
1143
}
1244

1345
/**
1446
* @template T
1547
* @param {T} value
1648
* @param {Map<T, Snapshot<T>>} cloned
49+
* @param {string} path
50+
* @param {string[]} paths
1751
* @returns {Snapshot<T>}
1852
*/
19-
function clone(value, cloned) {
53+
function clone(value, cloned, path, paths) {
2054
if (typeof value === 'object' && value !== null) {
2155
const unwrapped = cloned.get(value);
2256
if (unwrapped !== undefined) return unwrapped;
@@ -25,8 +59,8 @@ function clone(value, cloned) {
2559
const copy = /** @type {Snapshot<any>} */ ([]);
2660
cloned.set(value, copy);
2761

28-
for (const element of value) {
29-
copy.push(clone(element, cloned));
62+
for (let i = 0; i < value.length; i += 1) {
63+
copy.push(clone(value[i], cloned, DEV ? `${path}[${i}]` : path, paths));
3064
}
3165

3266
return copy;
@@ -39,16 +73,34 @@ function clone(value, cloned) {
3973

4074
for (var key in value) {
4175
// @ts-expect-error
42-
copy[key] = clone(value[key], cloned);
76+
copy[key] = clone(value[key], cloned, DEV ? `${path}.${key}` : path, paths);
4377
}
4478

4579
return copy;
4680
}
4781

4882
if (typeof (/** @type {T & { toJSON?: any } } */ (value).toJSON) === 'function') {
49-
return clone(/** @type {T & { toJSON(): any } } */ (value).toJSON(), cloned);
83+
return clone(
84+
/** @type {T & { toJSON(): any } } */ (value).toJSON(),
85+
cloned,
86+
DEV ? `${path}.toJSON()` : path,
87+
paths
88+
);
5089
}
5190
}
5291

53-
return /** @type {Snapshot<T>} */ (structuredClone(value));
92+
if (value instanceof EventTarget) {
93+
// can't be cloned
94+
return /** @type {Snapshot<T>} */ (value);
95+
}
96+
97+
try {
98+
return /** @type {Snapshot<T>} */ (structuredClone(value));
99+
} catch (e) {
100+
if (DEV) {
101+
paths.push(path);
102+
}
103+
104+
return /** @type {Snapshot<T>} */ (value);
105+
}
54106
}

packages/svelte/src/internal/shared/clone.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@ import { snapshot } from './clone';
22
import { assert, test } from 'vitest';
33
import { proxy } from '../client/proxy';
44

5+
function capture_warnings() {
6+
const warnings: string[] = [];
7+
8+
// eslint-disable-next-line no-console
9+
const warn = console.warn;
10+
11+
// eslint-disable-next-line no-console
12+
console.warn = (message) => warnings.push(message);
13+
14+
return () => {
15+
// eslint-disable-next-line no-console
16+
console.warn = warn;
17+
return warnings;
18+
};
19+
}
20+
521
test('primitive', () => {
622
assert.equal(42, snapshot(42));
723
});
@@ -101,3 +117,70 @@ test('reactive class', () => {
101117

102118
assert.equal(copy.get(1), 2);
103119
});
120+
121+
test('uncloneable value', () => {
122+
const fn = () => {};
123+
124+
const warnings = capture_warnings();
125+
const copy = snapshot(fn);
126+
127+
assert.equal(fn, copy);
128+
assert.deepEqual(warnings(), [
129+
'%c[svelte] state_snapshot_uncloneable\n%cValue cannot be cloned with `$state.snapshot` — the original value was returned'
130+
]);
131+
});
132+
133+
test('uncloneable properties', () => {
134+
const object = {
135+
a: () => {},
136+
b: () => {},
137+
c: [() => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}, () => {}]
138+
};
139+
140+
const warnings = capture_warnings();
141+
const copy = snapshot(object);
142+
143+
assert.notEqual(object, copy);
144+
assert.equal(object.a, copy.a);
145+
assert.equal(object.b, copy.b);
146+
147+
assert.notEqual(object.c, copy.c);
148+
assert.equal(object.c[0], copy.c[0]);
149+
150+
assert.deepEqual(warnings(), [
151+
`%c[svelte] state_snapshot_uncloneable
152+
%cThe following properties cannot be cloned with \`$state.snapshot\` — the return value contains the originals:
153+
154+
- <value>.a
155+
- <value>.b
156+
- <value>.c[0]
157+
- <value>.c[1]
158+
- <value>.c[2]
159+
- <value>.c[3]
160+
- <value>.c[4]
161+
- <value>.c[5]
162+
- <value>.c[6]
163+
- <value>.c[7]`
164+
]);
165+
});
166+
167+
test('many uncloneable properties', () => {
168+
const array = Array.from({ length: 100 }, () => () => {});
169+
170+
const warnings = capture_warnings();
171+
snapshot(array);
172+
173+
assert.deepEqual(warnings(), [
174+
`%c[svelte] state_snapshot_uncloneable
175+
%cThe following properties cannot be cloned with \`$state.snapshot\` — the return value contains the originals:
176+
177+
- <value>[0]
178+
- <value>[1]
179+
- <value>[2]
180+
- <value>[3]
181+
- <value>[4]
182+
- <value>[5]
183+
- <value>[6]
184+
- ...and 93 more`
185+
]);
186+
});

packages/svelte/src/internal/shared/warnings.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,23 @@ export function dynamic_void_element_content(tag) {
1616
// TODO print a link to the documentation
1717
console.warn("dynamic_void_element_content");
1818
}
19+
}
20+
21+
/**
22+
* The following properties cannot be cloned with `$state.snapshot` — the return value contains the originals:
23+
*
24+
* %properties%
25+
* @param {string | undefined | null} [properties]
26+
*/
27+
export function state_snapshot_uncloneable(properties) {
28+
if (DEV) {
29+
console.warn(`%c[svelte] state_snapshot_uncloneable\n%c${properties
30+
? `The following properties cannot be cloned with \`$state.snapshot\` — the return value contains the originals:
31+
32+
${properties}`
33+
: "Value cannot be cloned with `$state.snapshot` — the original value was returned"}`, bold, normal);
34+
} else {
35+
// TODO print a link to the documentation
36+
console.warn("state_snapshot_uncloneable");
37+
}
1938
}

0 commit comments

Comments
 (0)