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
pull/12433/head
Rich Harris 1 year ago committed by GitHub
parent e8453e75c6
commit 8d3c0266ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
breaking: use structuredClone inside `$state.snapshot`

@ -91,7 +91,7 @@ State declared with `$state.frozen` cannot be mutated; it can only be _reassigne
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). 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).
> 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. > 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(...)`.
## `$state.snapshot` ## `$state.snapshot`

@ -60,6 +60,10 @@
> The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files > The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files
## state_frozen_invalid_argument
> 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`
## state_prototype_fixed ## state_prototype_fixed
> Cannot set prototype of `$state` object > Cannot set prototype of `$state` object

@ -32,6 +32,75 @@ declare function $state<T>(initial: T): T;
declare function $state<T>(): T | undefined; declare function $state<T>(): T | undefined;
declare namespace $state { declare namespace $state {
type Primitive = string | number | boolean | null | undefined;
type TypedArray =
| Int8Array
| Uint8Array
| Uint8ClampedArray
| Int16Array
| Uint16Array
| Int32Array
| Uint32Array
| Float32Array
| Float64Array
| BigInt64Array
| BigUint64Array;
/** The things that `structuredClone` can handle — https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm */
export type Cloneable =
| ArrayBuffer
| DataView
| Date
| Error
| Map<any, any>
| RegExp
| Set<any>
| TypedArray
// web APIs
| Blob
| CryptoKey
| DOMException
| DOMMatrix
| DOMMatrixReadOnly
| DOMPoint
| DOMPointReadOnly
| DOMQuad
| DOMRect
| DOMRectReadOnly
| File
| FileList
| FileSystemDirectoryHandle
| FileSystemFileHandle
| FileSystemHandle
| ImageBitmap
| ImageData
| RTCCertificate
| VideoFrame;
/** Turn `SvelteDate`, `SvelteMap` and `SvelteSet` into their non-reactive counterparts. (`URL` is uncloneable.) */
type NonReactive<T> = T extends Date
? Date
: T extends Map<infer K, infer V>
? Map<K, V>
: T extends Set<infer K>
? Set<K>
: T;
type Snapshot<T> = T extends Primitive
? T
: T extends Cloneable
? NonReactive<T>
: T extends { toJSON(): infer R }
? R
: T extends Array<infer U>
? Array<Snapshot<U>>
: T extends object
? T extends { [key: string]: any }
? { [K in keyof T]: Snapshot<T[K]> }
: never
: never;
/** /**
* Declares reactive read-only state that is shallowly immutable. * Declares reactive read-only state that is shallowly immutable.
* *
@ -75,7 +144,7 @@ declare namespace $state {
* *
* @param state The value to snapshot * @param state The value to snapshot
*/ */
export function snapshot<T>(state: T): T; export function snapshot<T>(state: T): Snapshot<T>;
/** /**
* Compare two values, one or both of which is a reactive `$state(...)` proxy. * Compare two values, one or both of which is a reactive `$state(...)` proxy.

@ -423,7 +423,10 @@ const global_visitors = {
} }
if (rune === '$state.snapshot') { if (rune === '$state.snapshot') {
return /** @type {import('estree').Expression} */ (context.visit(node.arguments[0])); return b.call(
'$.snapshot',
/** @type {import('estree').Expression} */ (context.visit(node.arguments[0]))
);
} }
if (rune === '$state.is') { if (rune === '$state.is') {

@ -1,5 +1,5 @@
import { current_component_context, flush_sync, untrack } from './internal/client/runtime.js'; import { current_component_context, flush_sync, untrack } from './internal/client/runtime.js';
import { is_array } from './internal/client/utils.js'; import { is_array } from './internal/shared/utils.js';
import { user_effect } from './internal/client/index.js'; import { user_effect } from './internal/client/index.js';
import * as e from './internal/client/errors.js'; import * as e from './internal/client/errors.js';
import { lifecycle_outside_component } from './internal/shared/errors.js'; import { lifecycle_outside_component } from './internal/shared/errors.js';

@ -1,6 +1,5 @@
import { snapshot } from '../proxy.js'; import { snapshot } from '../../shared/clone.js';
import { inspect_effect, validate_effect } from '../reactivity/effects.js'; import { inspect_effect, validate_effect } from '../reactivity/effects.js';
import { array_prototype, get_prototype_of, object_prototype } from '../utils.js';
/** /**
* @param {() => any[]} get_value * @param {() => any[]} get_value
@ -13,47 +12,7 @@ export function inspect(get_value, inspector = console.log) {
let initial = true; let initial = true;
inspect_effect(() => { inspect_effect(() => {
inspector(initial ? 'init' : 'update', ...deep_snapshot(get_value())); inspector(initial ? 'init' : 'update', ...snapshot(get_value()));
initial = false; initial = false;
}); });
} }
/**
* Like `snapshot`, but recursively traverses into normal arrays/objects to find potential states in them.
* @param {any} value
* @param {Map<any, any>} visited
* @returns {any}
*/
function deep_snapshot(value, visited = new Map()) {
if (typeof value === 'object' && value !== null && !visited.has(value)) {
const unstated = snapshot(value);
if (unstated !== value) {
visited.set(value, unstated);
return unstated;
}
const prototype = get_prototype_of(value);
// Only deeply snapshot plain objects and arrays
if (prototype === object_prototype || prototype === array_prototype) {
let contains_unstated = false;
/** @type {any} */
const nested_unstated = Array.isArray(value) ? [] : {};
for (let key in value) {
const result = deep_snapshot(value[key], visited);
nested_unstated[key] = result;
if (result !== value[key]) {
contains_unstated = true;
}
}
visited.set(value, contains_unstated ? nested_unstated : value);
} else {
visited.set(value, value);
}
}
return visited.get(value) ?? value;
}

@ -4,7 +4,7 @@
import { STATE_SYMBOL } from '../constants.js'; import { STATE_SYMBOL } from '../constants.js';
import { render_effect, user_pre_effect } from '../reactivity/effects.js'; import { render_effect, user_pre_effect } from '../reactivity/effects.js';
import { dev_current_component_function } from '../runtime.js'; import { dev_current_component_function } from '../runtime.js';
import { get_prototype_of } from '../utils.js'; import { get_prototype_of } from '../../shared/utils.js';
import * as w from '../warnings.js'; import * as w from '../warnings.js';
/** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */ /** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */

@ -28,7 +28,7 @@ import {
resume_effect resume_effect
} from '../../reactivity/effects.js'; } from '../../reactivity/effects.js';
import { source, mutable_source, set } from '../../reactivity/sources.js'; import { source, mutable_source, set } from '../../reactivity/sources.js';
import { is_array, is_frozen } from '../../utils.js'; import { is_array, is_frozen } from '../../../shared/utils.js';
import { INERT, STATE_FROZEN_SYMBOL, STATE_SYMBOL } from '../../constants.js'; import { INERT, STATE_FROZEN_SYMBOL, STATE_SYMBOL } from '../../constants.js';
import { queue_micro_task } from '../task.js'; import { queue_micro_task } from '../task.js';
import { current_effect } from '../../runtime.js'; import { current_effect } from '../../runtime.js';

@ -20,7 +20,7 @@ export function append_styles(anchor, css) {
var target = /** @type {ShadowRoot} */ (root).host var target = /** @type {ShadowRoot} */ (root).host
? /** @type {ShadowRoot} */ (root) ? /** @type {ShadowRoot} */ (root)
: /** @type {Document} */ (root).head; : /** @type {Document} */ (root).head ?? /** @type {Document} */ (root.ownerDocument).head;
if (!target.querySelector('#' + css.hash)) { if (!target.querySelector('#' + css.hash)) {
const style = document.createElement('style'); const style = document.createElement('style');

@ -1,6 +1,6 @@
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { hydrating } from '../hydration.js'; import { hydrating } from '../hydration.js';
import { get_descriptors, get_prototype_of } from '../../utils.js'; import { get_descriptors, get_prototype_of } from '../../../shared/utils.js';
import { import {
AttributeAliases, AttributeAliases,
DelegatedEvents, DelegatedEvents,

@ -1,5 +1,5 @@
import { teardown } from '../../../reactivity/effects.js'; import { teardown } from '../../../reactivity/effects.js';
import { get_descriptor } from '../../../utils.js'; import { get_descriptor } from '../../../../shared/utils.js';
/** /**
* Makes an `export`ed (non-prop) variable available on the `$$props` object * Makes an `export`ed (non-prop) variable available on the `$$props` object

@ -1,7 +1,7 @@
import { createClassComponent } from '../../../../legacy/legacy-client.js'; import { createClassComponent } from '../../../../legacy/legacy-client.js';
import { destroy_effect, render_effect } from '../../reactivity/effects.js'; import { destroy_effect, render_effect } from '../../reactivity/effects.js';
import { append } from '../template.js'; import { append } from '../template.js';
import { define_property, object_keys } from '../../utils.js'; import { define_property, object_keys } from '../../../shared/utils.js';
/** /**
* @typedef {Object} CustomElementPropDefinition * @typedef {Object} CustomElementPropDefinition

@ -1,5 +1,5 @@
import { teardown } from '../../reactivity/effects.js'; import { teardown } from '../../reactivity/effects.js';
import { define_property, is_array } from '../../utils.js'; import { define_property, is_array } from '../../../shared/utils.js';
import { hydrating } from '../hydration.js'; import { hydrating } from '../hydration.js';
import { queue_micro_task } from '../task.js'; import { queue_micro_task } from '../task.js';

@ -1,10 +1,9 @@
import { noop } from '../../../shared/utils.js'; import { noop, is_function } from '../../../shared/utils.js';
import { effect } from '../../reactivity/effects.js'; import { effect } from '../../reactivity/effects.js';
import { current_effect, untrack } from '../../runtime.js'; import { current_effect, untrack } from '../../runtime.js';
import { raf } from '../../timing.js'; import { raf } from '../../timing.js';
import { loop } from '../../loop.js'; import { loop } from '../../loop.js';
import { should_intro } from '../../render.js'; import { should_intro } from '../../render.js';
import { is_function } from '../../utils.js';
import { current_each_item } from '../blocks/each.js'; import { current_each_item } from '../blocks/each.js';
import { TRANSITION_GLOBAL, TRANSITION_IN, TRANSITION_OUT } from '../../../../constants.js'; import { TRANSITION_GLOBAL, TRANSITION_IN, TRANSITION_OUT } from '../../../../constants.js';
import { BLOCK_EFFECT, EFFECT_RAN, EFFECT_TRANSPARENT } from '../../constants.js'; import { BLOCK_EFFECT, EFFECT_RAN, EFFECT_TRANSPARENT } from '../../constants.js';

@ -1,6 +1,6 @@
import { set, source } from '../../reactivity/sources.js'; import { set, source } from '../../reactivity/sources.js';
import { get } from '../../runtime.js'; import { get } from '../../runtime.js';
import { is_array } from '../../utils.js'; import { is_array } from '../../../shared/utils.js';
/** /**
* Under some circumstances, imports may be reactive in legacy mode. In that case, * Under some circumstances, imports may be reactive in legacy mode. In that case,

@ -262,6 +262,22 @@ export function rune_outside_svelte(rune) {
} }
} }
/**
* 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`
* @returns {never}
*/
export function state_frozen_invalid_argument() {
if (DEV) {
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\``);
error.name = 'Svelte error';
throw error;
} else {
// TODO print a link to the documentation
throw new Error("state_frozen_invalid_argument");
}
}
/** /**
* Cannot set prototype of `$state` object * Cannot set prototype of `$state` object
* @returns {never} * @returns {never}

@ -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/);
});

@ -91,6 +91,7 @@ export {
template_with_script, template_with_script,
text text
} from './dom/template.js'; } from './dom/template.js';
export { freeze } from './freeze.js';
export { derived, derived_safe_equal } from './reactivity/deriveds.js'; export { derived, derived_safe_equal } from './reactivity/deriveds.js';
export { export {
effect_tracking, effect_tracking,
@ -136,7 +137,6 @@ export {
pop, pop,
push, push,
unwrap, unwrap,
freeze,
deep_read, deep_read,
deep_read_state, deep_read_state,
getAllContexts, getAllContexts,
@ -150,7 +150,7 @@ export {
validate_prop_bindings validate_prop_bindings
} from './validate.js'; } from './validate.js';
export { raf } from './timing.js'; export { raf } from './timing.js';
export { proxy, snapshot, is } from './proxy.js'; export { proxy, is } from './proxy.js';
export { create_custom_element } from './dom/elements/custom-element.js'; export { create_custom_element } from './dom/elements/custom-element.js';
export { export {
child, child,
@ -159,6 +159,7 @@ export {
$window as window, $window as window,
$document as document $document as document
} from './dom/operations.js'; } from './dom/operations.js';
export { snapshot } from '../shared/clone.js';
export { noop } from '../shared/utils.js'; export { noop } from '../shared/utils.js';
export { export {
validate_component, validate_component,

@ -4,12 +4,11 @@ import {
array_prototype, array_prototype,
define_property, define_property,
get_descriptor, get_descriptor,
get_descriptors,
get_prototype_of, get_prototype_of,
is_array, is_array,
is_frozen, is_frozen,
object_prototype object_prototype
} from './utils.js'; } from '../shared/utils.js';
import { check_ownership, widen_ownership } from './dev/ownership.js'; import { check_ownership, widen_ownership } from './dev/ownership.js';
import { source, set } from './reactivity/sources.js'; import { source, set } from './reactivity/sources.js';
import { STATE_FROZEN_SYMBOL, STATE_SYMBOL } from './constants.js'; import { STATE_FROZEN_SYMBOL, STATE_SYMBOL } from './constants.js';
@ -94,63 +93,6 @@ export function proxy(value, parent = null, prev) {
return value; return value;
} }
/**
* @template {import('#client').ProxyStateObject} T
* @param {T} value
* @param {Map<T, Record<string | symbol, any>>} already_unwrapped
* @returns {Record<string | symbol, any>}
*/
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<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 = 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<number>} signal * @param {import('#client').Source<number>} signal
* @param {1 | -1} [d] * @param {1 | -1} [d]

@ -37,7 +37,7 @@ import {
import { set } from './sources.js'; import { set } from './sources.js';
import * as e from '../errors.js'; import * as e from '../errors.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { define_property } from '../utils.js'; import { define_property } from '../../shared/utils.js';
/** /**
* @param {'$effect' | '$effect.pre' | '$inspect'} rune * @param {'$effect' | '$effect.pre' | '$inspect'} rune

@ -6,7 +6,7 @@ import {
PROPS_IS_RUNES, PROPS_IS_RUNES,
PROPS_IS_UPDATED PROPS_IS_UPDATED
} from '../../../constants.js'; } from '../../../constants.js';
import { get_descriptor, is_function } from '../utils.js'; import { get_descriptor, is_function } from '../../shared/utils.js';
import { mutable_source, set, source } from './sources.js'; import { mutable_source, set, source } from './sources.js';
import { derived, derived_safe_equal } from './deriveds.js'; import { derived, derived_safe_equal } from './deriveds.js';
import { get, is_signals_recorded, untrack, update } from '../runtime.js'; import { get, is_signals_recorded, untrack, update } from '../runtime.js';

@ -15,7 +15,7 @@ import {
set_hydrate_node, set_hydrate_node,
set_hydrating set_hydrating
} from './dom/hydration.js'; } from './dom/hydration.js';
import { array_from } from './utils.js'; import { array_from } from '../shared/utils.js';
import { import {
all_registered_events, all_registered_events,
handle_event_propagation, handle_event_propagation,
@ -26,7 +26,6 @@ import * as w from './warnings.js';
import * as e from './errors.js'; import * as e from './errors.js';
import { validate_component } from '../shared/validate.js'; import { validate_component } from '../shared/validate.js';
import { assign_nodes } from './dom/template.js'; import { assign_nodes } from './dom/template.js';
import { queue_micro_task } from './dom/task.js';
/** /**
* This is normally true block effects should run their intro transitions * This is normally true block effects should run their intro transitions

@ -1,12 +1,5 @@
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { import { define_property, get_descriptors, get_prototype_of } from '../shared/utils.js';
define_property,
get_descriptors,
get_prototype_of,
is_frozen,
object_freeze
} from './utils.js';
import { snapshot } from './proxy.js';
import { import {
destroy_effect, destroy_effect,
effect, effect,
@ -28,8 +21,7 @@ import {
BLOCK_EFFECT, BLOCK_EFFECT,
ROOT_EFFECT, ROOT_EFFECT,
LEGACY_DERIVED_PROP, LEGACY_DERIVED_PROP,
DISCONNECTED, DISCONNECTED
STATE_FROZEN_SYMBOL
} from './constants.js'; } from './constants.js';
import { flush_tasks } from './dom/task.js'; import { flush_tasks } from './dom/task.js';
import { add_owner } from './dev/ownership.js'; import { add_owner } from './dev/ownership.js';
@ -1211,33 +1203,3 @@ if (DEV) {
throw_rune_error('$props'); throw_rune_error('$props');
throw_rune_error('$bindable'); throw_rune_error('$bindable');
} }
/**
* 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)
) {
// If the object is already proxified, then snapshot the value
if (STATE_SYMBOL in value) {
value = snapshot(value);
}
define_property(value, STATE_FROZEN_SYMBOL, {
value: true,
writable: true,
enumerable: false
});
// Freeze the object in DEV
if (DEV) {
object_freeze(value);
}
}
return value;
}

@ -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';
}

@ -1,5 +1,5 @@
import { untrack } from './runtime.js'; import { untrack } from './runtime.js';
import { get_descriptor, is_array } from './utils.js'; import { get_descriptor, is_array } from '../shared/utils.js';
import * as e from './errors.js'; import * as e from './errors.js';
/** regex of all html void element names */ /** regex of all html void element names */

@ -551,6 +551,8 @@ export { push, pop } from './context.js';
export { push_element, pop_element } from './dev.js'; export { push_element, pop_element } from './dev.js';
export { snapshot } from '../shared/clone.js';
export { export {
add_snippet_symbol, add_snippet_symbol,
validate_component, validate_component,

@ -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);
});

@ -6,3 +6,5 @@ export type Store<V> = {
export type SourceLocation = export type SourceLocation =
| [line: number, column: number] | [line: number, column: number]
| [line: number, column: number, SourceLocation[]]; | [line: number, column: number, SourceLocation[]];
export type Snapshot<T> = ReturnType<typeof $state.snapshot<T>>;

@ -1,3 +1,26 @@
// 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';
}
export const noop = () => {}; export const noop = () => {};
// Adapted from https://github.com/then/is-promise/blob/master/index.js // Adapted from https://github.com/then/is-promise/blob/master/index.js

@ -3,7 +3,7 @@ import { mutable_source, set } from '../internal/client/reactivity/sources.js';
import { user_pre_effect } from '../internal/client/reactivity/effects.js'; import { user_pre_effect } from '../internal/client/reactivity/effects.js';
import { hydrate, mount, unmount } from '../internal/client/render.js'; import { hydrate, mount, unmount } from '../internal/client/render.js';
import { get } from '../internal/client/runtime.js'; import { get } from '../internal/client/runtime.js';
import { define_property } from '../internal/client/utils.js'; import { define_property } from '../internal/shared/utils.js';
/** /**
* Takes the same options as a Svelte 4 component and the component function and returns a Svelte 4 compatible component. * Takes the same options as a Svelte 4 component and the component function and returns a Svelte 4 compatible component.

@ -2734,6 +2734,75 @@ declare function $state<T>(initial: T): T;
declare function $state<T>(): T | undefined; declare function $state<T>(): T | undefined;
declare namespace $state { declare namespace $state {
type Primitive = string | number | boolean | null | undefined;
type TypedArray =
| Int8Array
| Uint8Array
| Uint8ClampedArray
| Int16Array
| Uint16Array
| Int32Array
| Uint32Array
| Float32Array
| Float64Array
| BigInt64Array
| BigUint64Array;
/** The things that `structuredClone` can handle — https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm */
export type Cloneable =
| ArrayBuffer
| DataView
| Date
| Error
| Map<any, any>
| RegExp
| Set<any>
| TypedArray
// web APIs
| Blob
| CryptoKey
| DOMException
| DOMMatrix
| DOMMatrixReadOnly
| DOMPoint
| DOMPointReadOnly
| DOMQuad
| DOMRect
| DOMRectReadOnly
| File
| FileList
| FileSystemDirectoryHandle
| FileSystemFileHandle
| FileSystemHandle
| ImageBitmap
| ImageData
| RTCCertificate
| VideoFrame;
/** Turn `SvelteDate`, `SvelteMap` and `SvelteSet` into their non-reactive counterparts. (`URL` is uncloneable.) */
type NonReactive<T> = T extends Date
? Date
: T extends Map<infer K, infer V>
? Map<K, V>
: T extends Set<infer K>
? Set<K>
: T;
type Snapshot<T> = T extends Primitive
? T
: T extends Cloneable
? NonReactive<T>
: T extends { toJSON(): infer R }
? R
: T extends Array<infer U>
? Array<Snapshot<U>>
: T extends object
? T extends { [key: string]: any }
? { [K in keyof T]: Snapshot<T[K]> }
: never
: never;
/** /**
* Declares reactive read-only state that is shallowly immutable. * Declares reactive read-only state that is shallowly immutable.
* *
@ -2777,7 +2846,7 @@ declare namespace $state {
* *
* @param state The value to snapshot * @param state The value to snapshot
*/ */
export function snapshot<T>(state: T): T; export function snapshot<T>(state: T): Snapshot<T>;
/** /**
* Compare two values, one or both of which is a reactive `$state(...)` proxy. * Compare two values, one or both of which is a reactive `$state(...)` proxy.

Loading…
Cancel
Save