perf(muya): skip a code block re-render that produces identical DOM by Jocs · Pull Request #4876 · marktext/marktext · GitHub
Skip to content

perf(muya): skip a code block re-render that produces identical DOM - #4876

Merged
Jocs merged 2 commits into
developfrom
fix/muya-ime-codeblock-rerender-4851
Sep 22, 2026
Merged

Jocs merged 2 commits into
developfrom
fix/muya-ime-codeblock-rerender-4851

Conversation

@Jocs

@Jocs Jocs commented Jul 5, 2026 •

Copy link
Copy Markdown
Member

Summary

CodeBlockContent.update() ended every render in domNode.innerHTML = code. Assigning innerHTML always destroys and recreates the child nodes, even for a byte-identical string — so a code block tore down the text node the caret was sitting in and built a new one on every single keystroke, and on every render that changed nothing at all (a language switch resolving to the same highlight, a re-render triggered elsewhere, an undo landing on the same text).

Paragraphs never paid this cost: Format.inputHandler calls update() only when checkNeedRender reports a token change, and otherwise moves the caret without touching the DOM.

Change

Compare before writing:

function replaceIfChanged(domNode: HTMLElement, html: string) {
    if (domNode.innerHTML !== html)
        domNode.innerHTML = html
}
  • Both render paths (plain and Prism) go through it.
  • The trailing-break span ([Bug] code block 中换行不能准确表现 #5114) is now part of the compared string rather than appended afterwards, so a \n-terminated block is idempotent too instead of rebuilding on every pass.
  • Search still works: it renders through update(undefined, highlights), which changes the output, so the comparison re-renders — and clears — exactly as before. There is a test for both directions.
  • inputHandler's if (needRender) … else … had two identical branches and a // TODO: throttle render standing in for exactly this comparison; both are gone.

What this is not

An earlier revision of this PR claimed to fix #4851 (Japanese IME garbling in a code block) with a debounced re-highlight. That diagnosis was wrong, and the debounce was worse than useless — it only held within a 300 ms window, so ordinary IME typing (which pauses on every candidate selection) fell straight through it.

#4851 turned out to be #3468's mechanism: a code block whose text ends in a newline used to render that newline inside its own <span class="mu-line-end">, the caret landed inside the newline's own text node, and Chromium will not sustain a composition there. #5286 moved the newline back into the main text node and it has been fixed since v0.20.0-rc.2 — verified by running a real CDP-driven composition against the 0.20.0-beta.5 source (garbles) and against current develop (clean). That issue is now closed.

So this PR carries no bug fix. It removes work that was never needed, and the node identity it preserves is a precondition for anything composing against the live DOM.

Tests

Node identity is what a needless re-render destroys, so that is what the tests assert — not rendered values, which two freshly minted nodes compare equal on.

Unit (renderIdempotence.spec.ts) — unchanged render keeps the existing nodes; changed text still replaces them; a \n-terminated block is idempotent; search highlights still render and still clear.

E2E (e2e/tests/typing/codeblock-node-identity.spec.ts) — a real Chromium composition over CDP Input.imeSetComposition. The code block's text node must survive a run of IME commits, including with 500 ms pauses between them, in a Prism-highlighted block, and under plain typing.

Both idempotence unit cases and all four e2e cases fail without the change.

muya unit        1984/1985 (the 1 failure is a worktree-only `?inline` resolution artifact)
muya e2e         typing + blocks 115/115; full chromium run matches the baseline's known local flakes
eslint / tsc     clean
madge --circular clean

🤖 Generated with Claude Code

niyo-oyin added a commit to niyo-oyin/marktext-ja that referenced this pull request Jul 29, 2026
Four fixes, all the same failure class — editor code acting on keys or
input events that belong to an open IME composition:

- content.ts keydownHandler: guard Backspace/Delete with !isComposed,
  matching the existing Enter/arrows/Tab guards. Mid-composition they ran
  backspaceHandler/deleteHandler against a stale text snapshot and
  dislocated the cursor (upstream marktext#4956; reimplements PR marktext#4957).
- codeBlockContent: apply upstream PR marktext#4876 (Jocs) verbatim — an IME
  commit no longer rebuilds domNode.innerHTML synchronously (which
  destroyed the text node the IME was anchored to, corrupting Japanese
  input from the 2nd character, marktext#4851); the syntax re-highlight runs
  behind a 300ms trailing debounce instead. State updates stay
  synchronous; structural edits still rebuild synchronously.
- langInputContent: the code-fence language input had no composition
  handling at all — every mid-composition input event rebuilt innerHTML.
  Ignore inputs while composing and commit IME text without re-rendering,
  same mechanism as marktext#4876.
- Float tools: handleContentKeydown, the BaseScrollFloat nav handler and
  the BaseFloat Escape handler now ignore keys pressed while a
  composition is open — Enter/Escape/Tab/arrows operate the IME's
  candidate list, not the float.

All fixes are unit-tested with synthetic composition events; each new
spec fails on the pre-fix code. Muya suite 1454/1454, lint/tsc/madge
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ef4yFuXFbyKNTazkwUtygY
niyo-oyin added a commit to niyo-oyin/marktext-ja that referenced this pull request Jul 29, 2026
Four fixes, all the same failure class — editor code acting on keys or
input events that belong to an open IME composition:

- content.ts keydownHandler: guard Backspace/Delete with !isComposed,
  matching the existing Enter/arrows/Tab guards. Mid-composition they ran
  backspaceHandler/deleteHandler against a stale text snapshot and
  dislocated the cursor (upstream marktext#4956; reimplements PR marktext#4957).
- codeBlockContent: apply upstream PR marktext#4876 (Jocs) verbatim — an IME
  commit no longer rebuilds domNode.innerHTML synchronously (which
  destroyed the text node the IME was anchored to, corrupting Japanese
  input from the 2nd character, marktext#4851); the syntax re-highlight runs
  behind a 300ms trailing debounce instead. State updates stay
  synchronous; structural edits still rebuild synchronously.
- langInputContent: the code-fence language input had no composition
  handling at all — every mid-composition input event rebuilt innerHTML.
  Ignore inputs while composing and commit IME text without re-rendering,
  same mechanism as marktext#4876.
- Float tools: handleContentKeydown, the BaseScrollFloat nav handler and
  the BaseFloat Escape handler now ignore keys pressed while a
  composition is open — Enter/Escape/Tab/arrows operate the IME's
  candidate list, not the float.

All fixes are unit-tested with synthetic composition events; each new
spec fails on the pre-fix code. Muya suite 1454/1454, lint/tsc/madge
clean.
@Jocs
Jocs force-pushed the fix/muya-ime-codeblock-rerender-4851 branch from f495eca to f985c67 Compare September 22, 2026 03:31
Jocs and others added 2 commits September 22, 2026 21:46
CodeBlockContent.update() ended every render in `domNode.innerHTML = code`,
which destroys and recreates the block's child nodes even when the rebuilt
string is byte-identical. Every keystroke in a code block therefore tore down
the text node the caret was sitting in and built a new one, taking the native
selection and any IME composition anchored to it along — and so did every
render that changed nothing at all.

Compare before writing. `Format.inputHandler` already has this guard by way of
`checkNeedRender`, which is why paragraphs never paid the same cost.

Both render paths go through the check, and the trailing-break span (#5114) is
part of the compared string, so a `\n`-terminated block is idempotent too
rather than rebuilding on every pass.

`inputHandler`'s `if (needRender) … else …` had two identical branches and a
`// TODO: throttle render` standing in for exactly this comparison; both go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Node identity is what a needless re-render destroys, so that is what these
tests assert — not the rendered values, which two freshly minted nodes compare
equal on.

The unit spec covers update() directly: an unchanged render keeps the existing
nodes, changed text still replaces them, a `\n`-terminated block (which carries
the trailing-break span) is idempotent too, and search highlights still render
and still clear — the comparison must not swallow them.

The e2e spec drives a real Chromium composition over CDP
Input.imeSetComposition, the recipe ime-soft-line-break.spec.ts established, so
the browser runs its own composition machinery instead of the test staging the
DOM it expects. It asserts the code block's text node survives a run of IME
commits, including with 500ms pauses between them (where a debounce-based guard
would rebuild), in a Prism-highlighted block, and under plain typing.

Both idempotence unit cases and all four e2e cases fail without the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jocs
Jocs force-pushed the fix/muya-ime-codeblock-rerender-4851 branch from f985c67 to 9f05ead Compare September 22, 2026 13:47
@Jocs Jocs changed the title fix(muya): Japanese IME input breaks in a code block (#4851) perf(muya): skip a code block re-render that produces identical DOM Sep 22, 2026
@Jocs
Jocs merged commit 876f859 into develop Sep 22, 2026
7 checks passed
@Jocs
Jocs deleted the fix/muya-ime-codeblock-rerender-4851 branch September 22, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Japanese IME input breaks when typing immediately after a code fence

1 participant