apps/docs/content/releases/v4.3.0.mdx
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.
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.
<details> <summary>Migration guide</summary>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 β augment TLGlobalBindingPropsMap and use TLBinding<'binding-name'>:
declare module 'tldraw' {
export interface TLGlobalBindingPropsMap {
'my-binding': { offset: number }
}
}
type MyBinding = TLBinding`<'my-binding'>`
See the Custom Shapes Guide and the Pin Bindings example for details.
After registering, remove the TLBaseShape import β it's no longer used and will trip lint rules that flag unused imports.
Shape type names are global. TLGlobalShapePropsMap is a single shared registry, so two custom shapes that previously used the same type name in different files now collide. Pick a unique name for each, and remember the rename ripples beyond the type alias:
static override type = '...' on the ShapeUtil subclasseditor.createShape({ type: '...' }) and editor.updateShape({ type: '...' }) call siteTLGlobalShapePropsMap entry itselfUse as const on the static type field. TypeScript may widen static override type = 'my-shape' to string, which fails the new constraint. Add as const to keep it a string literal:
class MyShapeUtil extends ShapeUtil`<MyShape>` {
static override type = 'my-shape' as const
// ...
}
The same fix applies to BaseBoxShapeTool (and other tool) subclasses, which expose static override shapeType = '...'. If you see TS2416 on a tool's shapeType, add as const there too. The two fields are easy to confuse β check both.
Heterogeneous createShapes/updateShapes arrays may need a cast. Because TLShape is now a discriminated union over the global props map, a mapped array of mixed shape types no longer narrows automatically. For homogeneous arrays where every item shares one shape type, as const on the type field in the mapped object literal is enough. For genuinely heterogeneous arrays, go straight to as TLShapePartial[] (or as TLCreateShapePartial[] for createShapes):
// Heterogeneous update β the union doesn't narrow
editor.updateShapes(
shapes.map((s) => ({ id: s.id, type: s.type, x: s.x + 10 })) as TLShapePartial[]
)
Don't reach for satisfies TLShapePartial here β TLShapePartial is distributive over the TLShape union, so satisfies enforces a literal-per-variant constraint that mapped-over heterogeneous shapes can't meet. The cast is the right tool for this specific shape of code.
(contributed by @Andarist)
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.
onChange callbacks and manual persistence logic| 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.
</details> <details> <summary>Migration guide</summary>Existing code continues to work, however we have deprecated the following TLSocketRoom options:
initialSnapshotonDataChangeThese 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()
}
},
})
}
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.
<details> <summary>Breaking change details</summary>If you were reading or writing draw shape data programatically you might need to update your code to use the new format.
TLDrawShapeSegment.points renamed to .path and changed from VecModel[] to string (base64-encoded)scaleX and scaleY properties to draw and highlight shapesb64Vecs encoding utilities, e.g. getPointsFromDrawSegment helper. Use this if you need to manually read/write point data.Existing documents are automatically migrated.
</details>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.
DefaultTopPanel export removed from tldraw. The top panel component for displaying the offline indicator is now handled internally by PeopleMenu. (#7568)TextDirection export removed from tldraw. Use TipTap's native TextDirection extension instead. The richTextValidator now includes an optional attrs property - a migration may be necessary for older clients/custom shapes. (#7304)tlenvReactive atom to @tldraw/editor for reactive environment state tracking, including coarse pointer detection that updates when users switch between mouse and touch input. (#7296)hideAllTooltips() helper function for programmatically dismissing tooltips. (#7288)zoomToFitPadding option to TldrawOptions to customize the default padding used by zoom-to-fit operations. (#7602)snapThreshold option to TldrawOptions for configuring the snap distance, defaulting to 8 screen pixels. (#7543)resizeChildren configuration option to FrameShapeUtil to allow frame children to be resized proportionally when the frame is resized. (#7526)Editor.canEditShape() and Editor.canCropShape() methods to centralize shape permission checks. Add ShapeUtil.canEditWhileLocked() for shapes that remain editable when locked. (#7361)editor.getDebouncedZoomLevel() and editor.getEfficientZoomLevel() methods for improved zoom performance on dense canvases. Add debouncedZoom and debouncedZoomThreshold options. (#7235)showTextOutline option to TextShapeUtil, ArrowShapeUtil, and GeoShapeUtil via .configure() pattern. (#7314)getStroke, getStrokeOutlinePoints, and setStrokePointRadii. (#7400) (contributed by @VimHax)Box.isValid() method to check for finite coordinates. (#7532)spacebarPanning option to control whether spacebar activates pan mode. (#7312)TLSyncStorage API for TLSocketRoom. The initialSnapshot and onDataChange options are now deprecated in favor of the new storage option. (#7123)DefaultLabelColorStyle which is necessary for rich text in custom shapes. (#7114)data-coarse attribute that updates when users switch between mouse and touch input. (#7404)Number.isFinite() instead of arithmetic trick. (#7374)distanceToLineSegment returning squared distance instead of actual distance, causing hit testing (eraser, scribble select) to be too strict. (#7610) (contributed by @arpit-goblins)zoomToSelection to toggle between 100% zoom and zoom-to-fit behavior. (#7536)hideUi is true. (#7367)isCircle in their crop schema. (#7931)