Skip to content

[pull] main from tldraw:main#578

Merged
pull[bot] merged 4 commits into
code:mainfrom
tldraw:main
Jun 8, 2026
Merged

[pull] main from tldraw:main#578
pull[bot] merged 4 commits into
code:mainfrom
tldraw:main

Conversation

@pull

@pull pull Bot commented Jun 8, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

frolic and others added 4 commits June 8, 2026 10:40
Closes #8920.

In order to stop multi-tab editing from feeling sluggish, this PR keeps
a room at the full network sync rate whenever it has more than one
session. Today a tab drops into "solo" mode — which throttles network
sync to 1fps — whenever there are no *other users* present. But each
tab, window, or device the same user opens is its own session, so two
tabs of your own were both treated as single-player and both throttled,
leaving edits to propagate between your own tabs with up to ~1s of lag.

The fix is to count *all* other sessions in the room, not just other
users'. Every session pushes its presence once on connect (ungated by
presence mode), and the server never echoes a session its own record, so
the store already holds one `instance_presence` record per other session
— including your own other tabs and devices. An empty set means we're
genuinely the only session and can throttle to solo; anything else
(another user, or just another tab of our own) keeps us at full rate.

### Relationship to #8920 / #8949

This resolves #8920 by targeting the symptom users actually feel —
multi-tab edit lag — rather than its original framing of sharing a
single WebSocket to cut connection count. That heavier approach was
explored in #8949 (a leader/presenter/follower layer, ~850 lines of new
code) and closed in favor of this ~9-line change. Note this does **not**
reduce the WebSocket count: each tab keeps its own socket, as today. If
reducing connections per user becomes a priority later, #8949 remains
the reference for that work.

Because detection is server-mediated (via `instance_presence`) rather
than a `BroadcastChannel`, it also works across **separate devices**,
not just tabs in the same browser.

### Trade-off worth knowing

With multiple of your own sessions in full mode, each pushes live
presence. The editor renders one cursor per remote user
(`CollaboratorsManager` dedupes by `userId`, newest activity wins), so
collaborators see a single cursor that "teleports" to whichever of your
windows you last touched — rather than two cursors. This is already
today's behavior when you have multiple tabs open alongside other
collaborators; this PR just extends full mode to the no-other-users
case. Avoiding the teleport entirely is what the presenter role in #8949
buys.

One caveat: detection relies on presence being enabled (the default). A
consumer that disables presence pushes no `instance_presence` record, so
its multi-tab sessions would still throttle to solo.

### Per-session presence is preserved end-to-end

Worth flagging for future work: the collapse to one cursor per user
happens *only* in the editor's view layer — not in the protocol or the
store. The server assigns every session (each tab, window, or device)
its own `presenceId` and broadcasts a distinct `instance_presence`
record per session, and the client store keeps all of them (one per
other session, including the user's own other tabs). It's
`CollaboratorsManager.getCollaborators()` that groups those by `userId`
and keeps only the most-recently-active record per user — which is why
collaborators see one cursor rather than several.

So showing a cursor *per session* — multiple live cursors for one user,
one per tab/device — is already supported by the backend and the store.
It would only take a change to that editor-side derivation (keying by
the presence record instead of by `userId`), with no protocol or server
change. This PR doesn't do that, but it doesn't foreclose it either.

### Change type

- [x] `improvement`

### Test plan

1. Open a room in a single tab with no other users. Confirm it's in solo
mode (network sync throttled).
2. Open the same room in a second tab as the same user. Confirm both
tabs now sync at full rate — an edit in one appears in the other
promptly, not ~1s later.
3. Open the same room on a second device as the same user. Confirm the
same full-rate behavior (validates server-mediated, cross-device
detection).
4. Close the second tab/device. Confirm the remaining lone session
returns to solo throttling.
5. Confirm existing multi-user behavior is unchanged: with another user
present, the room is at full rate.

- [ ] Unit tests
- [ ] End to end tests

### Release notes

- Opening the same multiplayer room in multiple tabs, windows, or
devices now keeps changes syncing at full speed between them, instead of
throttling to once per second.

### Code changes

| Section   | LOC change |
| --------- | ---------- |
| Core code | +9 / -5    |

Of the 9 added lines, 7 are an explanatory comment; the behavioral
change is dropping the `userId` filter on the presence query and
simplifying the `presenceMode` branch.
…ed to `dev-template` script (#9021)

In order to test templates from another device on the local network,
this PR lets `yarn dev-template <name>` forward any extra arguments to
the template's vite dev server. For example, `yarn dev-template vite
--host` exposes the dev server on the LAN.

lazy applies passthrough args (`--`) to *every* matched task, so
forwarding `--host` through the combined `lazy run dev` would also hand
it to the `packages/tldraw` (chokidar) and `apps/bemo-worker` (tsx) dev
scripts, which would break. To scope the args correctly, the shared deps
keep running through lazy while vite runs directly in the template
directory with the extra args.

Because lazy installs no signal handlers of its own, the deps run in
their own process group (`set -m`) and cleanup signals the whole group
on exit, so lazy and the watchers it spawns are torn down instead of
orphaned. With no extra args, the original single `lazy run dev` fast
path is unchanged.

### Change type

- [x] `other`

### Test plan

1. Run `yarn dev-template vite` — behaves as before (template, tldraw,
and bemo-worker all start).
2. Run `yarn dev-template vite --host` — vite prints a Network URL; open
it from another device on the LAN.
3. Quit with Ctrl-C and confirm no orphaned `vite`, `chokidar`, or `tsx`
processes remain (`ps ax | grep -E 'chokidar|tsx'`).

### Code changes

| Section        | LOC change |
| -------------- | ---------- |
| Config/tooling | +23 / -1   |
In order to let the server templates build and run on current Node.js,
this PR bumps `better-sqlite3` from `^11.8.1` to `^12.10.0` in the two
server templates. The `11.10.0` native addon cannot compile against Node
26's V8 (it uses removed APIs like `Object::GetPrototype`,
`Context::GetIsolate`, and `PropertyCallbackInfo::This`) and ships no
Node 26 prebuilt binary, so `yarn install` fails with a `node-gyp` build
error. `better-sqlite3@12` ships prebuilt binaries for Node 24/25/26 and
builds cleanly.

The only breaking change across the 11 → 12 line is dropping EOL Node.js
18 (`engines` is now `20.x || 22.x || 23.x || 24.x || 25.x || 26.x`),
which is already below this repo's `node: ^20.0.0` requirement. There
are no JavaScript API changes, and the templates only use basic
`prepare`/`run`/`get`/`exec`, so no code changes are needed.

### Change type

- [x] `other` (chore — template dependency bump)

### Test plan

1. With Node 24/26 active, run `yarn dev-template
simple-server-example`.
2. Run `yarn dev-template socketio-server-example`.
3. Confirm the server starts and persists rooms via better-sqlite3
without a build error.

### Code changes

| Section        | LOC change |
| -------------- | ---------- |
| Templates      | +2 / -2    |
| Config/tooling | +6 / -6    |

### Release notes

- Bump `better-sqlite3` to v12 in the server templates so they install
and run on Node 24+ (including Node 26).
…ient (#8792)

In order to remove bookkeeping that has no downstream consumer, this PR
drops `useCallback` wrappers from event handlers in 8 components across
`apps/docs` and `apps/dotcom/client`. In every case the consumer
component is not wrapped in `React.memo`, the callback is not used as a
dependency in any other hook, and there is no other reason for the
cached reference to matter — so the `useCallback` was pure overhead.

Opening as a draft because I am unsure about whether there is something
I cannot see!

### Motivation

While reviewing `apps/docs/components/content/copy-button.tsx`, the
`useCallback` on `handleClick` looked unnecessary. The consumer
(`Button` in `apps/docs/components/common/button.tsx`) is not wrapped in
`React.memo`, the callback was never used as a hook dep, and `Button`
itself creates a fresh inline arrow function each render and forwards
_that_ to the real DOM `<button>` — so any reference stability from the
parent's `useCallback` was discarded one component down. Investigating
the same pattern across `apps/docs` and `apps/dotcom/client` surfaced 7
more files.

Related: https://react.dev/learn/you-might-not-need-an-effect

### What changed

Replaced `useCallback` wrappers with plain function declarations (and
dropped the now-unused `useCallback` imports):

| File | useCallbacks removed |
| ---- | -------------------- |
| `apps/docs/components/content/copy-button.tsx` | 1 |
| `apps/docs/components/docs/copy-markdown-button.tsx` | 1 |
| `apps/docs/components/docs/docs-feedback-widget.tsx` | 3 |
| `apps/dotcom/client/.../TlaSidebarCreateFileButton.tsx` | 1 |
| `apps/dotcom/client/.../TlaFileShareMenu/Tabs/TlaAnonCopyLinkTab.tsx`
| 1 |
| `apps/dotcom/client/.../TlaFileShareMenu/Tabs/TlaExportTab.tsx` | 6 |
| `apps/dotcom/client/.../dialogs/TlaDeleteFileDialog.tsx` | 1 |
| `apps/dotcom/client/.../TlaSidebar/components/TlaSidebarFileLink.tsx`
| 1 |

**Skipped:** `apps/docs/components/search/FullPageSearch.tsx`. Its
`handleKeyDown` is passed to a third-party Ariakit `Combobox`. I did not
want to remove memoization without verifying Ariakit's internals.

### Reasoning

For each file I verified three things:

1. The consumer component is not wrapped in `React.memo`. Confirmed for
`Button`, `TldrawUiButton` (`forwardRef` only, not `memo`),
`TlaShareMenuCopyButton`, `TlaMenuSwitch`, `TlaMenuSelect`, and
`TlaButton` (`forwardRef` only, not `memo`).
2. The callback is not referenced as a dependency in any other hook in
the same file.
3. Every handler is a local `const` inside its component body — no
cross-file consumption.

Without a memoized child or another hook depending on the function
reference, `useCallback` adds bookkeeping cost (deps comparison,
cached-function retention) without any downstream benefit. The deps
arrays exist because `eslint-plugin-react-hooks/exhaustive-deps`
requires them once `useCallback` is in use — not because the callback
relies on stale-capture semantics. Every closure here reads values that
should always be fresh, so removing `useCallback` is, if anything,
slightly safer (no risk of a missing dep producing a stale closure
later).

### Verification

- `yarn typecheck` from repo root: passes.
- `yarn lint-current` on changed files: 0 warnings, 0 errors.
- `yarn test run` in `apps/dotcom/client`: 133 tests pass.
- `yarn test run` in `apps/docs`: 10 tests pass.
- Manual smoke testing of affected UI surfaces: not done.

### Open questions for reviewers

1. Is there a team convention I'm missing where `useCallback` is
preferred prophylactically (e.g. in case a child is later memoized)? If
so, this PR contradicts that convention.
2. Are any of the affected dotcom components (`TlaButton`,
`TldrawUiButton`, etc.) intended to be wrapped in `React.memo` in the
future? If so, leaving the `useCallback`s in might be the right call.
3. Should `FullPageSearch.tsx` be cleaned up too? I left it because the
consumer is third-party.
4. The cleanup is consistent with React's official guidance ("you don't
need useCallback unless..."), but I want to confirm that matches this
codebase's preference before extending the audit.

### Proposition

If the reasoning holds, I'd like to extend a similar audit/cleanup pass
to other parts of the repo where the pattern recurs. I'd want sign-off
on the philosophy first.

### Change type

- [x] `other`

### Test plan

1. In docs, navigate to a page with a code block, click the copy button
— text should copy and the icon should briefly change to a checkmark.
2. In docs, on any docs page click the feedback widget thumbs up/down,
then submit feedback in the textarea — the success state should appear.
3. In tldraw.com, sign in and click the sidebar "Create file" button —
it should create a new file and open inline rename.
4. In tldraw.com, open the share menu on a file, switch to the Export
tab, toggle padding/background, change theme/format, click "Export
image" — should produce an image download.
5. In tldraw.com (signed out), open the share menu and click "Copy link"
— clipboard should contain a deep link.
6. In tldraw.com, right-click a file in the sidebar and choose Delete —
the confirm dialog should fire and the file should be deleted/forgotten.
7. In tldraw.com, hover the guest badge on a shared file in the sidebar
— clicking the tooltip should navigate to the file.

- [ ] Unit tests
- [ ] End to end tests

### Code changes

| Section         | LOC change |
| --------------- | ---------- |
| Documentation   | +29 / -32  |
| Apps            | +41 / -55  |
@pull pull Bot locked and limited conversation to collaborators Jun 8, 2026
@pull pull Bot added the ⤵️ pull label Jun 8, 2026
@pull
pull Bot merged commit d8336e1 into code:main Jun 8, 2026
@pull
pull Bot had a problem deploying to npm deploy June 8, 2026 15:13 Failure
@pull
pull Bot had a problem deploying to bemo-canary June 8, 2026 15:13 Failure
@pull
pull Bot had a problem deploying to bemo-canary June 8, 2026 15:13 Failure
@pull
pull Bot had a problem deploying to deploy-staging June 8, 2026 15:13 Failure
@pull
pull Bot had a problem deploying to vsce publish June 8, 2026 15:13 Failure
@pull
pull Bot had a problem deploying to deploy-production June 8, 2026 15:13 Failure
@pull
pull Bot had a problem deploying to deploy-staging June 8, 2026 15:13 Error
@pull
pull Bot had a problem deploying to npm deploy June 8, 2026 15:13 Failure
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants