use native structuredClone on node, cloudflare workers, and in tests (#3166)

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

<!--  Please select a 'Scope' label ️ -->

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

<!--  Please select a 'Type' label ️ -->

- [ ] `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
This commit is contained in:
Dan Groshev 2024-03-18 17:16:09 +00:00 committed by GitHub
parent 1951fc0e47
commit d7b80baa31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 135 additions and 141 deletions

View file

@ -28,4 +28,6 @@ apps/vscode/extension/editor/tldraw-assets.json
**/scripts/upload-sourcemaps.js
**/coverage/**/*
apps/dotcom/public/sw.js
apps/dotcom/public/sw.js
patchedJestJsDom.js

View file

@ -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: {

View file

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

View file

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

View file

@ -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 = {

View file

@ -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",

View file

@ -24,5 +24,10 @@
".next/types/**/*.ts",
"watcher.ts"
],
"exclude": ["node_modules", ".next"]
"exclude": ["node_modules", ".next"],
"references": [
{
"path": "../../packages/utils"
}
]
}

View file

@ -58,7 +58,7 @@
"roots": [
"<rootDir>"
],
"testEnvironment": "jsdom",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"transformIgnorePatterns": [
"node_modules/(?!(nanoid|nanoevents)/)"
],

View file

@ -15,7 +15,6 @@ import {
Vec,
VecModel,
ZERO_INDEX_KEY,
deepCopy,
getDefaultColorTheme,
resizeBox,
structuredClone,
@ -143,7 +142,7 @@ export class SpeechBubbleUtil extends ShapeUtil<SpeechBubbleShape> {
}
}
const next = deepCopy(shape)
const next = structuredClone(shape)
next.props.tail.x = newPoint.x / w
next.props.tail.y = newPoint.y / h

View file

@ -84,7 +84,7 @@
},
"jest": {
"preset": "config/jest/node",
"testEnvironment": "jsdom",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"fakeTimers": {
"enableGlobally": true
},

View file

@ -47,7 +47,6 @@ import {
assert,
compact,
dedupe,
deepCopy,
getIndexAbove,
getIndexBetween,
getIndices,
@ -5224,7 +5223,7 @@ export class Editor extends EventEmitter<TLEventMap> {
? getIndexBetween(shape.index, siblingAbove.index)
: getIndexAbove(shape.index)
let newShape: TLShape = deepCopy(shape)
let newShape: TLShape = structuredClone(shape)
if (
this.isShapeOfType<TLArrowShape>(shape, 'arrow') &&
@ -7867,13 +7866,13 @@ export class Editor extends EventEmitter<TLEventMap> {
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)) {

View file

@ -57,7 +57,7 @@
},
"jest": {
"preset": "config/jest/node",
"testEnvironment": "jsdom",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"fakeTimers": {
"enableGlobally": true
},

View file

@ -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<T>(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')
}

View file

@ -78,7 +78,7 @@
},
"jest": {
"preset": "config/jest/node",
"testEnvironment": "jsdom",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"fakeTimers": {
"enableGlobally": true
},

View file

@ -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<TLArrowShape> {
// 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<TLArrowShape> {
const terminals = getArrowTerminalsInArrowSpace(this.editor, shape)
const { start, end } = deepCopy<TLArrowShape['props']>(shape.props)
const { start, end } = structuredClone<TLArrowShape['props']>(shape.props)
let { bend } = shape.props
// Rescale start handle if it's not bound to a shape

View file

@ -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<TLImageShape> {
return
}
const crop = deepCopy(props.crop) || {
const crop = structuredClone(props.crop) || {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 },
}

View file

@ -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([
{

View file

@ -4,7 +4,7 @@ import {
TLImageShapeCrop,
TLShapePartial,
Vec,
deepCopy,
structuredClone,
} from '@tldraw/editor'
export type ShapeWithCrop = TLBaseShape<string, { w: number; h: number; crop: TLImageShapeCrop }>
@ -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))

View file

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

View file

@ -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<string, any>)
const handles = structuredClone(record.props.handles as Record<string, any>)
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<string, any>)
const handles = structuredClone(record.props.handles as Record<string, any>)
for (const id in handles) {
delete handles[id].canSnap
}

View file

@ -44,7 +44,7 @@
},
"jest": {
"preset": "config/jest/node",
"testEnvironment": "jsdom",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"moduleNameMapper": {
"^~(.*)": "<rootDir>/src/$1"
},

View file

@ -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<R extends UnknownRecord> {
public readonly schema: StoreSchema<R, any>,
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()))

View file

@ -37,9 +37,6 @@ export function debounce<T extends unknown[], U>(callback: (...args: T) => Promi
// @public
export function dedupe<T>(input: T[], equals?: (a: any, b: any) => boolean): T[];
// @public
export function deepCopy<T = unknown>(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<T>(value: T): value is typeof value extends undefined ? never : T;
// @internal (undocumented)
export const isNativeStructuredClone: boolean;
// @public
export function isNonNull<T>(value: T): value is typeof value extends null ? never : T;
@ -307,6 +307,9 @@ export function sortByIndex<T extends {
index: IndexKey;
}>(a: T, b: T): -1 | 0 | 1;
// @internal
export const STRUCTURED_CLONE_OBJECT_PROTOTYPE: any;
// @public
const structuredClone_2: <T>(i: T) => T;
export { structuredClone_2 as structuredClone }

View file

@ -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<T = "
},
{
"kind": "Content",
"text": "unknown"
},
{
"kind": "Content",
"text": ">(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",

View file

@ -50,6 +50,7 @@
}
},
"devDependencies": {
"jest-environment-jsdom": "^29.4.3",
"lazyrepo": "0.0.0-alpha.27"
}
}

View file

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

View file

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

View file

@ -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<T = unknown>(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.
*

View file

@ -30,12 +30,44 @@ export function isNonNullish<T>(
return value !== null && value !== undefined
}
function getStructuredClone(): [<T>(i: T) => T, boolean] {
if (typeof globalThis !== 'undefined' && (globalThis as any).structuredClone) {
return [globalThis.structuredClone as <T>(i: T) => T, true]
}
if (typeof global !== 'undefined' && (global as any).structuredClone) {
return [global.structuredClone as <T>(i: T) => T, true]
}
if (typeof window !== 'undefined' && (window as any).structuredClone) {
return [window.structuredClone as <T>(i: T) => T, true]
}
return [<T>(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 <T>(i: T) => 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({}))

View file

@ -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<string, unknown> {
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)
)
}

View file

@ -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(

View file

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