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', 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) 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