Skip to content

[lexical-extension][lexical-html][lexical-website][lexical-y] Feature: Block handles, short ids to show blocks to a model - #9531

Open
zurfyx wants to merge 3 commits into
mainfrom
zurfyx/block-handles
Open

zurfyx wants to merge 3 commits into
mainfrom
zurfyx/block-handles

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Description

BlockIdExtension gives blocks 21-character random ids, which is what a
document needs (unique without coordination, safe in JSON, HTML, paste and
collaboration) but costly in a prompt: about 14 o200k tokens per id, read and
copied back by the model for every block.

This adds handles: the shortest prefix of a block's id that no other
block's id starts with, at least 4 characters long, like a short git hash.
V1StGXR8_Z5jdHi6B-myT is shown to the model as V1St. Handles are derived
from the ids when they are read and never stored, so nothing in the document
changes and nothing has to persist between an agent's turns.

All of it is additive and @experimental:

  • @lexical/extension, next to $getBlockById:
    • $getBlockHandle(node): string | null: the node's handle, or null for
      a node that $getBlockById would not find by its id.
    • $getBlockHandles(): Map<id, handle>: every block's handle.
    • $resolveBlockHandle(handle, {read?}): BlockHandleResult, for untrusted
      input: a discriminated union instead of a throw, {status: 'found', node}, {status: 'not-found', handle}, {status: 'ambiguous', handle, candidates} (the matching blocks' handles, sorted, at most 10) or
      {status: 'invalid', handle, reason: 'too-short' | 'malformed'}. With
      read, the handles the model was shown ($getBlockHandles() at the
      read), a handle names the block it named in that read: found while
      that block exists, not-found once it is deleted.
    • MIN_BLOCK_HANDLE_LENGTH (4), and the handles in BlockIdOutput.
  • @lexical/html: BlockIdHTMLConfig.export widens from boolean to
    boolean | 'handle'. 'handle' writes data-block-handle
    (BLOCK_HANDLE_ATTRIBUTE) instead of data-block-id. Import reads only
    data-block-id, so a handle is never taken for an id.

Rules:

  • An id shorter than 4 characters, or one that another id starts with, is
    its own handle. An exact id always wins: with b1 and b12, b1 finds
    b1. A whole id is accepted wherever a handle is.
  • A prefix longer than the current handle still finds its block, as in git,
    so a handle read while another id shared more of its prefix keeps working
    after that block is deleted. A prefix shorter than 4 characters is
    invalid ('too-short') unless it is a whole id.
  • Handles are computed from the same blocks as $getBlockById: the indexed
    ids of the last committed state, all at once (sort, then compare each id
    with its neighbours, O(n log n)) and cached until a commit changes the set
    of ids. While the extension is disabled, each call scans the document, as
    $getBlockById does.
  • A handle names whichever block's id starts with it now. After remote or
    local changes, a handle from an earlier read resolves as not-found or
    ambiguous; a prefix freed by a deletion can be taken by a new block
    (about one chance in 16.7 million per new random id), and the docs show
    how to check the id read where an edit must reach exactly that block.

Nothing released changes: stored ids, the id format, $getBlockById (still
an exact match), export: true / false and HTML import behave as before.
TypeScript and Flow types are updated together.

Discovery is in the JSDoc of $getBlockById, BlockIdExtension and
BlockIdHTMLConfig.export, and in a new "Showing blocks to a model" section
of the Block IDs page: the round trip, the four statuses and what to tell
the model for each, the collaboration caveat, handles in HTML, and the token
cost.

Follow-up (second commit)

Follow-up from review of the block handles change:

  • Handles resolved against the wrong state. In the documented
    @lexical/y server pattern (editYState on the state the model read,
    then merge), resolving a handle inside the edit always found its block, so
    not-found and ambiguous never fired for changes made since the read.
    Resolving it against the latest state alone was not enough either: with
    ids b123 and b123456, deleting b123 made the handle b123 name the
    surviving b123456, and a prefix freed by a deletion could be taken by a
    block added since.

    $resolveBlockHandle(handle, {read}) takes the handles the model was shown
    (the map $getBlockHandles() returned at the read). The handle then names
    the block it named in that read: found while that block still exists in
    the state being read, not-found once it is deleted, and never ambiguous
    because of a block added since. Typos fail against the read's ids. The map
    is read as it is on each call, with no cached index. The docs, the JSDoc
    and the @lexical/y server recipe now do this: resolve in a readYState of
    the stored state with {read: shown}, then apply the edit to the read by
    id and merge. y-server.md points at it.

  • Edits replaced the block. The examples applied the model's edit with
    result.node.replace(...). That re-creates the block: with @lexical/y,
    text a collaborator typed in it meanwhile no longer stays where they typed
    it ("Revenue grew about 6%." merged as "about Revenue grew 6%."). The
    examples now change the block in place. The Block IDs page defines
    $setBlockText(block, text), which changes only the characters that
    differ in the block's own text nodes and returns false for a block whose
    text is not just its text nodes (line breaks, nested blocks).

  • Too-short handles were reported as deleted blocks. A string shorter
    than MIN_BLOCK_HANDLE_LENGTH that is not a whole id was not-found. It is
    now {status: 'invalid', reason: 'too-short'}, and every invalid result
    carries reason: 'too-short' | 'malformed', so the suggested feedback can
    say what is wrong.

TypeScript and Flow are updated together (BlockHandleOptions, the
reason field), and so is the changeset. The API has not been released yet.

Follow-up (third commit)

Two examples that review of this PR found unsafe:

  • The BlockIdHTMLConfig.export JSDoc resolved a handle from exported
    HTML without {read}, then removed the node. Here is how that goes wrong:

    1. The model is shown 9xQe1111 with the handle 9xQe.
    2. That block is deleted, and a block 9xQe2222 takes the prefix.
    3. The example deletes 9xQe2222, a block the model never saw.

    The example now captures $getBlockHandles() in the same read as
    $generateHtmlFromNodes, resolves the answer with
    $resolveBlockHandle(handle, {read: shown}), and edits the block in place
    with $setBlockText instead of removing it. The "Handles in HTML" section
    of the Block IDs page shows the same capture.

  • $setBlockText on the Block IDs page used text equality as its
    structure check: the block's concatenated text-node text equal to its
    getTextContent(). That does not prove the block holds only text. A list
    with one item and a paragraph with an inline decorator that has no text
    both passed, so they were rewritten instead of refused. The helper now
    checks the block's shape before changing anything: every child must be a
    text node, or an inline element (such as a link) that holds only text, at
    any depth. Line breaks, decorators (even ones without text), nested
    blocks and occupied named slots (at any level) are refused.

The test copy of the helper moves to
packages/lexical-html/src/__tests__/utils/setBlockText.ts, shared by the
docs tests and the HTML tests. A test now checks that it, the docs page's
copy and the @lexical/y test's copy are identical.

Changeset

.changeset/block-handles.md: [feature] for @lexical/extension and
@lexical/html.

Test plan

New tests: BlockIdExtension.handles.test.ts (the shortest-prefix rule and
the 4-character minimum, ids shorter than 4, ids that prefix others, counter
and b + base36 ids, exact ids, invalid and non-string input, deleted
blocks, a new block sharing the prefix making an old handle ambiguous with
the right candidates and longer next handles, blocks named in the same
update, earlier states, duplicates awaiting repair, the disabled extension,
headless and JSON-only use), BlockIdExtension.handles.property.test.ts
(random documents with random and adversarial ids under random edits: every
handle equals the brute-force one and resolves to its own block, and no
one-character typo resolves to another block unless it is that block's id
or starts with its current handle), BlockIdExtension.handlesCache.test.ts
(one handle build per set of ids), new export: 'handle' tests in
BlockIdHTMLExtension.test.ts (export, true/false unchanged, import
ignores data-block-handle, round trip of export, edit by handle and
resolve), the docs examples in BlockIdDocs.test.ts, and an opt-in perf
test.

Before

On main, with the new tests:

$ npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.test.ts packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.property.test.ts packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handlesCache.test.ts packages/lexical-html/src/__tests__/unit/BlockIdHTMLExtension.test.ts packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts
Error: Cannot find module '../../internal/blockHandles' imported from .../BlockIdExtension.handles.test.ts
TypeError: (0 , $getBlockHandles) is not a function
TypeError: (0 , $getBlockHandle) is not a function
 FAIL  BlockIdHTMLExtension.test.ts > BlockIdHTMLExtension: export: 'handle' > exports data-block-handle instead of data-block-id
AssertionError: expected '<h2 data-block-id="V1StGXR8_Z5jdHi6B-…' to be '<h2 data-block-handle="V1StG"><span s…' // Object.is equality
 Test Files  5 failed (5)
      Tests  17 failed | 18 passed (35)

(The export: true/false and import tests pass on main too: that
behaviour is unchanged.)

After

$ npx vitest run --project unit (same files)
 ✓ BlockIdHTMLExtension.test.ts (14 tests)
 ✓ BlockIdExtension.handlesCache.test.ts (1 test)
 ✓ BlockIdExtension.handles.test.ts (29 tests)
 ✓ BlockIdDocs.test.ts (8 tests)
 ✓ BlockIdExtension.handles.property.test.ts (13 tests)
 Test Files  5 passed (5)
      Tests  65 passed (65)

$ BLOCK_HANDLE_PROPERTY_SEEDS=300 BLOCK_HANDLE_TYPO_DOCS=30 npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.property.test.ts
block handle typo rate: 440 of 17158649 one-character typos (30 × 1000 blocks) named another block: 2.56e-5 (432 of them exactly that block's handle)
 Test Files  1 passed (1)
      Tests  301 passed (301)

$ npx vitest run --project unit packages/lexical-extension packages/lexical-html packages/lexical/
 Test Files  145 passed | 2 skipped (147)
      Tests  3016 passed | 2 skipped (3018)

$ npx vitest run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts
      Tests  374 passed (374)

$ pnpm run ci-check
Changeset check passed: 23 pending changeset(s) valid.
Found 0 errors

$ pnpm -C packages/lexical-website run build
[SUCCESS] Generated static files in "build".

Performance, 10,000 blocks of random ids (Apple silicon, Node 24, medians of
15):

$ BLOCK_ID_PERF=1 npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.perf.test.ts
  load (setEditorState) without BlockIdExtension: 13.10 ms
  load with it (builds the $getBlockById index):  30.97 ms
  index share of the load: 17.87 ms
  first $getBlockHandle after a commit (builds all handles): 4.80 ms
  $getBlockHandle for every block, cached:   7.68 ms
  $getBlockHandles(), cached:                6.35 ms
  $resolveBlockHandle for every handle:      11.92 ms
  $getBlockById for every id:                4.89 ms
  handle lengths: {"4":10013,"5":2}

Token cost, o200k (gpt-tokenizer, cross-checked with js-tiktoken, which
agreed on all 20,000 ids), 20 documents of 1,000 random ids:

Bare In data-block-…="…" In a JSON string
21-character id 14.42 15.41 15.44
Handle 2.92 3.91 3.94

Follow-up (second commit)

Before

The new tests on the previous commit of this PR:

$ npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.test.ts packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.property.test.ts packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts
 FAIL  BlockHandles.test.ts > block handles with @lexical/y > resolving against the stored state, applying to the read in place
- Expected
+ Received
  [
    null,
    "not-found",
-   "not-found",
    null,
+   "ambiguous",
  ]
 FAIL  BlockIdExtension.handles.test.ts > $resolveBlockHandle with the handles of a read > a block deleted since the read is not found, even if another id now starts with the handle
AssertionError: expected { id: 'b123456', status: 'found' } to deeply equal { handle: 'b123', status: 'not-found' }
 FAIL  BlockIdExtension.handles.test.ts > $resolveBlockHandle > a typo is not found; a prefix shorter than 4 characters is too short
AssertionError: expected { handle: '9xQ', status: 'not-found' } to deeply equal { handle: '9xQ', …(2) }
 FAIL  BlockIdExtension.handles.property.test.ts > block handles: property > seed 11
AssertionError: seed 11 step 10 ml2iFhmo: expected 'found' to be 'not-found'
 Test Files  4 failed (4)
      Tests  20 failed | 38 passed (58)

(In the @lexical/y case the third edit, meant for the deleted b123, was
applied to b123456.)

After

$ npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles*.ts packages/lexical-html/src/__tests__/unit/BlockId*.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts
      Tests  73 passed | 1 skipped (74)

$ BLOCK_HANDLE_PROPERTY_SEEDS=200 npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.property.test.ts
      Tests  201 passed (201)

$ npx vitest run --project unit packages/lexical-extension packages/lexical-html packages/lexical/ packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts
 Test Files  146 passed | 2 skipped (148)
      Tests  3024 passed | 2 skipped (3026)

$ npx vitest run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts
      Tests  375 passed (375)

$ pnpm run ci-check
Found 0 errors

Follow-up (third commit)

  • BlockIdHTMLExtension.test.ts:
    • The round trip follows the JSDoc: export and handles in one read, a
      pinned resolve, an in-place edit that keeps the same node.
    • New regression for a prefix freed by a deletion. The unpinned resolve
      finds 9xQe2222, the pinned resolve is not-found, and 9xQe2222 is
      left as it was.
  • BlockIdDocs.test.ts:
    • A one-item list, a paragraph with an empty inline decorator, a block
      whose slot holds a line break, and an inline link that hosts a slot are
      all refused, and the editor state is unchanged.
    • Text inside a link is edited in place.
    • The three copies of the helper are compared.

Before

The new refusal test against the previous helper:

$ npx vitest run --project unit packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts -t refuses
 FAIL  BlockIdDocs.test.ts > Block IDs documentation examples > Showing blocks to a model: $setBlockText refuses blocks that are not just text
AssertionError: expected true to be false // Object.is equality
 ❯ editor.update.discrete packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts:512:53
      Tests  1 failed | 11 skipped (12)

(Line 512 is expect($setBlockText(oneItem, 'rewritten')).toBe(false).) The
previous JSDoc's unpinned resolve finds 9xQe2222. The new HTML regression
asserts this before it shows that the pinned resolve is not-found.

After

$ npx vitest run --project unit packages/lexical-html/src/__tests__/unit/BlockId*.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles*.ts
      Tests  77 passed | 1 skipped (78)

$ npx vitest run --project unit packages/lexical-extension packages/lexical-html packages/lexical/ packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts
 Test Files  146 passed | 2 skipped (148)
      Tests  3028 passed | 2 skipped (3030)

$ npx vitest run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts
      Tests  375 passed (375)

$ pnpm run ci-check
Found 0 errors

$ pnpm -C packages/lexical-website run build
[SUCCESS] Generated static files in "build".

@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
lexical Ready Ready Preview Oct 30, 2026 7:33am UTC
lexical-playground Ready Ready Preview Oct 30, 2026 7:33am UTC

Request Review

@zurfyx
zurfyx requested a review from etrepum as a code owner October 29, 2026 19:30
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 29, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9531

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9531

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9531

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9531

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9531

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9531

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9531

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9531

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9531

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9531

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9531

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9531

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9531

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9531

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9531

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9531

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9531

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9531

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9531

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9531

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9531

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9531

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9531

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9531

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9531

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9531

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9531

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9531

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9531

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9531

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9531

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9531

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9531

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9531

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9531

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9531

commit: cc03df0

@etrepum

etrepum commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

It's probably worth noting in generateId that short handles depend on a high entropy prefix, so using an algorithm like uuidv7 with a msec timestamp+version prefix would not be a good id generator (in the standard layout).

}
}

describe('block handles: property', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably want to add an environment variable gate here too, if for no other reason than it spews stuff to the console every unit test run. It's probably also somewhat expensive though.

We do also have fast-check available so that could be used for property tests instead of DIY every time

Comment on lines +167 to +168
const mode =
$getExtensionOutput(BlockIdHTMLExtension).export.peek();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kind of thing could be done with disabledForSession instead of installing a no-op middleware that runs for every element even when it's not enabled

@zurfyx
zurfyx force-pushed the zurfyx/block-handles branch from d82d91c to 8850d18 Compare October 29, 2026 23:36
@zurfyx zurfyx changed the title [lexical-extension][lexical-html][lexical-website] Feature: Block handles, short ids to show blocks to a model [lexical-extension][lexical-html][lexical-website][lexical-y] Feature: Block handles, short ids to show blocks to a model Oct 29, 2026
…dles, short ids to show blocks to a model

## Description

`BlockIdExtension` gives blocks 21-character random ids, which is what a
document needs (unique without coordination, safe in JSON, HTML, paste and
collaboration) but costly in a prompt: about 14 o200k tokens per id, read and
copied back by the model for every block.

This adds **handles**: the shortest prefix of a block's id that no other
block's id starts with, at least 4 characters long, like a short git hash.
`V1StGXR8_Z5jdHi6B-myT` is shown to the model as `V1St`. Handles are derived
from the ids when they are read and never stored, so nothing in the document
changes and nothing has to persist between an agent's turns.

All of it is additive and `@experimental`:

- `@lexical/extension`, next to `$getBlockById`:
  - `$getBlockHandle(node): string | null`: the node's handle, or `null` for
    a node that `$getBlockById` would not find by its id.
  - `$getBlockHandles(): Map<id, handle>`: every block's handle.
  - `$resolveBlockHandle(handle): BlockHandleResult`, for untrusted input: a
    discriminated union instead of a throw, `{status: 'found', node}`,
    `{status: 'not-found', handle}`, `{status: 'ambiguous', handle,
    candidates}` (the matching blocks' current handles, sorted, at most 10)
    or `{status: 'invalid', handle}` (not 1 to 64 base64url characters, or
    not a string).
  - `MIN_BLOCK_HANDLE_LENGTH` (4), and the handles in `BlockIdOutput`.
- `@lexical/html`: `BlockIdHTMLConfig.export` widens from `boolean` to
  `boolean | 'handle'`. `'handle'` writes `data-block-handle`
  (`BLOCK_HANDLE_ATTRIBUTE`) instead of `data-block-id`. Import reads only
  `data-block-id`, so a handle is never taken for an id.

Rules:

- An id shorter than 4 characters, or one that another id starts with, is
  its own handle. An exact id always wins: with `b1` and `b12`, `b1` finds
  `b1`. A whole id is accepted wherever a handle is.
- A prefix longer than the current handle still finds its block, as in git,
  so a handle read while another id shared more of its prefix keeps working
  after that block is deleted. A prefix shorter than 4 characters finds
  nothing unless it is a whole id.
- Handles are computed from the same blocks as `$getBlockById`: the indexed
  ids of the last committed state, all at once (sort, then compare each id
  with its neighbours, O(n log n)) and cached until a commit changes the set
  of ids. While the extension is disabled, each call scans the document, as
  `$getBlockById` does.
- A handle names whichever block's id starts with it now. After remote or
  local changes, a handle from an earlier read resolves as `not-found` or
  `ambiguous`; a prefix freed by a deletion can be taken by a new block
  (about one chance in 16.7 million per new random id), and the docs show
  how to check the id read where an edit must reach exactly that block.

Nothing released changes: stored ids, the id format, `$getBlockById` (still
an exact match), `export: true` / `false` and HTML import behave as before.
TypeScript and Flow types are updated together.

Discovery is in the JSDoc of `$getBlockById`, `BlockIdExtension` and
`BlockIdHTMLConfig.export`, and in a new "Showing blocks to a model" section
of the Block IDs page: the round trip, the four statuses and what to tell
the model for each, the collaboration caveat, handles in HTML, and the token
cost.

## Changeset

`.changeset/block-handles.md`: `[feature]` for `@lexical/extension` and
`@lexical/html`.

## Test plan

New tests: `BlockIdExtension.handles.test.ts` (the shortest-prefix rule and
the 4-character minimum, ids shorter than 4, ids that prefix others, counter
and `b` + base36 ids, exact ids, invalid and non-string input, deleted
blocks, a new block sharing the prefix making an old handle ambiguous with
the right candidates and longer next handles, blocks named in the same
update, earlier states, duplicates awaiting repair, the disabled extension,
headless and JSON-only use), `BlockIdExtension.handles.property.test.ts`
(random documents with random and adversarial ids under random edits: every
handle equals the brute-force one and resolves to its own block, and no
one-character typo resolves to another block unless it is that block's id
or starts with its current handle), `BlockIdExtension.handlesCache.test.ts`
(one handle build per set of ids), new `export: 'handle'` tests in
`BlockIdHTMLExtension.test.ts` (export, `true`/`false` unchanged, import
ignores `data-block-handle`, round trip of export, edit by handle and
resolve), the docs examples in `BlockIdDocs.test.ts`, and an opt-in perf
test.

### Before

On `main`, with the new tests:

```
$ npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.test.ts packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.property.test.ts packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handlesCache.test.ts packages/lexical-html/src/__tests__/unit/BlockIdHTMLExtension.test.ts packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts
Error: Cannot find module '../../internal/blockHandles' imported from .../BlockIdExtension.handles.test.ts
TypeError: (0 , $getBlockHandles) is not a function
TypeError: (0 , $getBlockHandle) is not a function
 FAIL  BlockIdHTMLExtension.test.ts > BlockIdHTMLExtension: export: 'handle' > exports data-block-handle instead of data-block-id
AssertionError: expected '<h2 data-block-id="V1StGXR8_Z5jdHi6B-…' to be '<h2 data-block-handle="V1StG"><span s…' // Object.is equality
 Test Files  5 failed (5)
      Tests  17 failed | 18 passed (35)
```

(The `export: true`/`false` and import tests pass on `main` too: that
behaviour is unchanged.)

### After

```
$ npx vitest run --project unit (same files)
 ✓ BlockIdHTMLExtension.test.ts (14 tests)
 ✓ BlockIdExtension.handlesCache.test.ts (1 test)
 ✓ BlockIdExtension.handles.test.ts (29 tests)
 ✓ BlockIdDocs.test.ts (8 tests)
 ✓ BlockIdExtension.handles.property.test.ts (13 tests)
 Test Files  5 passed (5)
      Tests  65 passed (65)

$ BLOCK_HANDLE_PROPERTY_SEEDS=300 BLOCK_HANDLE_TYPO_DOCS=30 npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.property.test.ts
block handle typo rate: 440 of 17158649 one-character typos (30 × 1000 blocks) named another block: 2.56e-5 (432 of them exactly that block's handle)
 Test Files  1 passed (1)
      Tests  301 passed (301)

$ npx vitest run --project unit packages/lexical-extension packages/lexical-html packages/lexical/
 Test Files  145 passed | 2 skipped (147)
      Tests  3016 passed | 2 skipped (3018)

$ npx vitest run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts
      Tests  374 passed (374)

$ pnpm run ci-check
Changeset check passed: 23 pending changeset(s) valid.
Found 0 errors

$ pnpm -C packages/lexical-website run build
[SUCCESS] Generated static files in "build".
```

Performance, 10,000 blocks of random ids (Apple silicon, Node 24, medians of
15):

```
$ BLOCK_ID_PERF=1 npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.perf.test.ts
  load (setEditorState) without BlockIdExtension: 13.10 ms
  load with it (builds the $getBlockById index):  30.97 ms
  index share of the load: 17.87 ms
  first $getBlockHandle after a commit (builds all handles): 4.80 ms
  $getBlockHandle for every block, cached:   7.68 ms
  $getBlockHandles(), cached:                6.35 ms
  $resolveBlockHandle for every handle:      11.92 ms
  $getBlockById for every id:                4.89 ms
  handle lengths: {"4":10013,"5":2}
```

Token cost, o200k (`gpt-tokenizer`, cross-checked with `js-tiktoken`, which
agreed on all 20,000 ids), 20 documents of 1,000 random ids:

| | Bare | In `data-block-…="…"` | In a JSON string |
| --- | --- | --- | --- |
| 21-character id | 14.42 | 15.41 | 15.44 |
| Handle | 2.92 | 3.91 | 3.94 |
…: Resolve block handles against the read the model was shown, and edit blocks in place

## Description

Follow-up from review of the block handles change:

- **Handles resolved against the wrong state.** In the documented
  `@lexical/y` server pattern (`editYState` on the state the model read,
  then merge), resolving a handle inside the edit always found its block, so
  `not-found` and `ambiguous` never fired for changes made since the read.
  Resolving it against the latest state alone was not enough either: with
  ids `b123` and `b123456`, deleting `b123` made the handle `b123` name the
  surviving `b123456`, and a prefix freed by a deletion could be taken by a
  block added since.

  `$resolveBlockHandle(handle, {read})` takes the handles the model was shown
  (the map `$getBlockHandles()` returned at the read). The handle then names
  the block it named in that read: `found` while that block still exists in
  the state being read, `not-found` once it is deleted, and never `ambiguous`
  because of a block added since. Typos fail against the read's ids. The map
  is read as it is on each call, with no cached index. The docs, the JSDoc
  and the `@lexical/y` server recipe now do this: resolve in a `readYState` of
  the stored state with `{read: shown}`, then apply the edit to the read by
  id and merge. `y-server.md` points at it.
- **Edits replaced the block.** The examples applied the model's edit with
  `result.node.replace(...)`. That re-creates the block: with `@lexical/y`,
  text a collaborator typed in it meanwhile no longer stays where they typed
  it ("Revenue grew about 6%." merged as "about Revenue grew 6%."). The
  examples now change the block in place. The Block IDs page defines
  `$setBlockText(block, text)`, which changes only the characters that
  differ in the block's own text nodes and returns `false` for a block whose
  text is not just its text nodes (line breaks, nested blocks).
- **Too-short handles were reported as deleted blocks.** A string shorter
  than `MIN_BLOCK_HANDLE_LENGTH` that is not a whole id was `not-found`. It is
  now `{status: 'invalid', reason: 'too-short'}`, and every `invalid` result
  carries `reason: 'too-short' | 'malformed'`, so the suggested feedback can
  say what is wrong.

TypeScript and Flow are updated together (`BlockHandleOptions`, the
`reason` field), and so is the changeset. The API has not been released yet.

## Changeset

`.changeset/block-handles.md` is updated: `$resolveBlockHandle(handle,
{read})`, and the `invalid` reasons.

## Test plan

New and updated tests:

- `BlockIdExtension.handles.test.ts`: resolution against a read (a deleted
  block with a surviving prefix holder, a block added since, a freed prefix,
  typos, a map changed between calls), and the too-short and malformed
  reasons.
- `BlockIdExtension.handles.property.test.ts`: every step checks that the
  previous step's handles, resolved with that read, name the same blocks or
  are not found.
- `BlockHandles.test.ts` (`@lexical/y`): the documented server recipe end to
  end. Concurrent typing is kept in place, a deleted block is `not-found`
  (also with a surviving prefix holder), a block added since is not taken,
  resolving inside the edit of the read always finds, and `replace()`
  displaces the typing.
- `BlockIdDocs.test.ts`: the new docs examples, and `$setBlockText` cases
  (formatted runs, node boundaries, refusing line breaks and lists).

### Before

The new tests on the previous commit of this PR:

```
$ npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.test.ts packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.property.test.ts packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts
 FAIL  BlockHandles.test.ts > block handles with @lexical/y > resolving against the stored state, applying to the read in place
- Expected
+ Received
  [
    null,
    "not-found",
-   "not-found",
    null,
+   "ambiguous",
  ]
 FAIL  BlockIdExtension.handles.test.ts > $resolveBlockHandle with the handles of a read > a block deleted since the read is not found, even if another id now starts with the handle
AssertionError: expected { id: 'b123456', status: 'found' } to deeply equal { handle: 'b123', status: 'not-found' }
 FAIL  BlockIdExtension.handles.test.ts > $resolveBlockHandle > a typo is not found; a prefix shorter than 4 characters is too short
AssertionError: expected { handle: '9xQ', status: 'not-found' } to deeply equal { handle: '9xQ', …(2) }
 FAIL  BlockIdExtension.handles.property.test.ts > block handles: property > seed 11
AssertionError: seed 11 step 10 ml2iFhmo: expected 'found' to be 'not-found'
 Test Files  4 failed (4)
      Tests  20 failed | 38 passed (58)
```

(In the `@lexical/y` case the third edit, meant for the deleted `b123`, was
applied to `b123456`.)

### After

```
$ npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles*.ts packages/lexical-html/src/__tests__/unit/BlockId*.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts
      Tests  73 passed | 1 skipped (74)

$ BLOCK_HANDLE_PROPERTY_SEEDS=200 npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles.property.test.ts
      Tests  201 passed (201)

$ npx vitest run --project unit packages/lexical-extension packages/lexical-html packages/lexical/ packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts
 Test Files  146 passed | 2 skipped (148)
      Tests  3024 passed | 2 skipped (3026)

$ npx vitest run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts
      Tests  375 passed (375)

$ pnpm run ci-check
Found 0 errors
```
…the HTML handle example to the export's handles, and check a block's shape before $setBlockText

## Description

Two examples that review of this PR found unsafe:

- **The `BlockIdHTMLConfig.export` JSDoc** resolved a handle from exported
  HTML without `{read}`, then removed the node. Here is how that goes wrong:
  1. The model is shown `9xQe1111` with the handle `9xQe`.
  2. That block is deleted, and a block `9xQe2222` takes the prefix.
  3. The example deletes `9xQe2222`, a block the model never saw.

  The example now captures `$getBlockHandles()` in the same read as
  `$generateHtmlFromNodes`, resolves the answer with
  `$resolveBlockHandle(handle, {read: shown})`, and edits the block in place
  with `$setBlockText` instead of removing it. The "Handles in HTML" section
  of the Block IDs page shows the same capture.
- **`$setBlockText` on the Block IDs page** used text equality as its
  structure check: the block's concatenated text-node text equal to its
  `getTextContent()`. That does not prove the block holds only text. A list
  with one item and a paragraph with an inline decorator that has no text
  both passed, so they were rewritten instead of refused. The helper now
  checks the block's shape before changing anything: every child must be a
  text node, or an inline element (such as a link) that holds only text, at
  any depth. Line breaks, decorators (even ones without text), nested
  blocks and occupied named slots (at any level) are refused.

The test copy of the helper moves to
`packages/lexical-html/src/__tests__/utils/setBlockText.ts`, shared by the
docs tests and the HTML tests. A test now checks that it, the docs page's
copy and the `@lexical/y` test's copy are identical.

## Test plan

- `BlockIdHTMLExtension.test.ts`:
  - The round trip follows the JSDoc: export and handles in one read, a
    pinned resolve, an in-place edit that keeps the same node.
  - New regression for a prefix freed by a deletion. The unpinned resolve
    finds `9xQe2222`, the pinned resolve is `not-found`, and `9xQe2222` is
    left as it was.
- `BlockIdDocs.test.ts`:
  - A one-item list, a paragraph with an empty inline decorator, a block
    whose slot holds a line break, and an inline link that hosts a slot are
    all refused, and the editor state is unchanged.
  - Text inside a link is edited in place.
  - The three copies of the helper are compared.

### Before

The new refusal test against the previous helper:

```
$ npx vitest run --project unit packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts -t refuses
 FAIL  BlockIdDocs.test.ts > Block IDs documentation examples > Showing blocks to a model: $setBlockText refuses blocks that are not just text
AssertionError: expected true to be false // Object.is equality
 ❯ editor.update.discrete packages/lexical-html/src/__tests__/unit/BlockIdDocs.test.ts:512:53
      Tests  1 failed | 11 skipped (12)
```

(Line 512 is `expect($setBlockText(oneItem, 'rewritten')).toBe(false)`.) The
previous JSDoc's unpinned resolve finds `9xQe2222`. The new HTML regression
asserts this before it shows that the pinned resolve is `not-found`.

### After

```
$ npx vitest run --project unit packages/lexical-html/src/__tests__/unit/BlockId*.ts packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts packages/lexical-extension/src/__tests__/unit/BlockIdExtension.handles*.ts
      Tests  77 passed | 1 skipped (78)

$ npx vitest run --project unit packages/lexical-extension packages/lexical-html packages/lexical/ packages/lexical-y/src/__tests__/unit/BlockHandles.test.ts
 Test Files  146 passed | 2 skipped (148)
      Tests  3028 passed | 2 skipped (3030)

$ npx vitest run --project scripts-unit scripts/__tests__/unit/treeShakingSource.test.ts
      Tests  375 passed (375)

$ pnpm run ci-check
Found 0 errors

$ pnpm -C packages/lexical-website run build
[SUCCESS] Generated static files in "build".
```

This branch was successfully deployed

2 active deployments
Preview – lexical — cc03df09 Deployed Oct 30, 2026 by vercel[bot]
Preview – lexical-playground — cc03df09 Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants