+
{children}
- {/* While loading, show the inert placeholder editor (if provided) with
- the spinner layered in front of it. */}
- {!isReady && hasLoadingScreen && (
-
- {loadingScreen}
-
- )}
)
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}
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/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'
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')
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/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)
+}
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"