From d9c7b4290da592f31efca6d8a44a7cff7577056c Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Sun, 23 Aug 2026 17:56:32 +0000 Subject: [PATCH 1/3] docs: fix tldraw.dev page errors caught by the dotdev smoke test (#10539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our internal tldraw.dev smoke test (tldraw-internal "Dotdev test") crawls the live site and has been red for days on several docs-owned pages. This fixes the four docs-side causes. - **`/starter-kits/overview` hydration failure (React #418):** `Feature` wrapped its MDX children in a `

`, but MDX renders block children as their own `

`s — the nested `

` gets flattened by the HTML parser and hydration mismatches. The wrapper is now a `

`. - **Intermittent hydration failure on any page with a tldraw.com link (seen on `/starter-kits/shader`):** `TldrawLink` read posthog's session/distinct ids during the first client render, so when posthog loaded before hydration the client href already had `#session_id=…` appended while the server HTML didn't. The ids now start empty and are read in an effect. - **`Invalid URL: %C2%A0` console errors on `/reference/store/AtomMap`, `AtomSet`, `ReadonlySharedStyleMap`:** `getApiMarkdown` emits method headings as `name( )`, and for `[Symbol.iterator]` that produces `[Symbol.iterator]( )` — which markdown parses as a *link* whose href is a non-breaking space. Square brackets in member names are now escaped. (Note: these pages' member heading anchors change slightly since the heading text changes.) - **404 on `/starter-kits/agent`:** the architecture diagram used a literal JSX ``, which bypasses the MDX `img → Image` mapping and its `assetUrl` prefixing, so behind the tldraw.dev proxy it resolved against the marketing app and 404'd. Converted to a markdown image like the sibling starter-kit chart diagrams (it now gets the standard full-width image plate instead of the 400px centered styling). - **404 fonts on `/examples/custom-theme`:** the example hardcoded Comic Neue **v8** gstatic URLs, which Google retired. Bumped to the current v9 URLs (both verified 200) and left a comment on how to refresh them next time they rot. ### Change type - [x] `bugfix` ### Release notes - Fixed hydration errors, broken reference-page member links, a missing starter-kit image, and stale font URLs on tldraw.dev docs pages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 --- apps/docs/components/common/tldraw-link.tsx | 13 +++++++++++-- apps/docs/components/content/feature.tsx | 3 ++- apps/docs/content/starter-kits/agent.mdx | 6 +----- apps/docs/scripts/lib/getApiMarkdown.ts | 4 +++- .../examples/ui/custom-theme/CustomThemeExample.tsx | 8 +++++--- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/docs/components/common/tldraw-link.tsx b/apps/docs/components/common/tldraw-link.tsx index 753fbf5827d1..3c99bf6be68c 100644 --- a/apps/docs/components/common/tldraw-link.tsx +++ b/apps/docs/components/common/tldraw-link.tsx @@ -6,14 +6,23 @@ import { useEffect, useState } from 'react' export function TldrawLink(props: React.ComponentProps<'a'>) { const { href, children } = props const windowObject = typeof window !== 'undefined' ? window : null - const [sessionId, setSessionId] = useState(windowObject?.posthog?.get_session_id()) - const [distinctId, setDistinctId] = useState(windowObject?.posthog?.get_distinct_id()) + // Start empty so the first client render matches the server HTML — reading + // posthog during render appends `#session_id` to hrefs before hydration and + // causes a hydration mismatch whenever posthog loads first. + const [sessionId, setSessionId] = useState(undefined) + const [distinctId, setDistinctId] = useState(undefined) useEffect(() => { // Note this href check is intentionally loose, just to avoid // doing this setInterval for every link. We do the real check below. if (sessionId || !href?.includes('tldraw.com')) return + if (windowObject?.posthog) { + setSessionId(windowObject.posthog.get_session_id()) + setDistinctId(windowObject.posthog.get_distinct_id()) + return + } + // XXX: have to wait a bit for posthog to be ready. // there's unfortunately no event callback for this. const timeout = setInterval(() => { diff --git a/apps/docs/components/content/feature.tsx b/apps/docs/components/content/feature.tsx index 04071cb96415..3f77610b3b2d 100644 --- a/apps/docs/components/content/feature.tsx +++ b/apps/docs/components/content/feature.tsx @@ -16,7 +16,8 @@ export function Feature({

{title}

-

{children}

+ {/* MDX renders block children as their own `

`s, so this wrapper must not be a `

` — nested `

`s break hydration. */} +

{children}
) } diff --git a/apps/docs/content/starter-kits/agent.mdx b/apps/docs/content/starter-kits/agent.mdx index 0cf80c418a0e..8c725dadaf95 100644 --- a/apps/docs/content/starter-kits/agent.mdx +++ b/apps/docs/content/starter-kits/agent.mdx @@ -59,11 +59,7 @@ With its default configuration, the agent can perform the following actions: ### Architecture -Flowchart showing how user input is processed by an agent, which gathers a canvas screenshot, analyzes shapes, and builds context. These feed into an AI model, which performs further shape analysis, updates the canvas, and produces results that go back to the user as feedback. +![Flowchart showing how user input is processed by an agent, which gathers a canvas screenshot, analyzes shapes, and builds context. These feed into an AI model, which performs further shape analysis, updates the canvas, and produces results that go back to the user as feedback.](/images/starter-kits/chart-agent.png) ### 1. User input diff --git a/apps/docs/scripts/lib/getApiMarkdown.ts b/apps/docs/scripts/lib/getApiMarkdown.ts index a1b754928cd8..ec458b8cc883 100644 --- a/apps/docs/scripts/lib/getApiMarkdown.ts +++ b/apps/docs/scripts/lib/getApiMarkdown.ts @@ -303,7 +303,9 @@ function getItemTitle(item: ApiItem) { return 'Constructor' } - const name = item.displayName + // Escape square brackets so a name like `[Symbol.iterator]` followed by the + // `(\u00A0)` call parens below doesn't parse as a markdown link. + const name = item.displayName.replaceAll('[', '\\[').replaceAll(']', '\\]') if (item.kind === ApiItemKind.Method || item.kind === ApiItemKind.Function) { return `${name}(\u00A0)` } diff --git a/apps/examples/src/examples/ui/custom-theme/CustomThemeExample.tsx b/apps/examples/src/examples/ui/custom-theme/CustomThemeExample.tsx index 1b82e2c2f5bd..9b88af68f0e7 100644 --- a/apps/examples/src/examples/ui/custom-theme/CustomThemeExample.tsx +++ b/apps/examples/src/examples/ui/custom-theme/CustomThemeExample.tsx @@ -79,7 +79,9 @@ const pixelFont: TLThemeFont = { ], } -// Custom font — use a Google Font loaded via full URLs. +// Custom font — use a Google Font loaded via full URLs. These versioned +// gstatic URLs expire when Google revs the font; refresh them from +// https://fonts.googleapis.com/css2?family=Comic+Neue:wght@400;700 if they 404. const cursiveFont: TLThemeFont = { fontFamily: "'Comic Neue', cursive", icon:
Aa
, @@ -87,7 +89,7 @@ const cursiveFont: TLThemeFont = { { family: 'Comic Neue', src: { - url: 'https://fonts.gstatic.com/s/comicneue/v8/4UaErEJDsxBrF37olUeD_wHLwpteLwtHJlc.woff2', + url: 'https://fonts.gstatic.com/s/comicneue/v9/4UaHrEJDsxBrF37olUeD96rp57F2IwM.woff2', format: 'woff2', }, weight: 'normal', @@ -96,7 +98,7 @@ const cursiveFont: TLThemeFont = { { family: 'Comic Neue', src: { - url: 'https://fonts.gstatic.com/s/comicneue/v8/4UaFrEJDsxBrF37olUeD96_RTplUKylCNlcw_Q.woff2', + url: 'https://fonts.gstatic.com/s/comicneue/v9/4UaErEJDsxBrF37olUeD_xHM8pxULilENlY.woff2', format: 'woff2', }, weight: 'bold', From 225146a212aa97152b5ba750c3033c84a4a9d592 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Sun, 23 Aug 2026 17:59:18 +0000 Subject: [PATCH 2/3] fix(tlschema): make exportBackground default to true in one place (#10534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR makes `exportBackground` default to `true` in a single place. Closes #10521. ### Before `TLInstance`'s `withDefaultProperties` declared `exportBackground: false`, while `createIntegrityChecker` in `TLStore.ts` passed an explicit `exportBackground: true` when it created the instance record for a fresh tab. The explicit value won, so every fresh tab started with the background on ("Transparent" unchecked), but any other caller that created the instance record without the field would have started with the opposite value. ### After The record type's default is `exportBackground: true`, and the integrity checker no longer passes the field. `RecordType.create` spreads `createDefaultProperties()` first and only overrides keys whose value is not `undefined`, so the fresh-tab behavior is unchanged; the default now just lives in one place. ### Implementation notes Background on (Transparent unchecked) was chosen as the intended default because it is what every fresh tab already gets today. The legacy snapshot path in `extractSessionStateFromLegacySnapshot` still coerces a missing field with `!!`, which is untouched here because old snapshots always carried the field after the `AddExportBackground` migration. ### Change type - [x] `improvement` ### Test plan - [x] Unit tests — new `should default exportBackground to true when the instance omits it` in `packages/tlschema/src/TLStore.test.ts` fails on `main` (`false`) and passes here. The existing `should create missing instance state` test now exercises the record default instead of the explicit value and still passes. 1. Open a fresh tab, open the export menu, and confirm "Transparent" is unchecked. ### Release notes - Make `exportBackground` default to `true` in one place in `@tldraw/tlschema` ### Code changes | Section | LOC change | | --------- | ---------- | | Core code | +1 / -2 | | Tests | +9 / -1 | --- packages/tlschema/src/TLStore.test.ts | 10 +++++++++- packages/tlschema/src/TLStore.ts | 1 - packages/tlschema/src/records/TLInstance.ts | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/tlschema/src/TLStore.test.ts b/packages/tlschema/src/TLStore.test.ts index 56ea3e216ab8..decfe77f0eb0 100644 --- a/packages/tlschema/src/TLStore.test.ts +++ b/packages/tlschema/src/TLStore.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTLSchema } from './createTLSchema' import { CameraRecordType } from './records/TLCamera' import { TLDOCUMENT_ID } from './records/TLDocument' -import { TLINSTANCE_ID } from './records/TLInstance' +import { createInstanceRecordType, TLINSTANCE_ID } from './records/TLInstance' import { PageRecordType, TLPageId } from './records/TLPage' import { InstancePageStateRecordType } from './records/TLPageState' import { TLPOINTER_ID } from './records/TLPointer' @@ -439,6 +439,14 @@ describe('createIntegrityChecker', () => { expect(instance!.exportBackground).toBe(true) }) + it('should default exportBackground to true when the instance omits it', () => { + const instance = createInstanceRecordType(new Map()).create({ + id: TLINSTANCE_ID, + currentPageId: 'page:whatever' as TLPageId, + }) + expect(instance.exportBackground).toBe(true) + }) + it('should update instance to reference valid page when current page is invalid', () => { // Create a valid page const validPageId = 'page:valid' as TLPageId diff --git a/packages/tlschema/src/TLStore.ts b/packages/tlschema/src/TLStore.ts index c7dac1c1bd53..ffd07ee0998c 100644 --- a/packages/tlschema/src/TLStore.ts +++ b/packages/tlschema/src/TLStore.ts @@ -491,7 +491,6 @@ export function createIntegrityChecker(store: Store): () store.schema.types.instance.create({ id: TLINSTANCE_ID, currentPageId: getFirstPageId(), - exportBackground: true, }), ]) diff --git a/packages/tlschema/src/records/TLInstance.ts b/packages/tlschema/src/records/TLInstance.ts index 66962ca8a2f6..a47c99f2e935 100644 --- a/packages/tlschema/src/records/TLInstance.ts +++ b/packages/tlschema/src/records/TLInstance.ts @@ -296,7 +296,7 @@ export function createInstanceRecordType(stylesById: Map Date: Sun, 23 Aug 2026 18:00:28 +0000 Subject: [PATCH 3/3] fix(editor): flush local persistence on pagehide and when the tab is hidden (#10526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes a bug where up to 350 ms of edits were lost when a tab was closed, reloaded, or backgrounded. Closes #10513. ### Before `TLLocalSyncClient` throttles IndexedDB writes with a 350 ms timer and nothing flushed the queue when the page went away. Any change made inside that window, including the final characters typed into a note or the last slice of a drag, never reached the database. ### After The client listens for `pagehide` on `window` and for `visibilitychange` → `hidden` on `document`, and on either event calls the persist path immediately, bypassing the throttle timer. The throttle itself is unchanged for normal editing. The listeners are registered in the client's disposables and removed on `close()`. ### Implementation notes - `visibilitychange` is included because mobile browsers often discard a backgrounded page without firing `pagehide`; it also means switching tabs on desktop flushes early, which is harmless. - The flush is a no-op until the initial load has finished. The first write after connecting is always a full snapshot, so flushing before load would overwrite the saved document with an empty store (for example, a background tab closed before IndexedDB responded). A `didLoad` flag guards this. - If a write is already in flight when the event fires, the flush does nothing more; the in-flight transaction is the best we can do at that point. IndexedDB transactions started during `pagehide` generally complete. ### Change type - [x] `bugfix` ### Test plan - [x] Unit tests — `TLLocalSyncClient.test.ts`: `pagehide flushes pending changes without waiting for the throttle` and `hiding the tab flushes pending changes without waiting for the throttle` fail on `main` and pass here; plus tests that nothing is written before the initial load and that listeners are removed on `close()`. 1. Open `/develop`, type into a note, and close the tab within a quarter second. Reopen: the last characters are present. 2. Reload mid-drag: the shape is at its last dragged position rather than an earlier intermediate one. ### Release notes - Fix edits made in the last 350 ms before closing or reloading a tab being lost from local persistence. ### Code changes | Section | LOC change | | --------- | ---------- | | Core code | +24 / -0 | | Tests | +56 / -0 | --- .../lib/utils/sync/TLLocalSyncClient.test.ts | 56 +++++++++++++++++++ .../src/lib/utils/sync/TLLocalSyncClient.ts | 24 ++++++++ 2 files changed, 80 insertions(+) diff --git a/packages/editor/src/lib/utils/sync/TLLocalSyncClient.test.ts b/packages/editor/src/lib/utils/sync/TLLocalSyncClient.test.ts index 5be9156c09fa..003fa8a7897f 100644 --- a/packages/editor/src/lib/utils/sync/TLLocalSyncClient.test.ts +++ b/packages/editor/src/lib/utils/sync/TLLocalSyncClient.test.ts @@ -172,3 +172,59 @@ test('writes that come in during a persist operation will get persisted afterwar await tick() expect(client.db.storeChanges).toHaveBeenCalledTimes(1) }) + +test('pagehide flushes pending changes without waiting for the throttle', async () => { + const { client, tick } = testClient() + await tick() + client.store.put([PageRecordType.create({ name: 'test', index: 'a0' as IndexKey })]) + await tick() + expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1) + + client.store.put([PageRecordType.create({ name: 'test2', index: 'a1' as IndexKey })]) + expect(client.db.storeChanges).not.toHaveBeenCalled() + + window.dispatchEvent(new Event('pagehide')) + expect(client.db.storeChanges).toHaveBeenCalledTimes(1) +}) + +test('hiding the tab flushes pending changes without waiting for the throttle', async () => { + const { client, tick } = testClient() + await tick() + client.store.put([PageRecordType.create({ name: 'test', index: 'a0' as IndexKey })]) + await tick() + expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1) + + client.store.put([PageRecordType.create({ name: 'test2', index: 'a1' as IndexKey })]) + expect(client.db.storeChanges).not.toHaveBeenCalled() + + const visibilityState = vi.spyOn(document, 'visibilityState', 'get') + try { + visibilityState.mockReturnValue('hidden') + document.dispatchEvent(new Event('visibilitychange')) + expect(client.db.storeChanges).toHaveBeenCalledTimes(1) + } finally { + visibilityState.mockRestore() + } +}) + +test('pagehide does not write before the initial load has completed', async () => { + const { client } = testClient() + window.dispatchEvent(new Event('pagehide')) + expect(client.db.storeSnapshot).not.toHaveBeenCalled() + expect(client.db.storeChanges).not.toHaveBeenCalled() +}) + +test('pagehide and visibilitychange listeners are removed when the client closes', async () => { + const removeWindowListener = vi.spyOn(window, 'removeEventListener') + const removeDocumentListener = vi.spyOn(document, 'removeEventListener') + try { + const { client, tick } = testClient() + await tick() + client.close() + expect(removeWindowListener).toHaveBeenCalledWith('pagehide', expect.any(Function)) + expect(removeDocumentListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) + } finally { + removeWindowListener.mockRestore() + removeDocumentListener.mockRestore() + } +}) diff --git a/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts b/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts index 7f8cb69f72d3..23cfb1dbfb5b 100644 --- a/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts +++ b/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts @@ -68,6 +68,7 @@ export class TLLocalSyncClient { private disposables = new Set<() => void>() private diffQueue: Array | typeof UPDATE_INSTANCE_STATE> = [] private didDispose = false + private didLoad = false private shouldDoFullDBWrite = true private isReloading = false readonly persistenceKey: string @@ -143,6 +144,28 @@ export class TLLocalSyncClient { ) ) + if (typeof window !== 'undefined') { + // Persists are throttled, so without this the last PERSIST_THROTTLE_MS of edits are lost + // whenever the tab is closed or reloaded. `pagehide` is the last reliable event before + // unload on desktop; on mobile the page can be discarded without it firing, so we also + // flush when the tab is hidden. + const flush = () => { + // Before the initial load completes a full write would overwrite the saved + // document with an empty store. + if (!this.didLoad) return + this.persistIfNeeded() + } + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') flush() + } + window.addEventListener('pagehide', flush) + document.addEventListener('visibilitychange', onVisibilityChange) + this.disposables.add(() => { + window.removeEventListener('pagehide', flush) + document.removeEventListener('visibilitychange', onVisibilityChange) + }) + } + this.connect(onLoad, onLoadError) this.documentTypes = new Set( @@ -249,6 +272,7 @@ export class TLLocalSyncClient { this.disposables.add(() => { this.channel.close() }) + this.didLoad = true onLoad(this) } catch (e: any) { this.debug('error loading data from store', e)