From 9940630210881fdda810740f8124c8e43319da5f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 11 Jul 2024 14:24:54 -0400 Subject: [PATCH] move cloning logic into new file, use structuredClone, add tests --- .../3-transform/server/transform-server.js | 5 +- packages/svelte/src/internal/client/clone.js | 69 ++++++++++++ .../svelte/src/internal/client/clone.test.ts | 102 ++++++++++++++++++ .../svelte/src/internal/client/dom/css.js | 2 +- packages/svelte/src/internal/client/proxy.js | 58 ---------- 5 files changed, 176 insertions(+), 60 deletions(-) create mode 100644 packages/svelte/src/internal/client/clone.js create mode 100644 packages/svelte/src/internal/client/clone.test.ts diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 687984b395..b384c39dba 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -423,7 +423,10 @@ const global_visitors = { } if (rune === '$state.snapshot') { - return /** @type {import('estree').Expression} */ (context.visit(node.arguments[0])); + return b.call( + 'structuredClone', + /** @type {import('estree').Expression} */ (context.visit(node.arguments[0])) + ); } if (rune === '$state.is') { diff --git a/packages/svelte/src/internal/client/clone.js b/packages/svelte/src/internal/client/clone.js new file mode 100644 index 0000000000..35d1800250 --- /dev/null +++ b/packages/svelte/src/internal/client/clone.js @@ -0,0 +1,69 @@ +import { STATE_SYMBOL } from './constants.js'; +import { + define_property, + get_descriptors, + get_prototype_of, + is_array, + object_prototype +} from './utils.js'; + +/** + * @template T + * @param {T} value + * @returns {T} + */ +export function snapshot(value) { + return /** @type {T} */ ( + clone(/** @type {import('#client').ProxyStateObject} */ (value), new Map()) + ); +} + +/** + * @template {import('#client').ProxyStateObject} T + * @param {T} value + * @param {Map>} cloned + * @returns {Record} + */ +function clone(value, cloned) { + if (typeof value === 'object' && value != null) { + const unwrapped = cloned.get(value); + if (unwrapped !== undefined) { + return unwrapped; + } + + if (is_array(value)) { + /** @type {Record} */ + const array = []; + cloned.set(value, array); + for (const element of value) { + array.push(clone(element, cloned)); + } + return array; + } else if (get_prototype_of(value) === object_prototype) { + /** @type {Record} */ + const obj = {}; + const keys = Reflect.ownKeys(value); + const descriptors = get_descriptors(value); + cloned.set(value, obj); + + for (const key of keys) { + if (key === STATE_SYMBOL) continue; + if (descriptors[key].get) { + define_property(obj, key, descriptors[key]); + } else { + /** @type {T} */ + const property = value[key]; + obj[key] = clone(property, cloned); + } + } + + return obj; + } + + if (typeof value.toJSON === 'function') { + return clone(value.toJSON(), cloned); + } + } + + return structuredClone(value); +} diff --git a/packages/svelte/src/internal/client/clone.test.ts b/packages/svelte/src/internal/client/clone.test.ts new file mode 100644 index 0000000000..a2dce8dd6d --- /dev/null +++ b/packages/svelte/src/internal/client/clone.test.ts @@ -0,0 +1,102 @@ +import { snapshot } from './clone'; +import { assert, test } from 'vitest'; +import { proxy } from './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()); + + 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 extends Map { + 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); +}); diff --git a/packages/svelte/src/internal/client/dom/css.js b/packages/svelte/src/internal/client/dom/css.js index 6c10c32791..1cccb42b3e 100644 --- a/packages/svelte/src/internal/client/dom/css.js +++ b/packages/svelte/src/internal/client/dom/css.js @@ -20,7 +20,7 @@ export function append_styles(anchor, css) { var target = /** @type {ShadowRoot} */ (root).host ? /** @type {ShadowRoot} */ (root) - : /** @type {Document} */ (root).head; + : /** @type {Document} */ (root).head ?? /** @type {Document} */ (root.ownerDocument).head; if (!target.querySelector('#' + css.hash)) { const style = document.createElement('style'); diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index f5900ba957..24efea6a84 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -4,7 +4,6 @@ import { array_prototype, define_property, get_descriptor, - get_descriptors, get_prototype_of, is_array, is_frozen, @@ -94,63 +93,6 @@ export function proxy(value, parent = null, prev) { return value; } -/** - * @template {import('#client').ProxyStateObject} T - * @param {T} value - * @param {Map>} already_unwrapped - * @returns {Record} - */ -function unwrap(value, already_unwrapped) { - if (typeof value === 'object' && value != null && STATE_SYMBOL in value) { - const unwrapped = already_unwrapped.get(value); - if (unwrapped !== undefined) { - return unwrapped; - } - - if (is_array(value)) { - /** @type {Record} */ - const array = []; - already_unwrapped.set(value, array); - for (const element of value) { - array.push(unwrap(element, already_unwrapped)); - } - return array; - } else { - /** @type {Record} */ - const obj = {}; - const keys = Reflect.ownKeys(value); - const descriptors = get_descriptors(value); - already_unwrapped.set(value, obj); - - for (const key of keys) { - if (key === STATE_SYMBOL) continue; - 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 T - * @param {T} value - * @returns {T} - */ -export function snapshot(value) { - return /** @type {T} */ ( - unwrap(/** @type {import('#client').ProxyStateObject} */ (value), new Map()) - ); -} - /** * @param {import('#client').Source} signal * @param {1 | -1} [d]