diff --git a/.changeset/weak-flowers-sit.md b/.changeset/weak-flowers-sit.md new file mode 100644 index 000000000000..b18b6b4aa1e2 --- /dev/null +++ b/.changeset/weak-flowers-sit.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +patch structuredClone in dev to error on $state proxies diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md index 0102aafcbca1..505d05f85e4f 100644 --- a/documentation/docs/98-reference/.generated/shared-errors.md +++ b/documentation/docs/98-reference/.generated/shared-errors.md @@ -60,6 +60,12 @@ Certain lifecycle methods can only be used during component initialisation. To f `%name%` is not a store with a `subscribe` method ``` +### structured_clone_state_proxy + +``` +To correctly clone a `$state` proxy, use `$state.snapshot` instead of `structuredClone` +``` + ### svelte_element_invalid_this_value ``` diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md index 8b4c61303a07..b1aeeb0edff6 100644 --- a/packages/svelte/messages/shared-errors/errors.md +++ b/packages/svelte/messages/shared-errors/errors.md @@ -52,6 +52,10 @@ Certain lifecycle methods can only be used during component initialisation. To f > `%name%` is not a store with a `subscribe` method +## structured_clone_state_proxy + +> To correctly clone a `$state` proxy, use `$state.snapshot` instead of `structuredClone` + ## svelte_element_invalid_this_value > The `this` prop on `` must be a string, if defined diff --git a/packages/svelte/src/internal/shared/clone.js b/packages/svelte/src/internal/shared/clone.js index 4632fc3d68ed..8346db61826d 100644 --- a/packages/svelte/src/internal/shared/clone.js +++ b/packages/svelte/src/internal/shared/clone.js @@ -1,7 +1,15 @@ /** @import { Snapshot } from './types' */ import { DEV } from 'esm-env'; +import { STATE_SYMBOL } from '../client/constants.js'; import * as w from './warnings.js'; -import { get_prototype_of, is_array, object_prototype } from './utils.js'; +import * as e from './errors.js'; +import { + get_prototype_of, + is_array, + object_prototype, + structured_clone, + is_object +} from './utils.js'; /** * In dev, we keep track of which properties could not be cloned. In prod @@ -96,7 +104,7 @@ function clone(value, cloned, path, paths, original = null) { } if (value instanceof Date) { - return /** @type {Snapshot} */ (structuredClone(value)); + return /** @type {Snapshot} */ (structured_clone(value)); } if (typeof (/** @type {T & { toJSON?: any } } */ (value).toJSON) === 'function') { @@ -117,7 +125,7 @@ function clone(value, cloned, path, paths, original = null) { } try { - return /** @type {Snapshot} */ (structuredClone(value)); + return /** @type {Snapshot} */ (structured_clone(value)); } catch (e) { if (DEV) { paths.push(path); @@ -126,3 +134,374 @@ function clone(value, cloned, path, paths, original = null) { return /** @type {Snapshot} */ (value); } } + +// Patches `structuredClone` to error on `$state` proxies +if ('structuredClone' in globalThis) { // pretty sure this should be patched everywhere, correct this if i'm wrong + /** + * Creates a deep clone of an object. + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/structuredClone) + * @template T + * @param {T} value + * @param {StructuredSerializeOptions} [options] + * @returns {T} + */ + //@ts-expect-error + globalThis.structuredClone = function structuredClone(value, options) { + //@ts-expect-error + if (is_object(value) && STATE_SYMBOL in value) { + e.structured_clone_state_proxy(); + } + return structured_clone(value, options); + }; +} +// here's an alternative option, that has its own (somewhat spec-compliant) implementation of structuredClone +/** + * @type {Map} + * + * keeps a record of objects to prevent recursion caused by circular references + * > Key: an object that might be a circular reference + * Value: the value to replace the key with + */ +let clone_stack = new Map(); +// I know it's not technically a stack, but "clone stack"/"stack initiator" sounds better than "clone record"/"record initiator" +/**@type {any} */ +let stack_initiator = null; +/** + * Creates a deep clone of an object. + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/structuredClone) + * @template T + * @param {T} value + * @param {StructuredSerializeOptions} [options] + * @returns {T} + */ +function other_structured_clone(value, options) { + /** + * Current limitations + * - Cannot detect proxies (other than those created by `$state`) + * - Not all transferable objects are supported (https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) + * - Custom class instances cannot currently be cloned + * - I'm confused because `structuredClone(globalThis)` throws, while `structuredClone(new (class{})())` works fine + * The following object types currently cannot be cloned, or at least synchronously cloned: (some may need more research to confirm this) + * - FileList + * - VideoFrame + * - AudioData + * - RTCEncodedAudio/VideoFrame + * - RTCCertificate + * - FencedFrameConfig + * - GPUCompilationMessage/Info + * - ImageData/Bitmap + * - FileSystemDirectory/FileHandle + * - FileSystemHandle + * - CryptoKey + * - all streams (WritableStream, ReadableStream, etc) + * - MIDIAccess + * - RTCDataChannel + * - MessageChannel/Port + * - MediaSourceHandle + * I don't think most people would be cloning most of these object types, but it's still a concern + * I also don't know what other objects would follow the spec + */ + /** + * @param {string} type + * @returns {never} + */ + function DataCloneError(type) { + throw new DOMException( + `Failed to execute 'structuredClone' on 'Window': ${type} could not be cloned.`, + `DataCloneError` + ); + } + /** + * Run this before the function returns (if the function is returning an object) + */ + function clear_stack_initiator() { + if (stack_initiator === value) { + stack_initiator = null; + clone_stack = new Map(); + } + } + if (typeof value === 'symbol') { + DataCloneError(`Symbol(${value?.description})`); + } + if (typeof value === 'function') { + DataCloneError(value.toString()); + } + if (!is_object(value)) { + return value; + } + if (stack_initiator === null) { + stack_initiator = value; + clone_stack = new Map(); + } + // ArrayBuffer transferring (and some cloneable objects) aren't baseline/are baseline 2024, so it may not be available + let supports = { + transfer: 'transfer' in ArrayBuffer.prototype && 'byteLength' in ArrayBuffer.prototype, + webTransportError: 'WebTransportError' in globalThis, + gpuPipelineError: 'GPUPipelineError' in globalThis, + encodedAudioChunk: 'EncodedAudioChunk' in globalThis, + encodedVideoChunk: 'EncodedVideoChunk' in globalThis + }; + let is_pojo = value?.constructor === Object; + if (Array.isArray(value)) { + /**@type {any[]} */ + let res = []; + //@ts-ignore + clone_stack.set(value, res); + for (let index in value) { + if (value[index] === value) { + //@ts-ignore + res.push(res); + } else if (clone_stack.has(value[index])) { + res.push(clone_stack.get(value[index])); + } else { + res.push(other_structured_clone(value[index], options)); + } + } + clear_stack_initiator(); + //@ts-expect-error + return res; + } + if (is_pojo) { + //@ts-ignore + if (STATE_SYMBOL in value) { + e.structured_clone_state_proxy(); + } + /**@type {{[x:string]:any}} */ + let res = {}; + clone_stack.set(value, res); + //@ts-ignore + let entries = Object.entries(value); + for (let [key, v] of entries) { + if (value === v) { + res[key] = res; + } else if (clone_stack.has(v)) { + res[key] = clone_stack.get(v); + } else { + res[key] = other_structured_clone(v, options); + } + } + clear_stack_initiator(); + //@ts-expect-error + return res; + } + let { transfer = [] } = options ?? {}; + let res; + switch (value?.constructor) { + case Set: + res = new Set(); + clone_stack.set(value, res); + //@ts-expect-error + var arr = [...value]; + for (let item of arr) { + if (item === value) { + res.add(res); + } else if (clone_stack.has(item)) { + res.add(clone_stack.get(item)); + } else { + res.add(other_structured_clone(item, options)); + } + } + clear_stack_initiator(); + //@ts-expect-error + return res; + case Map: + res = new Map(); + clone_stack.set(value, res); + //@ts-expect-error + var entries = [...value.entries()]; + for (let [key, v] of entries) { + if (key !== value) { + if (clone_stack.has(key)) { + key = clone_stack.get(key); + } else { + key = other_structured_clone(key, options); + } + } else { + key = res; + } + if (v !== value) { + if (clone_stack.has(v)) { + v = clone_stack.get(v); + } else { + v = other_structured_clone(v, options); + } + } else { + v = res; + } + res.set(key, v); + } + clear_stack_initiator(); + //@ts-expect-error + return res; + case Number: + case String: + case Date: + case Boolean: + res = value?.valueOf?.(); + clear_stack_initiator(); + //@ts-expect-error + return new value.constructor(res); + case RegExp: + //@ts-expect-error + var { source, flags } = value; + res = new RegExp(source, flags); + clear_stack_initiator(); + //@ts-ignore + return res; + case Error: + case TypeError: + case EvalError: + case ReferenceError: + case SyntaxError: + case RangeError: + case URIError: + case DOMException: + //@ts-expect-error + var { name, message, stack, cause } = value; // not sure if error.cause should be cloned as well + //@ts-ignore + res = new value.constructor(message, name, cause !== undefined ? { cause } : undefined); + res.stack = stack; + clear_stack_initiator(); + return res; + case ArrayBuffer: + //@ts-ignore + if (transfer.includes(value) && supports.transfer) { + //@ts-expect-error + res = value.transfer(); + } else { + //@ts-expect-error + res = value.slice(0); + } + clear_stack_initiator(); + return res; + case DataView: + case Int8Array: + case Int16Array: + case Int32Array: + case Uint8Array: + case Uint8ClampedArray: + case Uint16Array: + case Uint32Array: + case BigUint64Array: + case BigInt64Array: + case Float32Array: + case Float64Array: + //@ts-expect-error + var { /**@type {ArrayBuffer} */ buffer } = value; + if (transfer.includes(buffer) && supports.transfer) { + res = buffer.transfer(); + } else { + res = buffer?.slice?.(0); + } + //@ts-ignore + res = new value.constructor(res); + clear_stack_initiator(); + return res; + case Blob: + //@ts-expect-error + res = value.slice(0); + clear_stack_initiator(); + return res; + case File: + //@ts-expect-error + res = new File([value.slice(0)], value.name); + clear_stack_initiator(); + //@ts-ignore + return res; + case DOMPoint: + case DOMPointReadOnly: + //@ts-expect-error + var { x, y, z, w } = value; + //@ts-expect-error + res = new value.constructor(x, y, z, w); + clear_stack_initiator(); + //@ts-ignore + return res; + case DOMMatrix: + case DOMMatrixReadOnly: + //@ts-expect-error + var { m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44 } = + value; + //@ts-expect-error + res = new value.constructor([ + m11, + m12, + m13, + m14, + m21, + m22, + m23, + m24, + m31, + m32, + m33, + m34, + m41, + m42, + m43, + m44 + ]); + clear_stack_initiator(); + return res; + case DOMQuad: + //@ts-expect-error + var { p1, p2, p3, p4 } = value; + p1 = other_structured_clone(p1); + p2 = other_structured_clone(p2); + p3 = other_structured_clone(p3); + p4 = other_structured_clone(p4); + //@ts-expect-error + res = new value.constructor(p1, p2, p3, p4); + clear_stack_initiator(); + return res; + case DOMRect: + case DOMRectReadOnly: + //@ts-expect-error + var { x: x1, y: y1, width, height } = value; + //@ts-expect-error + res = new value.constructor(x1, y1, width, height); + clear_stack_initiator(); + return res; + default: + //@ts-ignore + if (supports.webTransportError && value.constructor === WebTransportError) { + let { streamErrorCode, message } = value; + res = new WebTransportError(message, { streamErrorCode }); + clear_stack_initiator(); + //@ts-ignore + return res; + //@ts-ignore - typescript freaks out because GPUPipelineError isn't baseline + } else if (supports.gpuPipelineError && value.constructor === GPUPipelineError) { + //@ts-ignore + let { reason, message } = value; + //@ts-ignore + res = new GPUPipelineError(message, { reason }); + clear_stack_initiator(); + return res; + } else if ( + //@ts-ignore + (supports.encodedAudioChunk && value.constructor === EncodedAudioChunk) || + //@ts-ignore + (supports.encodedVideoChunk && value.constructor === EncodedVideoChunk) + ) { + let { type, timestamp, duration, byteLength } = value; + let buffer = new ArrayBuffer(byteLength); + //@ts-ignore + value.copyTo(buffer); + //@ts-expect-error + res = new value.constructor({ + type, + timestamp, + duration, + data: buffer, + transfer: [buffer] + }); + clear_stack_initiator(); + //@ts-ignore + return res; + } + } + clear_stack_initiator(); + //@ts-expect-error + return {}; +} diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js index 26d6822cdb29..32ca850cf81e 100644 --- a/packages/svelte/src/internal/shared/errors.js +++ b/packages/svelte/src/internal/shared/errors.js @@ -62,4 +62,19 @@ export function svelte_element_invalid_this_value() { } else { throw new Error(`https://svelte.dev/e/svelte_element_invalid_this_value`); } +} + +/** + * To correctly clone a `$state` proxy, use `$state.snapshot` instead of `structuredClone` + * @returns {never} + */ +export function structured_clone_state_proxy() { + if (DEV) { + const error = new Error(`structured_clone_state_proxy\nTo correctly clone a \`$state\` proxy, use \`$state.snapshot\` instead of \`structuredClone\`\nhttps://svelte.dev/e/structured_clone_state_proxy`); + + error.name = 'Svelte error'; + throw error; + } else { + throw new Error(`https://svelte.dev/e/structured_clone_state_proxy`); + } } \ No newline at end of file diff --git a/packages/svelte/src/internal/shared/utils.js b/packages/svelte/src/internal/shared/utils.js index 92d29d9e1d68..1a1e59dcd7ff 100644 --- a/packages/svelte/src/internal/shared/utils.js +++ b/packages/svelte/src/internal/shared/utils.js @@ -9,6 +9,7 @@ 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; +export var structured_clone = globalThis.structuredClone; /** * @param {any} thing @@ -79,3 +80,12 @@ export function fallback(value, fallback, lazy = false) { : /** @type {V} */ (fallback) : value; } + +/** + * Returns true if the value is an object that is not null + * @param {any} value + * @returns {boolean} + */ +export function is_object(value) { + return typeof value === "object" && value !== null; +} \ No newline at end of file