Skip to content

chore(deps): bump oxlint to 1.66 to fix worktree pre-commit hook#8945

Merged
frolic merged 2 commits into
mainfrom
frolic/oxlint-disable-nested-config
May 29, 2026
Merged

chore(deps): bump oxlint to 1.66 to fix worktree pre-commit hook#8945
frolic merged 2 commits into
mainfrom
frolic/oxlint-disable-nested-config

Conversation

@frolic

@frolic frolic commented May 27, 2026

Copy link
Copy Markdown
Contributor

In order to let pre-commit hooks run cleanly from inside git worktrees, this PR bumps oxlint from 1.58.0 to 1.66.0.

The bug

When a worktree lives under the main repo (for example .claude/worktrees/<name>/), oxlint 1.58 walks up from the file being linted, finds the worktree's .oxlintrc.json, and then keeps walking and also finds the main repo's .oxlintrc.json. It treats one of the two identical configs as a "nested" config and rejects options.typeAware:

The `options.typeAware` option is only supported in the root config,
but it was found in /Users/.../.oxlintrc.json

The net effect: the lint-staged step in .husky/pre-commit blows up for every commit made from inside a worktree, regardless of what the diff is, which forces contributors (and Claude Code agents) to use git commit --no-verify.

The fix

Upstream fixed this in oxlint 1.64 — oxc-project/oxc#22272 ("load root config by searching up parent directories"). Bumping to 1.66 (latest available in our registry mirror) picks up the fix at the source. No config or script changes needed.

The earlier commit on this branch added a --disable-nested-config workaround flag to lint-staged and internal/scripts/lint.ts; the follow-up commit reverts it now that the bump makes it unnecessary.

Change type

  • other

Test plan

  1. git worktree add .claude/worktrees/test-worktree
  2. cd .claude/worktrees/test-worktree && yarn install
  3. yarn oxlint packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts — exits 0, no options.typeAware parse error.
  4. Make any trivial change inside the worktree, git add it, and git commit (without --no-verify) — pre-commit hook completes cleanly.

Code changes

Section LOC change
Config/tooling +85 / -85

(package.json +1 / -1, yarn.lock +84 / -84.)

When a git worktree lives under the main repo (e.g. .claude/worktrees/<name>),
oxlint walks up from the staged file, finds the worktree's .oxlintrc.json,
and then keeps walking and finds the main repo's .oxlintrc.json as well.
It treats one as a "nested" config and rejects options.typeAware with:

  The `options.typeAware` option is only supported in the root config

This blocks the pre-commit hook for every commit made from a worktree, forcing
contributors (and Claude Code agents) to use `git commit --no-verify`.

This repo only has a single root .oxlintrc.json, so the cleanest fix is to
pass --disable-nested-config wherever oxlint is invoked — in the lint-staged
config and in the lint script.

Upstream: oxc-project/oxc#21426 and #19932 track the underlying behaviour.
@vercel

vercel Bot commented May 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
examples Ready Ready Preview May 27, 2026 2:17pm
6 Skipped Deployments
Project Deployment Actions Updated (UTC)
analytics Ignored Ignored Preview May 27, 2026 2:17pm
chat-template Ignored Ignored Preview May 27, 2026 2:17pm
tldraw-docs Ignored Ignored Preview May 27, 2026 2:17pm
tldraw-new Ignored Ignored Preview May 27, 2026 2:17pm
tldraw-shader Ignored Ignored Preview May 27, 2026 2:17pm
workflow-template Ignored Ignored Preview May 27, 2026 2:17pm

Request Review

@huppy-bot huppy-bot Bot added the other ⚙️ Changes that don't affect SDK users, e.g. internal or .com changes label May 27, 2026
@cursor

cursor Bot commented May 27, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Tooling-only flag change with no runtime or security impact; intended to be a no-op outside nested worktree paths.

Overview
Adds --disable-nested-config to every oxlint invocation so lint uses only the first config file it finds instead of walking up the directory tree.

package.json lint-staged and internal/scripts/lint.ts (used by yarn lint) are updated. This fixes pre-commit and yarn lint failures in git worktrees nested under the main repo, where oxlint was loading duplicate .oxlintrc.json files and erroring on options.typeAware. With a single root oxlint config, behavior in a normal checkout should stay the same.

Reviewed by Cursor Bugbot for commit 0755de3. Bugbot is set up for automated code reviews on this repo. Configure here.

@frolic
frolic marked this pull request as draft May 27, 2026 11:17
The worktree config bug was fixed upstream in oxlint 1.64 (oxc-project/oxc#22272
"load root config by searching up parent directories"). Bumping from 1.58 to 1.66
resolves it at the source, so the `--disable-nested-config` workaround added in
the previous commit is no longer needed and is reverted here.

Verified by re-running `oxlint <file>` from inside `.claude/worktrees/<name>/`
with the plain `oxlint` command (no flag): exits 0, no parse error. Full repo
`oxlint .` also passes on 1.66.
@frolic frolic changed the title fix(repo): pass --disable-nested-config to oxlint chore(deps): bump oxlint to 1.66 to fix worktree pre-commit hook May 27, 2026
@frolic
frolic marked this pull request as ready for review May 27, 2026 14:20
@frolic
frolic requested a review from max-drake May 27, 2026 14:27
@frolic
frolic added this pull request to the merge queue May 29, 2026
Merged via the queue into main with commit 162927a May 29, 2026
21 checks passed
@frolic
frolic deleted the frolic/oxlint-disable-nested-config branch May 29, 2026 10:52
kaznaan added a commit to lumalabs/tldraw that referenced this pull request Jul 6, 2026
* fix(docs): canonical URLs for SEO — metadata, sitemap, and footer links (#8952)

Emit rel=canonical on every article via alias→canonical mapping
(quick-start, installation, releases, examples, starter-kits). Filter
sitemap to canonical paths only. Point footer, category menu, and 404
nav at canonical hrefs instead of URLs that 301.

### Change type

- [ ] `bugfix`
- [x] `improvement`
- [ ] `feature`
- [ ] `api`
- [ ] `other`

* fix(docs): fixed the quick-start and some more redirections (#8959)

Seems I went a bit too quickly with the previous work on docs and
canonical urls and actually removed the thing that makes the quick-start
page actually work

### Change type

- [x] `bugfix`
- [ ] `improvement`
- [ ] `feature`
- [ ] `api`
- [ ] `other`

* fix(infra): restore workspace:* rewrite in published tarballs (#8954)

## Summary

The latest few `tldraw@next` versions on npm ship with `"workspace:*"`
literals in their `package.json`, which breaks installs for any consumer
outside this monorepo:

```
$ yarn up tldraw@next
Error: @tldraw/driver@workspace:*: Workspace not found (@tldraw/driver@workspace:*)
```

Inspecting the broken tarball confirms the source `package.json`
(tab-indented, `workspace:*` deps) is shipped untouched:

```
$ npm pack [email protected]
$ tar -xzOf tldraw-5.1.0-next.d83788c14b6f.tgz package/package.json | grep '@tldraw/'
        "@tldraw/driver": "workspace:*",
        "@tldraw/editor": "workspace:*",
        "@tldraw/store":  "workspace:*",
```

Comparing to a prior good publish (`5.1.0-next.d7c83ba698ae`) shows the
expected output — 2-space-indented `package.json` with rewritten npm
versions. The registry metadata also tells the story:

| | Good (`d7c83ba698ae`) | Bad (`d83788c14b6f`) |
|---|---|---|
| `_npmUser` | `tldraw-personal <[email protected]>` | `GitHub Actions
<[email protected]>` |
| `_npmVersion` | (yarn) | `11.8.0` (npm CLI) |
| provenance | none | OIDC attestation |

## Root cause

PR #8913 swapped the publish call in
`internal/scripts/lib/publishing.ts` from `yarn npm publish` to `npm
publish`. The PR motivation — switching to npm's OIDC trusted-publisher
flow — was correct, but the swap had a silent side effect: `yarn npm
publish` rewrites `workspace:*` dependency specifiers into concrete
sibling versions during pack, and `npm publish` doesn't (the workspace
protocol is a yarn-specific extension that npm has no concept of).

The swap turns out to have been unnecessary. **Yarn 4.10**
([yarnpkg/berry#6898](https://github.com/yarnpkg/berry/pull/6898), Oct
2025) added native OIDC trusted-publishing support. When `yarn npm
publish` runs in GitHub Actions with `permissions: id-token: write`
granted, yarn requests the GitHub-issued OIDC token, exchanges it with
the npm registry, and publishes — exactly like the npm CLI does.
Provenance attestations are produced automatically. This repo is already
on yarn 4.12 via the `packageManager` field, so OIDC was supported the
whole time #8913 was being written.

## Change

This PR reverts only the publish-call change from #8913 and updates the
surrounding comment to reflect the yarn-driven OIDC path. The
workflow-side changes from #8913 (`permissions: id-token: write` on each
publish job, removal of `NPM_TOKEN` env, trusted-publisher configuration
on npmjs.com) are unchanged and remain required.

`--tolerate-republish` is restored alongside the yarn invocation since
it's a yarn-specific flag; the substring-match catch block (added in
#8913 to compensate for npm CLI not having `--tolerate-republish`) is
kept as a belt-and-braces defense, because the original code comment
notes `--tolerate-republish` is unreliable for canary versions.

## Test plan

- [ ] Merge to `main` and watch `publish.yml` run `canary` mode
end-to-end on the next push to `main`.
- [ ] After publish, `npm pack <new-canary-version>` and confirm
dependency specifiers in the tarball's `package.json` show concrete
versions (e.g. `"@tldraw/editor": "5.1.0-canary.<sha>"`), not
`"workspace:*"`.
- [ ] Confirm the new canary version still shows a provenance
attestation badge on npmjs.com (yarn 4.10+ produces these automatically
under OIDC).
- [ ] In a consumer repo, run `yarn up tldraw@canary` and verify it
resolves without the "Workspace not found" error.

After this lands, the `5.1.0-next.*` versions published since #8913
merged are still broken on npm; the registry doesn't allow unpublishing
after 72 hours so they'll need to be left as-is, with the next good
`next` publish (post-merge) being the recommended upgrade target.

* fix(ci): tolerate Supabase CLI branch-provisioning output in deploy preview (#8964)

In order to stop the "Deploy .com to preview" workflow from failing on
the "Create Supabase preview branch" step, this PR makes the
branch-provisioning wait loop tolerant of the Supabase CLI's output
while a branch is still being created.

The Supabase CLI
[v2.102.0](https://github.com/supabase/cli/releases/tag/v2.102.0)
[ported the `branches` commands from Go to native
TypeScript](https://github.com/supabase/cli/pull/5374). Because our
workflow installs the CLI with `version: latest`, CI picked this up
automatically. The rewrite changed what `supabase branches get -o json`
writes to **stdout** while a branch is still provisioning: the old (Go)
CLI wrote empty stdout there, but the new (TS) CLI renders a non-JSON
error/status line. The workflow piped that straight into an unguarded
`jq`, which exited with code 5 on the parse error, and since the step
runs under `set -e` the whole step aborted on the first loop iteration —
before the retry loop could wait for the branch to become ready.

This was a CLI behavior change, not a regression in our code: the script
had been unchanged since the [Supabase preview-DB
migration](https://github.com/tldraw/tldraw/pull/8066), and the same
workflow passed on older CLI versions.

The fix adds `2>/dev/null || true` to `supabase branches get` and to
both `jq` calls so that, while the branch is provisioning, non-JSON /
non-zero output is treated as "not ready yet" and the loop keeps
retrying as it was always meant to. Once the branch is ready the CLI
returns valid JSON, `jq` extracts `POSTGRES_URL`, and the loop breaks
normally. Field names (`POSTGRES_URL`, `POSTGRES_URL_NON_POOLING`) are
unchanged in the new CLI, so no other adjustments are needed, and the
CLI stays on `latest`.

### Change type

- [x] `other` (CI)

### Test plan

1. Open a PR that triggers the "Deploy .com to preview" workflow with a
brand-new preview branch (one that has to provision from scratch).
2. Confirm the "Create Supabase preview branch" step logs "Waiting for
branch to be ready (i/24)..." while provisioning and then succeeds,
instead of failing with `jq: parse error` / exit code 5.

### Code changes

| Section        | LOC change |
| -------------- | ---------- |
| Config/tooling | +8 / -3    |

* chore(deps): bump oxlint to 1.66 to fix worktree pre-commit hook (#8945)

In order to let pre-commit hooks run cleanly from inside git worktrees,
this PR bumps `oxlint` from 1.58.0 to 1.66.0.

### The bug

When a worktree lives under the main repo (for example
`.claude/worktrees/<name>/`), oxlint 1.58 walks up from the file being
linted, finds the worktree's `.oxlintrc.json`, and then keeps walking
and also finds the main repo's `.oxlintrc.json`. It treats one of the
two identical configs as a "nested" config and rejects
`options.typeAware`:

```
The `options.typeAware` option is only supported in the root config,
but it was found in /Users/.../.oxlintrc.json
```

The net effect: the lint-staged step in `.husky/pre-commit` blows up for
every commit made from inside a worktree, regardless of what the diff
is, which forces contributors (and Claude Code agents) to use `git
commit --no-verify`.

### The fix

Upstream fixed this in oxlint 1.64 —
[oxc-project/oxc#22272](https://github.com/oxc-project/oxc/pull/22272)
("load root config by searching up parent directories"). Bumping to 1.66
(latest available in our registry mirror) picks up the fix at the
source. No config or script changes needed.

The earlier commit on this branch added a `--disable-nested-config`
workaround flag to `lint-staged` and `internal/scripts/lint.ts`; the
follow-up commit reverts it now that the bump makes it unnecessary.

### Change type

- [x] `other`

### Test plan

1. `git worktree add .claude/worktrees/test-worktree`
2. `cd .claude/worktrees/test-worktree && yarn install`
3. `yarn oxlint packages/tldraw/src/lib/ui/hooks/useClipboardEvents.ts`
— exits 0, no `options.typeAware` parse error.
4. Make any trivial change inside the worktree, `git add` it, and `git
commit` (without `--no-verify`) — pre-commit hook completes cleanly.

### Code changes

| Section        | LOC change   |
| -------------- | ------------ |
| Config/tooling | +85 / -85    |

(`package.json` +1 / -1, `yarn.lock` +84 / -84.)

* fix(dotcom): always deploy Zero for dotcom previews (#8967)

Closes #8965

## What

Dotcom previews now always deploy a single-node Zero backend, so the
`@tldraw.com` "proper Zero" opt-in is valid on every preview.

Previously a plain `dotcom-preview-please` deploy set
`DEPLOY_ZERO=false` unless the `preview-flyio-zero-deploy-please` label
was present, but the client still opted any `@tldraw.com` user into
proper Zero — pointing staff at the placeholder backend
`zero-backend-not-deployed.tldraw.com`, which doesn't exist for the
preview.

This takes option 2 from the issue (deploy Zero for previews) rather
than gating the opt-in client-side.

## Changes

- **`.github/workflows/deploy-dotcom.yml`** — "Determine zero deploy
target": any PR preview now sets `DEPLOY_ZERO=flyio` (single-node).
Removed the `preview-flyio-zero-deploy-please` label check, the
commented-out auto-label block, and the diff-based `apps/dotcom/`
fallback that resolved to `false`. `production`/`main` stay on
`flyio-multinode`.
- **`internal/scripts/deploy-dotcom.ts`** — `getZeroUrl()` preview case
now always returns the deployed fly URL; removed the dead
`zero-backend-not-deployed` preview branch.

## Pruning / cleanup

No prune changes needed: each preview creates `pr-<n>-zero-cache`, which
already matches `prune-preview-deploys.ts`'s `ZERO_CACHE_APP_REGEX` and
is destroyed daily once the PR has been closed for >2 days.

## Follow-ups

- Delete the now-unused `preview-flyio-zero-deploy-please` GitHub label
(not referenced anywhere in code).
- Every dotcom preview now provisions a fly `zero-cache` app + runs Zero
migrations against its Supabase branch DB — small added per-preview
cost/latency.

## Test plan

1. Open a PR and apply `dotcom-preview-please`.
2. Sign into the preview with a `@tldraw.com` account.
3. Confirm the console logs `[Zero] Using proper Zero (@tldraw.com
email)` against the preview's `pr-<n>-zero-cache.fly.dev` backend and
that Zero-backed features work.

* feat(dotcom): link signed-in logo to tldraw.dev and track logo clicks (#8979)

In order to extend the tldraw.com → tldraw.dev logo link to signed-in
users, this PR makes the sidebar workspace logo an external link to
tldraw.dev, and routes both logo links through the shared `ExternalLink`
wrapper so they emit click analytics consistently. It follows up #8938,
which repointed the logo only on the anonymous editor panel (where the
logo sits in the top-left).

**Signed-in logo (new link).** Signed-in users never see the anonymous
panel's logo — for them the logo lives in the sidebar via
`TlaSidebarWorkspaceLink`, which until now was a non-clickable `<div>`.
It now renders `ExternalLink` to `https://tldraw.dev`, opening in a new
tab with `target="_blank"`, `rel="noopener noreferrer"`, and an
`aria-label` (the logo is image-only). It reuses the same
`utm_campaign=top-left-logo` value as #8938 so both logos roll up
together on tldraw.dev's side.

**Anonymous logo (tracking + consistency).** #8938 used a raw `<a>` for
the anon top-left logo, which had no click tracking. It now uses
`ExternalLink` too, so both surfaces emit PostHog events —
`top-left-logo-clicked` (anon) and `sidebar-logo-clicked` (signed-in) —
distinct names so the two can be segmented, while the shared UTM
campaign still attributes them together. This also moves `target`/`rel`
into the wrapper rather than hand-maintained attributes.

Relates to #8871. Follows up #8938.

### Change type

- [x] `improvement`

### Test plan

1. **Signed in** (`yarn dev-app`, then sign in): click the tldraw logo
at the top of the left sidebar.
2. **Anonymous** (logged out, shared file, or published file): click the
tldraw logo in the top-left of the editor.
3. In both cases confirm a new tab opens at
`https://tldraw.dev?utm_source=dotcom&utm_medium=organic&utm_campaign=top-left-logo`,
and the original tab stays on tldraw.com.
4. Inspect each logo's `<a>` element: `target="_blank"`, `rel="noopener
noreferrer"`, `aria-label="tldraw.dev"`.

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

### Release notes

- Link the tldraw logo in the signed-in sidebar to tldraw.dev.

### Code changes

| Section | LOC change |
| --- | --- |
| Apps | +15 / -8 |

Co-authored-by: Jessica Claire Edwards <[email protected]>

* trigger working next npm package  (#8975)

### Change type

- [ ] `bugfix`
- [ ] `improvement`
- [ ] `feature`
- [ ] `api`
- [x] `other`

* test(dotcom): fix flaky e2e sign-out (#8966)

In order to stop the dotcom `not-logged-in.spec.ts › can sign out` e2e
test from intermittently failing, this PR de-flakes the sidebar sign-out
fixture.

The fixture located the button with a loose `getByText('Sign out')` and
clicked it without waiting for the Radix user-settings dropdown to
finish opening. Playwright could therefore click mid-animation ("element
is not stable") or match a node that re-portals and detaches ("element
is detached from the DOM").

The fix targets the stable testid the menu item already renders
(`dialog.sign-out`, from the `TldrawUiMenuItem` `${sourceId}.${id}`
convention) and waits for the dropdown menu content to become visible in
`openUserSettingsMenu()` before any child-item click, so child clicks no
longer race the open animation. The change is confined to the e2e
fixture; no project-wide `reducedMotion` override is used, so
`cookie-consent.spec.ts` (which relies on animations running) is
unaffected.

### Change type

- [x] `other`

### Test plan

1. From `apps/dotcom/client`, run the sign-out test repeatedly to
confirm the flake is gone:
   `yarn e2e --grep "can sign out" --repeat-each=5`
2. Or from the repo root: `yarn e2e-dotcom`.

- [x] End to end tests

### Code changes

| Section | LOC change |
| ------- | ---------- |
| Tests   | +3 / -1    |

* fix(utils): reject pending promise on debounce.cancel() (#8683)

In order to stop callers from hanging forever when a debounced function
is cancelled mid-flight, this PR makes `debounce(...).cancel()` reject
the pending promise with `Error('Debounced function was cancelled')`.
Follow-up to a review note on #8604, where `cancel()` was updated to
release captured arguments but the pending promise was still left
unsettled.

Previously, `cancel()` cleared the pending timeout but never resolved or
rejected the in-flight promise — so any code that did `await
debouncedFn(...)` and then called `.cancel()` (or had it called
externally, e.g. via a hook cleanup) would hang forever. To avoid
breaking the common fire-and-forget pattern (`useZoomCss`,
`Editor.deepLink`, search debouncers, bookmark debouncer, VS Code change
responder, etc.), an internal no-op `.catch` is attached so callers that
discard the promise don't trigger unhandled-rejection warnings.
Consumers that `await` or chain `.then` / `.catch` on the returned
promise still observe the rejection through their own chain.

### Change type

- [x] `bugfix`

### Test plan

- [x] Unit tests

New tests cover:

- `cancel()` rejects all pending callers from the same debounce window
with the expected error.
- A fresh call after `cancel()` gets a brand-new promise (and verifies
state is reset cleanly).
- `cancel()` is a no-op when nothing is pending; double-cancel is safe.
- No `unhandledRejection` event fires when callers discard the promise
and call `cancel()` (verifies the fire-and-forget pattern is
unaffected).

### Release notes

- Fixed `debounce(...).cancel()` leaving any in-flight promise
unsettled. Calling `cancel()` while a debounced call is pending now
rejects the returned promise with `Error('Debounced function was
cancelled')`. Code that `await`s a debounced call across a cancel should
wrap it in `try/catch`.

### Code changes

| Section   | LOC change |
| --------- | ---------- |
| Core code | +21 / -3   |
| Tests     | +62 / -0   |

Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Copilot <[email protected]>

* fix(tldraw): indent list on Tab in note shape instead of creating new note (#8958)

In order to make list editing in note shapes behave like text and geo
shape labels, this PR stops `Tab` from creating a new adjacent note when
the caret is inside a bullet or ordered list. Closes #8713.

Previously the note's keydown handler always created a new note on
`Tab`, while the rich text editor's own keymap *also* indented the list
item on the original note. This produced two bugs: an unwanted new note
was created and focused, and the original note's list item was silently
indented. Now, when a bullet or ordered list is active, the handler
bails out early and lets the rich text editor indent the list item.

### Change type

- [x] `bugfix`

### Test plan

1. Create a note shape and start editing it.
2. Open the rich text toolbar and turn on a bullet list (or ordered
list).
3. Type some text, press `Enter` to start a second item, then press
`Tab`.
4. Confirm the second item is indented under the first and no new note
is created.
5. With no list active, confirm `Tab` still creates an adjacent note as
before.

### Release notes

- Fixed a bug where pressing `Tab` while editing a list inside a note
shape created a new note instead of indenting the list item.

* fix(tldraw): fix color picker crash by persisting the new styles  (#8980)

Fixing the app example page for custom colors: the example did not take
reloading into account and when a shape is created with a custom style
unfortunately it crashes the app because we don't persist the new
additions to palette and fonts

### Change type

- [x] `bugfix`
- [ ] `improvement`
- [ ] `feature`
- [ ] `api`
- [ ] `other`

### Test plan

1. go to example `/color-picker`
2. add a colour in the palette and some fonts
3. create a shape with new colour and font
4. reload 

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

### Release notes

- Fixed a bug in color-picker example that occurs when reloading the
canvas without persisting the added styles

Co-authored-by: Guillaume <[email protected]>

* refactor: enforce tldraw/no-exported-arrow-const repo-wide (#8947)

In order to actually enforce `tldraw/no-exported-arrow-const` everywhere
it was originally intended, this PR removes the three `.oxlintrc.json`
overrides that turned it off for `packages/editor`, `packages/tldraw`,
`packages/utils`, `apps/examples`, and `templates`, and converts the 50
violations those overrides were hiding.

The pattern is mechanical: `export const fn = (args) => {...}` becomes
`export function fn(args) {...}`. Async functions get `export async
function`. For React component aliases like `const X:
TLErrorFallbackComponent = (...) =>` the form becomes `export const X:
TLErrorFallbackComponent = function X(...) {...}` — a named function
expression keeps the type alias visible to api-extractor (the rule only
fires on arrows, not function expressions). No behavior changes.

### Affected directories

| Directory | Files | Violations |
| --- | ---: | ---: |
| `packages/tldraw` | 14 | 19 |
| `apps/examples` | 8 | 16 |
| `packages/editor` | 10 | 11 |
| `packages/utils` | 4 | 4 |
| **Total** | **36** | **50** |

### Background

Relates to #7572. The rule was added in #8258 along with the JS plugin,
but with these overrides in place it has never fired in the packages it
most needed to apply to. The plugin itself works correctly — see commit
history for the exploration that confirmed that.

### API changes

The shift to function declarations changes how api-extractor renders the
affected exports — `const X: (args) => Y` becomes `function X(args): Y`.
No call-site changes required; the public signatures are equivalent.

Specifically affected `@public` exports:

- `@tldraw/editor`: `DefaultSvgDefs`, `isSafeFloat`,
`stopEventPropagation`
- `@tldraw/tldraw`: `handleNativeOrMenuCopy`,
`TldrawUiContextualToolbar`, `TldrawUiToolbarToggleGroup`,
`TldrawUiToolbarToggleItem`, `truncateStringWithEllipsis`,
`useSelectedShapesAnnouncer`, `interpolateSegments`
- `@tldraw/utils`: `Image`, `noop`, `safeParseUrl`, `isAvifAnimated`

`DefaultErrorFallback` keeps its `TLErrorFallbackComponent` alias in the
api-report — typed as `export const DefaultErrorFallback:
TLErrorFallbackComponent = function DefaultErrorFallback(...) {...}` so
the alias survives.

### Change type

- [x] `improvement`

### Test plan

- `yarn oxlint .` — 0 errors.
- `yarn typecheck` — passes.
- `yarn test run` in `packages/editor`, `packages/tldraw`,
`packages/utils` — all green.
- `yarn api-check` — passes (api reports are regenerated).

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

### Code changes

| Section         | LOC change |
| --------------- | ---------- |
| Core code       | +97 / -88  |
| Automated files | +12 / -12  |
| Documentation   | +24 / -18  |
| Config/tooling  | +0 / -3    |

* chore: ban bare lodash imports (#8984)

In order to keep `import { x } from 'lodash'` from silently pulling in
the full ~72 KB lodash library, this PR adds an oxlint
`no-restricted-imports` rule for the bare `lodash` package and migrates
the existing usages to `lodash/X` deep imports.

Lodash's main package is CommonJS — its `lodash.js` is a UMD wrapper
that `module.exports = _`, with no `module` or `exports` field. Neither
Rollup (Vite) nor esbuild can tree-shake that. Bundling a one-line entry
confirmed this on the version actually installed (`[email protected]`):

| Import shape                             | Vite (Rollup) | esbuild |
| ---------------------------------------- | ------------: | ------: |
| `import { throttle } from 'lodash'`      |       72.1 KB | 73.8 KB |
| `import _ from 'lodash'`                 |       72.6 KB | 73.8 KB |
| `import throttle from 'lodash/throttle'` |        4.2 KB |  3.7 KB |
| `import isEqual from 'lodash/isEqual'`   |       15.5 KB | 16.0 KB |

Deep imports (`lodash/X`) work because each method lives in its own file
in the main package and `require`s only the helpers it actually needs.
They tree-shake naturally under both bundlers, and we only need one
`lodash` dep + one `@types/lodash` per workspace instead of one per
method.

Affected:

- `apps/vscode/extension/src/WebViewMessageHandler.ts` was importing `{
isEqual }` from `lodash`, shipping the full ~72 KB library in the
extension bundle. Now uses `lodash/isEqual` (~16 KB).
- `apps/examples/*` had four files using bare `lodash`. Migrated
`throttle`, `isEqual`, `reduce`, and `capitalize` to `lodash/X` deep
imports. `lodash.round(x)` → `Math.round(x)` in `GlobShapeUtil.tsx`
because every call site is single-argument and behaves identically
without the precision parameter.
- `apps/dotcom/client/scripts/build.ts` was using `_.chunk` — replaced
with a 3-line inline `for` loop. The build script is Node-only (no
bundle impact) and chunking has no edge cases here, so it didn't seem
worth adding `lodash` + `@types/lodash` to `apps/dotcom/client` for one
call site.
- `packages/state`'s fuzz test was using `lodash.times` — now
`lodash/times`.
- Dropped truly-unused `lodash` / `lodash.throttle` deps from
`packages/state-react` and `templates/sync-cloudflare`.

The new lint rule restricts only the bare `'lodash'` specifier, so
`lodash/X` deep imports, `lodash-es`, and the existing `lodash.<method>`
per-method packages (`packages/utils`, `apps/dotcom/sync-worker`,
`apps/dotcom/client`'s `lodash.pick`) all still pass.

### Change type

- [x] `improvement`

### Test plan

1. `npx oxlint` from the repo root → 0 errors across the codebase.
2. With a planted `import { capitalize } from 'lodash'`, the rule fires
with a message pointing to `lodash/X` deep imports, `lodash-es`, and
per-method packages.
3. `yarn typecheck` → clean.
4. `packages/state` fuzz tests → 21/21 pass.

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

### Release notes

- Shrink the VS Code extension bundle by ~56 KB by switching `isEqual`
off of the full lodash package.

### Code changes

| Section        | LOC change |
| -------------- | ---------- |
| Core code      | +0 / -2    |
| Tests          | +1 / -1    |
| Documentation  | +14 / -14  |
| Apps           | +6 / -3    |
| Templates      | +0 / -2    |
| Config/tooling | +17 / -4   |

* fix(tldraw): don't steal selection when pasting mid-interaction (#8976)

## Overview

In order to keep an in-progress interaction intact when content is
pasted mid-gesture, this PR stops `defaultHandleExternalTldrawContent`
from selecting the pasted shapes while the user is dragging a handle,
translating, resizing, or rotating.

This is an **alternative** to #8968 for the same underlying problem
(#8305). Where #8968 keeps the binding-hint overlay alive by gating the
overlay's `isActive`/`getOverlays` on the interaction state, this PR
instead avoids the root cause for the paste case: the pasted content no
longer steals selection from the shape being manipulated. Opened as a
draft so the two approaches can be compared side by side.

When the user pastes tldraw content while mid-interaction — for example
dragging an arrow's endpoint handle — the default handler used to
`select` the newly pasted shapes. That stole selection from the shape
under manipulation and interrupted the gesture (the arrow's in-progress
binding hint, for instance, disappeared until the drag ended). With this
change the selection is left untouched while mid-interaction, so the
gesture continues uninterrupted. When not mid-interaction, paste still
selects the pasted content as before.

Because we no longer reselect mid-interaction, the selection bounds
before and after paste are identical, which would make the paste "puff"
overlap check always pass. The puff is meant to signal that the
newly-selected pasted content landed on the old selection, so it's now
skipped while mid-interaction.

## Scope note

The known undo-grouping quirk tracked in #8969 (paste landing in the
same undo entry as the drag) is **intentionally left out of scope**
here, to keep this change focused and comparable to #8968.

Thanks to @SomeHats for suggesting this approach in
https://github.com/tldraw/tldraw/pull/8968#issuecomment-4577902029.

## Change type

- [x] `bugfix`

## Test plan

1. Create a shape and an arrow bound to it.
2. Start dragging the arrow's free endpoint handle.
3. While still dragging, paste some tldraw content.
4. The arrow stays selected and its binding hint remains visible; the
pasted content is added without stealing selection.
5. Paste while idle — the pasted content is selected as normal.

- [x] Unit tests added in `SelectTool.test.ts` covering dragging a
handle, translating, resizing, the no-puff case, and the idle
(still-selects) case.

## Release notes

- Pasting content while dragging, translating, resizing, or rotating no
longer steals selection from the shape being manipulated.

## Code changes

| Area | Files | +/- |
| --- | --- | --- |
| Core | `defaultExternalContentHandlers.ts` | +19/-1 |
| Tests | `SelectTool.test.ts` | +134/-0 |

Relates to #8305. Alternative to #8968.

* feat(examples): add native flex layout shape example (#8715)

Partially inspired by customer request, partially to showcase new
framelike behavior, this example adds a drag-and-droppable container
that uses native css flexbox as the layout engine.


https://github.com/user-attachments/assets/508d8f8e-bb33-45f1-b9e9-87c1d18d5b63


### Change type

- [ ] `bugfix`
- [ ] `improvement`
- [x] `feature`
- [ ] `api`
- [ ] `other`

### Test plan

1. Open the flex layout example.
2. Select the frame background and use the contextual toolbar to toggle
horizontal/vertical layout.
3. Drag the outside green rectangle into the layout and verify the
hint/drop line, pointer-up reparenting, and growth.
4. Drag a child out and verify it deparents and the layout shrinks
during mouse move before pointer-up.

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

### Release notes

- Added a flex layout custom shape example.

<sub>To show artifacts inline, <a
href="https://cursor.com/dashboard/cloud-agents#team-pull-requests">enable</a>
in settings.</sub>
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-d6af83b5-a09f-4f7a-abc7-0a597d9d6e13"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-d6af83b5-a09f-4f7a-abc7-0a597d9d6e13"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>

---------

Co-authored-by: Cursor <[email protected]>

* docs(releases): update release notes (#9007)

In order to keep the next release notes aligned with what will ship in
v5.1.0, this updates `apps/docs/content/releases/next.mdx` from the
`production` branch (freeze week). All 14 existing entries are present
on production, so no stale entries needed pruning and no archival was
required (`last_version` stays at v5.0.2).

Two new editor performance entries were added to the Improvements
section — skipping spatial index updates on prop-only shape diffs
(#8799) and tiering `notVisibleShapes` per-shape subscriptions (#8804).
These are complementary changes that improve drawing performance on
pages with many shapes, so they're grouped into a single entry. The
intro paragraph now mentions canvas performance improvements.

Other production PRs since the last update were skipped per the style
guide: same-cycle page-menu polish (#8927, #8924, which fix the
in-release page-menu redesign), internal refactors and manager hardening
(#8833, #8895), examples-only additions (#8914, #8916, #8837), and the
usual infra/chore/dotcom/docs/CI changes.

### Change type

- [x] `other`

### Test plan

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

### Code changes

| Section       | LOC change |
| ------------- | ---------- |
| Documentation | +2 / -1    |

* Revert "feat(tldraw): add allowVideoAutoplay option to TldrawOptions" (#9010)

Reverts tldraw/tldraw#8943

(don't wanna ship this yet while pending discussion #8915)

* other(tldraw): revert press q to copy hovered styles (#8917) (#9011)

In order to ship the upcoming release without the in-progress "press q
to copy hovered styles" feature, this PR reverts #8917.

The original feature added a `copy-hovered-styles` action that let users
press `q` while hovering a shape to copy its styles onto the
next-created shape. We want to release without it, so this revert
removes the action, its keyboard shortcut entry, and the associated
translation key.

The only non-trivial part of the revert was a conflict in the generated
`api-report.api.md`: a later PR added the `page-menu.max-pages-reached`
translation key in the same union type. That key is preserved; only
`action.copy-hovered-styles` was removed.

### Change type

- [x] `other` (revert)

### Test plan

1. Hover a shape, press `q`, then draw a new shape.
2. Confirm the hovered shape's styles are no longer copied (the keyboard
shortcut is gone).

### Release notes

- Remove the "press q to copy hovered styles" action.

### API changes

- Removed `'action.copy-hovered-styles'` from the `TLUiTranslationKey`
union.

### Code changes

| Section         | LOC change |
| --------------- | ---------- |
| Core code       | +0 / -33   |
| Automated files | +1 / -1    |
| Config/tooling  | +0 / -1    |

* Bump versions to 5.1.0 [skip ci]

* fix(ci): check out Discord notify action when a step fails before checkout (#9013)

In order to make our failure alerts reliable, this PR ensures the
Discord notify step can always find its local action. The notify step
uses a local action (`./.github/actions/discord-fail-notify`), but
several publish workflows run steps before `Check out code` (e.g.
`Determine publish mode`, `Generate GH token`). When one of those fails,
checkout is skipped, so the local action can't be found and the failure
notification itself fails — as seen in [this
run](https://github.com/tldraw/tldraw/actions/runs/26878352925/job/79271600922):

```
Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under '.github/actions/discord-fail-notify'.
Did you forget to run actions/checkout before running your local action?
```

The fix adds a `failure()`-gated sparse checkout of just the action
immediately before each notify step, so the action is always present
regardless of where the failure occurred. It's a no-op on success.

A scan of all workflows found four with this pattern (a `failure()`
local-action notify after a step that runs before checkout):
`publish.yml`, `bump-versions.yml`, `publish-editor-extensions.yml`,
`publish-vscode-extension.yml`. Other workflows using the action run
checkout as their first step, so they're unaffected.

### Change type

- [x] `other`

### Test plan

- Cannot be meaningfully tested without deliberately failing a publish
workflow; verified by inspecting step ordering across all workflows.

### Code changes

| Section        | LOC change |
| -------------- | ---------- |
| Config/tooling | +28 / -0   |

* feat(dotcom): global Clerk CAPTCHA mount point (#8960)

In order to stop every new auth surface from having to render its own
CAPTCHA target, this PR hoists Clerk's `#clerk-captcha` element to the
always-mounted app root (`TlaRootProviders`) so any Clerk client call
anywhere in the app can attach its challenge. Closes #8951.

Previously the element only existed inside `TlaSignInDialog` (added in
#7105), so any Clerk call outside that dialog — a resend after the
dialog closes, or future flows like adding an email, MFA, passkeys
(#8774), or password reset — could fail with a CAPTCHA error in
production. The element now lives inside the themed `.tla` container for
the lifetime of the page, the dialog-scoped duplicate is removed (Clerk
only uses the first matching element), and global styling keeps an
invisible challenge hidden while centering a visible one over the app.

### Change type

- [x] `improvement`

### Test plan

1. Sign in / sign up with email and confirm the verification flow still
works, including the invisible CAPTCHA path.
2. Trigger a resend after closing the sign-in dialog and confirm the
Clerk call no longer errors for a missing CAPTCHA target.
3. Inspect the DOM and confirm a single `#clerk-captcha` is present at
the app root and that the empty element is invisible / takes no space.

### Release notes

- Mount the Clerk CAPTCHA target globally so authentication flows
outside the sign-in dialog no longer fail with a CAPTCHA error.

### Code changes

| Section | LOC change |
| ------- | ---------- |
| Apps    | +21 / -8   |

* fix(editor): restore pre-pinch selection on touch devices (#9006)

In order to stop a two-finger pinch from leaving the selection changed
on touch devices, this PR captures the pre-gesture selection on the
first `pointer_down` and rolls back any incidental change when the pinch
starts. Fixes #8397.

On touch, the first finger of a pinch fires a `pointer_down` that can
change the selection (for example by landing on a different shape)
before the second finger starts the gesture. The snapshot used to roll
this back was taken at `pinch_start`, by which point the change had
already happened, so the incidental selection persisted after the pinch.
Now the snapshot is taken on the first `pointer_down` only, and
`pinch_start` keeps that snapshot rather than overwriting it. When no
`pointer_down` precedes the pinch — Safari delivers trackpad pinches as
gesture events — `pinch_start` still captures the live selection, so the
Safari fix from #6907 / #7777 is preserved.

The rollback happens at `pinch_start` (as soon as we know it's a pinch),
not at `pinch_end`, so the pre-gesture selection is what's shown
*during* the pinch rather than the wrong shape appearing selected for
the whole gesture and snapping back on release.

The regression dates to #7777 (which made the `pinch_start` snapshot
unconditional to fix a Safari case) and was surfaced by the
gesture-handling rewrite in #8392. A deeper follow-up — unifying pinch
detection and selection onto a single pointer event stream — is tracked
separately.

### Change type

- [x] `bugfix`

### Test plan

Three layers of coverage, each of which fails on the pre-fix behavior
and passes with the fix:

- `packages/tldraw/src/test/pinch.test.ts` — unit tests via
`TestEditor`: rollback at pinch start (not just end), empty selection,
consecutive pinches, the Safari/no-pointer-down path, and the editing
guard.
- `packages/tldraw/src/test/pinch-dom.test.tsx` — integration tests that
drive the real canvas handlers (`useCanvasEvents`, `useGestureEvents`)
with native `pointerdown` and `touchstart`/`touchmove`/`touchend`
events, including the case where the second finger's `pointer_down` is
processed before `pinch_start`.
- `apps/examples/e2e/tests/test-camera.spec.ts` — a Mobile Chrome e2e
that dispatches a real two-finger touch pinch via CDP
`Input.dispatchTouchEvent`, asserting the selection is rolled back both
during and after the pinch.

Manual check on a touch device: select a shape, start a two-finger pinch
with the first finger landing on a different shape, zoom, lift both
fingers — the originally selected shape stays selected throughout.

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

### Release notes

- Fix a two-finger pinch unintentionally changing the selection on touch
devices.

### Code changes

| Section   | LOC change |
| --------- | ---------- |
| Core code | +39 / -6   |
| Tests     | +376 / -0  |

* fix(tldraw): correct fill dropdown trigger tooltip (#9023)

In order to stop the fill dropdown trigger from showing a misleading
tooltip, this PR makes the trigger describe what the dropdown opens
instead of the currently selected value. Closes #8955.

The fill style panel shows the main fills (`none`, `semi`, `solid`) as
buttons and the extra fills (`pattern`, `lined fill`, `fill`) behind a
dropdown trigger. The trigger's tooltip was always built from the
current selection, so hovering it with a main fill selected showed
something like "Fill — Solid" — a value that isn't even in that
dropdown.

Now the tooltip only names the selected value when that value is
actually one of the dropdown's items; otherwise it just shows the style
name ("Fill"). This mirrors the existing icon logic, which already falls
back to the first item when the current value isn't in the dropdown.
Standalone dropdown pickers (geo, spline, arrowhead kind) always hold
the current value, so their tooltips are unchanged.

### Change type

- [x] `bugfix`

### Test plan

1. Select a shape with a fill (or set the fill to `none`, `semi`, or
`solid`).
2. Hover the fill dropdown trigger (the last item in the fill row).
3. The tooltip reads "Fill", not the name of the currently selected
fill.
4. Open the dropdown and select an extra fill (e.g. pattern); the
trigger tooltip then reads "Fill — Pattern".

- [x] End to end tests

### Release notes

- Fix the fill style dropdown trigger showing a misleading tooltip for
the currently selected fill.

* improvement(dotcom): let owners demote themselves to admin (#9035)

Lets group owners demote themselves to admin from the group settings
dialog, but only when the group has another owner. The role dropdown now
appears on your own member row when `ownersCount > 1`; with a single
owner you still see read-only role text.

The backend mutator `setGroupMemberRole` already permits self-targeting
and blocks demoting the last owner, so this is a client-only change to
surface the dropdown in the valid case.

### Change type

- [ ] `bugfix`
- [x] `improvement`
- [ ] `feature`
- [ ] `api`
- [ ] `other`

### Test plan

1. Create a group with two owners.
2. Open group settings as one of the owners.
3. On your own row, change your role from Owner to Admin — succeeds.
4. With only one owner, confirm your own row shows read-only role text
(no dropdown).

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

### Release notes

- Group owners can now demote themselves to admin, as long as another
owner remains.

* other(dotcom): disable zero-cache startup message in dev (#9036)

In order to stop the Zero Cloud promo banner from printing on every
local zero-cache startup, this PR sets `ZERO_ENABLE_STARTUP_MESSAGE=0`
in the dev `.env`, alongside the existing `DO_NOT_TRACK` opt-out.

### Change type

- [x] `other`

### Test plan

1. Run the zero-cache dev server (`yarn dev` in
`apps/dotcom/zero-cache`).
2. Confirm the "Cloud Zero is now available" startup message no longer
prints.

* feat(editor): simplify multi-click handling to double-click (#8897)

This simplifies tldraw's multi-click handling. Previously `ClickManager`
tracked double, triple, and quadruple clicks, each emitting its own
event with `down`, `up`, and `settle` phases. In practice only
double-click and the hand tool's zoom shortcuts used the higher counts,
and the `settle` phase couldn't tell a multi-click that finished with
the pointer held apart from one that finished with it released.

This PR:

- Removes `triple_click` and `quadruple_click` entirely.
`TLCLickEventName` is now just `'double_click'`, and the `onTripleClick`
/ `onQuadrupleClick` handlers are gone from `TLEventHandlers` and
`StateNode`.
- Splits the `settle` phase into `settle-down` and `settle-up`.
`ClickManager` tracks whether the pointer is pressed when the click
timer fires and emits the matching phase.
- Fires double-click-to-edit on the `down` phase. Select tool child
states (`Idle`, `PointingShape`, `PointingCanvas`, `PointingHandle`,
`PointingSelection`, and the crop states) now act on the second
pointer-down instead of waiting for pointer-up, so editing and handle
behavior trigger immediately.
- Updates `HandTool` to keep only double-click zoom-in (on `settle-up`);
triple-click zoom-out and quadruple-click reset/zoom-to-fit are removed.

### API changes

- Breaking! `TLCLickEventName` no longer includes `'triple_click'` or
`'quadruple_click'`; only `'double_click'` remains.
- Breaking! `onTripleClick` and `onQuadrupleClick` are removed from
`TLEventHandlers` and `StateNode`.
- Breaking! `TLClickEventInfo.phase` replaces `'settle'` with
`'settle-down'` and `'settle-up'`. Consumers that checked `phase ===
'settle'` should switch to `'settle-up'` (or branch on both if they care
whether the pointer is still pressed).
- `TLClickState` drops `'pendingTriple'` and `'pendingQuadruple'`.

### Change type

- [x] `api`

### Test plan

1. With the hand tool, double-click the canvas and release — should zoom
in.
2. With the select tool, double-click a shape — should enter editing
immediately on the second press.
3. Confirm triple- and quadruple-clicks no longer zoom out or reset zoom
with the hand tool.

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

### Release notes

- Simplified multi-click handling: triple- and quadruple-click events
are removed, and the `settle` click phase is split into `settle-down`
and `settle-up` so tools can tell whether a multi-click finished with
the pointer held or released.

### Code changes

| Section          | LOC change   |
| ---------------- | ------------ |
| Core code        | +157 / -112  |
| API report       | +3 / -15     |
| Tests and infra  | +107 / -141  |
| Documentation    | +55 / -63    |

---------

Co-authored-by: Max Drake <[email protected]>

* chore(examples): disable console.createTask to declutter Performance flame chart (#8312)

In order to make the Chrome Performance flame chart more readable when
profiling the examples app, this PR disables React's
`console.createTask` instrumentation by setting it to `undefined` before
the app script loads.

React uses `console.createTask` to wrap every component render in a "Run
console task" entry. While this provides async stack traces in DevTools,
it adds significant visual noise to the Performance panel's flame chart,
making it harder to identify actual performance bottlenecks.

### Change type

- [x] `improvement`

### Test plan

1. Open the examples app in Chrome
2. Record a Performance trace
3. Verify the flame chart no longer shows "Run console task" wrappers
around component renders

### Release notes

- N/A (internal development tooling change)

### Code changes

| Section         | LOC change |
| --------------- | ---------- |
| Apps            | +6 / -0   |

* chore(deps): bump jsdom to v29 (#9019)

In order to run tests against a modern jsdom — and specifically to get a
native `PointerEvent` constructor (jsdom 25 has none, which forces
hand-rolled mocks) — this PR bumps `jsdom` from v25.0.1 to v29.1.1,
fixes the test fallout the upgrade surfaces, and then removes the test
polyfills jsdom now implements natively. It's split into two commits so
the upgrade and the polyfill cleanup review independently. Relates to
#9006, where a new test had to polyfill `PointerEvent` because of the
old jsdom.

### What changed between jsdom v25 and v29

Newly available behaviours (relied on or cleaned up here):

- **`PointerEvent` constructor** (v27) — implemented (without jsdom ever
firing pointer events itself); preserves `pointerId`, `pointerType`,
`clientX/Y`, etc.
- **`TouchEvent` constructor** (v27) — implemented; preserves `touches`.
- **`movementX`/`movementY` on `MouseEvent`** (v27).
- **`TextEncoder`/`TextDecoder`** on jsdom `Window`s (v27.4) — and
exposed on the vitest global, so the hand-rolled polyfills are now
redundant.
- Other event constructors (v27): `BeforeUnloadEvent`, `BlobEvent`,
`DeviceMotionEvent`, `DeviceOrientationEvent`, `PromiseRejectionEvent`,
`TransitionEvent`.
- New CSS selector engine (v27), CSSOM overhaul (v29),
`blob.text()/arrayBuffer()/bytes()` (v28.1), `getComputedStyle()`
specificity and `!important` fixes (v28.1).

Breaking changes handled:

- **`element.click()` now fires a `PointerEvent` instead of a
`MouseEvent`** (v27). No fallout — our suites drive clicks through the
editor, not via `element.click()` with `MouseEvent` assertions.
- **jsdom now fires real pointer events**, so React's canvas handlers
reach the pointer-capture API, which jsdom still doesn't implement.
Added no-op
`setPointerCapture`/`releasePointerCapture`/`hasPointerCapture` stubs to
the shared vitest setup.
- **CSSOM overhaul (v29)** changed SVG-export style serialization.
Updated the `getSvgString` snapshot — it now serializes the shapes' real
styles instead of `getComputedStyle` polyfill placeholders (a fidelity
improvement).
- **jsdom 28+ ships a global `WebSocket`** (its bundled undici) whose
events fail the Node `EventTarget` realm check in the jsdom test
environment. sync-core tests now point the global `WebSocket` at the
`ws` package, matching the `WebSocketServer` they connect to.
- **Minimum Node** rose to v20.19.0+ / v22.13.0+ / v24.0.0+ (v27.0.1,
v29.0.0). CI runs Node 24.13.1, so this is fine.

### Polyfills removed vs kept (commit 2)

Each shim was removed and the affected suites (editor 809, tldraw 2468,
sync-core 608) re-run to confirm they stay green.

Removed (jsdom 29 / the vitest jsdom env provides them natively):

- `PointerEvent` and `TouchEvent` constructor mocks (jsdom added these
in v27)
- the `DOMRect` polyfill (jsdom's native `DOMRect` is complete and
spec-accurate)
- the `TextEncoder`/`TextDecoder` polyfills — not just redundant but
counterproductive: the shared `setup.ts` did `import { TextEncoder }
from 'util'`, whose broken ESM interop on the CJS builtin bound to
`undefined` and *clobbered* the native global, which the per-package
`require()` calls then merely restored.
- the `requestAnimationFrame`/`cancelAnimationFrame` polyfill (guarded;
jsdom provides both)
- the `delete global.crypto; global.crypto = require('crypto')`
reassignment in `setup.ts` (jsdom's `window.crypto` already exposes
`subtle`; the `node:crypto` module does not). The `@peculiar/webcrypto`
fallback for crypto-less environments is kept.
- the guarded `CSSStyleDeclaration`
`getPropertyValue`/`setProperty`/`removeProperty` fallbacks (jsdom
implements all three)

Kept (jsdom 29 still doesn't provide these, or the shim does more than
the native API):

- the `DragEvent` mock (jsdom has no `DragEvent` constructor)
- `ResizeObserver`, `FontFace`, `document.fonts`, `window.matchMedia`,
`URL.createObjectURL`, `HTMLImageElement.prototype.decode`,
`CSS.supports`, `Path2D.prototype.roundRect`, `fake-indexeddb`
- the `@peculiar/webcrypto` crypto fallback, the sync-core `WebSocket`
override, and the tldraw `getComputedStyle` override

### Change type

- [x] `other`

### Test plan

- jsdom-based suites pass: `packages/editor` (809), `packages/tldraw`
(2468), `packages/sync-core` (608), `packages/mermaid` (53), `apps/docs`
(18), `apps/dotcom/client` (133).
- Each polyfill removal was independently verified by removing it and
re-running the affected suites; ones that broke (e.g. the native
`WebSocket`, `DragEvent`) were kept.
- `yarn typecheck` passes; no public API changes (api-extractor reports
unchanged).

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

### Code changes

| Section         | LOC change  |
| --------------- | ----------- |
| Automated files | +4 / -6     |
| Config/tooling  | +244 / -257 |

* refactor(editor): decouple FocusManager from select tool state (#9003)

In order to remove an upward dependency from the editor package on the
tldraw package's tool tree, this PR changes `FocusManager` to stop
checking the select tool's `'select.editing_shape'` state path. As
reported in #8888, the editor package referenced a tldraw-package tool
state held together only by a string, which would break silently if the
select tool were renamed, restructured, or swapped out.

Instead, `FocusManager` now uses the editor's own first-class,
tool-agnostic editing state via `editor.getEditingShapeId()`. This is
the same state that `select.editing_shape` sets on enter and clears on
exit, so the focus-ring behavior is equivalent — but it no longer
couples the editor to a specific tool, and it correctly suppresses the
focus ring whenever any tool is editing a shape.

The blur point from #8888 ("`editor.blur({ blurContainer: false })`
bypasses the manager") was already resolved separately by #8895, which
centralized the blur contract on `FocusManager`.

### Change type

- [x] `improvement`

### Test plan

1. Double-click a shape to start editing its text label.
2. Press Tab or an arrow key — the container focus ring should stay
suppressed while editing.
3. Tab into the contextual toolbar — the focus ring should still be
allowed there.

- [x] Unit tests

### Code changes

| Section   | LOC change |
| --------- | ---------- |
| Core code | +1 / -2    |
| Tests     | +4 / -4    |

Closes #8888

* feat(templates): port agent highlights to overlay util (#8764)

In order to render the agent template's context and viewport-bounds
highlights through tldraw's first-class overlay system instead of
bespoke React/SVG components mounted via \`OnTheCanvas\`, this PR
replaces the \`AgentViewportBoundsHighlights\`, \`ContextHighlights\`,
\`AreaHighlight\`, and \`PointHighlight\` components with a single
\`AgentHighlightOverlayUtil\` registered through the new
\`overlayUtils\` prop. The overlay util enumerates active agents,
derives area/point overlays from each agent's active request and
selected context items, and draws them on both the main canvas and the
minimap with the existing dashed/animated styling.

This branch is a clean replay of
[\`max/port-agent-starter-highlight-to-overlays\`](https://github.com/tldraw/tldraw/compare/main...max/port-agent-starter-highlight-to-overlays)
split into reviewable commits.

The second commit is supporting work that the overlay port surfaced:
\`FocusedFontSize\` now derives the focused font size from the text
shape util's display values (via \`getDisplayValues\`) instead of a
static multiplier table and a hard-coded 16px base, so the conversion
stays in sync with whatever the editor actually renders. The forward
conversion now also forwards the text shape's \`font\` prop so the
nearest size is chosen per-font.

### Change type

- [x] \`improvement\`

### Test plan

1. \`yarn dev-template agent\` and open the demo.
2. Send a prompt so the agent runs; confirm the dashed viewport-bounds
highlight and the agent-side context area highlights render on the
canvas while generating, including the \"Reviewing\" / \"Agent ...'s
view\" labels.
3. Pick context (shape, multiple shapes, area, point) before sending;
confirm each highlight kind renders correctly with the selection-stroke
colour.
4. Open the minimap and confirm filled rounded-rect highlights pulse
while generating and stay solid otherwise.
5. Edit a text shape, change its \`size\`/\`scale\`/\`font\` and confirm
focused font size round-trips correctly across a request.

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

### Release notes

- Agent template now renders highlights through an \`OverlayUtil\`,
including minimap support.

### Code changes

| Section   | LOC change   |
| --------- | ------------ |
| Templates | +434 / -460  |

* fix(editor): keep focal point fixed when clamping zoom to min/max (#8957)

Thinks this works better than preserving the center, wdyt @steveruizok?

In order to stop the viewport from shifting when a zoom gesture
overshoots the zoom limits, this PR makes `getConstrainedCamera`
preserve the caller's focal point (e.g. the cursor) when it clamps the
zoom, instead of snapping back to the viewport center. Closes #8942.

When you wheel/pinch zoom out past the minimum (or in past the maximum),
the gesture computes a camera that keeps the point under the cursor
fixed at the *requested* zoom. The old code then clamped the zoom and
re-derived `x`/`y` to keep the viewport *center* fixed, discarding the
cursor anchor — so the canvas visibly jumped. Now the clamped camera
keeps the same focal point fixed. When that focal point happens to be
the viewport center (e.g. the zoom-out button), the math reduces to the
previous center-preserving behaviour, so nothing else changes.
`zoomIn`/`zoomOut` are unaffected because they snap exactly onto zoom
steps and never overshoot.

### Before


https://github.com/user-attachments/assets/0cc13c03-2ea5-4642-bcf4-2c5f62e57e4d

### After



https://github.com/user-attachments/assets/b800a301-b101-4e98-8303-3e5801b7bbeb


### Change type

- [x] `bugfix`

### Test plan

1. Open the editor at any zoom level with the pointer away from the
screen center.
2. Wheel- or pinch-zoom out until the zoom clamps at the minimum.
3. The point under the cursor should stay put as the zoom hits the
limit; further zoom-out gestures should not move the canvas.
4. Repeat zooming in past the maximum.

- [x] Unit tests

### Release notes

- Fix the viewport shifting slightly when zooming past the minimum or
maximum zoom level.

### Code changes

| Section   | LOC change |
| --------- | ---------- |
| Core code | +20 / -14  |
| Tests     | +97 / -1   |

* fix(dotcom): require source read access when creating a file from another file (#9042)

* chore(skills): rework /issue into a confidence-scored Q&A flow (#9034)

In order to make `/issue` useful for quick, low-context reports without
sending downstream agents past missing intent, this PR creates issues
immediately and then matures them through a confidence-scored follow-up
flow.

The old flow front-loaded codebase research before creating the issue.
The new flow inverts that: create the issue from the user's initial
description, read back the problem in product terms, score how complete
the captured intent is, and ask the questions that would fill the gaps —
updating the issue in place as answers arrive.

### What changes

- **Create first, mature later.** The skill creates the issue right away
so the user can track it, rather than researching the codebase to death
first.
- **Standard body shape.** The body starts with the user's verbatim
description, then a short product-facing **readback** of the problem, a
numbered list of **Open questions**, and a plain-text **Confidence**
status line (e.g. `Confidence: 84%, ready to get started.`).
- **Light investigation.** Codebase searching is kept to just enough to
ground the confidence score and ask sharp questions. Anything answerable
from the code should not become an open question, and implementation
breadcrumbs (file paths, function names, fix recipes) stay out of the
body unless the user asks for them.
- **Q&A loop.** As the user answers, the issue is updated via `gh issue
edit`: answers are written beneath each question, the readback is
revised, the confidence line is recomputed, and the issue framing
(title, type, labels) can be adjusted if an answer changes what the
issue is.
- **Critical vs. deferred questions.** Questions prefixed `Critical:`
block readiness and cannot be skipped. Non-critical questions the user
chooses not to answer are marked `_Deferred by user; not blocking
implementation._` and do not block.
- **Downstream guardrails.** The `take` skill now treats unresolved
`Critical:` questions, `_Awaiting answer._` placeholders, and the `More
Info Needed` label as blockers before implementation.
- **Issue type helper.** `skills/issue/scripts/set-issue-type.sh` sets
GitHub issue types via GraphQL because `gh issue create --type` is
unreliable across versions.

### Change type

- [x] `other`

### Test plan

1. Read the `/issue` workflow and confirm follow-up answers retrigger
the skill and update the issue body in place.
2. Read the `write-issue` standards and confirm the readback, open
questions, and deferred-question conventions are documented.
3. Read the `take` workflow and confirm unresolved agent-drafted
questions block implementation.
4. Run `git diff --check`.

### Code changes

| Section        | LOC change |
| -------------- | ---------- |
| Config/tooling | +110 / -23 |

* chore(publish): include DOCS.md in published npm packages (#8503)

In order to make package documentation and release history available to
users browsing npm / unpkg package artifacts, this PR ships authored
`DOCS.md` files for packages that have them, generates a `DOCS.md`
rollup for the `tldraw` package from the docs-site content during
`prepack`, and generates a `RELEASE_NOTES.md` rollup for the `tldraw`
package from the release notes for the package version. Closes #8502.

### Change type

- [x] `improvement`

### Test plan

1. `yarn oxfmt --check --no-error-on-unmatched-pattern
internal/scripts/generate-tldraw-package-docs.ts
internal/scripts/prepack.ts internal/scripts/postpack.sh
packages/tldraw/README.md`
2. `git diff --check`
3. `yarn build-types`
4. `cd packages/tldraw && yarn pack --out "$tmp/tldraw.tgz"`, then
inspect the tarball and verify `package/DOCS.md` and
`package/RELEASE_NOTES.md` are present.
5. `cd packages/state && yarn pack --dry-run --json | rg
'DOCS|RELEASE_NOTES|package\.json'`, then verify packages with authored
docs include `DOCS.md` without adding `RELEASE_NOTES.md`.

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

### Release notes

- Include package `DOCS.md` files in published npm packages that have
them.
- Include generated `DOCS.md` and `RELEASE_NOTES.md` files in the
published `tldraw` package.

### Code changes

| Section        | LOC change |
| -------------- | ---------- |
| Config/tooling | +372 / -2  |
| Documentation  | +36 / -0   |

* docs(releases): update 5.1 release (#9052)

Moves release notes from next -> 5.1, sets up next with changes since
the release. Also updates our team members for our new members!

* feat(sync): keep full sync rate with multiple sessions in a room (#8988)

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`…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

other ⚙️ Changes that don't affect SDK users, e.g. internal or .com changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants