Save a page without wrecking it, and spend that on the page list's commands (BL-13502) - #8209
Save a page without wrecking it, and spend that on the page list's commands (BL-13502)#8209JohnThomson wants to merge 1 commit into
Conversation
|
[Claude Opus 5 from John Thomson's machine during preflight] Consulted Devin through three review rounds during this preflight, most recently up to It found three real bugs, all now fixed and each with its own resolved thread above:
It also raised a set of "is this intended?" flags. Four turned out to be worth acting on and are fixed and resolved (a missing disk write from the Devin's own re-review of |
c5d0e0f to
37d206b
Compare
|
[Claude Opus 5 from John Thomson's machine during preflight] Consulted Devin through four review rounds during this second preflight, most recently up to It found five more real defects, all fixed, each with a resolved thread above: an id that isn't a valid CSS identifier would have aborted the whole save and posted an error string instead of the page; the AI image editor stayed silent on two of the three ways its save can fail; a test kept a comment claiming an assertion it had lost; a failed save request was reported as though Bloom had refused; and the committed benchmark scripts only ran on my machine. Two flags are left open for John, both about visible behaviour rather than defects: whether dropping the 100ms delay before a context-menu command is worth the small scheduling race it leaves (the delay was, undocumented, what made the ordering safe), and whether Copy Page should still move you to the page it copied when that isn't the page you are on. Also worth recording from this run: preflight's own local review caught a deadlock risk I had introduced myself — dropping that deferral had left the command running inline in the API handler, while two of those commands open modal dialogs this same server must serve and the handler still holds the API lock. Across both preflight runs Devin has found ten real defects in this work. None were caught by the tests or by driving the real UI. CI is green; CodeRabbit does not review this repo ( |
| if ( | ||
| SavePageInPlaceThen(pageContentFromBrowser, doBeforeSaveToDisk, forceFullSave) | ||
| != InPlaceSaveOutcome.Declined | ||
| ) |
There was a problem hiding this comment.
[Devin] Bug: Clicking a page or using a page command does nothing at all after another program has changed the book
A page command is abandoned without doing anything and without telling the caller when the book on disk has been replaced by another program, so the user's click appears to be ignored with no message and no page change.
Impact: after an outside program rewrites the book, clicking a thumbnail (or Duplicate/Delete/Paste/reorder) silently does nothing until the user leaves the Edit tab, with no explanation.
Why the Refused outcome is dropped rather than handled
SavePageInPlaceThen returns InPlaceSaveOutcome.Refused when _reloadFromDiskOnLeavingEditTab is set, deliberately distinguishing it from Declined so the caller will not fall back to the ask-the-browser route (which would clobber the other program's file). But SaveThen treats every non-Declined outcome the same: it simply returns. So for Refused (and for Failed) neither the save, the action, the navigation, nor doIfNotInRightStateToSave happens.
For a plain page click (PageListController.OnPageSelectedChanged, whose action is just () => pageId) there is nothing dangerous about navigating — only about saving — yet the navigation is dropped too. Previously the same click went through ToSavePending and always ended in a navigation.
A reasonable fix is to let SaveThen distinguish the outcomes: on Refused, skip the save but still honour the caller's intent (e.g. navigate).
There was a problem hiding this comment.
Accurate about the code path, and I am leaving it open for the developer rather than acting on it, because the fix Devin proposes is a user-facing behaviour choice in an already-degraded state.
Two things narrow it a lot. First, the premise is largely designed away: the only place _reloadFromDiskOnLeavingEditTab is set (EditingModel, in the external-change handler) immediately follows it with _view.WorkspaceView.ChangeTab(Workspace.WorkspaceTab.collection) — Bloom deliberately kicks the user out of the Edit tab, precisely because reloading under a live editor proved fragile. So the user is not normally sitting in the Edit tab clicking thumbnails in this state; the window is whatever gap exists before the tab actually changes.
Second, the suggested "skip the save but still navigate" is only safe for the callers whose action is pure. For a plain page click the action is () => pageId and navigating would be harmless. But Duplicate, Delete and Paste express their whole effect in that action — running it is what changes the book — and that is exactly what must not happen when the book on disk is about to replace ours. So there is no single "honour the caller's intent" rule; it would need to distinguish page-changing callers from book-changing ones.
Worth noting the previous behaviour was not a better one to return to: before this fix the same click fell through to the ask-the-browser path and overwrote the other program's file, which is the bug this Refused outcome exists to stop. Doing nothing is strictly safer than that.
The cheap middle option, if the developer wants one, is to call doIfNotInRightStateToSave() on Refused so the caller at least learns the command was dropped. Every current caller passes an empty handler, so it would change nothing visible today, but it would stop the silence being structural.
|
[Claude Opus 5 from John Thomson's machine during preflight] Consulted Devin through two more review rounds in this third preflight, most recently up to Seven findings now have their own threads above; six are fixed and resolved, one is left open for John. Fixed this round:
Also given threads, having been fixed in earlier rounds without one: the "Duplicate Page Many Times" deadlock (the 100 ms deferral is what releases the API lock before a modal dialog opens — it is back, with the reason recorded at the call site), and balloon data being rewritten on saves of pages where balloon editing is suspended. Left open for John: after an outside program rewrites the book, a thumbnail click or page command is now dropped silently. That is strictly safer than the old behaviour, which overwrote the other program's file, and the state is largely designed away — setting the flag immediately kicks the user to the Collection tab. But "skip the save and navigate anyway" is only safe for callers whose action is pure, so it needs a human call. See the thread on Full suites green at Not yet done: the live smoke test. The Windows session on this machine is locked, so Bloom's WebView2 has no visible page and CDP sees only |
|
[Claude Opus 5 from John Thomson's machine] Manual verification: reordering pages preserves unsaved changes. John tested this by hand, which closes the one functional gap left open in the consultation log above. It matters because page move is the path the last fix on this branch touches. Why it had to be tested by hand: page reorder cannot be driven over CDP. Synthesized mouse events do not engage react-grid-layout's Still untested anywhere: typing during Talking Book playback. It needs a book with recorded audio, and every such book in this collection lives in a different Bloom collection. The change itself is covered by four unit tests in |
Saving a page used to destroy it. To get the page's content, Bloom stripped the live page -- pulled the toolbox tool off it, took CKEditor down, unwound the canvas-element machinery -- and what was left was no longer editable. That is why a save was always followed by a reload: the state machine had a whole state (SavedAndStripped) whose only purpose was to remember that the page had to be navigated away from before the user could touch it again. Now the content is gathered from a CLONE. getBodyContentForSavePage() clones the body, cleans the clone, and never touches the live page. A save can therefore finish and leave the user exactly where they were, which is what savePageWithoutReloading() does -- the AI image editor's post-commit save is the first caller, and it no longer yanks the page out from under its own overlay. The same clone makes page-list commands one step instead of two. A click, Copy, Paste, Duplicate, Delete or reorder now sends the outgoing page's content along WITH the command, so C# merges it, runs the command and navigates in a single pass (SaveThen's pageContentFromBrowser, reaching EditingStateMachine.ToSavedInPlaceThenNavigating). Previously each of these had to ask the browser for content and wait for a second round trip. Because a save no longer strips the page, the teardown that used to ride along with it needed a home of its own: pageUnloading() now detaches the tool, resets the controls above the page and cleans up the canvas machinery, and EditingView.OnHideEditTab calls it when the user leaves the Edit tab (nothing unloads the page frame on that path). Notable pieces: - InPlaceSaveOutcome distinguishes Saved / Declined / Failed / Refused. Only Declined may fall back to the old ask-the-browser route: Failed means the action may already have run, and Refused means an external process replaced the book and this page must not be written at all. - pageContentDelays.ts is the single gate that stops content being captured while an async command that should be saved is still running. - Tool markup is now taken off the clone via ITool.removeToolMarkup, the same method detachFromPage runs on the live page, so the two cannot drift. removeReaderMarkup.ts and niceScrollCleanup.ts split that work out. - The Talking Book tool stamps the highlight spans it creates, so undoing them cannot strip highlight markup the book itself contains. - src/BloomExe/Edit/SavingWithoutReloading.md explains the design, and the two benchmark scripts it cites measure the round trip this removes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
62117e6 to
8f2e327
Compare
|
[Claude Opus 5 from John Thomson's machine during preflight] Consulted Devin on 2026-08-20, up to commit Re-review clean — no new findings. This was the first review of the branch after it was squashed to a single commit and brought up to date with master (94 commits). Devin raised no Bugs, no Investigate flags and no Informational items against this HEAD. Everything it found across the previous rounds — 14 defects in all — already has its own thread above with a documented outcome. One remains open by decision, not by oversight: after an outside program rewrites the book, a thumbnail click or page command is now dropped silently. That is strictly safer than the old behaviour, which overwrote the other program's file, and the alternative is only safe for callers whose action is pure — so it is a judgement call for John rather than something to fix unilaterally. Note that squashing rewrote the branch, so the existing threads are anchored to commits that no longer exist and GitHub shows them as outdated. Their text and outcomes are unaffected. CI: |
The problem. Saving a page used to destroy it. To read the page's content, Bloom stripped the
live DOM — detached the toolbox tool, took CKEditor down, unwound the canvas-element machinery —
leaving a page that could be saved but no longer edited. Every save therefore had to end by
navigating away and reloading, which is the only reason the state machine has a
SavedAndStrippedstate: to remember that the page is broken. It also made every page-list command a two-step round
trip — ask the browser for the content, wait for the answer on a separate API call, then act.
What this PR changes.
(
savePageWithoutReloading). The AI image editor's post-commit save is the first caller: it nolonger pulls the page out from under its own overlay.
content with the command, so C# merges it, runs the command and navigates in one pass.
the page, canvas cleanup) now lives in
pageUnloading(), whichEditingView.OnHideEditTabalsocalls — nothing unloads the page frame when you just leave the Edit tab.
InPlaceSaveOutcomedistinguishes Saved / Declined / Failed / Refused, so only "nothing at allhappened" falls back to the old ask-the-browser route.
pageContentDelays.tsis the single gate that stops content being captured while an asynccommand whose results should be saved is still running.
src/BloomExe/Edit/SavingWithoutReloading.mdexplains the design, what has been converted, andwhat is left. This is deliberately not for the current release: it is written to be cheap to
merge later — new behaviour in new files, and no reshaping of existing code just to add to it.
Ref: https://issues.bloomlibrary.org/youtrack/issue/BL-13502
Devin review
This change is