Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions apps/docs/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import localFont from 'next/font/local'
import AutoRefresh from '@/components/common/autorefresh'
import { Footer } from '@/components/navigation/footer'
import { Header } from '@/components/navigation/header'
import { assetUrl } from '@/utils/asset-url'
import { cn } from '@/utils/cn'
import Analytics from './analytics'
import './github-dark.css'
Expand Down Expand Up @@ -37,13 +38,13 @@ export const metadata: Metadata = {
statusBarStyle: 'black',
},
icons: [
{ rel: 'shortcut icon', url: '/favicon.svg' },
{ rel: 'icon', url: '/favicon-32x32.svg', sizes: '32x32' },
{ rel: 'icon', url: '/favicon-16x16.svg', sizes: '16x16' },
{ rel: 'apple-touch-icon', url: '/apple-touch-icon.png' },
{ rel: 'apple-touch-icon', url: '/apple-touch-icon-152x152.svg', sizes: '152x152' },
{ rel: 'apple-touch-icon', url: '/apple-touch-icon-180x180.svg', sizes: '180x180' },
{ rel: 'apple-touch-icon', url: '/apple-touch-icon-167x167.svg', sizes: '167x167' },
{ rel: 'shortcut icon', url: assetUrl('/favicon.svg') },
{ rel: 'icon', url: assetUrl('/favicon-32x32.svg'), sizes: '32x32' },
{ rel: 'icon', url: assetUrl('/favicon-16x16.svg'), sizes: '16x16' },
{ rel: 'apple-touch-icon', url: assetUrl('/apple-touch-icon.png') },
{ rel: 'apple-touch-icon', url: assetUrl('/apple-touch-icon-152x152.svg'), sizes: '152x152' },
{ rel: 'apple-touch-icon', url: assetUrl('/apple-touch-icon-180x180.svg'), sizes: '180x180' },
{ rel: 'apple-touch-icon', url: assetUrl('/apple-touch-icon-167x167.svg'), sizes: '167x167' },
],
}

Expand Down
7 changes: 5 additions & 2 deletions apps/docs/components/content/image.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { assetUrl } from '@/utils/asset-url'
import { TldrawLink } from '../common/tldraw-link'

export function Image(props: any) {
const src = typeof props.src === 'string' ? assetUrl(props.src) : props.src
const href = props.href ?? src
return (
<span className="block">
<TldrawLink
href={props.href ?? props.src}
href={href}
className="block bg-zinc-100 dark:bg-zinc-800 py-1 sm:rounded-2xl -mx-5 sm:mx-0 sm:px-1"
>
<img alt={props.title} {...props} className="w-full sm:rounded-xl !my-0 shadow" />
<img alt={props.title} {...props} src={src} className="w-full sm:rounded-xl !my-0 shadow" />
</TldrawLink>
{props.caption && (
<span className="block text-xs text-zinc-500 mt-3 text-center text-balance">
Expand Down
1 change: 1 addition & 0 deletions apps/docs/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const REWRITE_DOMAIN = 'tldrawdotdev.framer.website'

const nextConfig = {
reactStrictMode: true,
assetPrefix: process.env.ASSET_PREFIX || undefined,
experimental: {
scrollRestoration: true,
},
Expand Down
55 changes: 55 additions & 0 deletions apps/docs/utils/asset-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { assetUrl } from './asset-url'

describe('assetUrl', () => {
it('returns the original path when ASSET_PREFIX is unset', () => {
const prev = process.env.ASSET_PREFIX
try {
delete process.env.ASSET_PREFIX
expect(assetUrl('/favicon.svg')).toBe('/favicon.svg')
} finally {
process.env.ASSET_PREFIX = prev
}
})

it('returns the original path when ASSET_PREFIX is empty/whitespace', () => {
const prev = process.env.ASSET_PREFIX
try {
process.env.ASSET_PREFIX = ' '
expect(assetUrl('/favicon.svg')).toBe('/favicon.svg')
} finally {
process.env.ASSET_PREFIX = prev
}
})

it('prefixes root-relative paths', () => {
const prev = process.env.ASSET_PREFIX
try {
process.env.ASSET_PREFIX = 'https://docs.example.com'
expect(assetUrl('/images/foo.png')).toBe('https://docs.example.com/images/foo.png')
} finally {
process.env.ASSET_PREFIX = prev
}
})

it('strips a trailing slash from the prefix', () => {
const prev = process.env.ASSET_PREFIX
try {
process.env.ASSET_PREFIX = 'https://docs.example.com/'
expect(assetUrl('/images/foo.png')).toBe('https://docs.example.com/images/foo.png')
} finally {
process.env.ASSET_PREFIX = prev
}
})

it('does not change non-root-relative paths', () => {
const prev = process.env.ASSET_PREFIX
try {
process.env.ASSET_PREFIX = 'https://docs.example.com'
expect(assetUrl('https://cdn.example.com/a.png')).toBe('https://cdn.example.com/a.png')
expect(assetUrl('relative.png')).toBe('relative.png')
} finally {
process.env.ASSET_PREFIX = prev
}
})
})
6 changes: 6 additions & 0 deletions apps/docs/utils/asset-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function assetUrl(path: string): string {
if (!path.startsWith('/')) return path
const prefix = process.env.ASSET_PREFIX?.trim()
if (!prefix) return path
return `${prefix.replace(/\/$/, '')}${path}`
}
1 change: 1 addition & 0 deletions skills/tldraw-migrate/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
references/
155 changes: 155 additions & 0 deletions skills/tldraw-migrate/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
---
name: tldraw-migrate
description: Migrate a project to a newer version of the tldraw SDK. Use when upgrading tldraw packages, fixing TypeScript errors after a tldraw upgrade, or when the user mentions tldraw migration.
argument-hint: '[previous-version]'
disable-model-invocation: true
user-invocable: true
---

# tldraw migration assistant

You are helping migrate a project to a newer version of the tldraw SDK. Follow this process carefully.

Previous version (auto-detected from git history; pass an explicit version as `/tldraw-migrate <version>` to override): !`node ${CLAUDE_SKILL_DIR}/detect-versions.mjs $ARGUMENTS`

## Resources (auto-fetched on invocation)

!`mkdir -p ${CLAUDE_SKILL_DIR}/references && CHANGELOG=${CLAUDE_SKILL_DIR}/references/tldraw-releases.txt && PREV=$(node ${CLAUDE_SKILL_DIR}/detect-versions.mjs $ARGUMENTS) && curl --fail -sS https://tldraw.dev/llms-releases.txt | node ${CLAUDE_SKILL_DIR}/filter-changelog.mjs "$PREV" > "$CHANGELOG" && echo "Saved changelog (from $PREV) to $CHANGELOG ($(wc -l < "$CHANGELOG") lines)"`

!`DOCS=${CLAUDE_SKILL_DIR}/references/tldraw-full-docs.txt && if [ -s "$DOCS" ]; then echo "Using cached full docs at $DOCS ($(wc -l < "$DOCS") lines) — delete the file to refresh"; else curl --fail -sS https://tldraw.dev/llms-full.txt -o "$DOCS" && echo "Saved full docs to $DOCS ($(wc -l < "$DOCS") lines)"; fi`

- **[Filtered changelog](references/tldraw-releases.txt)** — release notes for versions between the previous version and now. Read this first to understand what changed.
- **[Full docs](references/tldraw-full-docs.txt)** — complete tldraw SDK docs (~1.5MB). Do NOT read this upfront. Use Grep or Read with line ranges to search for specific topics as needed (e.g., custom shapes, TLTextOptions).

## Step 1: Understand the environment

Before making any changes, scan the project to understand what you're working with. Run these in parallel:

- **Package manager**: Check for lock files (`yarn.lock`, `pnpm-lock.yaml`, `bun.lockb`, `package-lock.json`). Use the corresponding tool throughout.
- **tldraw packages**: `grep -E "tldraw|@tldraw" package.json` — which packages are installed, and at what versions? Note: not every project uses all tldraw packages.
- **Import style**: `grep -r "from '@tldraw/" src/ --include="*.ts" --include="*.tsx" -l | head -5` — does the project import from `'tldraw'`, `'@tldraw/editor'`, or both? This affects module augmentation targets.
- **TypeScript**: Check `package.json` for a `typecheck` or `tsc` script. Also check the TypeScript version — is it a direct dependency or does the project rely on a global install?
- **Build tool**: Check `package.json` scripts for the build command (vite, next, webpack, esbuild, etc.)
- **Linter**: Check for eslint/biome config files (`.eslintrc*`, `eslint.config.*`, `biome.json`). A linter may help catch deprecations later.
- **Monorepo**: Is `package.json` at the working directory root, or is this a nested package? Check for workspaces config.

## Step 2: Upgrade packages

Using the detected package manager, upgrade all tldraw packages that are already in the project's dependencies to the latest version. Don't add new packages the project doesn't already use.

## Step 3: Identify all TypeScript errors

Run the project's typecheck command (from Step 1) and categorize the errors:

1. **Count errors by TS code** (e.g., TS2344, TS2786) to understand the distribution
2. **List unique files with errors** to understand the scope
3. **Identify error patterns** — common categories in tldraw upgrades:
- React types mismatch (TS2786 "cannot be used as JSX component") — usually means `@types/react` version doesn't match what tldraw bundles. To find the bundled version: with npm or yarn classic, check `node_modules/@tldraw/editor/node_modules/@types/react/package.json`; with pnpm or yarn berry, run the package manager's "why" command (e.g. `pnpm why @types/react`, `yarn why @types/react`) to see the resolved range.
- Custom shape type registration (TS2344 "does not satisfy constraint TLShape/TLBaseBoxShape") — tldraw v4.3+ requires module augmentation of `TLGlobalShapePropsMap`
- Custom binding type registration — same pattern with `TLGlobalBindingPropsMap`
- `BaseBoxShapeTool.shapeType` type mismatch (TS2416) — needs `as const`
- API renames/removals (TS2305, TS2724) — check changelog for specific migrations
- `createShapes`/`updateShapes` type widening (TS2345) — mapped arrays need `TLShapePartial` or `TLCreateShapePartial` casts

## Step 4: Fix errors in order of impact

Fix in this order (each fix eliminates many downstream errors):

### 4a. Fix React types

If you see TS2786 "bigint not assignable to ReactNode" errors, upgrade `@types/react` and `@types/react-dom` to match tldraw's bundled version.

### 4b. Register custom shapes and bindings

For every `TLBaseShape<'name', Props>` in the codebase, add module augmentation. Use the import style detected in Step 1 as the module target:

```ts
declare module 'tldraw' {
interface TLGlobalShapePropsMap {
'shape-name': {
/* props */
}
}
}
type MyShape = TLShape<'shape-name'>
```

Same pattern for bindings with `TLGlobalBindingPropsMap` and `TLBinding<'name'>`.

**IMPORTANT**: If multiple files use the same shape type name with different props, rename them to be unique — they all share the global type registry.

Add `as const` to all `static override shapeType = '...'` properties.

### 4c. Fix API renames and removals

Cross-reference each TS2305/TS2724/TS2339 error with the changelog. Common patterns:

- Removed exports: find replacement by grepping the tldraw type definitions
- Renamed properties: check changelog for the new name
- Changed method signatures: check the current type definitions

To find tldraw type definitions, check which paths exist — the location varies by version:

- `node_modules/tldraw/dist-cjs/index.d.ts`
- `node_modules/tldraw/dist/index.d.ts`
- `node_modules/@tldraw/editor/dist-cjs/index.d.ts`
- `node_modules/@tldraw/tlschema/dist-cjs/index.d.ts`

### 4d. Fix TipTap imports if needed

If the project uses TipTap (`@tiptap/*` in dependencies or imports), the tldraw upgrade may require upgrading TipTap as well. Starting with tldraw v4.2, tldraw bundles TipTap v3 as a transitive dependency. If the project pins TipTap v2 packages directly, this will cause version conflicts and broken imports. **Upgrade the project's direct `@tiptap/*` dependencies to v3** to match what tldraw expects — leaving them on v2 will not work.

After upgrading, fix any breaking TipTap v2 → v3 changes:

- **Default → named exports**: If a default import fails, switch to a named import: `import StarterKit from '@tiptap/starter-kit'` → `import { StarterKit } from '@tiptap/starter-kit'`
- **Renames**: If a named import fails, check the package's actual exports: `grep 'export' node_modules/@tiptap/<package>/dist/index.js | head` — some extensions were renamed in TipTap v3 (e.g., `TextStyle` → `TextStyleKit`).
- **Transaction handler types**: If TipTap event handler types break, import the proper types: `import { EditorEvents } from '@tiptap/core'` and type handlers as `(props: EditorEvents['transaction']) => void` rather than inline type annotations.

### 4e. Fix remaining type errors

- `createShapes`/`updateShapes` with `.map()`: The proper fix is to add `as const` to the `type` field in the mapped object literal so TypeScript narrows it to the string literal type. If that's not sufficient, use a `satisfies` annotation on the return value. Only as a last resort, cast the result to `TLCreateShapePartial` or `TLShapePartial`.
- TipTap extension commands not on `ChainedCommands`: use `declare module '@tiptap/core'` augmentation to register custom commands.
- **General rule**: every `as` cast you add is tech debt. Before adding one, exhaust these alternatives in order:
1. `as const` on object literals to narrow string literal types
2. `satisfies` annotations to check types without widening
3. Proper generic type parameters on the call site
4. Module augmentation to teach TypeScript about your types
5. Only then, a targeted `as` cast with a comment explaining why it's needed

## Step 5: Fix deprecations

After all type errors are resolved, find and fix deprecated API usage. These still compile but should be migrated.

1. **Find deprecated symbols** — grep the tldraw type definitions for `@deprecated` annotations. Also search the changelog for "deprecated" in `${CLAUDE_SKILL_DIR}/references/tldraw-releases.txt`.

2. **Check if a linter is configured** — look for eslint, biome, or other linting config in the project. If available, run the linter — it may flag deprecated usage automatically (e.g., eslint's `deprecation/deprecation` rule). If no linter is configured, skip to step 3.

3. **Search the project source** for each deprecated symbol found in step 1. Replace with the recommended alternative from the `@deprecated` JSDoc comment or changelog.

## Step 6: Verify

Run the project's typecheck and build commands (as discovered in Step 1).

If errors remain, repeat the categorize-and-fix cycle.

### Post-migration sanity check

After all errors are resolved, do a quick audit:

1. **Count `as` casts before vs after**: Run `grep -rn ' as ' --include='*.ts' --include='*.tsx' src/` and compare against the same grep on the pre-migration code (use `git stash` or `git show HEAD:path`). **The migration should add no more than a small handful of new casts** (ideally zero). If you added more than ~5 new `as` casts across the entire migration, go back and fix them — you are almost certainly using the new API incorrectly.
2. **Review every cast you added**: For each new `as` cast, verify it's truly necessary by checking whether `as const`, `satisfies`, generic type parameters, or module augmentation could replace it. Remove or replace any that have a cleaner alternative.
3. **Verify no stubs or dead code**: If you stubbed out removed APIs (e.g., replaced a removed function with a no-op), make sure the calling code doesn't depend on the return value. If it does, find the proper replacement in the changelog or docs.

## Quality checks

- **Type safety is paramount.** The goal is a migration that is as type-safe as the original code. Do NOT add `as any`, `as unknown`, or broad type casts. Do NOT add `@ts-ignore` or `@ts-expect-error`. These are never acceptable — if you can't make the types work, you don't understand the new API yet. Stop and read the changelog and type definitions before continuing.
- **Prefer TypeScript's narrowing features over casts.** Use `as const` for literal types, `satisfies` for type-checking without widening, generic parameters for call sites, and module augmentation for extending interfaces. These are the right tools for a migration — `as` casts are not.
- Use parallel agents for fixing large batches of files with the same pattern.
- **Don't just make errors go away — understand the new API.** When a method signature changes (e.g., new parameters added, property renamed to a richer type), read the changelog AND the current type definitions to understand _why_ it changed. A fix that compiles but passes hardcoded/dummy values where the new API expects real data is worse than a type error — it silently degrades behavior. For example, if a function gains new required parameters, check what shape props or editor state should feed those parameters rather than passing `0` or `1`.
- **When unsure about an API pattern**, grep the full docs (`${CLAUDE_SKILL_DIR}/references/tldraw-full-docs.txt`) for usage examples of that specific API. The docs contain code samples that show the canonical way to use each API.

## Tips

- Read `${CLAUDE_SKILL_DIR}/references/tldraw-releases.txt` for the filtered changelog — this is the primary source for what changed
- Grep `${CLAUDE_SKILL_DIR}/references/tldraw-full-docs.txt` when you need docs on a specific API
- When searching tldraw types, try multiple paths — the dist directory structure varies by version and package manager
104 changes: 104 additions & 0 deletions skills/tldraw-migrate/detect-versions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* Detects the previous tldraw version and writes it to stdout.
*
* Resolution order:
* 1. An explicit version passed as argv[2] (e.g., "/tldraw-migrate 4.0.0").
* 2. The version of any tldraw package on the main/master branch — but only
* if it differs from the working tree (otherwise the user is migrating
* directly on main and main already reflects the new version).
* 3. The version in HEAD~1.
* 4. The version in the current working tree (last resort: assumes the user
* hasn't bumped yet, so the working tree IS the previous version).
*
* Outputs just the version string (no trailing newline) so the SKILL.md can
* interpolate it into other commands.
*/

import { execSync } from 'child_process'
import { readFileSync } from 'fs'

function exec(cmd) {
try {
return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
} catch {
return null
}
}

const TLDRAW_PACKAGES = [
'tldraw',
'@tldraw/editor',
'@tldraw/tldraw',
'@tldraw/store',
'@tldraw/tlschema',
'@tldraw/state',
'@tldraw/state-react',
'@tldraw/sync',
'@tldraw/sync-core',
'@tldraw/utils',
'@tldraw/validate',
]

function extractTldrawVersion(packageJsonStr) {
try {
const pkg = JSON.parse(packageJsonStr)
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
for (const name of TLDRAW_PACKAGES) {
const raw = deps[name]
if (!raw) continue
const cleaned = String(raw).replace(/[\^~>=<\s]/g, '')
const match = cleaned.match(/(\d+\.\d+\.\d+)/)
if (match) return match[1]
}
return null
} catch {
return null
}
}

function readWorkingTreeVersion() {
try {
return extractTldrawVersion(readFileSync('package.json', 'utf8'))
} catch {
return null
}
}

function emit(version) {
process.stdout.write(version)
process.exit(0)
}

const explicit = process.argv[2]?.trim()
if (explicit) {
const cleaned = explicit.replace(/^v/, '')
const match = cleaned.match(/^(\d+\.\d+\.\d+)/)
if (match) emit(match[1])
}

const workingTreeVersion = readWorkingTreeVersion()

for (const branch of ['main', 'master']) {
const content = exec(`git show ${branch}:package.json`)
if (!content) continue
const ver = extractTldrawVersion(content)
if (!ver) continue
// If the branch version matches the working tree, the user likely bumped
// directly on this branch — main isn't a useful "before" reference.
if (workingTreeVersion && ver === workingTreeVersion) continue
emit(ver)
}

const parent = exec('git show HEAD~1:package.json')
if (parent) {
const ver = extractTldrawVersion(parent)
if (ver && ver !== workingTreeVersion) emit(ver)
}

if (workingTreeVersion) emit(workingTreeVersion)

console.error(
'Could not detect previous tldraw version. Pass it as an argument: /tldraw-migrate 4.0.0'
)
process.exit(1)
Loading
Loading