Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

breaking: use structuredClone inside $state.snapshot #12413

Merged
merged 14 commits into from
Jul 14, 2024
5 changes: 5 additions & 0 deletions .changeset/perfect-actors-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

breaking: use structuredClone inside `$state.snapshot`
2 changes: 1 addition & 1 deletion documentation/docs/03-runes/01-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

> 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`

Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/client-errors/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@

> 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

> Cannot set prototype of `$state` object
Expand Down
71 changes: 70 additions & 1 deletion packages/svelte/src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,75 @@ declare function $state<T>(initial: T): T;
declare function $state<T>(): T | undefined;

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.
*
Expand Down Expand Up @@ -75,7 +144,7 @@ declare namespace $state {
*
* @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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,10 @@ const global_visitors = {
}

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') {
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/index-client.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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 * as e from './internal/client/errors.js';
import { lifecycle_outside_component } from './internal/shared/errors.js';
Expand Down
45 changes: 2 additions & 43 deletions packages/svelte/src/internal/client/dev/inspect.js
Original file line number Diff line number Diff line change
@@ -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 { array_prototype, get_prototype_of, object_prototype } from '../utils.js';

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

inspect_effect(() => {
inspector(initial ? 'init' : 'update', ...deep_snapshot(get_value()));
inspector(initial ? 'init' : 'update', ...snapshot(get_value()));
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;
}
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/dev/ownership.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { STATE_SYMBOL } from '../constants.js';
import { render_effect, user_pre_effect } from '../reactivity/effects.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';

/** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/dom/blocks/each.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
resume_effect
} from '../../reactivity/effects.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 { queue_micro_task } from '../task.js';
import { current_effect } from '../../runtime.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/dom/css.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DEV } from 'esm-env';
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 {
AttributeAliases,
DelegatedEvents,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createClassComponent } from '../../../../legacy/legacy-client.js';
import { destroy_effect, render_effect } from '../../reactivity/effects.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
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/dom/elements/events.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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 { queue_micro_task } from '../task.js';

Expand Down
Original file line number Diff line number Diff line change
@@ -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 { current_effect, untrack } from '../../runtime.js';
import { raf } from '../../timing.js';
import { loop } from '../../loop.js';
import { should_intro } from '../../render.js';
import { is_function } from '../../utils.js';
import { current_each_item } from '../blocks/each.js';
import { TRANSITION_GLOBAL, TRANSITION_IN, TRANSITION_OUT } from '../../../../constants.js';
import { BLOCK_EFFECT, EFFECT_RAN, EFFECT_TRANSPARENT } from '../../constants.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/internal/client/dom/legacy/misc.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { set, source } from '../../reactivity/sources.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,
Expand Down
16 changes: 16 additions & 0 deletions packages/svelte/src/internal/client/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
* @returns {never}
Expand Down
40 changes: 40 additions & 0 deletions packages/svelte/src/internal/client/freeze.js
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 16 additions & 0 deletions packages/svelte/src/internal/client/freeze.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
5 changes: 3 additions & 2 deletions packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export {
template_with_script,
text
} from './dom/template.js';
export { freeze } from './freeze.js';
export { derived, derived_safe_equal } from './reactivity/deriveds.js';
export {
effect_tracking,
Expand Down Expand Up @@ -136,7 +137,6 @@ export {
pop,
push,
unwrap,
freeze,
deep_read,
deep_read_state,
getAllContexts,
Expand All @@ -150,7 +150,7 @@ export {
validate_prop_bindings
} from './validate.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 {
child,
Expand All @@ -159,6 +159,7 @@ export {
$window as window,
$document as document
} from './dom/operations.js';
export { snapshot } from '../shared/clone.js';
export { noop } from '../shared/utils.js';
export {
validate_component,
Expand Down
Loading
Loading