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
2 changes: 2 additions & 0 deletions .github/workflows/deploy-dotcom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,5 @@ jobs:
ZERO_R2_BUCKET_NAME: ${{ secrets.ZERO_R2_BUCKET_NAME }}
ZERO_R2_ACCESS_KEY_ID: ${{ secrets.ZERO_R2_ACCESS_KEY_ID }}
ZERO_R2_SECRET_ACCESS_KEY: ${{ secrets.ZERO_R2_SECRET_ACCESS_KEY }}
ZERO_OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.ZERO_OTEL_EXPORTER_OTLP_ENDPOINT }}
ZERO_OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.ZERO_OTEL_EXPORTER_OTLP_HEADERS }}
44 changes: 42 additions & 2 deletions apps/docs/content/sdk-features/ui-components.mdx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
title: UI components
created_at: 12/17/2025
updated_at: 12/29/2025
updated_at: 3/25/2026
keywords:
- ui
- components
- toolbar
- menu
- customization
status: published
date: 12/29/2025
date: 3/25/2026
readability: 8
voice: 8
completeness: 9
Expand Down Expand Up @@ -76,6 +76,46 @@ The **NavigationPanel** provides page navigation, zoom controls, and the minimap

Each slot is optional. Pass `null` as an override to hide a component entirely, or provide your own React component to replace the default implementation.

### Slot props

The UI portion of the `components` prop is shaped by [TLUiComponents](?). Every key is optional: use `null` to hide that slot, or pass a React component. When a slot has a documented props type in the table below, import that type from `tldraw` and type your replacement as `React.ComponentType<…>` (or implement the matching props). When the props column says **none**, the SDK does not declare extra props for that slot beyond what a plain `ComponentType` allows.

| Slot | Props (import from `tldraw`) |
| ------------------------- | ---------------------------------------------------------------------- |
| `ContextMenu` | [TLUiContextMenuProps](?) |
| `ActionsMenu` | [TLUiActionsMenuProps](?) |
| `HelpMenu` | [TLUiHelpMenuProps](?) |
| `ZoomMenu` | [TLUiZoomMenuProps](?) |
| `MainMenu` | [TLUiMainMenuProps](?) |
| `Minimap` | none |
| `StylePanel` | [TLUiStylePanelProps](?) |
| `PageMenu` | none |
| `NavigationPanel` | none |
| `Toolbar` | none |
| `RichTextToolbar` | [TLUiRichTextToolbarProps](?) |
| `ImageToolbar` | none |
| `VideoToolbar` | none |
| `KeyboardShortcutsDialog` | [TLUiKeyboardShortcutsDialogProps](?) |
| `QuickActions` | [TLUiQuickActionsProps](?) |
| `HelperButtons` | [TLUiHelperButtonsProps](?) |
| `DebugPanel` | none |
| `DebugMenu` | none |
| `MenuPanel` | none |
| `TopPanel` | none |
| `SharePanel` | none |
| `CursorChatBubble` | none |
| `Dialogs` | none |
| `Toasts` | none |
| `A11y` | none |
| `FollowingIndicator` | none |
| `PeopleMenu` | none (default component: optional `children` via [PeopleMenuProps](?)) |
| `PeopleMenuAvatar` | [TLUiPeopleMenuAvatarProps](?) |
| `PeopleMenuFacePile` | [TLUiPeopleMenuFacePileProps](?) |
| `PeopleMenuItem` | [TLUiPeopleMenuItemProps](?) |
| `UserPresenceEditor` | none |

This table is an index; the authoritative list and types remain [TLUiComponents](?) in the API reference and in the package typings.

### UI hooks

Components access editor functionality through specialized hooks.
Expand Down
2 changes: 1 addition & 1 deletion apps/dotcom/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"dependencies": {
"@clerk/clerk-react": "^5.53.3",
"@clerk/elements": "^0.23.74",
"@rocicorp/zero": "1.0.0",
"@rocicorp/zero": "1.2.0",
"@sentry/integrations": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tldraw/assets": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion apps/dotcom/sync-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"dependencies": {
"@clerk/backend": "^1.23.7",
"@pierre/storage": "^1.1.0",
"@rocicorp/zero": "1.0.0",
"@rocicorp/zero": "1.2.0",
"@supabase/auth-helpers-remix": "^0.2.6",
"@supabase/supabase-js": "^2.48.1",
"@tldraw/dotcom-shared": "workspace:*",
Expand Down
133 changes: 133 additions & 0 deletions apps/dotcom/sync-worker/src/healthCheckRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,137 @@ export const healthCheckRoutes = createRouter<Environment>()
await db.destroy()
}
})
// Combined postgres health check: db size, changelog size, WAL retention, replication slots, and
// tlpr replicator status. Grouped into a single endpoint because updown.io charges per check
// invocation. Failures include the sub-check name so alerts remain distinguishable.
.get('/health-check/postgres', async (_, env) => {
const db = createPostgresConnectionPool(env, '/health-check/postgres')
const failures: string[] = []
const okDetails: string[] = []
try {
// db-size
try {
const thresholdGb = parseFloat(env.HEALTH_CHECK_DB_SIZE_THRESHOLD_GB ?? '4')
const result = await sql<{ size_bytes: string }>`
SELECT pg_database_size(current_database()) AS size_bytes
`.execute(db)
const sizeGb = parseInt(result.rows[0].size_bytes, 10) / (1024 * 1024 * 1024)
if (sizeGb > thresholdGb) {
failures.push(`db-size: ${sizeGb.toFixed(2)} GB > ${thresholdGb} GB threshold`)
} else {
okDetails.push(`db: ${sizeGb.toFixed(2)} GB`)
}
} catch (_e) {
failures.push('db-size: query failed')
}

// changelog-size
try {
const thresholdMb = parseFloat(env.HEALTH_CHECK_CHANGELOG_SIZE_THRESHOLD_MB ?? '1024')
const result = await sql<{ size_bytes: string }>`
SELECT pg_total_relation_size('"zero_0/cdc"."changeLog"') AS size_bytes
`.execute(db)
const sizeMb = parseInt(result.rows[0].size_bytes, 10) / (1024 * 1024)
if (sizeMb > thresholdMb) {
failures.push(`changelog-size: ${sizeMb.toFixed(0)} MB > ${thresholdMb} MB threshold`)
} else {
okDetails.push(`changelog: ${sizeMb.toFixed(0)} MB`)
}
} catch (_e) {
failures.push('changelog-size: query failed')
}

// wal-size
try {
const thresholdMb = parseFloat(env.HEALTH_CHECK_WAL_SIZE_THRESHOLD_MB ?? '1024')
const result = await sql<{
slot_name: string
retained_bytes: string
}>`
SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes
FROM pg_replication_slots
`.execute(db)
const overThreshold = result.rows.filter(
(row) => parseInt(row.retained_bytes, 10) / (1024 * 1024) > thresholdMb
)
if (overThreshold.length > 0) {
const details = overThreshold
.map((r) => {
const mb = (parseInt(r.retained_bytes, 10) / (1024 * 1024)).toFixed(0)
return `${r.slot_name}: ${mb} MB`
})
.join(', ')
failures.push(`wal-size: ${details} > ${thresholdMb} MB threshold`)
} else {
const maxMb = result.rows.reduce(
(max, r) => Math.max(max, parseInt(r.retained_bytes, 10) / (1024 * 1024)),
0
)
okDetails.push(`wal: ${maxMb.toFixed(0)} MB`)
}
} catch (_e) {
failures.push('wal-size: query failed')
}

// replication-slots
try {
const result = await sql<{
slot_name: string
active: boolean
wal_status: string | null
}>`
SELECT slot_name, active, wal_status
FROM pg_replication_slots
WHERE slot_name LIKE 'zero_%' OR slot_name LIKE 'tlpr_%'
`.execute(db)
const unhealthy = result.rows.filter(
(row) => row.wal_status === 'lost' || row.wal_status === 'unreserved'
)
if (unhealthy.length > 0) {
const details = unhealthy
.map((r) => `${r.slot_name}: wal_status=${r.wal_status}`)
.join(', ')
failures.push(`replication-slots: ${details}`)
} else {
okDetails.push(`slots: ${result.rows.length} ok`)
}
} catch (_e) {
failures.push('replication-slots: query failed')
}

// tlpr-replicator
try {
const result = await sql<{
slot_name: string
active: boolean
wal_status: string | null
}>`
SELECT slot_name, active, wal_status
FROM pg_replication_slots
WHERE slot_name LIKE 'tlpr_%'
`.execute(db)
if (result.rows.length === 0) {
failures.push('tlpr-replicator: no slot found')
} else {
const slot = result.rows[0]
if (!slot.active) {
failures.push(`tlpr-replicator: ${slot.slot_name} not active`)
} else if (slot.wal_status === 'lost' || slot.wal_status === 'unreserved') {
failures.push(`tlpr-replicator: ${slot.slot_name} wal_status=${slot.wal_status}`)
} else {
okDetails.push('tlpr: active')
}
}
} catch (_e) {
failures.push('tlpr-replicator: query failed')
}

if (failures.length > 0) {
return new Response(`FAIL ${failures.join('; ')}`, { status: 500 })
}
return new Response(`ok (${okDetails.join(', ')})`, { status: 200 })
} finally {
await db.destroy()
}
})
.all('*', notFound)
3 changes: 3 additions & 0 deletions apps/dotcom/sync-worker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export interface Environment {
MULTIPLAYER_SERVER: string | undefined

HEALTH_CHECK_BEARER_TOKEN: string | undefined
HEALTH_CHECK_DB_SIZE_THRESHOLD_GB: string | undefined
HEALTH_CHECK_CHANGELOG_SIZE_THRESHOLD_MB: string | undefined
HEALTH_CHECK_WAL_SIZE_THRESHOLD_MB: string | undefined

ANALYTICS_API_URL: string | undefined
ANALYTICS_API_TOKEN: string | undefined
Expand Down
10 changes: 10 additions & 0 deletions apps/dotcom/sync-worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,21 @@ MULTIPLAYER_SERVER = "http://localhost:3000"
name = "main-tldraw-multiplayer"
workers_dev = true # todo: remove this once clients are updated

[env.staging.vars]
HEALTH_CHECK_DB_SIZE_THRESHOLD_GB = "4"
HEALTH_CHECK_CHANGELOG_SIZE_THRESHOLD_MB = "1024"
HEALTH_CHECK_WAL_SIZE_THRESHOLD_MB = "1024"

# production gets the proper name
[env.production]
name = "tldraw-multiplayer"
workers_dev = true # todo: remove this once clients are updated

[env.production.vars]
HEALTH_CHECK_DB_SIZE_THRESHOLD_GB = "10"
HEALTH_CHECK_CHANGELOG_SIZE_THRESHOLD_MB = "1024"
HEALTH_CHECK_WAL_SIZE_THRESHOLD_MB = "2048"

#################### Routing ####################
# no custom routes for previews and development

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,5 @@ ZERO_LITESTREAM_CONFIG_PATH = "/etc/litestream.yml"
ZERO_LITESTREAM_BACKUP_URL = "s3://__ZERO_R2_BUCKET_NAME/__BACKUP_PATH"
ZERO_LOG_LEVEL = "debug"
DO_NOT_TRACK = "1"
OTEL_NODE_RESOURCE_DETECTORS = "env,host,os"
OTEL_RESOURCE_ATTRIBUTES = "service.name=zero-rm,service.namespace=dotcom,deployment.environment=__TLDRAW_ENV,service.version=__ZERO_VERSION"
2 changes: 2 additions & 0 deletions apps/dotcom/zero-cache/flyio-view-syncer.template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,7 @@ ZERO_CVR_MAX_CONNS = "__VS_CVR_MAX_CONNS"
ZERO_CHANGE_MAX_CONNS = "__VS_CHANGE_MAX_CONNS"
ZERO_LOG_LEVEL = "debug"
DO_NOT_TRACK = "1"
OTEL_NODE_RESOURCE_DETECTORS = "env,host,os"
OTEL_RESOURCE_ATTRIBUTES = "service.name=zero-vs,service.namespace=dotcom,deployment.environment=__TLDRAW_ENV,service.version=__ZERO_VERSION"
ZERO_LITESTREAM_CONFIG_PATH = "/etc/litestream.yml"
ZERO_LITESTREAM_BACKUP_URL = "s3://__ZERO_R2_BUCKET_NAME/__BACKUP_PATH"
2 changes: 1 addition & 1 deletion apps/dotcom/zero-cache/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
},
"type": "module",
"dependencies": {
"@rocicorp/zero": "1.0.0",
"@rocicorp/zero": "1.2.0",
"kysely": "^0.28.12",
"pg": "^8.13.1"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const components: Required<TLUiComponents> = {
Toasts: null,
A11y: null,
FollowingIndicator: null,
PeopleMenu: null,
PeopleMenuAvatar: null,
PeopleMenuItem: null,
PeopleMenuFacePile: null,
UserPresenceEditor: null,
}

export default function UiComponentsHiddenExample() {
Expand Down
10 changes: 10 additions & 0 deletions internal/scripts/deploy-dotcom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ const env = makeEnv([
'ZERO_R2_BUCKET_NAME',
'ZERO_R2_ACCESS_KEY_ID',
'ZERO_R2_SECRET_ACCESS_KEY',
'ZERO_OTEL_EXPORTER_OTLP_ENDPOINT',
'ZERO_OTEL_EXPORTER_OTLP_HEADERS',
])

// Multinode (flyio-multinode) is for staging + production, previews use single as it is faster / cheaper
Expand Down Expand Up @@ -738,6 +740,8 @@ function updateFlyioReplicationManagerToml(appName: string, backupPath: string):
.replaceAll('__VM_CPUS', String(zeroVm.rm.cpus))
.replaceAll('__VM_MEMORY', zeroVm.rm.memory)
.replaceAll('__VOLUME_SIZE', zeroVm.volumeSize)
.replaceAll('__TLDRAW_ENV', env.TLDRAW_ENV)
.replaceAll('__ZERO_VERSION', zeroVersion)

fs.writeFileSync(flyioTomlFile, updatedContent, 'utf-8')
}
Expand Down Expand Up @@ -774,6 +778,8 @@ function updateFlyioViewSyncerToml(
.replaceAll('__VM_CPUS', String(zeroVm.vs.cpus))
.replaceAll('__VM_MEMORY', zeroVm.vs.memory)
.replaceAll('__VOLUME_SIZE', zeroVm.volumeSize)
.replaceAll('__TLDRAW_ENV', env.TLDRAW_ENV)
.replaceAll('__ZERO_VERSION', zeroVersion)

fs.writeFileSync(flyioTomlFile, updatedContent, 'utf-8')

Expand Down Expand Up @@ -812,6 +818,8 @@ async function deployZeroViaFlyIoMultiNode() {
// Zero uses the AWS SDK to talk to R2 (S3-compatible), so it expects AWS_* env vars
`AWS_ACCESS_KEY_ID=${env.ZERO_R2_ACCESS_KEY_ID}`,
`AWS_SECRET_ACCESS_KEY=${env.ZERO_R2_SECRET_ACCESS_KEY}`,
`OTEL_EXPORTER_OTLP_ENDPOINT=${env.ZERO_OTEL_EXPORTER_OTLP_ENDPOINT}`,
`OTEL_EXPORTER_OTLP_HEADERS=${env.ZERO_OTEL_EXPORTER_OTLP_HEADERS}`,
'-a',
flyioReplAppName,
],
Expand Down Expand Up @@ -841,6 +849,8 @@ async function deployZeroViaFlyIoMultiNode() {
// Zero uses the AWS SDK to talk to R2 (S3-compatible), so it expects AWS_* env vars
`AWS_ACCESS_KEY_ID=${env.ZERO_R2_ACCESS_KEY_ID}`,
`AWS_SECRET_ACCESS_KEY=${env.ZERO_R2_SECRET_ACCESS_KEY}`,
`OTEL_EXPORTER_OTLP_ENDPOINT=${env.ZERO_OTEL_EXPORTER_OTLP_ENDPOINT}`,
`OTEL_EXPORTER_OTLP_HEADERS=${env.ZERO_OTEL_EXPORTER_OTLP_HEADERS}`,
'-a',
flyioAppName,
],
Expand Down
2 changes: 1 addition & 1 deletion packages/dotcom-shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"files": [],
"type": "module",
"dependencies": {
"@rocicorp/zero": "1.0.0",
"@rocicorp/zero": "1.2.0",
"@tldraw/state": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/tlschema": "workspace:*",
Expand Down
Loading
Loading