Skip to content

Commit

Permalink
use native structuredClone on node, cloudflare workers, and in tests (#…
Browse files Browse the repository at this point in the history
…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](jsdom/jsdom#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
  • Loading branch information
si14 committed Mar 18, 2024
1 parent 1951fc0 commit d7b80ba
Show file tree
Hide file tree
Showing 32 changed files with 135 additions and 141 deletions.
4 changes: 3 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions apps/docs/app/api/ai/route.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
1 change: 1 addition & 0 deletions apps/docs/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion apps/docs/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,10 @@
".next/types/**/*.ts",
"watcher.ts"
],
"exclude": ["node_modules", ".next"]
"exclude": ["node_modules", ".next"],
"references": [
{
"path": "../../packages/utils"
}
]
}
2 changes: 1 addition & 1 deletion apps/dotcom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"roots": [
"<rootDir>"
],
"testEnvironment": "jsdom",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"transformIgnorePatterns": [
"node_modules/(?!(nanoid|nanoevents)/)"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
Vec,
VecModel,
ZERO_INDEX_KEY,
deepCopy,
getDefaultColorTheme,
resizeBox,
structuredClone,
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
},
"jest": {
"preset": "config/jest/node",
"testEnvironment": "jsdom",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"fakeTimers": {
"enableGlobally": true
},
Expand Down
7 changes: 3 additions & 4 deletions packages/editor/src/lib/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ import {
assert,
compact,
dedupe,
deepCopy,
getIndexAbove,
getIndexBetween,
getIndices,
Expand Down Expand Up @@ -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') &&
Expand Down Expand Up @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion packages/namespaced-tldraw/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
},
"jest": {
"preset": "config/jest/node",
"testEnvironment": "jsdom",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"fakeTimers": {
"enableGlobally": true
},
Expand Down
12 changes: 11 additions & 1 deletion packages/store/src/lib/devFreeze.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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')
}
Expand Down
2 changes: 1 addition & 1 deletion packages/tldraw/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
},
"jest": {
"preset": "config/jest/node",
"testEnvironment": "jsdom",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"fakeTimers": {
"enableGlobally": true
},
Expand Down
6 changes: 3 additions & 3 deletions packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ import {
Vec,
arrowShapeMigrations,
arrowShapeProps,
deepCopy,
getArrowTerminalsInArrowSpace,
getDefaultColorTheme,
mapObjectMapValues,
objectMapEntries,
structuredClone,
toDomPrecision,
useIsEditing,
} from '@tldraw/editor'
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/tldraw/src/lib/shapes/image/ImageShapeUtil.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {
TLOnDoubleClickHandler,
TLShapePartial,
Vec,
deepCopy,
imageShapeMigrations,
imageShapeProps,
structuredClone,
toDomPrecision,
} from '@tldraw/editor'
import { useEffect, useState } from 'react'
Expand Down Expand Up @@ -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 },
}
Expand Down
4 changes: 2 additions & 2 deletions packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
TLImageShapeCrop,
TLShapePartial,
Vec,
deepCopy,
structuredClone,
} from '@tldraw/editor'

export type ShapeWithCrop = TLBaseShape<string, { w: number; h: number; crop: TLImageShapeCrop }>
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions packages/tlschema/src/shapes/TLLineShape.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion packages/tlsync/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
},
"jest": {
"preset": "config/jest/node",
"testEnvironment": "jsdom",
"testEnvironment": "../../../packages/utils/patchedJestJsDom.js",
"moduleNameMapper": {
"^~(.*)": "<rootDir>/src/$1"
},
Expand Down
8 changes: 8 additions & 0 deletions packages/tlsync/src/lib/TLSyncRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()))

Expand Down
9 changes: 6 additions & 3 deletions packages/utils/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 }
Expand Down

0 comments on commit d7b80ba

Please sign in to comment.