Repository navigation
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
lexical
@lexical/a11y
@lexical/clipboard
@lexical/code
@lexical/code-core
@lexical/code-prism
@lexical/code-shiki
@lexical/compiler
@lexical/devtools-core
@lexical/dragon
@lexical/eslint-plugin
@lexical/extension
@lexical/fast-check
@lexical/file
@lexical/hashtag
@lexical/headless
@lexical/history
@lexical/html
@lexical/internal
@lexical/link
@lexical/list
@lexical/mark
@lexical/markdown
@lexical/mdast
@lexical/offset
@lexical/overflow
@lexical/plain-text
@lexical/react
@lexical/rich-text
@lexical/selection
@lexical/table
@lexical/tailwind
@lexical/text
@lexical/utils
@lexical/y
@lexical/yjs
commit: |
|
It's probably worth noting in |
| } | ||
| } | ||
|
|
||
| describe('block handles: property', () => { |
There was a problem hiding this comment.
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
| const mode = | ||
| $getExtensionOutput(BlockIdHTMLExtension).export.peek(); |
There was a problem hiding this comment.
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
d82d91c to
8850d18
Compare
…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".
```
8850d18 to
cc03df0
Compare
Description
BlockIdExtensiongives blocks 21-character random ids, which is what adocument 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-myTis shown to the model asV1St. Handles are derivedfrom 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, ornullfora node that
$getBlockByIdwould not find by its id.$getBlockHandles(): Map<id, handle>: every block's handle.$resolveBlockHandle(handle, {read?}): BlockHandleResult, for untrustedinput: 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'}. Withread, the handles the model was shown ($getBlockHandles()at theread), a handle names the block it named in that read:
foundwhilethat block exists,
not-foundonce it is deleted.MIN_BLOCK_HANDLE_LENGTH(4), and the handles inBlockIdOutput.@lexical/html:BlockIdHTMLConfig.exportwidens frombooleantoboolean | 'handle'.'handle'writesdata-block-handle(
BLOCK_HANDLE_ATTRIBUTE) instead ofdata-block-id. Import reads onlydata-block-id, so a handle is never taken for an id.Rules:
its own handle. An exact id always wins: with
b1andb12,b1findsb1. A whole id is accepted wherever a handle is.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.$getBlockById: the indexedids 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
$getBlockByIddoes.local changes, a handle from an earlier read resolves as
not-foundorambiguous; 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(stillan exact match),
export: true/falseand HTML import behave as before.TypeScript and Flow types are updated together.
Discovery is in the JSDoc of
$getBlockById,BlockIdExtensionandBlockIdHTMLConfig.export, and in a new "Showing blocks to a model" sectionof 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/yserver pattern (editYStateon the state the model read,then merge), resolving a handle inside the edit always found its block, so
not-foundandambiguousnever fired for changes made since the read.Resolving it against the latest state alone was not enough either: with
ids
b123andb123456, deletingb123made the handleb123name thesurviving
b123456, and a prefix freed by a deletion could be taken by ablock added since.
$resolveBlockHandle(handle, {read})takes the handles the model was shown(the map
$getBlockHandles()returned at the read). The handle then namesthe block it named in that read:
foundwhile that block still exists inthe state being read,
not-foundonce it is deleted, and neverambiguousbecause 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/yserver recipe now do this: resolve in areadYStateofthe stored state with
{read: shown}, then apply the edit to the read byid and merge.
y-server.mdpoints 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 thatdiffer in the block's own text nodes and returns
falsefor a block whosetext 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_LENGTHthat is not a whole id wasnot-found. It isnow
{status: 'invalid', reason: 'too-short'}, and everyinvalidresultcarries
reason: 'too-short' | 'malformed', so the suggested feedback cansay what is wrong.
TypeScript and Flow are updated together (
BlockHandleOptions, thereasonfield), 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.exportJSDoc resolved a handle from exportedHTML without
{read}, then removed the node. Here is how that goes wrong:9xQe1111with the handle9xQe.9xQe2222takes the prefix.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 placewith
$setBlockTextinstead of removing it. The "Handles in HTML" sectionof the Block IDs page shows the same capture.
$setBlockTexton the Block IDs page used text equality as itsstructure check: the block's concatenated text-node text equal to its
getTextContent(). That does not prove the block holds only text. A listwith 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 thedocs tests and the HTML tests. A test now checks that it, the docs page's
copy and the
@lexical/ytest's copy are identical.Changeset
.changeset/block-handles.md:[feature]for@lexical/extensionand@lexical/html.Test plan
New tests:
BlockIdExtension.handles.test.ts(the shortest-prefix rule andthe 4-character minimum, ids shorter than 4, ids that prefix others, counter
and
b+ base36 ids, exact ids, invalid and non-string input, deletedblocks, 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 inBlockIdHTMLExtension.test.ts(export,true/falseunchanged, importignores
data-block-handle, round trip of export, edit by handle andresolve), the docs examples in
BlockIdDocs.test.ts, and an opt-in perftest.
Before
On
main, with the new tests:(The
export: true/falseand import tests pass onmaintoo: thatbehaviour is unchanged.)
After
Performance, 10,000 blocks of random ids (Apple silicon, Node 24, medians of
15):
Token cost, o200k (
gpt-tokenizer, cross-checked withjs-tiktoken, whichagreed on all 20,000 ids), 20 documents of 1,000 random ids:
data-block-…="…"Follow-up (second commit)
Before
The new tests on the previous commit of this PR:
(In the
@lexical/ycase the third edit, meant for the deletedb123, wasapplied to
b123456.)After
Follow-up (third commit)
BlockIdHTMLExtension.test.ts:pinned resolve, an in-place edit that keeps the same node.
finds
9xQe2222, the pinned resolve isnot-found, and9xQe2222isleft as it was.
BlockIdDocs.test.ts:whose slot holds a line break, and an inline link that hosts a slot are
all refused, and the editor state is unchanged.
Before
The new refusal test against the previous helper:
(Line 512 is
expect($setBlockText(oneItem, 'rewritten')).toBe(false).) Theprevious JSDoc's unpinned resolve finds
9xQe2222. The new HTML regressionasserts this before it shows that the pinned resolve is
not-found.After