From 6059bcf3dc7e5de1dd9615d1d7ddc8567b663af5 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 19 Jun 2026 23:04:01 +0100 Subject: [PATCH 1/5] fix(dotcom): remove editor load animations (fade-in and loading spinners) (#9347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to make tldraw.com files open without any animated loading state, this PR removes the editor load animations from the dotcom tla app: the fade-in shroud and the loading screens that show a spinner. The editor chrome and placeholder already appear instantly, so these animations only flashed a spinner and faded content in before the real document hard-cut in. This is scoped entirely to `apps/dotcom/client/src/tla/` — the tldraw SDK (`packages/editor`, `packages/tldraw`) is unchanged, so the default `LoadingScreen`, `DefaultSpinner`, and `.tl-loading` fade are untouched for SDK consumers. What changed: - Set `LoadingScreen: null` on the shared tla `components` object, so the file editor, local editor, and root-providers editor mount with no SDK spinner or fade. The placeholder editor's now-redundant override was folded away. - The auth gate in `useUser` no longer renders a `` with a ``; it renders a blank editor surface while auth loads. - Removed the fade-in transitions from the `useIsReady` shroud (page and file-content opacity fades) and deleted a dead `.spinner` fade rule. Content now hard-cuts in once the editor is ready. Relates to #9249 ### Change type - [x] `improvement` ### Test plan 1. Open a file on tldraw.com (and a local file at `/`): the editor and its content appear with no fade-in and no spinner. 2. While signed-out/auth is loading, confirm no spinner shows — just a blank editor surface. 3. Confirm the SDK examples app still shows the default loading spinner and fade (SDK behavior unchanged). - [ ] Unit tests - [ ] End to end tests ### Code changes | Section | LOC change | | ------- | ---------- | | Apps | +10 / -37 | --- .../tla/components/TlaEditor/TlaEditor.tsx | 36 ++------------ .../src/tla/hooks/useIsReady.module.css | 49 ------------------- .../client/src/tla/hooks/useIsReady.tsx | 25 ++-------- apps/dotcom/client/src/tla/hooks/useUser.tsx | 11 ++--- 4 files changed, 10 insertions(+), 111 deletions(-) diff --git a/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx b/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx index ad62db5698b3..7948bba5fa76 100644 --- a/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx +++ b/apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx @@ -68,6 +68,9 @@ export const components: TLComponents = { SharePanel: TlaEditorSharePanel, Dialogs: null, Toasts: null, + // No loading screen on tla editors: the editor only mounts once it's ready, + // so a spinner would only flash before the content appears. + LoadingScreen: null, } interface TlaEditorProps { @@ -76,43 +79,12 @@ interface TlaEditorProps { deepLinks?: boolean } -// Components for the inert placeholder editor shown while the real file syncs. -// Same UI as the real editor (toolbar, menus, panels) so the chrome appears -// instantly, plus a nulled loading screen to avoid a flash of its own spinner -// before the (instant) empty local store finishes loading. -const placeholderComponents: TLComponents = { ...components, LoadingScreen: null } - -/** - * An empty, non-interactive tldraw editor shown immediately while the real file - * connects and syncs. It renders the full editor chrome (toolbar, menus, panels) - * over its own ephemeral empty store, so the UI pops in instantly and only the - * document content fills in once synced. It never autofocuses, and the - * ReadyWrapper overlay disables pointer events so nothing here is interactive. - */ -function TlaEditorLoadingPlaceholder() { - const app = useMaybeApp() - return ( - - - - ) -} - export function TlaEditor(props: TlaEditorProps) { // force re-mount when the file slug changes to prevent state from leaking between files return ( <> - }> + diff --git a/apps/dotcom/client/src/tla/hooks/useIsReady.module.css b/apps/dotcom/client/src/tla/hooks/useIsReady.module.css index a37c6946334e..070c4915cb79 100644 --- a/apps/dotcom/client/src/tla/hooks/useIsReady.module.css +++ b/apps/dotcom/client/src/tla/hooks/useIsReady.module.css @@ -13,57 +13,8 @@ .innerContainer { flex-grow: 1; height: 100%; - transition: opacity 0.12s ease-in; } .innerContainer:not(.isReady) { opacity: 0; } - -.spinner { - transition: opacity 1s ease-in; - position: absolute; - inset: 0; - display: flex; - justify-content: center; - align-items: center; -} - -.spinner:not(.showSpinner) { - opacity: 0; -} - -.loadingScreen { - position: absolute; - inset: 0; - /* The placeholder editor is purely visual: never let it receive input. */ - pointer-events: none; -} - -/* - * When a placeholder editor is shown during loading there's already an editor on - * screen, so the real editor hard-cuts in rather than fading. - */ -.withLoadingScreen .innerContainer { - transition: none; -} - -.withLoadingScreen .innerContainer:not(.isReady) { - opacity: 1; -} - -/* - * Fade just the file content in once the editor is ready, leaving the main UI, - * background, and grid untouched so they line up with the placeholder. - */ -.withLoadingScreen .innerContainer :global(.tl-shapes), -.withLoadingScreen .innerContainer :global(.tl-canvas-overlays), -.withLoadingScreen .innerContainer :global(.tl-canvas__in-front) { - transition: opacity 0.2s ease-in; -} - -.withLoadingScreen .innerContainer:not(.isReady) :global(.tl-shapes), -.withLoadingScreen .innerContainer:not(.isReady) :global(.tl-canvas-overlays), -.withLoadingScreen .innerContainer:not(.isReady) :global(.tl-canvas__in-front) { - opacity: 0; -} diff --git a/apps/dotcom/client/src/tla/hooks/useIsReady.tsx b/apps/dotcom/client/src/tla/hooks/useIsReady.tsx index 12b8e137a586..a418e6824266 100644 --- a/apps/dotcom/client/src/tla/hooks/useIsReady.tsx +++ b/apps/dotcom/client/src/tla/hooks/useIsReady.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames' -import React, { PropsWithChildren, ReactNode, useCallback, useContext } from 'react' +import React, { PropsWithChildren, useCallback, useContext } from 'react' import styles from './useIsReady.module.css' /* @@ -18,13 +18,9 @@ export function useSetIsReady() { return React.useContext(ReadyContext).setIsReady } -export function ReadyWrapper({ - children, - loadingScreen, -}: PropsWithChildren<{ loadingScreen?: ReactNode }>) { +export function ReadyWrapper({ children }: PropsWithChildren) { const parent = useContext(ReadyContext) const [isReady, _setIsReady] = React.useState(false) - const hasLoadingScreen = !!loadingScreen const setIsReady = useCallback(async () => { _setIsReady(true) }, []) @@ -36,28 +32,13 @@ export function ReadyWrapper({ return ( -
+
{children}
- {/* While loading, show the inert placeholder editor (if provided) with - the spinner layered in front of it. */} - {!isReady && hasLoadingScreen && ( - - )}
) diff --git a/apps/dotcom/client/src/tla/hooks/useUser.tsx b/apps/dotcom/client/src/tla/hooks/useUser.tsx index d0d8be2661c1..74bf06a61de0 100644 --- a/apps/dotcom/client/src/tla/hooks/useUser.tsx +++ b/apps/dotcom/client/src/tla/hooks/useUser.tsx @@ -1,7 +1,7 @@ import { useAuth, useUser as useClerkUser } from '@clerk/clerk-react' import type { UserResource } from '@clerk/types' import { ReactNode, createContext, useContext, useMemo } from 'react' -import { DefaultSpinner, LoadingScreen, assert, useShallowObjectIdentity } from 'tldraw' +import { assert, useShallowObjectIdentity } from 'tldraw' import { useMaybeApp } from './useAppState' interface TldrawUser { @@ -45,13 +45,8 @@ export function UserProvider({ children }: { children: ReactNode }) { }, [getToken, isSignedIn, user, app]) if (!isLoaded || !isAuthLoaded || !app) { - return ( -
- - - -
- ) + // Render a blank editor surface while auth loads, with no spinner or fade. + return
} return {children} From 166d3202fff29aa0c3c46e1f5bbdf8b17cf3ba73 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 19 Jun 2026 23:40:19 +0100 Subject: [PATCH 2/5] perf(tldraw): dedupe redundant icon preloads on editor mount (#9351) In order to avoid hundreds of redundant `Image.decode()` calls when the editor mounts, this PR deduplicates icon preloads by their underlying resource. The `AssetUrlsProvider` effect preloads every icon URL, but all ~324 icon URLs point at the same SVG sprite sheet and differ only by their `#fragment` (e.g. `0_merged.svg#tool-arrow`). The fragment doesn't change the resource, so the effect was creating one `Image` and `decode()` per icon for what is really a single file. This was visible as repeated, slow work in `asset-urls.tsx` during editor load. The effect now strips the fragment and tracks preloaded resources in a `Set`, so each unique resource is decoded once. Embed icons (separate files) are unaffected and still preload individually. ### Change type - [x] `improvement` ### Test plan 1. Load the editor and confirm icons render correctly (toolbar, menus, style panel). 2. In the Performance panel, record editor load and confirm the icon preload work in `asset-urls.tsx` no longer repeats per-icon. - [ ] Unit tests - [ ] End to end tests ### Release notes - Reduce redundant icon preloading work on editor mount by deduplicating sprite-sheet preloads. --- .../tldraw/src/lib/ui/context/asset-urls.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/tldraw/src/lib/ui/context/asset-urls.tsx b/packages/tldraw/src/lib/ui/context/asset-urls.tsx index b1b70c933b66..07c92e831792 100644 --- a/packages/tldraw/src/lib/ui/context/asset-urls.tsx +++ b/packages/tldraw/src/lib/ui/context/asset-urls.tsx @@ -21,16 +21,17 @@ export interface AssetUrlsProviderProps { */ export function AssetUrlsProvider({ assetUrls, children }: AssetUrlsProviderProps) { useEffect(() => { - for (const src of Object.values(assetUrls.icons)) { + // Many icon URLs point at the same sprite sheet and differ only by their + // `#fragment` identifier (e.g. `0_merged.svg#tool-arrow`). The fragment + // doesn't change the underlying resource, so preload each unique resource + // once rather than creating hundreds of redundant Image decodes. + const preloaded = new Set() + for (const src of [...Object.values(assetUrls.icons), ...Object.values(assetUrls.embedIcons)]) { if (!src) continue - const image = Image() - image.crossOrigin = 'anonymous' - image.src = src - image.decode().catch(noop) - } - for (const src of Object.values(assetUrls.embedIcons)) { - if (!src) continue + const resource = src.split('#')[0] + if (preloaded.has(resource)) continue + preloaded.add(resource) const image = Image() image.crossOrigin = 'anonymous' From aabc8d8c0db095bbf3dc1d4d25001eba4071b2bb Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 19 Jun 2026 23:47:50 +0100 Subject: [PATCH 3/5] fix(editor): only auto-enable pen mode for direct-display stylus input (#9316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🙇‍♂️ Tested with Desktop + Wacom (correctly does not enter pen mode) and iPad (correctly enters pen mode). --- In order to stop desktop graphics tablets from hijacking the editor into pen mode, this PR makes pen mode auto-enable only for stylus input that looks like direct manipulation on the display (e.g. Apple Pencil on an iPad or a Surface Pen on a touchscreen). Previously, any `pointerdown` with `pointerType === 'pen'` flipped the editor into pen mode, which then ignores touch/mouse input. That's correct for a touchscreen stylus, but wrong for an indirect desktop tablet (e.g. a Wacom Intuos) used alongside a mouse, where it would unexpectedly disable the mouse. We now distinguish direct from indirect pens using implicit pointer capture: direct-manipulation pointers receive implicit capture on `pointerdown`, so the canvas already holds capture before we call `setPointerCapture` ourselves. Indirect tablet styluses do not. This check is deliberately scoped to auto-entering pen mode — `isPen` still drives pen-specific drawing and pressure handling for all pens, so an indirect tablet stylus continues to draw with pressure; it just no longer forces pen mode. ### Change type - [x] `bugfix` ### Test plan 1. On a desktop with an indirect graphics tablet (e.g. Wacom Intuos), draw with the stylus — the editor should keep responding to mouse and touch input (pen mode stays off). 2. On an iPad with an Apple Pencil (or a touchscreen with a Surface Pen), draw with the stylus — the editor should enter pen mode and ignore palm/touch input as before. - [x] Unit tests - [ ] End to end tests ### API changes - Added optional `isPenDirect` field to `TLPointerEventInfo`, indicating whether a pen event appears to be direct-display manipulation. Drawing and pressure behavior remain driven by `isPen`. ### Code changes | Section | LOC change | | --------------- | ---------- | | Core code | +41 / -3 | | Tests | +69 / -1 | | Automated files | +1 / -0 | ### Release notes - Fix desktop graphics tablets (e.g. Wacom) incorrectly switching the editor into pen mode. Pen mode now auto-enables only for direct-display stylus input such as Apple Pencil on iPad or Surface Pen on a touchscreen. --- packages/driver/src/lib/Driver.ts | 4 ++ packages/editor/api-report.api.md | 2 + packages/editor/src/lib/editor/Editor.ts | 6 ++- .../src/lib/editor/types/event-types.ts | 7 +++ .../editor/src/lib/globals/environment.ts | 7 +++ .../editor/src/lib/hooks/useCanvasEvents.ts | 7 ++- packages/editor/src/lib/utils/pointer.test.ts | 30 ++++++++++++- packages/editor/src/lib/utils/pointer.ts | 24 +++++++++++ .../tldraw/src/test/commands/penmode.test.ts | 43 +++++++++++++++++++ 9 files changed, 126 insertions(+), 4 deletions(-) diff --git a/packages/driver/src/lib/Driver.ts b/packages/driver/src/lib/Driver.ts index 7da0e4071a44..3393ff58c503 100644 --- a/packages/driver/src/lib/Driver.ts +++ b/packages/driver/src/lib/Driver.ts @@ -233,6 +233,10 @@ export class Driver { ...modifiers, } as TLPointerEventInfo + // In simulated input, a pen is assumed to be a direct-display pen (which auto-enables pen + // mode) unless a test explicitly opts out with `isPenDirect: false`. + if (info.isPenDirect === undefined) info.isPenDirect = info.isPen + if (tlenv.isDarwin && info.button === 0 && info.ctrlKey && !info.metaKey) { info.button = 2 } diff --git a/packages/editor/api-report.api.md b/packages/editor/api-report.api.md index fd16b9b9bebb..a86057676e03 100644 --- a/packages/editor/api-report.api.md +++ b/packages/editor/api-report.api.md @@ -3962,6 +3962,7 @@ export const tlenv: { isFirefox: boolean; isIos: boolean; isSafari: boolean; + isTouchDevice: boolean; isWebview: boolean; }; @@ -4470,6 +4471,7 @@ export type TLPointerEvent = (info: TLPointerEventInfo) => void; // @public (undocumented) export type TLPointerEventInfo = TLBaseEventInfo & { + isPenDirect?: boolean; button: number; isPen: boolean; name: TLPointerEventName; diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 61dae35e354f..0005d43dff2b 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -11223,8 +11223,10 @@ export class Editor extends EventEmitter { inputs.setIsPointing(true) inputs.setIsDragging(false) - // If pen mode is off but we're not already in pen mode, turn that on - if (!isPenMode && isPen) { + // If pen mode is off, turn it on for direct-display pen input only (e.g. Apple + // Pencil on an iPad or a Surface Pen on a touchscreen). Indirect desktop tablet + // styluses still draw as pens, but should not auto-enable pen mode. + if (!isPenMode && info.isPenDirect) { this.updateInstanceState({ isPenMode: true }) // Once pen mode is on, touch input is ignored, so we discard the // in-progress touch interaction . diff --git a/packages/editor/src/lib/editor/types/event-types.ts b/packages/editor/src/lib/editor/types/event-types.ts index 9f543a24bf93..1c4e6e5e5bf5 100644 --- a/packages/editor/src/lib/editor/types/event-types.ts +++ b/packages/editor/src/lib/editor/types/event-types.ts @@ -63,6 +63,13 @@ export type TLPointerEventInfo = TLBaseEventInfo & { pointerId: number button: number isPen: boolean + /** + * Whether this pen event appears to be direct manipulation on the display (e.g. Apple Pencil on + * an iPad or a Surface Pen on a touchscreen) rather than indirect input from a desktop graphics + * tablet (e.g. a Wacom Intuos). Only direct-display pens should auto-enable pen mode. Drawing and + * pressure behavior is driven by `isPen` and applies to all pens regardless of this flag. + */ + isPenDirect?: boolean } & TLPointerEventTarget /** @public */ diff --git a/packages/editor/src/lib/globals/environment.ts b/packages/editor/src/lib/globals/environment.ts index b0116cfaa04c..d46a00919c75 100644 --- a/packages/editor/src/lib/globals/environment.ts +++ b/packages/editor/src/lib/globals/environment.ts @@ -17,6 +17,10 @@ const tlenv = { isWebview: false, isDarwin: false, hasCanvasSupport: false, + // Whether the device has a touch screen (an integrated coarse pointer, e.g. an iPad or a + // touchscreen laptop). Unlike `isCoarsePointer`, this reflects the device's hardware rather than + // the pointer currently in use, so it stays stable when a pen or mouse is used. + isTouchDevice: false, } let isForcedFinePointer = false @@ -32,6 +36,9 @@ if (typeof window !== 'undefined') { } tlenv.hasCanvasSupport = 'Promise' in window && 'HTMLCanvasElement' in window isForcedFinePointer = tlenv.isFirefox && !tlenv.isAndroid && !tlenv.isIos + tlenv.isTouchDevice = + ('navigator' in window && window.navigator.maxTouchPoints > 0) || + (typeof window.matchMedia === 'function' && window.matchMedia('(any-pointer: coarse)').matches) } /** diff --git a/packages/editor/src/lib/hooks/useCanvasEvents.ts b/packages/editor/src/lib/hooks/useCanvasEvents.ts index c4c92e56fc8e..29b70bf48e13 100644 --- a/packages/editor/src/lib/hooks/useCanvasEvents.ts +++ b/packages/editor/src/lib/hooks/useCanvasEvents.ts @@ -8,7 +8,7 @@ import { setPointerCapture, } from '../utils/dom' import { getPointerInfo } from '../utils/getPointerInfo' -import { getPointerEventButton, isSecondaryClickEvent } from '../utils/pointer' +import { getPointerEventButton, isDirectDisplayPen, isSecondaryClickEvent } from '../utils/pointer' import { useEditor } from './useEditor' export function useCanvasEvents() { @@ -39,6 +39,10 @@ export function useCanvasEvents() { if (button !== 0 && button !== 1 && button !== 2 && button !== 5) return + // Detect direct-display pen input (Apple Pencil, Surface Pen on a touchscreen) so we + // only auto-enable pen mode for it, not for an indirect desktop tablet stylus. + const isPenDirect = isDirectDisplayPen(e) + setPointerCapture(e.currentTarget, e) editor.dispatch({ @@ -46,6 +50,7 @@ export function useCanvasEvents() { target: 'canvas', name: 'pointer_down', ...getPointerInfo(editor, e), + isPenDirect, }) } diff --git a/packages/editor/src/lib/utils/pointer.test.ts b/packages/editor/src/lib/utils/pointer.test.ts index 80b6e2668821..812c4d31b48f 100644 --- a/packages/editor/src/lib/utils/pointer.test.ts +++ b/packages/editor/src/lib/utils/pointer.test.ts @@ -1,7 +1,7 @@ import { tlenv } from '../globals/environment' import { TestEditor } from '../test/TestEditor' import { getPointerInfo } from './getPointerInfo' -import { isSecondaryClickEvent } from './pointer' +import { isDirectDisplayPen, isSecondaryClickEvent } from './pointer' const originalIsDarwin = tlenv.isDarwin @@ -21,6 +21,34 @@ describe('isSecondaryClickEvent', () => { }) }) +describe('isDirectDisplayPen', () => { + const originalIsTouchDevice = tlenv.isTouchDevice + + afterEach(() => { + tlenv.isTouchDevice = originalIsTouchDevice + }) + + function event(pointerType: string) { + return { pointerType, pointerId: 1 } as unknown as PointerEvent + } + + it('treats a pen on a touch-capable device as a direct-display pen', () => { + tlenv.isTouchDevice = true + expect(isDirectDisplayPen(event('pen'))).toBe(true) + }) + + it('treats a pen on a non-touch device as indirect', () => { + tlenv.isTouchDevice = false + expect(isDirectDisplayPen(event('pen'))).toBe(false) + }) + + it('is never true for mouse or touch input, even on a touch-capable device', () => { + tlenv.isTouchDevice = true + expect(isDirectDisplayPen(event('mouse'))).toBe(false) + expect(isDirectDisplayPen(event('touch'))).toBe(false) + }) +}) + describe('ctrl + left-click on macOS fires as a right-click (regression)', () => { // Regression test for https://github.com/tldraw/tldraw/issues/8217 // On macOS, a ctrl + left-click should be treated as a right-click diff --git a/packages/editor/src/lib/utils/pointer.ts b/packages/editor/src/lib/utils/pointer.ts index 5d28d78aea12..121556cde75d 100644 --- a/packages/editor/src/lib/utils/pointer.ts +++ b/packages/editor/src/lib/utils/pointer.ts @@ -1,5 +1,29 @@ +import type React from 'react' import { tlenv } from '../globals/environment' +/** + * Decide whether a pen pointer event looks like direct manipulation on the display (e.g. Apple + * Pencil on an iPad or a Surface Pen on a touchscreen) rather than indirect input from a desktop + * graphics tablet (e.g. a Wacom Intuos). + * + * We can't tell the two apart from the pointer event itself: both report `pointerType: 'pen'`, and + * implicit pointer capture — which in theory distinguishes direct-manipulation pointers — isn't + * reliably observable across browsers (notably WebKit/iPad). Instead we key off the device: a + * direct-display pen draws on a touch-capable screen, while an indirect graphics tablet is used on + * a non-touch desktop alongside a mouse. A device with no touch input therefore can't host a + * direct-display pen. + * + * Note this uses {@link tlenv.isTouchDevice} — the device's fixed touch capability — not the + * editor's dynamic `isCoarsePointer` state, which a pen `pointerdown` flips to coarse regardless + * of device. + * + * @internal + */ +export function isDirectDisplayPen(e: React.PointerEvent | PointerEvent): boolean { + if (e.pointerType !== 'pen') return false + return tlenv.isTouchDevice +} + /** @internal */ interface PointerLike { button: number diff --git a/packages/tldraw/src/test/commands/penmode.test.ts b/packages/tldraw/src/test/commands/penmode.test.ts index 3a4bde3a518c..a7be58eff959 100644 --- a/packages/tldraw/src/test/commands/penmode.test.ts +++ b/packages/tldraw/src/test/commands/penmode.test.ts @@ -28,6 +28,49 @@ it('ignores touch events while in pen mode', async () => { expect(editor.getCurrentPageShapes().length).toBe(0) }) +describe('auto-entering pen mode', () => { + it('auto-enables pen mode for direct-display pen input', () => { + expect(editor.getInstanceState().isPenMode).toBe(false) + editor.pointerDown(100, 100, { isPen: true, isPenDirect: true }) + expect(editor.getInstanceState().isPenMode).toBe(true) + }) + + it('does not auto-enable pen mode for indirect pen input', () => { + expect(editor.getInstanceState().isPenMode).toBe(false) + // An indirect desktop tablet stylus (e.g. a Wacom Intuos) still reports as a pen, but should + // not flip the editor into pen mode. + editor.pointerDown(100, 100, { isPen: true, isPenDirect: false }) + expect(editor.getInstanceState().isPenMode).toBe(false) + }) + + it('still draws with indirect pen input without entering pen mode', () => { + editor.setCurrentTool('draw') + editor.pointerDown(100, 100, { isPen: true, isPenDirect: false }) + editor.pointerMove(110, 110, { isPen: true, isPenDirect: false }) + editor.pointerMove(120, 120, { isPen: true, isPenDirect: false }) + editor.pointerUp(120, 120, { isPen: true, isPenDirect: false }) + + expect(editor.getInstanceState().isPenMode).toBe(false) + expect(editor.getCurrentPageShapes().length).toBe(1) + }) + + it('ignores non-pen input once pen mode has been enabled', () => { + editor.setCurrentTool('draw') + // A direct-display pen enables pen mode. + editor.pointerDown(300, 300, { isPen: true, isPenDirect: true }) + editor.pointerUp(300, 300, { isPen: true, isPenDirect: true }) + expect(editor.getInstanceState().isPenMode).toBe(true) + + const shapeCount = editor.getCurrentPageShapes().length + + // A subsequent touch (non-pen) is ignored while in pen mode. + editor.pointerDown(100, 100, { isPen: false }) + editor.pointerMove(120, 120, { isPen: false }) + editor.pointerUp(120, 120, { isPen: false }) + expect(editor.getCurrentPageShapes().length).toBe(shapeCount) + }) +}) + describe('forgiving palm touches when entering pen mode', () => { it('does not connect a palm touch to the stylus stroke', () => { editor.setCurrentTool('draw') From 330056fcf073864a6ede5467f0208015e8afc639 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Sat, 20 Jun 2026 00:12:30 +0100 Subject: [PATCH 4/5] perf(utils): inline fractional indexing and speed up index key generation (#9352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🙇‍♂️ The fractional indexing libraries we were using were doing silly things. I was never really happy that we moved to an external lib for this, it's a small thing and we benefit from just using the parts that we need and improving those. **What's changed?** This PR vendors a trimmed, base-62-specialized version of `fractional-indexing` and `jittered-fractional-indexing` (both CC0-1.0 public domain) into `packages/utils`, replacing the `jittered-fractional-indexing` dependency. tldraw only ever uses the default base-62 alphabet, so specializing away the `digits` parameter lets us hoist per-call allocations and remove redundant work on hot paths: - `validateIndexKey` (run on every IndexKey validation) now validates directly instead of generating and discarding a jittered key, which previously ran the full key generator ~31 times per call. - The jitter loop validates its two inputs once instead of re-validating both endpoints on every bisection. - `validateOrderKey` is allocation-free (no per-call `repeat`/`slice`), and digit lookups use char-code arithmetic instead of scanning the 62-character alphabet. - Jitter is reduced from 30 to 16 bits, which shortens every generated key by ~3 characters and roughly halves per-key generation work. **What prompted the change?** Index keys are generated and validated constantly — on every shape insert, reorder, and record validation — so this is a genuine hot path. The dependency's `validateOrderKey` allocated a fresh 26-character string on every call, and `validateIndexKey` was generating and throwing away a fully jittered key just to check validity. Inlining the library lets us cut that waste and drop a third-party dependency at the same time. **What do I need to know?** The public API of `@tldraw/utils` is unchanged (`getIndices*`, `getIndexBetween`, `validateIndexKey`, etc. keep their signatures); the vendored core is internal. Reducing jitter from 30 to 16 bits keeps collision probability below ~0.1% even for ~10 simultaneous inserts into the same gap, and existing keys stay valid — only newly generated keys are affected. ### Change type - [x] `improvement` ### Test plan - [x] Unit tests The existing reordering and validation suites pass unchanged. A new `fractionalIndexing.test.ts` pins byte-for-byte compatibility against the upstream library's published vectors and adds ordering/validity invariants across the full base-62 alphabet (integer carry/borrow, jittered and non-jittered generators). ### Release notes - Removed the `jittered-fractional-indexing` dependency and sped up index key generation and validation. Newly generated index keys are slightly shorter. ### Code changes | Section | LOC change | | -------------- | ---------- | | Core code | +282 / -7 | | Tests | +138 / -0 | | Config/tooling | +0 / -18 | --- packages/utils/package.json | 1 - .../utils/src/lib/fractionalIndexing.test.ts | 138 +++++++++ packages/utils/src/lib/fractionalIndexing.ts | 275 ++++++++++++++++++ packages/utils/src/lib/reordering.ts | 14 +- yarn.lock | 17 -- 5 files changed, 420 insertions(+), 25 deletions(-) create mode 100644 packages/utils/src/lib/fractionalIndexing.test.ts create mode 100644 packages/utils/src/lib/fractionalIndexing.ts diff --git a/packages/utils/package.json b/packages/utils/package.json index ad2f18576f20..bf028bd3ee5c 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -43,7 +43,6 @@ "lint": "yarn run -T tsx ../../internal/scripts/lint.ts" }, "dependencies": { - "jittered-fractional-indexing": "^1.0.0", "lodash.isequal": "^4.5.0", "lodash.isequalwith": "^4.4.0", "lodash.throttle": "^4.1.1", diff --git a/packages/utils/src/lib/fractionalIndexing.test.ts b/packages/utils/src/lib/fractionalIndexing.test.ts new file mode 100644 index 000000000000..3fc2e4e39764 --- /dev/null +++ b/packages/utils/src/lib/fractionalIndexing.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + generateNJitteredKeysBetween, + generateNKeysBetween, + validateOrderKey, +} from './fractionalIndexing' + +// Single-key helper: the non-jittered generator with n=1. +const keyBetween = (a: string | null, b: string | null) => generateNKeysBetween(a, b, 1)[0] + +describe('fractionalIndexing', () => { + // Golden vectors from the upstream `fractional-indexing` README. These pin the + // exact base-62 output, proving the vendored/specialized core stays + // byte-for-byte compatible with the original library. + describe('generateKeyBetween (golden vectors)', () => { + it.each([ + [null, null, 'a0'], + ['a0', null, 'a1'], + ['a1', null, 'a2'], + [null, 'a0', 'Zz'], + ['a0', 'a1', 'a0V'], + ['a1', 'a2', 'a1V'], + ])('between %o and %o is %o', (a, b, expected) => { + expect(keyBetween(a, b)).toBe(expected) + }) + }) + + describe('generateNKeysBetween (golden vectors)', () => { + it.each<[string | null, string | null, number, string[]]>([ + [null, null, 2, ['a0', 'a1']], + ['a1', null, 2, ['a2', 'a3']], + [null, 'a0', 2, ['Zy', 'Zz']], + ['a0', 'a1', 2, ['a0G', 'a0V']], + [null, null, 5, ['a0', 'a1', 'a2', 'a3', 'a4']], + ])('%o keys between %o and %o', (a, b, n, expected) => { + expect(generateNKeysBetween(a, b, n)).toEqual(expected) + }) + + it('returns an empty array for n = 0', () => { + expect(generateNKeysBetween(null, null, 0)).toEqual([]) + }) + }) + + describe('errors', () => { + it('throws when a >= b', () => { + expect(() => keyBetween('a1', 'a0')).toThrow() + expect(() => keyBetween('a0', 'a0')).toThrow() + }) + + it('throws on invalid input keys', () => { + expect(() => keyBetween('a00', null)).toThrow() // trailing zero + expect(() => keyBetween('', null)).toThrow() + expect(() => keyBetween('foo', null)).toThrow() + }) + }) + + describe('validateOrderKey', () => { + it.each(['a0', 'a1', 'a0V', 'Zz', 'a0G', 'b127'])('accepts %s', (key) => { + expect(() => validateOrderKey(key)).not.toThrow() + }) + + it.each([ + '', // empty + 'a', // integer part too short + '1', // invalid head + 'a00', // trailing zero in fraction + 'foo', // invalid head + 'A' + '0'.repeat(26), // the reserved smallest-integer key + ])('rejects %o', (key) => { + expect(() => validateOrderKey(key)).toThrow() + }) + }) + + // Invariant tests: independent of exact output, these catch ordering or + // digit-mapping regressions across the whole alphabet (e.g. a bad charCode + // offset in digitIndex, or carry/borrow bugs when integers roll over). + describe('invariants', () => { + it('generateNKeysBetween yields strictly increasing, valid, in-bounds keys', () => { + const keys = generateNKeysBetween('a0', 'a1', 200) + expect(keys).toHaveLength(200) + let prev = 'a0' + for (const key of keys) { + expect(() => validateOrderKey(key)).not.toThrow() + expect(key > prev).toBe(true) + expect(key < 'a1').toBe(true) + prev = key + } + }) + + it('stays ordered while repeatedly inserting after the last key (integer carries)', () => { + // 200 sequential keys force the integer part to roll over many digits, + // exercising incrementInteger across the full base-62 alphabet. + let key = keyBetween(null, null) + let prev = '' + for (let i = 0; i < 200; i++) { + expect(() => validateOrderKey(key)).not.toThrow() + expect(key > prev).toBe(true) + prev = key + key = keyBetween(key, null) + } + }) + + it('stays ordered while repeatedly inserting before the first key (integer borrows)', () => { + let key = keyBetween(null, null) + let next = 'zzzzz' + for (let i = 0; i < 200; i++) { + expect(() => validateOrderKey(key)).not.toThrow() + expect(key < next).toBe(true) + next = key + key = keyBetween(null, key) + } + }) + + it('generateNJitteredKeysBetween yields ordered, valid, in-bounds, distinct keys', () => { + const keys = generateNJitteredKeysBetween('a0', 'a5', 100) + expect(keys).toHaveLength(100) + expect(new Set(keys).size).toBe(100) + let prev = 'a0' + for (const key of keys) { + expect(() => validateOrderKey(key)).not.toThrow() + expect(key > prev).toBe(true) + expect(key < 'a5').toBe(true) + prev = key + } + }) + + it('jittered keys land strictly between their neighbours', () => { + const a = keyBetween(null, null) + const b = keyBetween(a, null) + for (let i = 0; i < 50; i++) { + const mid = generateNJitteredKeysBetween(a, b, 1)[0] + expect(mid > a).toBe(true) + expect(mid < b).toBe(true) + expect(() => validateOrderKey(mid)).not.toThrow() + } + }) + }) +}) diff --git a/packages/utils/src/lib/fractionalIndexing.ts b/packages/utils/src/lib/fractionalIndexing.ts new file mode 100644 index 000000000000..1e762386ddb8 --- /dev/null +++ b/packages/utils/src/lib/fractionalIndexing.ts @@ -0,0 +1,275 @@ +// Fractional indexing, specialized to tldraw's needs. +// +// This is a vendored and trimmed version of the `fractional-indexing` (by +// arv@rocicorp.dev) and `jittered-fractional-indexing` (by +// github@nathanhleung.com) packages, both released under CC0-1.0 (public +// domain). See https://observablehq.com/@dgreensp/implementing-fractional-indexing +// for the original algorithm. +// +// We only ever use the default base-62 alphabet, so the `digits` parameter has +// been specialized away. This lets us hoist the per-call allocations that the +// upstream `validateOrderKey` made (notably `digits[0].repeat(26)`) into module +// constants, and skip redundant re-validation inside the jitter loop, which are +// both on hot paths (every index generation and every IndexKey validation). + +// Digits must be in ascending character-code order. +const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' +const ZERO = DIGITS[0] // '0' +const LARGEST_DIGIT = DIGITS[DIGITS.length - 1] // 'z' +// The reserved "smallest integer" key, which has no valid predecessor. +const SMALLEST_INTEGER = 'A' + ZERO.repeat(26) +// Number of random bisections used to spread concurrently-generated keys apart, +// reducing the chance of collisions when multiple clients insert into the same +// gap at once. Each bit costs one extra key generation and ~0.17 characters of +// key length, so this trades collision resistance against compute and key size. +// 16 keeps collisions below ~0.1% even for ~10 simultaneous same-position +// inserts, which is ample headroom for real multiplayer use. +const JITTER_BITS = 16 + +function getIntegerLength(head: string): number { + if (head >= 'a' && head <= 'z') { + return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2 + } else if (head >= 'A' && head <= 'Z') { + return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2 + } + throw new Error('invalid order key head: ' + head) +} + +function getIntegerPart(key: string): string { + const integerPartLength = getIntegerLength(key[0]) + if (integerPartLength > key.length) { + throw new Error('invalid order key: ' + key) + } + return key.slice(0, integerPartLength) +} + +// Map a base-62 digit to its value. Faster than DIGITS.indexOf() (which scans +// the alphabet) on the hot key-generation path. DIGITS is in char-code order: +// '0'-'9' -> 0-9, 'A'-'Z' -> 10-35, 'a'-'z' -> 36-61. +function digitIndex(char: string): number { + const code = char.charCodeAt(0) + if (code <= 57) return code - 48 + if (code <= 90) return code - 55 + return code - 61 +} + +/** + * Throws if `key` is not a canonical order key. Allocation-free in the common + * case: no `repeat`/`slice` per call. + */ +export function validateOrderKey(key: string): void { + if (key === SMALLEST_INTEGER) { + throw new Error('invalid order key: ' + key) + } + // getIntegerPart throws if the first character is bad or the key is too short. + const i = getIntegerPart(key) + // The fractional part (everything after the integer part) must not end in the + // smallest digit, as a trailing zero is a non-canonical representation. + if (key.length > i.length && key[key.length - 1] === ZERO) { + throw new Error('invalid order key: ' + key) + } +} + +// `a` may be empty string, `b` is null or non-empty string. +// `a < b` lexicographically if `b` is non-null. No trailing zeros allowed. +function midpoint(a: string, b: string | null): string { + if (b != null && a >= b) { + throw new Error(a + ' >= ' + b) + } + if (a.slice(-1) === ZERO || (b && b.slice(-1) === ZERO)) { + throw new Error('trailing zero') + } + if (b) { + // Remove the longest common prefix, padding `a` with zeros as we go. We + // don't need to pad `b`, because it can't end before `a` while traversing + // the common prefix. + let n = 0 + while ((a[n] || ZERO) === b[n]) { + n++ + } + if (n > 0) { + return b.slice(0, n) + midpoint(a.slice(n), b.slice(n)) + } + } + // First digits (or lack of digit) are different. + const digitA = a ? digitIndex(a[0]) : 0 + const digitB = b != null ? digitIndex(b[0]) : DIGITS.length + if (digitB - digitA > 1) { + const midDigit = Math.round(0.5 * (digitA + digitB)) + return DIGITS[midDigit] + } + // First digits are consecutive. + if (b && b.length > 1) { + return b.slice(0, 1) + } + // `b` is null or has length 1 (a single digit). The first digit of `a` is the + // previous digit to `b`, or the largest digit if `b` is null. + return DIGITS[digitA] + midpoint(a.slice(1), null) +} + +// May return null, as there is a largest integer. +function incrementInteger(x: string): string | null { + const [head, ...digs] = x.split('') + let carry = true + for (let i = digs.length - 1; carry && i >= 0; i--) { + const d = digitIndex(digs[i]) + 1 + if (d === DIGITS.length) { + digs[i] = ZERO + } else { + digs[i] = DIGITS[d] + carry = false + } + } + if (carry) { + if (head === 'Z') return 'a' + ZERO + if (head === 'z') return null + const h = String.fromCharCode(head.charCodeAt(0) + 1) + if (h > 'a') { + digs.push(ZERO) + } else { + digs.pop() + } + return h + digs.join('') + } + return head + digs.join('') +} + +// May return null, as there is a smallest integer. +function decrementInteger(x: string): string | null { + const [head, ...digs] = x.split('') + let borrow = true + for (let i = digs.length - 1; borrow && i >= 0; i--) { + const d = digitIndex(digs[i]) - 1 + if (d === -1) { + digs[i] = LARGEST_DIGIT + } else { + digs[i] = DIGITS[d] + borrow = false + } + } + if (borrow) { + if (head === 'a') return 'Z' + LARGEST_DIGIT + if (head === 'A') return null + const h = String.fromCharCode(head.charCodeAt(0) - 1) + if (h < 'Z') { + digs.push(LARGEST_DIGIT) + } else { + digs.pop() + } + return h + digs.join('') + } + return head + digs.join('') +} + +// Generate a key strictly between `a` and `b`, without validating the inputs. +// `a`/`b` are order keys or null (START/END). `a < b` if both are non-null. +function keyBetweenUnchecked(a: string | null, b: string | null): string { + if (a != null && b != null && a >= b) { + throw new Error(a + ' >= ' + b) + } + if (a == null) { + if (b == null) return 'a' + ZERO + const ib = getIntegerPart(b) + const fb = b.slice(ib.length) + if (ib === SMALLEST_INTEGER) { + return ib + midpoint('', fb) + } + if (ib < b) return ib + const res = decrementInteger(ib) + if (res == null) throw new Error('cannot decrement any more') + return res + } + if (b == null) { + const ia = getIntegerPart(a) + const fa = a.slice(ia.length) + const i = incrementInteger(ia) + return i == null ? ia + midpoint(fa, null) : i + } + const ia = getIntegerPart(a) + const fa = a.slice(ia.length) + const ib = getIntegerPart(b) + const fb = b.slice(ib.length) + if (ia === ib) { + return ia + midpoint(fa, fb) + } + const i = incrementInteger(ia) + if (i == null) throw new Error('cannot increment any more') + if (i < b) return i + return ia + midpoint(fa, null) +} + +// Generate `n` keys between `a` and `b`, without validating the inputs. +// Intermediate keys are valid by construction, so they're never re-validated. +function nKeysBetweenUnchecked(a: string | null, b: string | null, n: number): string[] { + if (n === 0) return [] + if (n === 1) return [keyBetweenUnchecked(a, b)] + if (b == null) { + let c = keyBetweenUnchecked(a, b) + const result = [c] + for (let i = 0; i < n - 1; i++) { + c = keyBetweenUnchecked(c, b) + result.push(c) + } + return result + } + if (a == null) { + let c = keyBetweenUnchecked(a, b) + const result = [c] + for (let i = 0; i < n - 1; i++) { + c = keyBetweenUnchecked(a, c) + result.push(c) + } + result.reverse() + return result + } + const mid = Math.floor(n / 2) + const c = keyBetweenUnchecked(a, b) + return [...nKeysBetweenUnchecked(a, c, mid), c, ...nKeysBetweenUnchecked(c, b, n - mid - 1)] +} + +// Bisect `[low, high]` JITTER_BITS times, following a random walk, so that keys +// generated concurrently land in different sub-ranges. `low`/`high` must already +// be valid (or null); the intermediate midpoints are valid by construction. +function jitterBetween(low: string | null, high: string | null): string { + let mid = keyBetweenUnchecked(low, high) + for (let i = 0; i < JITTER_BITS; i++) { + if (Math.random() < 0.5) { + low = mid + } else { + high = mid + } + mid = keyBetweenUnchecked(low, high) + } + return mid +} + +/** + * Generate `n` jittered keys evenly spaced between `a` and `b`. Inputs are + * validated once; everything downstream is generated, hence trusted. + */ +export function generateNJitteredKeysBetween( + a: string | null, + b: string | null, + n: number +): string[] { + if (n === 0) return [] + if (a != null) validateOrderKey(a) + if (b != null) validateOrderKey(b) + const keys = nKeysBetweenUnchecked(a, b, n + 1) + const result = new Array(n) + for (let i = 0; i < n; i++) { + result[i] = jitterBetween(keys[i], keys[i + 1]) + } + return result +} + +/** + * Generate `n` keys evenly spaced between `a` and `b`, without jitter. Used in + * tests for deterministic output. + */ +export function generateNKeysBetween(a: string | null, b: string | null, n: number): string[] { + if (n === 0) return [] + if (a != null) validateOrderKey(a) + if (b != null) validateOrderKey(b) + return nKeysBetweenUnchecked(a, b, n) +} diff --git a/packages/utils/src/lib/reordering.ts b/packages/utils/src/lib/reordering.ts index f31410cb6f94..25ca15e7f077 100644 --- a/packages/utils/src/lib/reordering.ts +++ b/packages/utils/src/lib/reordering.ts @@ -1,11 +1,11 @@ -import { generateKeyBetween, generateNKeysBetween } from 'jittered-fractional-indexing' - -const generateNKeysBetweenWithNoJitter = (a: string | null, b: string | null, n: number) => { - return generateNKeysBetween(a, b, n, { jitterBits: 0 }) -} +import { + generateNJitteredKeysBetween, + generateNKeysBetween, + validateOrderKey, +} from './fractionalIndexing' const generateKeysFn = - process.env.NODE_ENV === 'test' ? generateNKeysBetweenWithNoJitter : generateNKeysBetween + process.env.NODE_ENV === 'test' ? generateNKeysBetween : generateNJitteredKeysBetween /** * A string made up of an integer part followed by a fraction part. The fraction point consists of @@ -30,7 +30,7 @@ export const ZERO_INDEX_KEY = 'a0' as IndexKey */ export function validateIndexKey(index: string): asserts index is IndexKey { try { - generateKeyBetween(index, null) + validateOrderKey(index) } catch { throw new Error('invalid index: ' + index) } diff --git a/yarn.lock b/yarn.lock index 6c94eaae4b14..5efb03ab3f8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12220,7 +12220,6 @@ __metadata: "@types/lodash.isequalwith": "npm:^4.4.9" "@types/lodash.throttle": "npm:^4.1.9" "@types/lodash.uniq": "npm:^4.5.9" - jittered-fractional-indexing: "npm:^1.0.0" lazyrepo: "npm:0.0.0-alpha.27" lodash.isequal: "npm:^4.5.0" lodash.isequalwith: "npm:^4.4.0" @@ -20701,13 +20700,6 @@ __metadata: languageName: node linkType: hard -"fractional-indexing@npm:^3.2.0": - version: 3.2.0 - resolution: "fractional-indexing@npm:3.2.0" - checksum: 10/9c35f80cd3c9c40d22ae245f23d6fa4da77b0a0296b35a7af46c06b7f7cd57ea5db676df88cdce9e268424622c7910b1550052de2819d23b44c2005958e1ac03 - languageName: node - linkType: hard - "framer-motion@npm:^11.18.2": version: 11.18.2 resolution: "framer-motion@npm:11.18.2" @@ -22879,15 +22871,6 @@ __metadata: languageName: node linkType: hard -"jittered-fractional-indexing@npm:^1.0.0": - version: 1.0.0 - resolution: "jittered-fractional-indexing@npm:1.0.0" - dependencies: - fractional-indexing: "npm:^3.2.0" - checksum: 10/fa479654ec02629638cf9284e459682d3593ced2d4f7791636465d0546a16d1a57cac4d98754d7ac9244d4c98c7340ccaea4a546d9db71d670e39738ec481b63 - languageName: node - linkType: hard - "jju@npm:~1.4.0": version: 1.4.0 resolution: "jju@npm:1.4.0" From 09bf3188661d23de7f0cdad85295124b436a3b22 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Sat, 20 Jun 2026 00:26:42 +0100 Subject: [PATCH 5/5] feat(skills): add simplify-trace skill (#9353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In order to reason about large Chrome DevTools performance traces without loading tens to hundreds of MB of JSON into context, this PR adds a `simplify-trace` agent skill that summarizes a trace into a compact, few-KB markdown report. The skill ships a Node script (`scripts/simplify-trace.mjs`) plus `SKILL.md` guidance on when and how to use it. The report surfaces the work taking too long or happening too often: long tasks (main-thread jank, with offsets), hottest event types by self time, most frequent event types, hottest JS functions (with `file:line` from the embedded V8 CPU profile), and a self-time-by-category breakdown. Opt-in sections cover a per-second main-thread timeline, a network waterfall, and WebSocket lifecycle. `--window START-END` scopes the whole report to a time slice, which is how you isolate a single action in an otherwise idle "what happens when I do X" recording. The script is read-only — it summarizes the trace and never modifies it. ### Change type - [x] `other` (internal agent tooling) ### Test plan 1. Run the script on a Chrome DevTools trace: `node skills/simplify-trace/scripts/simplify-trace.mjs --out /tmp/report.md` 2. Confirm the markdown report includes the summary header, long tasks, hottest events, frequent events, hottest JS functions, and categories. 3. Re-run with `--window START-END`, `--only timeline`, and `--list` and confirm scoping and section selection work. ### Code changes | Section | LOC change | | -------------- | ---------- | | Config/tooling | +549 / -0 | Co-authored-by: Claude Opus 4.8 --- skills/simplify-trace/SKILL.md | 67 +++ .../simplify-trace/scripts/simplify-trace.mjs | 482 ++++++++++++++++++ 2 files changed, 549 insertions(+) create mode 100644 skills/simplify-trace/SKILL.md create mode 100644 skills/simplify-trace/scripts/simplify-trace.mjs diff --git a/skills/simplify-trace/SKILL.md b/skills/simplify-trace/SKILL.md new file mode 100644 index 000000000000..d623e6c3c08e --- /dev/null +++ b/skills/simplify-trace/SKILL.md @@ -0,0 +1,67 @@ +--- +name: simplify-trace +description: Summarize a large Chrome DevTools performance trace into a compact markdown report so it can be reasoned about without loading the whole file. Use when given a Chrome/DevTools/Performance-panel trace (a multi-MB `Trace-*.json` or `*.json` with `traceEvents`) and asked to find what is slow, what runs too often, long tasks, jank, or hot JS functions. +--- + +# Simplify trace + +Chrome DevTools performance traces are tens to hundreds of MB of JSON — far too large to read directly. This skill turns one into a few-KB markdown report that surfaces the work taking too long or happening too often. + +## Usage + +Run the script on the trace file. It prints markdown to stdout (or `--out FILE`): + +```bash +node skills/simplify-trace/scripts/simplify-trace.mjs [--top N] [--long-task-ms MS] [--window START-END] [--out report.md] +``` + +- `--top N` — rows per table (default 25). +- `--long-task-ms MS` — long-task threshold (default 50). +- `--window START-END` — scope the whole report to a time slice (offsets in ms from trace start). +- `--only a,b` / `--except a,b` / `--all` — pick which sections to emit. +- `--match TEXT` — case-insensitive filter for rows (event names, function names, URLs). +- `--list` — print the available section keys and exit. +- `--out FILE` — write to a file instead of stdout. +- Traces over ~500 MB: prefix `node --max-old-space-size=8192`. + +Default to running with `--out` to a temp file for big traces, then read the report. Don't read the raw trace. + +## Sections + +Default sections: `summary`, `longtasks`, `events`, `frequent`, `functions`, `categories`. Opt-in: `timeline` (per-second main-thread busy time — use to locate activity), `network` (resource/fetch waterfall with TTFB/duration/size), `websocket` (WebSocket lifecycle — `/app/file` doc sync). Run `--list` for the full set. Interrogate narrowly, e.g.: + +```bash +# where is the activity? then window to it +node …/simplify-trace.mjs trace.json --only timeline +# what was the network/socket doing during the action? +node …/simplify-trace.mjs trace.json --only network,websocket --window 62000-69000 --match tldraw +``` + +The trace's `metadata.startTime` (ISO/UTC) anchors offset 0 to wall-clock, so trace offsets can be lined up against server logs (zero-cache, sync-worker) by timestamp. For an idle, network-quiet gap, the trace shows *when* but not *why* — add `performance.mark()`/`console.timeStamp()` in the client path and they appear on the trace timeline. + +## "What happens when I do X" traces + +A recording of a single action (switch file, open menu) is mostly idle setup time, which dilutes the action across the whole trace. Window to the action instead: + +1. Run once with no window. Note where activity is — the long tasks' offsets, or bucket main-thread busy-time per second with a quick inline script to find the active span. +2. Re-run with `--window START-END` around that span. All tables then describe only the action. + +The recording artifact `CpuProfiler::StartProfiling` (the profiler turning on, ~50–60ms) is excluded from the long-task table, including when it's nested inside a `RunTask`. If long tasks shows "None", the action genuinely has no single blocking task — look at aggregate self time, GC, and animation-loop events instead. + +## What the report contains + +- **Header** — event count, wall-clock span, sampled JS CPU time, and idle %. +- **Long tasks** — top-level tasks over the threshold (main-thread jank), with the time offset where each occurred. +- **Hottest event types (self time)** — where engine/browser time actually goes (layout, GC, paint, function calls), excluding time spent in nested children. +- **Most frequent event types (count)** — work happening too often. +- **Hottest JS functions** — bottom-up self time from the embedded V8 CPU profile, with `file:line`. Synthetic `(idle)`/`(program)` frames are excluded here (idle is in the header). +- **Self time by category** — high-level breakdown across trace categories. + +## How to read it + +- A function high in **self time** is the actual cost; high **total** but low self means the cost is in its callees — follow the call tree. +- High **count** with low avg = death by a thousand cuts (often a reactive/render loop firing too often); investigate why it fires, not its per-call cost. +- Long tasks point at *when* jank happened; cross-reference the offset against what the user was doing. +- Minified names (`r`, `Tg`) come with a `file:line` — use it to locate the source. + +The script only summarizes; it does not modify the trace. diff --git a/skills/simplify-trace/scripts/simplify-trace.mjs b/skills/simplify-trace/scripts/simplify-trace.mjs new file mode 100644 index 000000000000..00326873b6e4 --- /dev/null +++ b/skills/simplify-trace/scripts/simplify-trace.mjs @@ -0,0 +1,482 @@ +#!/usr/bin/env node +// Summarize a Chrome DevTools performance trace into a compact markdown report. +// Surfaces the work that takes too long or happens too often, so an agent can +// reason about a multi-hundred-MB trace without loading the whole thing. +// +// Usage: +// node simplify-trace.mjs [--top N] [--long-task-ms MS] [--out FILE] +// +// For very large traces (>~500MB) increase Node's heap: +// node --max-old-space-size=8192 simplify-trace.mjs + +import fs from 'node:fs' + +// Sections the report can emit. `default` is the compact overview; the rest are +// opt-in via --only / --all so the tool can answer a narrow question. +const SECTIONS = { + summary: 'header: counts, span, CPU time, idle %', + timeline: 'per-second main-thread busy time (find when activity happens)', + longtasks: 'top-level tasks over the threshold (main-thread jank)', + events: 'hottest event types by self time', + frequent: 'most frequent event types by count', + functions: 'hottest JS functions from the CPU profile (self time)', + categories: 'self time grouped by trace category', + network: 'resource/fetch waterfall: offset, TTFB, duration, size, URL', + websocket: 'WebSocket lifecycle: create/destroy/send/receive/handshake', +} +const DEFAULT_SECTIONS = ['summary', 'longtasks', 'events', 'frequent', 'functions', 'categories'] +const ALL_SECTIONS = Object.keys(SECTIONS) + +function parseList(s) { + return s.split(',').map((x) => x.trim()).filter(Boolean) +} + +function parseArgs(argv) { + const opts = { top: 25, longTaskMs: 50, out: null, file: null, window: null, only: null, except: null, match: null, list: false } + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === '--top') opts.top = Number(argv[++i]) + else if (a === '--long-task-ms') opts.longTaskMs = Number(argv[++i]) + else if (a === '--out') opts.out = argv[++i] + else if (a === '--only') opts.only = parseList(argv[++i]) + else if (a === '--except') opts.except = parseList(argv[++i]) + else if (a === '--all') opts.only = ALL_SECTIONS.slice() + else if (a === '--match') opts.match = argv[++i] + else if (a === '--wall-clock') opts.wallClock = true + else if (a === '--list') opts.list = true + else if (a === '--window') { + // "-" as offsets from the trace start + const [s, e] = argv[++i].split('-').map(Number) + opts.window = { start: s, end: e } + } else if (!a.startsWith('--')) opts.file = a + } + return opts +} + +// Recording artifacts: the profiler starting/stopping shows up as a 50-60ms +// "task" that is not app work. Exclude it from the long-task ranking so it +// doesn't masquerade as the worst jank. +const ARTIFACT_NAMES = new Set(['CpuProfiler::StartProfiling', 'CpuProfiler::StopProfiling']) + +const opts = parseArgs(process.argv.slice(2)) + +if (opts.list) { + console.log('Sections (--only a,b / --except a,b / --all):\n') + for (const k of ALL_SECTIONS) { + const def = DEFAULT_SECTIONS.includes(k) ? ' (default)' : '' + console.log(` ${k.padEnd(11)} ${SECTIONS[k]}${def}`) + } + process.exit(0) +} +if (!opts.file) { + console.error( + 'Usage: node simplify-trace.mjs [--top N] [--long-task-ms MS] [--window START-END]\n' + + ' [--only a,b | --except a,b | --all] [--match TEXT] [--out FILE] [--list]' + ) + process.exit(1) +} + +// Resolve which sections to emit, preserving a stable display order. +let wantSections = opts.only ? opts.only : DEFAULT_SECTIONS.slice() +const unknown = wantSections.filter((k) => !SECTIONS[k]) +if (unknown.length) { + console.error(`Unknown section(s): ${unknown.join(', ')}. Run --list to see options.`) + process.exit(1) +} +if (opts.except) wantSections = wantSections.filter((k) => !opts.except.includes(k)) +const selected = new Set(wantSections) +const matchLc = opts.match ? opts.match.toLowerCase() : null +const matches = (s) => !matchLc || (s || '').toLowerCase().includes(matchLc) + +const ms = (us) => Math.round(us / 100) / 10 // microseconds -> ms, 1 decimal +const pct = (n, total) => (total ? Math.round((n / total) * 1000) / 10 : 0) + +const raw = JSON.parse(fs.readFileSync(opts.file, 'utf8')) +const events = Array.isArray(raw) ? raw : raw.traceEvents || [] + +// ---- process / thread names (from metadata 'M' events) ------------------- +const threadNames = new Map() // `${pid}:${tid}` -> name +const procNames = new Map() // pid -> name +for (const e of events) { + if (e.ph !== 'M') continue + if (e.name === 'thread_name') threadNames.set(`${e.pid}:${e.tid}`, e.args?.name) + if (e.name === 'process_name') procNames.set(e.pid, e.args?.name) +} + +// ---- trace bounds -------------------------------------------------------- +// Ignore ts <= 0: metadata ('M') and some bookkeeping events report ts:0 and +// would otherwise pin the trace start to zero and break every offset. +let minTs = Infinity +let maxTs = -Infinity +for (const e of events) { + if (typeof e.ts !== 'number' || e.ts <= 0) continue + if (e.ts < minTs) minTs = e.ts + const end = e.ts + (e.dur || 0) + if (end > maxTs) maxTs = end +} +const traceDur = maxTs - minTs + +// ---- optional time window ------------------------------------------------ +// --window START-END scopes the whole report to a slice of the recording +// (offsets in ms from trace start) — use it to focus on the moment an action +// happened instead of averaging it against idle time. +const winLo = opts.window ? minTs + opts.window.start * 1000 : -Infinity +const winHi = opts.window ? minTs + opts.window.end * 1000 : Infinity +const inWin = (ts) => ts >= winLo && ts <= winHi +let scopedCount = 0 + +// ---- aggregate complete (X) events by name, with self time --------------- +// Self time = own duration minus the duration of directly-nested children on +// the same thread. We reconstruct nesting with a per-thread stack. +const byThread = new Map() +for (const e of events) { + if (!inWin(e.ts)) continue + scopedCount++ + if (e.ph !== 'X' || typeof e.dur !== 'number') continue + const key = `${e.pid}:${e.tid}` + if (!byThread.has(key)) byThread.set(key, []) + byThread.get(key).push(e) +} + +const agg = new Map() // name -> { count, total, self, max } +const topLevelTasks = [] // candidate "long tasks" +const selfByCat = new Map() // category -> self time + +function bump(map, k, dur) { + map.set(k, (map.get(k) || 0) + dur) +} + +for (const [, list] of byThread) { + // Sort by start asc, then by duration desc so parents precede children. + list.sort((a, b) => a.ts - b.ts || b.dur - a.dur) + const stack = [] + for (const e of list) { + while (stack.length && e.ts >= stack[stack.length - 1].ts + stack[stack.length - 1].dur) { + stack.pop() + } + const depth = stack.length + // subtract this event's duration from its parent's self time + if (stack.length) stack[stack.length - 1]._childDur += e.dur + // attribute artifact time (e.g. profiler start) to the enclosing + // top-level task at any depth, so it can be discounted later + if (stack.length && ARTIFACT_NAMES.has(e.name)) stack[0]._artifactDur = (stack[0]._artifactDur || 0) + e.dur + e._childDur = 0 + stack.push(e) + if (depth === 0) topLevelTasks.push(e) + } +} + +for (const [, list] of byThread) { + for (const e of list) { + const self = e.dur - (e._childDur || 0) + let a = agg.get(e.name) + if (!a) { + a = { count: 0, total: 0, self: 0, max: 0 } + agg.set(e.name, a) + } + a.count++ + a.total += e.dur + a.self += self + if (e.dur > a.max) a.max = e.dur + const cat = (e.cat || '').split(',')[0] || '(none)' + bump(selfByCat, cat, self) + } +} + +// ---- CPU profile reconstruction (Profile + ProfileChunk) ----------------- +// Group chunks by profile id, accumulate node tree + samples, attribute each +// sample's timeDelta to its node. Aggregate self/total time per call frame. +const profiles = new Map() // id -> { nodes: Map, selfByNode: Map } +function getProfile(id) { + if (!profiles.has(id)) profiles.set(id, { nodes: new Map(), selfByNode: new Map() }) + return profiles.get(id) +} +for (const e of events) { + if (e.name !== 'ProfileChunk' && e.name !== 'Profile') continue + const id = e.id ?? `${e.pid}:${e.tid}` + const p = getProfile(id) + const data = e.args?.data || {} + const cp = data.cpuProfile || data + if (Array.isArray(cp.nodes)) { + for (const n of cp.nodes) p.nodes.set(n.id, n) + // build parent links: nodes either list children ids or carry a parent id + for (const n of cp.nodes) { + if (n.parent != null) n._parent = n.parent + if (Array.isArray(n.children)) { + for (const c of n.children) { + const child = p.nodes.get(c) + if (child) child._parent = n.id + } + } + } + } + // Always accumulate nodes (they're defined incrementally and an in-window + // sample may reference one declared earlier), but only attribute samples + // from chunks inside the window. Chunk-level granularity is coarse but fine + // for focusing on a moment. + if (!inWin(e.ts)) continue + const samples = cp.samples || data.samples + const deltas = data.timeDeltas || cp.timeDeltas + if (Array.isArray(samples) && Array.isArray(deltas)) { + for (let i = 0; i < samples.length; i++) { + const nid = samples[i] + const dt = deltas[i] || 0 + p.selfByNode.set(nid, (p.selfByNode.get(nid) || 0) + dt) + } + } +} + +// Aggregate by call frame across all profiles. +const frameSelf = new Map() // key -> { self, fn, loc } +const nodeTotal = new Map() // `${id}:${nodeId}` -> total (self + descendants) +let cpuTotal = 0 +function frameKey(cf) { + const fn = cf.functionName || '(anonymous)' + const loc = cf.url ? `${cf.url}:${cf.lineNumber ?? '?'}` : cf.codeType || '' + return { key: `${fn}@@${loc}`, fn, loc } +} +for (const [, p] of profiles) { + // self per frame + roll self up to ancestors for total time + for (const [nid, self] of p.selfByNode) { + cpuTotal += self + const node = p.nodes.get(nid) + if (!node) continue + const { key, fn, loc } = frameKey(node.callFrame || {}) + let f = frameSelf.get(key) + if (!f) { + f = { self: 0, total: 0, fn, loc, count: 0 } + frameSelf.set(key, f) + } + f.self += self + // walk ancestors, add to each frame's total (dedup per frame per sample-origin) + const seenFrames = new Set() + let cur = node + let guard = 0 + while (cur && guard++ < 1000) { + const fk = frameKey(cur.callFrame || {}) + if (!seenFrames.has(fk.key)) { + seenFrames.add(fk.key) + let tf = frameSelf.get(fk.key) + if (!tf) { + tf = { self: 0, total: 0, fn: fk.fn, loc: fk.loc, count: 0 } + frameSelf.set(fk.key, tf) + } + tf.total += self + } + cur = cur._parent != null ? p.nodes.get(cur._parent) : null + } + } +} + +// ---- section builders ---------------------------------------------------- +const out = [] +const N = opts.top +const offset = (ts) => ms(ts - minTs) // ts -> ms offset from trace start + +// Wall-clock correlation: metadata.startTime (UTC) anchors offset 0, so trace +// timestamps can be lined up against server logs (zero-cache, sync-worker). +const startEpoch = raw.metadata?.startTime ? Date.parse(raw.metadata.startTime) : NaN +const wallEnabled = opts.wallClock && !Number.isNaN(startEpoch) +const wall = (ts) => new Date(startEpoch + (ts - minTs) / 1000).toISOString().slice(11, 23) + 'Z' +// header cell + row cell for an optional "UTC" column on time-localized tables +const utcHead = wallEnabled ? ' UTC |' : '' +const utcCell = (ts) => (wallEnabled ? ` ${wall(ts)} |` : '') + +const builders = { + summary() { + out.push(`# Trace summary: ${opts.file.split('/').pop()}`) + out.push('') + if (opts.window) { + out.push(`- **Window: ${opts.window.start}–${opts.window.end} ms** (of ${ms(traceDur)} ms total)`) + out.push(`- Events in window: **${scopedCount.toLocaleString()}** / ${events.length.toLocaleString()}`) + } else { + out.push(`- Events: **${events.length.toLocaleString()}**`) + out.push(`- Wall-clock span: **${ms(traceDur)} ms**`) + } + if (cpuTotal) out.push(`- Sampled CPU time (JS): **${ms(cpuTotal)} ms**`) + out.push(`- Threads with timed work: **${byThread.size}**`) + if (cpuTotal) { + const idle = frameSelf.get('(idle)@@other')?.self || 0 + out.push(`- Idle (sampled): **${ms(idle)} ms (${pct(idle, cpuTotal)}%)**`) + } + out.push('') + }, + + timeline() { + // Per-second main-thread busy time over the WHOLE trace (ignores --window; + // its purpose is to locate when activity happens so you can then window). + const buckets = new Map() + for (const e of topLevelTasks) { + const sec = Math.floor(offset(e.ts) / 1000) + buckets.set(sec, (buckets.get(sec) || 0) + e.dur) + } + out.push(`## Main-thread busy time (per second)`) + out.push('') + out.push('| sec | busy (ms) |') + out.push('|---|---|') + ;[...buckets.entries()] + .sort((a, b) => a[0] - b[0]) + .filter(([, d]) => d / 1000 >= 5) + .forEach(([s, d]) => out.push(`| ${s} | ${ms(d)} |`)) + out.push('') + }, + + longtasks() { + const longMs = opts.longTaskMs + const tasks = topLevelTasks + // drop tasks that are themselves an artifact or are >50% artifact time + .filter((e) => e.dur >= longMs * 1000 && !ARTIFACT_NAMES.has(e.name) && (e._artifactDur || 0) < e.dur * 0.5) + .sort((a, b) => b.dur - a.dur) + .slice(0, N) + out.push(`## Long tasks (top-level ≥ ${longMs} ms)`) + out.push('') + if (!tasks.length) { + out.push(`_None ≥ ${longMs} ms (excluding profiler artifacts)._`) + } else { + out.push(`| # | dur (ms) | @ offset (ms) |${utcHead} name | thread |`) + out.push(`|---|---|---|${wallEnabled ? '---|' : ''}---|---|`) + tasks.forEach((e, i) => { + const tn = threadNames.get(`${e.pid}:${e.tid}`) || `tid ${e.tid}` + out.push(`| ${i + 1} | ${ms(e.dur)} | ${offset(e.ts)} |${utcCell(e.ts)} ${e.name} | ${tn} |`) + }) + } + out.push('') + }, + + events() { + const rows = [...agg.entries()] + .map(([name, a]) => ({ name, ...a })) + .filter((a) => matches(a.name)) + .sort((a, b) => b.self - a.self) + .slice(0, N) + out.push(`## Hottest event types (by self time)`) + out.push('') + out.push('| name | count | self (ms) | total (ms) | avg (ms) | max (ms) |') + out.push('|---|---|---|---|---|---|') + for (const a of rows) { + out.push( + `| ${a.name} | ${a.count.toLocaleString()} | ${ms(a.self)} | ${ms(a.total)} | ${ms(a.total / a.count)} | ${ms(a.max)} |` + ) + } + out.push('') + }, + + frequent() { + const rows = [...agg.entries()] + .map(([name, a]) => ({ name, ...a })) + .filter((a) => matches(a.name)) + .sort((a, b) => b.count - a.count) + .slice(0, N) + out.push(`## Most frequent event types (by count)`) + out.push('') + out.push('| name | count | total (ms) | self (ms) | avg (ms) |') + out.push('|---|---|---|---|---|') + for (const a of rows) { + out.push(`| ${a.name} | ${a.count.toLocaleString()} | ${ms(a.total)} | ${ms(a.self)} | ${ms(a.total / a.count)} |`) + } + out.push('') + }, + + functions() { + if (!frameSelf.size) return + // Drop synthetic idle/program frames; idle is reported in the header. + const noise = new Set(['(idle)', '(program)', '(root)']) + const rows = [...frameSelf.values()] + .filter((f) => f.self > 0 && !noise.has(f.fn) && (matches(f.fn) || matches(f.loc))) + .sort((a, b) => b.self - a.self) + .slice(0, N) + out.push(`## Hottest JS functions (CPU profile, self time)`) + out.push('') + out.push('| function | self (ms) | self % | total (ms) | location |') + out.push('|---|---|---|---|---|') + for (const f of rows) out.push(`| ${f.fn} | ${ms(f.self)} | ${pct(f.self, cpuTotal)}% | ${ms(f.total)} | ${f.loc} |`) + out.push('') + }, + + categories() { + const rows = [...selfByCat.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15) + out.push(`## Self time by category`) + out.push('') + out.push('| category | self (ms) |') + out.push('|---|---|') + for (const [cat, dur] of rows) out.push(`| ${cat} | ${ms(dur)} |`) + out.push('') + }, + + network() { + // Reconstruct requests from Resource* events keyed by requestId. + const NET = new Set(['ResourceSendRequest', 'ResourceReceiveResponse', 'ResourceFinish']) + const reqs = new Map() + for (const e of events) { + if (!NET.has(e.name)) continue + const d = e.args?.data || {} + const id = d.requestId + if (!id) continue + let r = reqs.get(id) + if (!r) reqs.set(id, (r = {})) + if (e.name === 'ResourceSendRequest') { + r.url = d.url + r.send = e.ts + r.method = d.requestMethod + } else if (e.name === 'ResourceReceiveResponse') { + r.resp = e.ts + r.status = d.statusCode + } else if (e.name === 'ResourceFinish') { + r.finish = e.ts + r.size = d.encodedDataLength + } + } + const list = [...reqs.values()] + .filter((r) => r.send && inWin(r.send) && matches(r.url)) + .sort((a, b) => a.send - b.send) + out.push(`## Network requests (${list.length})`) + out.push('') + if (!list.length) { + out.push('_None in scope._') + out.push('') + return + } + out.push(`| @ offset (ms) |${utcHead} ttfb (ms) | dur (ms) | size (KB) | status | url |`) + out.push(`|---|${wallEnabled ? '---|' : ''}---|---|---|---|---|`) + for (const r of list.slice(0, N)) { + const ttfb = r.resp ? ms(r.resp - r.send) : '-' + const dur = r.finish ? ms(r.finish - r.send) : '-' + const kb = r.size ? Math.round(r.size / 102.4) / 10 : '-' + const url = (r.url || '').replace(/^https?:\/\//, '').slice(0, 90) + out.push(`| ${offset(r.send)} |${utcCell(r.send)} ${ttfb} | ${dur} | ${kb} | ${r.status || ''} | ${url} |`) + } + if (list.length > N) out.push(`\n_…${list.length - N} more (raise --top)._`) + out.push('') + }, + + websocket() { + const ws = events + .filter((e) => /WebSocket/.test(e.name || '') && inWin(e.ts)) + .sort((a, b) => a.ts - b.ts) + out.push(`## WebSocket timeline (${ws.length})`) + out.push('') + if (!ws.length) { + out.push('_No WebSocket events in scope._') + out.push('') + return + } + out.push(`| @ offset (ms) |${utcHead} event | url |`) + out.push(`|---|${wallEnabled ? '---|' : ''}---|---|`) + for (const e of ws.slice(0, N)) { + const url = (e.args?.data?.url || '').slice(0, 80) + out.push(`| ${offset(e.ts)} |${utcCell(e.ts)} ${e.name} | ${url} |`) + } + out.push('') + }, +} + +// Emit selected sections in canonical order. +for (const key of ALL_SECTIONS) if (selected.has(key)) builders[key]() + +const report = out.join('\n').replace(/\n{3,}/g, '\n\n') +if (opts.out) { + fs.writeFileSync(opts.out, report) + console.error(`Wrote ${opts.out} (${report.length} bytes) from ${(fs.statSync(opts.file).size / 1e6).toFixed(1)} MB trace`) +} else { + process.stdout.write(report) +}