From d7b80baa316237ee2ad982d4ae96df2ecc795065 Mon Sep 17 00:00:00 2001 From: Dan Groshev Date: Mon, 18 Mar 2024 17:16:09 +0000 Subject: [PATCH] use native structuredClone on node, cloudflare workers, and in tests (#3166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, we only use native `structuredClone` in the browser, falling back to `JSON.parse(JSON.stringify(...))` elsewhere, despite Node supporting `structuredClone` [since v17](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) and Cloudflare Workers supporting it [since 2022](https://blog.cloudflare.com/standards-compliant-workers-api/). This PR adjusts our shim to use the native `structuredClone` on all platforms, if available. Additionally, `jsdom` doesn't implement `structuredClone`, a bug [open since 2022](https://github.com/jsdom/jsdom/issues/3363). This PR patches `jsdom` environment in all packages/apps that use it for tests. Also includes a driveby removal of `deepCopy`, a function that is strictly inferior to `structuredClone`. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff - [ ] `bugfix` — Bug fix - [ ] `feature` — New feature - [x] `improvement` — Improving existing features - [x] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. A smoke test would be enough - [ ] Unit Tests - [x] End to end tests --- .eslintignore | 4 +- .eslintrc.js | 4 ++ .github/workflows/checks.yml | 6 +- apps/docs/app/api/ai/route.ts | 1 + apps/docs/app/api/search/route.ts | 1 + apps/docs/package.json | 1 + apps/docs/tsconfig.json | 7 +- apps/dotcom/package.json | 2 +- .../SpeechBubble/SpeechBubbleUtil.tsx | 3 +- packages/editor/package.json | 2 +- packages/editor/src/lib/editor/Editor.ts | 7 +- packages/namespaced-tldraw/package.json | 2 +- packages/store/src/lib/devFreeze.ts | 12 +++- packages/tldraw/package.json | 2 +- .../src/lib/shapes/arrow/ArrowShapeUtil.tsx | 6 +- .../src/lib/shapes/image/ImageShapeUtil.tsx | 4 +- .../lib/shapes/line/LineShapeUtil.test.tsx | 4 +- .../childStates/Crop/children/crop_helpers.ts | 4 +- .../tools/SelectTool/childStates/Cropping.ts | 4 +- packages/tlschema/src/shapes/TLLineShape.ts | 12 +++- packages/tlsync/package.json | 2 +- packages/tlsync/src/lib/TLSyncRoom.ts | 8 +++ packages/utils/api-report.md | 9 ++- packages/utils/api/api.json | 66 ------------------- packages/utils/package.json | 1 + packages/utils/patchedJestJsDom.js | 10 +++ packages/utils/src/index.ts | 10 ++- packages/utils/src/lib/object.ts | 34 ---------- packages/utils/src/lib/value.ts | 40 +++++++++-- packages/validate/src/lib/validation.ts | 5 +- scripts/prepack.ts | 1 + yarn.lock | 2 + 32 files changed, 135 insertions(+), 141 deletions(-) create mode 100644 packages/utils/patchedJestJsDom.js diff --git a/.eslintignore b/.eslintignore index 3fd48ab97..cfd713ada 100644 --- a/.eslintignore +++ b/.eslintignore @@ -28,4 +28,6 @@ apps/vscode/extension/editor/tldraw-assets.json **/scripts/upload-sourcemaps.js **/coverage/**/* -apps/dotcom/public/sw.js \ No newline at end of file +apps/dotcom/public/sw.js + +patchedJestJsDom.js \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index ca67e5fb3..64fab1966 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -66,6 +66,10 @@ module.exports = { message: 'Use the getFromSessionStorage/setInSessionStorage helpers instead', }, ], + 'no-restricted-globals': [ + 'error', + { name: 'structuredClone', message: 'Use structuredClone from @tldraw/util instead' }, + ], }, parser: '@typescript-eslint/parser', parserOptions: { diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8435dc2f5..d5cdc5bcf 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -38,15 +38,15 @@ jobs: - name: Check for installation warnings run: 'yarn | grep -vzq "with warnings"' + - name: Check tsconfigs + run: yarn check-tsconfigs + - name: Typecheck run: yarn build-types - name: Check scripts run: yarn check-scripts - - name: Check tsconfigs - run: yarn check-tsconfigs - - name: Check PR template run: yarn update-pr-template --check diff --git a/apps/docs/app/api/ai/route.ts b/apps/docs/app/api/ai/route.ts index 693bf8e13..51c5cb79a 100644 --- a/apps/docs/app/api/ai/route.ts +++ b/apps/docs/app/api/ai/route.ts @@ -1,6 +1,7 @@ import { SearchResult } from '@/types/search-types' import { getDb } from '@/utils/ContentDatabase' import { SEARCH_RESULTS, searchBucket, sectionTypeBucket } from '@/utils/search-api' +import { structuredClone } from '@tldraw/utils' import assert from 'assert' import { NextRequest } from 'next/server' diff --git a/apps/docs/app/api/search/route.ts b/apps/docs/app/api/search/route.ts index 6a1a030b3..848ced297 100644 --- a/apps/docs/app/api/search/route.ts +++ b/apps/docs/app/api/search/route.ts @@ -1,6 +1,7 @@ import { SearchResult } from '@/types/search-types' import { getDb } from '@/utils/ContentDatabase' import { SEARCH_RESULTS, searchBucket, sectionTypeBucket } from '@/utils/search-api' +import { structuredClone } from '@tldraw/utils' import { NextRequest } from 'next/server' type Data = { diff --git a/apps/docs/package.json b/apps/docs/package.json index 4b1302103..2ab775178 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -49,6 +49,7 @@ "@microsoft/tsdoc": "^0.14.2", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-navigation-menu": "^1.1.4", + "@tldraw/utils": "workspace:*", "@types/broken-link-checker": "^0.7.1", "@types/node": "~20.11", "@types/sqlite3": "^3.1.9", diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index cb1a00c31..f4b9cbc21 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -24,5 +24,10 @@ ".next/types/**/*.ts", "watcher.ts" ], - "exclude": ["node_modules", ".next"] + "exclude": ["node_modules", ".next"], + "references": [ + { + "path": "../../packages/utils" + } + ] } diff --git a/apps/dotcom/package.json b/apps/dotcom/package.json index 79a17c432..cbeb0d7c6 100644 --- a/apps/dotcom/package.json +++ b/apps/dotcom/package.json @@ -58,7 +58,7 @@ "roots": [ "" ], - "testEnvironment": "jsdom", + "testEnvironment": "../../../packages/utils/patchedJestJsDom.js", "transformIgnorePatterns": [ "node_modules/(?!(nanoid|nanoevents)/)" ], diff --git a/apps/examples/src/examples/speech-bubble/SpeechBubble/SpeechBubbleUtil.tsx b/apps/examples/src/examples/speech-bubble/SpeechBubble/SpeechBubbleUtil.tsx index cb33adde6..c8465b179 100644 --- a/apps/examples/src/examples/speech-bubble/SpeechBubble/SpeechBubbleUtil.tsx +++ b/apps/examples/src/examples/speech-bubble/SpeechBubble/SpeechBubbleUtil.tsx @@ -15,7 +15,6 @@ import { Vec, VecModel, ZERO_INDEX_KEY, - deepCopy, getDefaultColorTheme, resizeBox, structuredClone, @@ -143,7 +142,7 @@ export class SpeechBubbleUtil extends ShapeUtil { } } - const next = deepCopy(shape) + const next = structuredClone(shape) next.props.tail.x = newPoint.x / w next.props.tail.y = newPoint.y / h diff --git a/packages/editor/package.json b/packages/editor/package.json index 92395c174..6b5d134aa 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -84,7 +84,7 @@ }, "jest": { "preset": "config/jest/node", - "testEnvironment": "jsdom", + "testEnvironment": "../../../packages/utils/patchedJestJsDom.js", "fakeTimers": { "enableGlobally": true }, diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 5ce42d153..867a5c8f6 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -47,7 +47,6 @@ import { assert, compact, dedupe, - deepCopy, getIndexAbove, getIndexBetween, getIndices, @@ -5224,7 +5223,7 @@ export class Editor extends EventEmitter { ? getIndexBetween(shape.index, siblingAbove.index) : getIndexAbove(shape.index) - let newShape: TLShape = deepCopy(shape) + let newShape: TLShape = structuredClone(shape) if ( this.isShapeOfType(shape, 'arrow') && @@ -7867,13 +7866,13 @@ export class Editor extends EventEmitter { let newShape: TLShape if (preserveIds) { - newShape = deepCopy(shape) + newShape = structuredClone(shape) idMap.set(shape.id, shape.id) } else { const id = idMap.get(shape.id)! // Create the new shape (new except for the id) - newShape = deepCopy({ ...shape, id }) + newShape = structuredClone({ ...shape, id }) } if (rootShapeIds.includes(shape.id)) { diff --git a/packages/namespaced-tldraw/package.json b/packages/namespaced-tldraw/package.json index 327e8c344..913ccc7fc 100644 --- a/packages/namespaced-tldraw/package.json +++ b/packages/namespaced-tldraw/package.json @@ -57,7 +57,7 @@ }, "jest": { "preset": "config/jest/node", - "testEnvironment": "jsdom", + "testEnvironment": "../../../packages/utils/patchedJestJsDom.js", "fakeTimers": { "enableGlobally": true }, diff --git a/packages/store/src/lib/devFreeze.ts b/packages/store/src/lib/devFreeze.ts index d06dc34b0..d1c9622d4 100644 --- a/packages/store/src/lib/devFreeze.ts +++ b/packages/store/src/lib/devFreeze.ts @@ -1,3 +1,5 @@ +import { STRUCTURED_CLONE_OBJECT_PROTOTYPE } from '@tldraw/utils' + /** * Freeze an object when in development mode. Copied from * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze @@ -17,7 +19,15 @@ export function devFreeze(object: T): T { return object } const proto = Object.getPrototypeOf(object) - if (proto && !(proto === Array.prototype || proto === Object.prototype)) { + if ( + proto && + !( + Array.isArray(object) || + proto === Object.prototype || + proto === null || + proto === STRUCTURED_CLONE_OBJECT_PROTOTYPE + ) + ) { console.error('cannot include non-js data in a record', object) throw new Error('cannot include non-js data in a record') } diff --git a/packages/tldraw/package.json b/packages/tldraw/package.json index 6cdd7539b..288cc2773 100644 --- a/packages/tldraw/package.json +++ b/packages/tldraw/package.json @@ -78,7 +78,7 @@ }, "jest": { "preset": "config/jest/node", - "testEnvironment": "jsdom", + "testEnvironment": "../../../packages/utils/patchedJestJsDom.js", "fakeTimers": { "enableGlobally": true }, diff --git a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx index 8f63fcacf..8b4acb359 100644 --- a/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx @@ -27,11 +27,11 @@ import { Vec, arrowShapeMigrations, arrowShapeProps, - deepCopy, getArrowTerminalsInArrowSpace, getDefaultColorTheme, mapObjectMapValues, objectMapEntries, + structuredClone, toDomPrecision, useIsEditing, } from '@tldraw/editor' @@ -194,7 +194,7 @@ export class ArrowShapeUtil extends ShapeUtil { // Start or end, pointing the arrow... - const next = deepCopy(shape) as TLArrowShape + const next = structuredClone(shape) as TLArrowShape if (this.editor.inputs.ctrlKey) { // todo: maybe double check that this isn't equal to the other handle too? @@ -420,7 +420,7 @@ export class ArrowShapeUtil extends ShapeUtil { const terminals = getArrowTerminalsInArrowSpace(this.editor, shape) - const { start, end } = deepCopy(shape.props) + const { start, end } = structuredClone(shape.props) let { bend } = shape.props // Rescale start handle if it's not bound to a shape diff --git a/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx b/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx index 18003f450..28dea94ea 100644 --- a/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx @@ -7,9 +7,9 @@ import { TLOnDoubleClickHandler, TLShapePartial, Vec, - deepCopy, imageShapeMigrations, imageShapeProps, + structuredClone, toDomPrecision, } from '@tldraw/editor' import { useEffect, useState } from 'react' @@ -254,7 +254,7 @@ export class ImageShapeUtil extends BaseBoxShapeUtil { return } - const crop = deepCopy(props.crop) || { + const crop = structuredClone(props.crop) || { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, } diff --git a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx index 3595afb92..2737264b0 100644 --- a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx +++ b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx @@ -3,8 +3,8 @@ import { TLGeoShape, TLLineShape, createShapeId, - deepCopy, sortByIndex, + structuredClone, } from '@tldraw/editor' import { TestEditor } from '../../../test/TestEditor' import { TL } from '../../../test/test-jsx' @@ -278,7 +278,7 @@ describe('Misc', () => { it('preserves handle positions on spline type change', () => { editor.select(id) const shape = getShape() - const prevPoints = deepCopy(shape.props.points) + const prevPoints = structuredClone(shape.props.points) editor.updateShapes([ { diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/crop_helpers.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/crop_helpers.ts index ab36c8712..7b5bf68f8 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/crop_helpers.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/Crop/children/crop_helpers.ts @@ -4,7 +4,7 @@ import { TLImageShapeCrop, TLShapePartial, Vec, - deepCopy, + structuredClone, } from '@tldraw/editor' export type ShapeWithCrop = TLBaseShape @@ -44,7 +44,7 @@ export function getTranslateCroppedImageChange( const yCrop = oldCrop.bottomRight.y - oldCrop.topLeft.y const xCrop = oldCrop.bottomRight.x - oldCrop.topLeft.x - const newCrop = deepCopy(oldCrop) + const newCrop = structuredClone(oldCrop) newCrop.topLeft.x = Math.min(1 - xCrop, Math.max(0, newCrop.topLeft.x - delta.x / w)) newCrop.topLeft.y = Math.min(1 - yCrop, Math.max(0, newCrop.topLeft.y - delta.y / h)) diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/Cropping.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/Cropping.ts index 167253693..aa2bcbc94 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/Cropping.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/Cropping.ts @@ -9,7 +9,7 @@ import { TLPointerEventInfo, TLShapePartial, Vec, - deepCopy, + structuredClone, } from '@tldraw/editor' import { MIN_CROP_SIZE } from './Crop/crop-constants' import { CursorTypeMap } from './PointingResizeHandle' @@ -101,7 +101,7 @@ export class Cropping extends StateNode { const change = currentPagePoint.clone().sub(originPagePoint).rot(-shape.rotation) const crop = props.crop ?? this.getDefaultCrop() - const newCrop = deepCopy(crop) + const newCrop = structuredClone(crop) const newPoint = new Vec(shape.x, shape.y) const pointDelta = new Vec(0, 0) diff --git a/packages/tlschema/src/shapes/TLLineShape.ts b/packages/tlschema/src/shapes/TLLineShape.ts index 12d9089e2..2de66362a 100644 --- a/packages/tlschema/src/shapes/TLLineShape.ts +++ b/packages/tlschema/src/shapes/TLLineShape.ts @@ -1,5 +1,11 @@ import { defineMigrations } from '@tldraw/store' -import { IndexKey, deepCopy, getIndices, objectMapFromEntries, sortByIndex } from '@tldraw/utils' +import { + IndexKey, + getIndices, + objectMapFromEntries, + sortByIndex, + structuredClone, +} from '@tldraw/utils' import { T } from '@tldraw/validate' import { StyleProp } from '../styles/StyleProp' import { DefaultColorStyle } from '../styles/TLColorStyle' @@ -52,14 +58,14 @@ export const lineShapeMigrations = defineMigrations({ migrators: { [lineShapeVersions.AddSnapHandles]: { up: (record: any) => { - const handles = deepCopy(record.props.handles as Record) + const handles = structuredClone(record.props.handles as Record) for (const id in handles) { handles[id].canSnap = true } return { ...record, props: { ...record.props, handles } } }, down: (record: any) => { - const handles = deepCopy(record.props.handles as Record) + const handles = structuredClone(record.props.handles as Record) for (const id in handles) { delete handles[id].canSnap } diff --git a/packages/tlsync/package.json b/packages/tlsync/package.json index 0f7ddd1d4..2215b64a0 100644 --- a/packages/tlsync/package.json +++ b/packages/tlsync/package.json @@ -44,7 +44,7 @@ }, "jest": { "preset": "config/jest/node", - "testEnvironment": "jsdom", + "testEnvironment": "../../../packages/utils/patchedJestJsDom.js", "moduleNameMapper": { "^~(.*)": "/src/$1" }, diff --git a/packages/tlsync/src/lib/TLSyncRoom.ts b/packages/tlsync/src/lib/TLSyncRoom.ts index a815329e2..adef32028 100644 --- a/packages/tlsync/src/lib/TLSyncRoom.ts +++ b/packages/tlsync/src/lib/TLSyncRoom.ts @@ -13,10 +13,12 @@ import { DocumentRecordType, PageRecordType, TLDOCUMENT_ID } from '@tldraw/tlsch import { IndexKey, Result, + assert, assertExists, exhaustiveSwitchError, getOwnProperty, hasOwnProperty, + isNativeStructuredClone, objectMapEntries, objectMapKeys, } from '@tldraw/utils' @@ -208,6 +210,12 @@ export class TLSyncRoom { public readonly schema: StoreSchema, snapshot?: RoomSnapshot ) { + assert( + isNativeStructuredClone, + 'TLSyncRoom is supposed to run either on Cloudflare Workers' + + 'or on a 18+ version of Node.js, which both support the native structuredClone API' + ) + // do a json serialization cycle to make sure the schema has no 'undefined' values this.serializedSchema = JSON.parse(JSON.stringify(schema.serialize())) diff --git a/packages/utils/api-report.md b/packages/utils/api-report.md index 5eb4b1373..e1379999c 100644 --- a/packages/utils/api-report.md +++ b/packages/utils/api-report.md @@ -37,9 +37,6 @@ export function debounce(callback: (...args: T) => Promi // @public export function dedupe(input: T[], equals?: (a: any, b: any) => boolean): T[]; -// @public -export function deepCopy(obj: T): T; - // @internal export function deleteFromLocalStorage(key: string): void; @@ -140,6 +137,9 @@ export function invLerp(a: number, b: number, t: number): number; // @public export function isDefined(value: T): value is typeof value extends undefined ? never : T; +// @internal (undocumented) +export const isNativeStructuredClone: boolean; + // @public export function isNonNull(value: T): value is typeof value extends null ? never : T; @@ -307,6 +307,9 @@ export function sortByIndex(a: T, b: T): -1 | 0 | 1; +// @internal +export const STRUCTURED_CLONE_OBJECT_PROTOTYPE: any; + // @public const structuredClone_2: (i: T) => T; export { structuredClone_2 as structuredClone } diff --git a/packages/utils/api/api.json b/packages/utils/api/api.json index 1f3e89e29..a018ba983 100644 --- a/packages/utils/api/api.json +++ b/packages/utils/api/api.json @@ -357,72 +357,6 @@ ], "name": "dedupe" }, - { - "kind": "Function", - "canonicalReference": "@tldraw/utils!deepCopy:function(1)", - "docComment": "/**\n * Deep copy function for TypeScript.\n *\n * @param obj - Target value to be copied.\n *\n * @example\n * ```ts\n * const A = deepCopy({ a: 1, b: { c: 2 } })\n * ```\n *\n * @see\n *\n * Source - project, ts-deeply https://github.com/ykdr2017/ts-deepcopy\n *\n * @see\n *\n * Code - pen https://codepen.io/erikvullings/pen/ejyBYg\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "export declare function deepCopy(obj: " - }, - { - "kind": "Content", - "text": "T" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Content", - "text": "T" - }, - { - "kind": "Content", - "text": ";" - } - ], - "fileUrlPath": "packages/utils/src/lib/object.ts", - "returnTypeTokenRange": { - "startIndex": 5, - "endIndex": 6 - }, - "releaseTag": "Public", - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "obj", - "parameterTypeTokenRange": { - "startIndex": 3, - "endIndex": 4 - }, - "isOptional": false - } - ], - "typeParameters": [ - { - "typeParameterName": "T", - "constraintTokenRange": { - "startIndex": 0, - "endIndex": 0 - }, - "defaultTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - } - } - ], - "name": "deepCopy" - }, { "kind": "TypeAlias", "canonicalReference": "@tldraw/utils!ErrorResult:type", diff --git a/packages/utils/package.json b/packages/utils/package.json index bc9de8e54..187b7ca3b 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -50,6 +50,7 @@ } }, "devDependencies": { + "jest-environment-jsdom": "^29.4.3", "lazyrepo": "0.0.0-alpha.27" } } diff --git a/packages/utils/patchedJestJsDom.js b/packages/utils/patchedJestJsDom.js new file mode 100644 index 000000000..5266f5d6a --- /dev/null +++ b/packages/utils/patchedJestJsDom.js @@ -0,0 +1,10 @@ +import JSDOMEnvironment from 'jest-environment-jsdom' + +export default class FixJSDOMEnvironment extends JSDOMEnvironment { + constructor(...args) { + super(...args) + + // fixes https://github.com/jsdom/jsdom/issues/3363 + this.global.structuredClone = structuredClone + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 222d78917..b008b9753 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -27,7 +27,6 @@ export { MediaHelpers } from './lib/media' export { invLerp, lerp, modulate, rng } from './lib/number' export { areObjectsShallowEqual, - deepCopy, filterEntries, getOwnProperty, hasOwnProperty, @@ -64,5 +63,12 @@ export { } from './lib/storage' export { fpsThrottle, throttleToNextFrame } from './lib/throttle' export type { Expand, RecursivePartial, Required } from './lib/types' -export { isDefined, isNonNull, isNonNullish, structuredClone } from './lib/value' +export { + STRUCTURED_CLONE_OBJECT_PROTOTYPE, + isDefined, + isNativeStructuredClone, + isNonNull, + isNonNullish, + structuredClone, +} from './lib/value' export { warnDeprecatedGetter } from './lib/warnDeprecatedGetter' diff --git a/packages/utils/src/lib/object.ts b/packages/utils/src/lib/object.ts index 954804f45..924e2c496 100644 --- a/packages/utils/src/lib/object.ts +++ b/packages/utils/src/lib/object.ts @@ -19,40 +19,6 @@ export function getOwnProperty(obj: object, key: string): unknown { return obj[key] } -/** - * Deep copy function for TypeScript. - * - * @example - * - * ```ts - * const A = deepCopy({ a: 1, b: { c: 2 } }) - * ``` - * - * @param obj - Target value to be copied. - * @public - * @see Source - project, ts-deeply https://github.com/ykdr2017/ts-deepcopy - * @see Code - pen https://codepen.io/erikvullings/pen/ejyBYg - */ -export function deepCopy(obj: T): T { - if (!obj) return obj - if (Array.isArray(obj)) { - const arr: unknown[] = [] - const length = obj.length - for (let i = 0; i < length; i++) arr.push(deepCopy(obj[i])) - return arr as unknown as T - } else if (typeof obj === 'object') { - const keys = Object.keys(obj!) - const length = keys.length - const newObject: any = {} - for (let i = 0; i < length; i++) { - const key = keys[i] - newObject[key] = deepCopy((obj as any)[key]) - } - return newObject - } - return obj -} - /** * An alias for `Object.keys` that treats the object as a map and so preserves the type of the keys. * diff --git a/packages/utils/src/lib/value.ts b/packages/utils/src/lib/value.ts index 5e4841536..09ada81ea 100644 --- a/packages/utils/src/lib/value.ts +++ b/packages/utils/src/lib/value.ts @@ -30,12 +30,44 @@ export function isNonNullish( return value !== null && value !== undefined } +function getStructuredClone(): [(i: T) => T, boolean] { + if (typeof globalThis !== 'undefined' && (globalThis as any).structuredClone) { + return [globalThis.structuredClone as (i: T) => T, true] + } + + if (typeof global !== 'undefined' && (global as any).structuredClone) { + return [global.structuredClone as (i: T) => T, true] + } + + if (typeof window !== 'undefined' && (window as any).structuredClone) { + return [window.structuredClone as (i: T) => T, true] + } + + return [(i: T): T => (i ? JSON.parse(JSON.stringify(i)) : i), false] +} + +const _structuredClone = getStructuredClone() + /** * Create a deep copy of a value. Uses the structuredClone API if available, otherwise uses JSON.parse(JSON.stringify()). * * @param i - The value to clone. * @public */ -export const structuredClone = - typeof window !== 'undefined' && (window as any).structuredClone - ? (window.structuredClone as (i: T) => T) - : (i: T): T => (i ? JSON.parse(JSON.stringify(i)) : i) +export const structuredClone = _structuredClone[0] + +/** + * @internal + */ +export const isNativeStructuredClone = _structuredClone[1] + +/** + * When we patch structuredClone in jsdom for testing (see https://github.com/jsdom/jsdom/issues/3363), + * the Object that is used as a prototype for the cloned object is not the same as the Object in + * the code under test (that comes from jsdom's fake global context). This constant is used in + * our code to work around this case. + * + * This is also the case for Array prototype, but that problem can be worked around with an + * Array.isArray() check. + * @internal + */ +export const STRUCTURED_CLONE_OBJECT_PROTOTYPE = Object.getPrototypeOf(structuredClone({})) diff --git a/packages/validate/src/lib/validation.ts b/packages/validate/src/lib/validation.ts index cbb6ffb64..b9d4d21f3 100644 --- a/packages/validate/src/lib/validation.ts +++ b/packages/validate/src/lib/validation.ts @@ -1,6 +1,7 @@ import { IndexKey, JsonValue, + STRUCTURED_CLONE_OBJECT_PROTOTYPE, exhaustiveSwitchError, getOwnProperty, hasOwnProperty, @@ -698,7 +699,9 @@ function isPlainObject(value: unknown): value is Record { return ( typeof value === 'object' && value !== null && - (value.constructor === Object || !value.constructor) + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null || + Object.getPrototypeOf(value) === STRUCTURED_CLONE_OBJECT_PROTOTYPE) ) } diff --git a/scripts/prepack.ts b/scripts/prepack.ts index 8c1b49232..c68de2c54 100644 --- a/scripts/prepack.ts +++ b/scripts/prepack.ts @@ -23,6 +23,7 @@ export async function preparePackage({ sourcePackageDir }: { sourcePackageDir: s const cssFiles = glob.sync(path.join(sourcePackageDir, '*.css')) // construct the final package.json + // eslint-disable-next-line no-restricted-globals const newManifest = structuredClone({ // filter out comments ...Object.fromEntries( diff --git a/yarn.lock b/yarn.lock index ed74a2faf..8d7c66e8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7188,6 +7188,7 @@ __metadata: "@microsoft/tsdoc": "npm:^0.14.2" "@radix-ui/react-accordion": "npm:^1.1.2" "@radix-ui/react-navigation-menu": "npm:^1.1.4" + "@tldraw/utils": "workspace:*" "@types/broken-link-checker": "npm:^0.7.1" "@types/node": "npm:~20.11" "@types/sqlite3": "npm:^3.1.9" @@ -7463,6 +7464,7 @@ __metadata: version: 0.0.0-use.local resolution: "@tldraw/utils@workspace:packages/utils" dependencies: + jest-environment-jsdom: "npm:^29.4.3" lazyrepo: "npm:0.0.0-alpha.27" languageName: unknown linkType: soft