Skip to content

fix(desktop): flush pending edits before saving (dropped last keystroke)#4859

Merged
Jocs merged 3 commits into
developfrom
fix/3803-flush-before-save
Jul 5, 2026
Merged

fix(desktop): flush pending edits before saving (dropped last keystroke)#4859
Jocs merged 3 commits into
developfrom
fix/3803-flush-before-save

Conversation

@Jocs

@Jocs Jocs commented Jul 4, 2026

Copy link
Copy Markdown
Member

Problem

Fixes #3803.

Reports of "lost content despite saving" — most dramatically the tab-switch corruption already fixed in #2938 (PR #4658), but a narrower path survives that fix: the save itself can drop the last keystroke.

The engine commits edits into the renderer store's currentFile.markdown on a deferred requestAnimationFrame (json-change). FILE_SAVE / FILE_SAVE_AS (packages/desktop/src/renderer/src/store/editor.ts) read currentFile.markdown and send it to the main process without flushing that pending op first. So a keystroke typed in the same frame as Cmd+S (or Save As) is written to disk as the previous content — with no dirty indicator, matching the "a trailing character went missing" reports. The window is ~16 ms normally, but widens under main-thread congestion (large docs, spellcheck, diagram re-render).

Tab switching already handles this: UPDATE_CURRENT_FILE emits bus.emit('flush-active-editor') before reading, which the editor flushes into currentFile synchronously (the #2938 mechanism). The save paths just never did.

Fix

Emit flush-active-editor at the start of FILE_SAVE and FILE_SAVE_AS, before snapshotting currentFile.markdown. This reuses the existing, synchronous flush wiring (editor.vue flushActiveEditoreditor.flush()json-changeLISTEN_FOR_CONTENT_CHANGE), so the latest keystroke is captured in the saved file.

Tests

test/unit/specs/flush-before-save.spec.ts (Pinia store, modeled on editor-store-anchor.spec.ts): asserts FILE_SAVE and FILE_SAVE_AS each emit flush-active-editor before sending their save IPC (mt::response-file-save / -save-as). Verified RED on develop (no flush emitted) → GREEN with the fix. typecheck and eslint pass.

The end-to-end no-lost-keystroke behavior depends on the already-tested flush mechanism (#2938's flushPendingOps.spec.ts + the flush-active-editor wiring); this PR adds the missing emit on the save paths and guards it.

… isn't lost (#3803)

The engine commits edits to the store's currentFile.markdown on a deferred
requestAnimationFrame (json-change). FILE_SAVE / FILE_SAVE_AS snapshotted
currentFile.markdown and shipped it to the main process without flushing
first, so a keystroke typed in the same frame as Cmd+S (or Save As) — a
window that widens under main-thread load on large docs — was dropped from
the written file, with no dirty indicator. Tab switching already guards
against this by emitting 'flush-active-editor' (the #2938 fix), but the save
paths did not.

Emit 'flush-active-editor' at the start of FILE_SAVE and FILE_SAVE_AS; the
editor flushes the queued op into currentFile synchronously before its
markdown is read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

Build artifacts for PR #4859:

Run: https://github.com/marktext/marktext/actions/runs/28730589380

Artifact Size Link
marktext-windows-arm64 275.7 MB Download
marktext-linux 627.8 MB Download
marktext-macos-x64 293.1 MB Download
marktext-windows-x64 283.9 MB Download
marktext-macos-arm64 282.8 MB Download

…paths (#3803)

Follow-up cleanup: the flush-before-read was a copy-pasted bus.emit at two
call sites (and a third pre-existing copy in UPDATE_CURRENT_FILE). Extract a
single flushActiveEditor() action and route all of them through it. Also
apply it to MOVE_FILE_TO and RESPONSE_FOR_RENAME, which read
currentFile.markdown and persist it via the exact same pattern as FILE_SAVE
— so "move to" / renaming an untitled file no longer drops a same-frame
keystroke either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@Jocs Jocs left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed the mechanism end-to-end — the fix is correct. flush() cancels the rAF and synchronously applies the queued op → synchronous json-change (mitt) → LISTEN_FOR_CONTENT_CHANGE sets tab.markdown, and since currentFile is the same object reference as the tab, the destructure that follows reads the freshly-committed markdown. No ordering / re-entrancy / double-save issue, and source-code mode is safe for an independent reason (it commits synchronously per keystroke via cursorActivity — its commitTimer is declared but never armed, so there is no debounce).

No correctness bug. A few test-strength / cleanup notes inline.

One cross-cutting note on the spec: with no editor.vue / listener mounted, bus.emit('flush-active-editor') fires into the void, so in the test the flush is a no-op and the seeded markdown: 'hello' is sent regardless of the fix. The test therefore locks the store's emit-ordering contract but does not exercise the actual "last keystroke captured before save" behavior — that end-to-end guarantee rests entirely on #2938's flushPendingOps.spec.ts. Acceptable given the PR says as much, just flagging that the headline behavior has no direct coverage here.

const saveCall = sendSpy.mock.calls.find((c) => c[0] === 'mt::response-file-save')
expect(saveCall).toBeTruthy()
// The flush must happen before the save payload is sent.
expect(emitSpy.mock.invocationCallOrder[0]).toBeLessThan(sendSpy.mock.invocationCallOrder[0])

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This ordering assertion guards the wrong boundary (same at :86 and :103). It asserts flush emit < IPC send, but the #3803 bug is captured at the const { …, markdown } = this.currentFile read, which sits between the flush and the send. A regression that moved the flush to after the markdown snapshot but before the send would still pass this test while re-introducing the dropped-keystroke bug. Consider anchoring the assertion to the read instead — or, stronger, wire a flush-active-editor listener that mutates currentFile.markdown and assert the sent payload reflects it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 664449d. The spec now wires a real flush-active-editor listener that commits the pending keystroke into currentFile.markdown (mirroring editor.vue), and asserts the sent IPC payload carries it (markdown arg === the flushed value) for FILE_SAVE / FILE_SAVE_AS / MOVE_FILE_TO / RESPONSE_FOR_RENAME. Verified it is anchored at the read: temporarily moving flushActiveEditor() below the const { …, markdown } = this.currentFile destructure turns the FILE_SAVE payload test RED (it then sends the stale snapshot).

Comment on lines +67 to +71
expect(emitSpy).toHaveBeenCalledWith('flush-active-editor')
const saveCall = sendSpy.mock.calls.find((c) => c[0] === 'mt::response-file-save')
expect(saveCall).toBeTruthy()
// The flush must happen before the save payload is sent.
expect(emitSpy.mock.invocationCallOrder[0]).toBeLessThan(sendSpy.mock.invocationCallOrder[0])

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Minor: invocationCallOrder[0] assumes the flush is the first emit of any kind. It holds today only because flushActiveEditor() is the first line of each action; if an earlier bus.emit is ever added, [0] silently points at an unrelated emit and the assertion becomes meaningless while staying green. Locating the flush call's own index via emitSpy.mock.calls would pin it robustly. (Also: the MOVE_FILE_TO case asserts only sendSpy).toHaveBeenCalled() rather than filtering the exact channel like the FILE_SAVE cases.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. Dropped the invocationCallOrder[0] ordering assertions in favor of payload-content checks. The one remaining order check (RESPONSE_FOR_RENAME rename branch) locates each emit by event name via emitSpy.mock.calls.findIndex(c => c[0] === event), not a positional [0]. The MOVE_FILE_TO test now filters the exact mt::response-file-save channel instead of a bare toHaveBeenCalled().


RESPONSE_FOR_RENAME(): void {
if (!this.currentFile) return
this.flushActiveEditor()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

RESPONSE_FOR_RENAME gets the flush but has no test — the spec covers FILE_SAVE / FILE_SAVE_AS / MOVE_FILE_TO only. Since this path takes the else branch (bus.emit('rename')) when pathname is set, it needs a flush emit < 'rename' emit assertion rather than the sendSpy shape. Removing this flush later would go undetected.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. Added two RESPONSE_FOR_RENAME tests: the untitled branch (pathname: "") asserts the flushed markdown reaches the mt::response-file-save payload, and the existing-file branch asserts the flush is emitted before "rename" — so silently removing the flush would fail the suite.


MOVE_FILE_TO(): void {
if (!this.currentFile) return
this.flushActiveEditor()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Cleanup nit (also applies to RESPONSE_FOR_RENAME at :762): in the pathname branch this action sends { id, pathname } (and RESPONSE_FOR_RENAME emits 'rename') and never transmits markdown, so flushing there commits nothing useful — the flush only matters in each action's untitled/no-pathname branch, which does send markdown. Harmless and arguably simpler than branching the flush, so fine to leave as-is; noting it so it's a conscious choice.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Left as-is per your note — the flush stays at the top of MOVE_FILE_TO / RESPONSE_FOR_RENAME (a harmless no-op in the pathname branch, simpler than duplicating it into each untitled branch). Both are now covered: the payload assertion exercises the untitled branch where markdown is actually sent, and the flush-before-rename ordering guards the kept flush in the pathname branch.

…3803 review)

The prior spec only asserted flush-emit < IPC-send ordering, but the #3803 bug
lives at the `const { …, markdown } = this.currentFile` read that sits between
them — a flush moved after that read would still pass. Wire a real
`flush-active-editor` listener that commits the pending keystroke (mirroring
editor.vue) and assert the SENT PAYLOAD carries it, so a regression past the
read boundary is caught. Also: cover RESPONSE_FOR_RENAME (both the untitled
payload path and the existing-file rename-emit path), filter the exact IPC
channel in the MOVE_FILE_TO case, and anchor ordering by event name instead of
the fragile invocationCallOrder[0].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Jocs Jocs merged commit c907b29 into develop Jul 5, 2026
18 checks passed
@Jocs Jocs deleted the fix/3803-flush-before-save branch July 5, 2026 05:33
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.

Lost my content despite saving

1 participant