v4.3.0
This release introduces several significant changes: a new pattern for defining custom shape/binding typings, pluggable storage for TLSocketRoom with a new SQLite option, reactive editor.inputs, and optimized draw shape encoding. It also adds various other API improvements, performance optimizations, and bug fixes, including better support for React 19.
New pattern for defining custom shape/binding types (breaking change) (#7091)
We've improved the developer experience of working with custom shape and binding types. There's now less boilerplate and fewer gotchas when using tldraw APIs in a type-safe manner.
This is a minor breaking change at the type level—your code will still run, but you'll get TypeScript errors until you migrate.
Migration guide
When declaring types for custom shapes, you can now use TypeScript's module augmentation feature to provide more specific types for the custom shape.
Before:
import { TLBaseShape } from 'tldraw'
// Shapes were defined by using the helper TLBaseShape type
type MyShape = TLBaseShape<'my-shape', { w: number; h: number; text: string }>
After:
import { TLShape } from 'tldraw'
const MY_SHAPE = 'my-shape'
// We now use TypeScript's module augmentation feature to allow
// extending the builtin TLShape type.
declare module 'tldraw' {
export interface TLGlobalShapePropsMap {
[MY_SHAPE]: { w: number; h: number; text: string }
}
}
type MyShape = TLShape<typeof MY_SHAPE>
The benefit of this new system is that Editor APIs such as createShape now know about your custom shapes automatically:
// Just works - TypeScript validates props and provides autocomplete
editor.createShape({ type: 'my-shape', props: { w: 100, h: 100, text: 'Hello' } })
// Will cause a TypeScript error for `text`
editor.createShape({ type: 'my-shape', props: { w: 100, h: 100, text: 123 } })
The same pattern applies to custom bindings. See the Custom Shapes Guide and the Pin Bindings example for details.
(contributed by @Andarist)
Pluggable storage for TLSocketRoom + SQLite support (#7320, #7123)
We've refactored the TLSocketRoom API to support a pluggable storage layer. We're providing two implementations:
SQLiteSyncStorage– Automatically persists room state to SQLite. Recommended for production.InMemorySyncStorage– Keeps state in memory with manual persistence via callbacks (previous built-in behavior).
We recommend switching to SQLiteSyncStorage if your environment supports SQLite (Cloudflare Durable Objects, Node.js, Bun, Deno). It provides automatic persistence, lower memory usage, and faster startup times.
Why SQLite?
- Automatic persistence: Data survives process restarts without manual snapshot handling
- Lower memory usage: No need to keep entire documents in memory
- Faster startup: No need to load the document into memory before accepting socket connections
- Simpler code: No more
onChangecallbacks and manual persistence logic
Platform support
| Platform | Wrapper | SQLite Library |
| -------------------------- | -------------------------------- | --------------------------------- |
| Cloudflare Durable Objects | DurableObjectSqliteSyncWrapper | Built-in ctx.storage |
| Node.js/Deno | NodeSqliteWrapper | better-sqlite3 or node:sqlite |
See the Cloudflare template and the Node server example respectively. Bun support should be straightforward to add.
Migration guide
Existing code continues to work, however we have deprecated the following TLSocketRoom options:
initialSnapshotonDataChange
These are replaced by the new storage option. We've also deprecated the TLSocketRoom.updateStore method, which has been supplanted by storage.transaction.
Before:
const existingSnapshot = loadExistingSnapshot()
const room = new TLSocketRoom({
initialSnapshot: existingSnapshot,
onDataChange: () => {
persistSnapshot(room.getCurrentSnapshot())
},
})
If you want to keep the same behavior with in-memory document storage and manual persistence:
import { InMemorySyncStorage, TLSocketRoom } from '@tldraw/sync-core'
const room = new TLSocketRoom({
storage: new InMemorySyncStorage({
snapshot: existingSnapshot,
onChange() {
saveToDatabase(storage.getSnapshot())
},
}),
})
However, we recommend switching to SQLite. Users of our Cloudflare template should follow the migration guide on the sync docs page.
If you're using TLSocketRoom on Node, creating the room should end up looking something like this:
import Database from 'better-sqlite3'
import { SQLiteSyncStorage, NodeSqliteWrapper, TLSocketRoom, RoomSnapshot } from '@tldraw/sync-core'
async function createRoom(roomId: string) {
const db = new Database(`path/to/${roomId}.db`)
const sql = new NodeSqliteWrapper(db)
let snapshot: RoomSnapshot | undefined = undefined
if (!SQLiteSyncStorage.hasBeenInitialized(sql)) {
// This db hasn't been used before, so if it's a pre-existing
// document, load the legacy room snapshot
snapshot = await loadExistingSnapshot()
}
const storage = new SQLiteSyncStorage({ sql, snapshot })
return new TLSocketRoom({
storage,
onSessionRemoved(room, args) {
if (args.numSessionsRemaining === 0) {
room.close()
db.close()
}
},
})
}
Optimized draw shape encoding (#7364, #7710)
Draw and highlight shape point data is now stored using a compact delta-encoded binary format instead of JSON arrays. This reduces storage size by approximately 80% while preserving stroke fidelity.
Breaking change details
If you were reading or writing draw shape data programatically you might need to update your code to use the new format.
TLDrawShapeSegment.pointsrenamed to.pathand changed fromVecModel[]tostring(base64-encoded)- Added
scaleXandscaleYproperties to draw and highlight shapes - New exports:
b64Vecsencoding utilities, e.g.getPointsFromDrawSegmenthelper. Use this if you need to manually read/write point data.
Existing documents are automatically migrated.
Reactive inputs (#7312)
Refactored editor.inputs to use reactive atoms via the new InputsManager class. All input state is now accessed via getter methods (e.g., editor.inputs.getCurrentPagePoint(), editor.inputs.getShiftKey()). Direct property access is deprecated but still supported for backwards compatibility.
API changes
- 💥
DefaultTopPanelexport removed fromtldraw. The top panel component for displaying the offline indicator is now handled internally byPeopleMenu. (#7568) - 💥
TextDirectionexport removed fromtldraw. Use TipTap's nativeTextDirectionextension instead. TherichTextValidatornow includes an optionalattrsproperty - a migration may be necessary for older clients/custom shapes. (#7304) - Add
tlenvReactiveatom to@tldraw/editorfor reactive environment state tracking, including coarse pointer detection that updates when users switch between mouse and touch input. (#7296) - Add
hideAllTooltips()helper function for programmatically dismissing tooltips. (#7288) - Add
zoomToFitPaddingoption toTldrawOptionsto customize the default padding used by zoom-to-fit operations. (#7602) - Add
snapThresholdoption toTldrawOptionsfor configuring the snap distance, defaulting to 8 screen pixels. (#7543)
Improvements
- Improve coarse pointer detection by replacing CSS media queries with a reactive
data-coarseattribute that updates when users switch between mouse and touch input. (#7404) - Add CSS containment to main toolbar and text measurement element for improved rendering performance. (#7406) (#7407)
- Improve cross-realm support by scoping canvas event listeners to the editor container's ownerDocument. (#7113)
- Simplify ImmutableMap implementation for better code clarity. (#7431)
- Improve code readability in number validator by using
Number.isFinite()instead of arithmetic trick. (#7374) - Upgrade to React 19 with all necessary type and configuration changes for compatibility. (#7317)
- Improve signal graph traversal performance by eliminating per-recursion closure allocations. (#7430)
Bug fixes
- Fix migrations for draw and highlight shapes to be idempotent, preventing errors when migrations run multiple times. (#7389)
- Fix dot detection in draw and highlight shapes after the point compression change. (#7365)
- Fix clicking a shape's text label while editing to re-focus the input and select all text. (#7342)
- Fix pasting at cursor to correctly account for frames and parent containers. (#7277)
- Fix editing mode to exit when dragging causes the text input to blur. (#7291)
- Fix CommonJS build issues with TipTap imports in rich text module. (#7282)
- Fix iOS automatically zooming in on input fields by ensuring 16px minimum font size. (#7118)
- Fix
distanceToLineSegmentreturning squared distance instead of actual distance, causing hit testing (eraser, scribble select) to be too strict. (#7610) (contributed by )