mirror of https://github.com/sveltejs/svelte
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 * regeneratepull/12433/head
parent
e8453e75c6
commit
8d3c0266ce
@ -0,0 +1,5 @@
|
||||
---
|
||||
'svelte': patch
|
||||
---
|
||||
|
||||
breaking: use structuredClone inside `$state.snapshot`
|
@ -0,0 +1,40 @@
|
||||
import { DEV } from 'esm-env';
|
||||
import { define_property, is_array, is_frozen, object_freeze } from '../shared/utils.js';
|
||||
import { STATE_FROZEN_SYMBOL, STATE_SYMBOL } from './constants.js';
|
||||
import * as e from './errors.js';
|
||||
|
||||
/**
|
||||
* Expects a value that was wrapped with `freeze` and makes it frozen in DEV.
|
||||
* @template T
|
||||
* @param {T} value
|
||||
* @returns {Readonly<T>}
|
||||
*/
|
||||
export function freeze(value) {
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!is_frozen(value) &&
|
||||
!(STATE_FROZEN_SYMBOL in value)
|
||||
) {
|
||||
var copy = /** @type {T} */ (value);
|
||||
|
||||
if (STATE_SYMBOL in value) {
|
||||
e.state_frozen_invalid_argument();
|
||||
}
|
||||
|
||||
define_property(copy, STATE_FROZEN_SYMBOL, {
|
||||
value: true,
|
||||
writable: true,
|
||||
enumerable: false
|
||||
});
|
||||
|
||||
// Freeze the object in DEV
|
||||
if (DEV) {
|
||||
object_freeze(copy);
|
||||
}
|
||||
|
||||
return /** @type {Readonly<T>} */ (copy);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import { freeze } from './freeze.js';
|
||||
import { assert, test } from 'vitest';
|
||||
import { proxy } from './proxy.js';
|
||||
|
||||
test('freezes an object', () => {
|
||||
const frozen = freeze({ a: 1 });
|
||||
|
||||
assert.throws(() => {
|
||||
// @ts-expect-error
|
||||
frozen.a += 1;
|
||||
}, /Cannot assign to read only property/);
|
||||
});
|
||||
|
||||
test('throws if argument is a state proxy', () => {
|
||||
assert.throws(() => freeze(proxy({})), /state_frozen_invalid_argument/);
|
||||
});
|
@ -1,22 +0,0 @@
|
||||
// Store the references to globals in case someone tries to monkey patch these, causing the below
|
||||
// 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_assign = Object.assign;
|
||||
export var is_frozen = Object.isFrozen;
|
||||
export var object_freeze = Object.freeze;
|
||||
export var define_property = Object.defineProperty;
|
||||
export var get_descriptor = Object.getOwnPropertyDescriptor;
|
||||
export var get_descriptors = Object.getOwnPropertyDescriptors;
|
||||
export var object_prototype = Object.prototype;
|
||||
export var array_prototype = Array.prototype;
|
||||
export var get_prototype_of = Object.getPrototypeOf;
|
||||
|
||||
/**
|
||||
* @param {any} thing
|
||||
* @returns {thing is Function}
|
||||
*/
|
||||
export function is_function(thing) {
|
||||
return typeof thing === 'function';
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
/** @import { Snapshot } from './types' */
|
||||
import { get_prototype_of, is_array, object_prototype } from './utils.js';
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T} value
|
||||
* @returns {Snapshot<T>}
|
||||
*/
|
||||
export function snapshot(value) {
|
||||
return clone(value, new Map());
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T} value
|
||||
* @param {Map<T, Snapshot<T>>} cloned
|
||||
* @returns {Snapshot<T>}
|
||||
*/
|
||||
function clone(value, cloned) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const unwrapped = cloned.get(value);
|
||||
if (unwrapped !== undefined) return unwrapped;
|
||||
|
||||
if (is_array(value)) {
|
||||
const copy = /** @type {Snapshot<any>} */ ([]);
|
||||
cloned.set(value, copy);
|
||||
|
||||
for (const element of value) {
|
||||
copy.push(clone(element, cloned));
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
if (get_prototype_of(value) === object_prototype) {
|
||||
/** @type {Snapshot<any>} */
|
||||
const copy = {};
|
||||
cloned.set(value, copy);
|
||||
|
||||
for (var key in value) {
|
||||
// @ts-expect-error
|
||||
copy[key] = clone(value[key], cloned);
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
if (typeof (/** @type {T & { toJSON?: any } } */ (value).toJSON) === 'function') {
|
||||
return clone(/** @type {T & { toJSON(): any } } */ (value).toJSON(), cloned);
|
||||
}
|
||||
}
|
||||
|
||||
return /** @type {Snapshot<T>} */ (structuredClone(value));
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
import { snapshot } from './clone';
|
||||
import { assert, test } from 'vitest';
|
||||
import { proxy } from '../client/proxy';
|
||||
|
||||
test('primitive', () => {
|
||||
assert.equal(42, snapshot(42));
|
||||
});
|
||||
|
||||
test('array', () => {
|
||||
const array = [1, 2, 3];
|
||||
const copy = snapshot(array);
|
||||
|
||||
assert.deepEqual(copy, array);
|
||||
assert.notEqual(copy, array);
|
||||
});
|
||||
|
||||
test('object', () => {
|
||||
const object = { a: 1, b: 2, c: 3 };
|
||||
const copy = snapshot(object);
|
||||
|
||||
assert.deepEqual(copy, object);
|
||||
assert.notEqual(copy, object);
|
||||
});
|
||||
|
||||
test('proxied state', () => {
|
||||
const object = proxy({
|
||||
a: {
|
||||
b: {
|
||||
c: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const copy = snapshot(object);
|
||||
|
||||
assert.deepEqual(copy, object);
|
||||
assert.notEqual(copy, object);
|
||||
|
||||
object.a.b.c = 2;
|
||||
assert.equal(copy.a.b.c, 1);
|
||||
});
|
||||
|
||||
test('cycles', () => {
|
||||
const object: { self?: any } = {};
|
||||
object.self = object;
|
||||
const copy = snapshot(object);
|
||||
|
||||
assert.equal(copy.self, copy);
|
||||
});
|
||||
|
||||
test('class with state field', () => {
|
||||
class Foo {
|
||||
x = 1;
|
||||
#y = 2;
|
||||
|
||||
get y() {
|
||||
return this.#y;
|
||||
}
|
||||
}
|
||||
|
||||
const copy = snapshot(new Foo());
|
||||
|
||||
// @ts-expect-error I can't figure out a way to exclude prototype properties
|
||||
assert.deepEqual(copy, { x: 1 });
|
||||
});
|
||||
|
||||
test('class with toJSON', () => {
|
||||
class Foo {
|
||||
x = 1;
|
||||
#y = 2;
|
||||
|
||||
get y() {
|
||||
return this.#y;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const copy = snapshot(new Foo());
|
||||
|
||||
assert.deepEqual(copy, { x: 1, y: 2 });
|
||||
});
|
||||
|
||||
test('reactive class', () => {
|
||||
class SvelteMap<T, U> extends Map<T, U> {
|
||||
constructor(init?: Iterable<[T, U]>) {
|
||||
super(init);
|
||||
}
|
||||
}
|
||||
|
||||
const map = new SvelteMap([[1, 2]]);
|
||||
const copy = snapshot(map);
|
||||
|
||||
assert.ok(copy instanceof Map);
|
||||
assert.notOk(copy instanceof SvelteMap);
|
||||
|
||||
assert.equal(copy.get(1), 2);
|
||||
});
|
Loading…
Reference in new issue