Skip to content

Save a page without wrecking it, and spend that on the page list's commands (BL-13502) - #8209

Draft
JohnThomson wants to merge 1 commit into
masterfrom
BL-13502-save-without-reload
Draft

Save a page without wrecking it, and spend that on the page list's commands (BL-13502)#8209
JohnThomson wants to merge 1 commit into
masterfrom
BL-13502-save-without-reload

Conversation

@JohnThomson

@JohnThomson JohnThomson commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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 SavedAndStripped
state: 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.

  • The content is gathered from a clone of the body, and the live page is never touched.
  • A save can therefore finish and leave the user exactly where they were
    (savePageWithoutReloading). The AI image editor's post-commit save is the first caller: it no
    longer pulls the page out from under its own overlay.
  • Page-list commands — click, Copy, Paste, Duplicate, Delete, reorder — send the outgoing page's
    content with the command, so C# merges it, runs the command and navigates in one pass.
  • Teardown that used to ride along with the save (detaching the tool, resetting the controls above
    the page, canvas cleanup) now lives in pageUnloading(), which EditingView.OnHideEditTab also
    calls — nothing unloads the page frame when you just leave the Edit tab.
  • InPlaceSaveOutcome distinguishes Saved / Declined / Failed / Refused, so only "nothing at all
    happened" falls back to the old ask-the-browser route.
  • pageContentDelays.ts is the single gate that stops content being captured while an async
    command whose results should be saved is still running.

src/BloomExe/Edit/SavingWithoutReloading.md explains the design, what has been converted, and
what 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 Reviewable

Comment thread src/BloomExe/web/controllers/EditingViewApi.cs
Comment thread src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
Comment thread src/BloomExe/Edit/EditingStateMachine.cs
Comment thread src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
Comment thread src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
Comment thread src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts
Comment thread src/BloomBrowserUI/bookEdit/pageThumbnailList/currentPageContent.ts
Comment thread src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 from John Thomson's machine during preflight] Consulted Devin through three review rounds during this preflight, most recently up to e10b109.

It found three real bugs, all now fixed and each with its own resolved thread above:

  • the reader tools' editing highlight was being saved into the book, because their cleanup only ever worked on the live page and the save now works from a clone;
  • editView/savePageInPlace reported success even when the save was refused;
  • removeToolboxMarkup() had lost every caller, so the open toolbox tool was no longer shut down when the user left a page.

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 NoPage state, a brittle dependency on the reply's content type, and a page-list command that could vanish silently if the page frame navigated mid-wait). Four are deliberate design decisions or genuine trade-offs and are left open for John: the clone-cleanup ordering (which no single order gets right for both the reader tools and Talking Book), whether canvas-element alternates should still be conditional on canvas editing being on, and the click-time content snapshot.

Devin's own re-review of 38e8647 reported no unresolved bugs remaining. CI is green; CodeRabbit does not review this repo (auto_review is disabled in .coderabbit.yml).

Comment thread src/BloomExe/Edit/EditingStateMachine.cs
Comment thread src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
@JohnThomson
JohnThomson force-pushed the BL-13502-save-without-reload branch from c5d0e0f to 37d206b Compare August 17, 2026 21:58
Comment thread src/BloomBrowserUI/bookEdit/aiImageEditor/aiEditorOverlay.ts
Comment thread src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.ts
Comment thread src/BloomBrowserUI/bookEdit/aiImageEditor/aiEditorOverlay.test.ts
Comment thread src/BloomExe/web/PageListApi.cs
Comment thread src/BloomExe/Edit/EditingModel.cs
Comment thread .claude/skills/run-bloom/benchPageChange.mjs
Comment thread src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 from John Thomson's machine during preflight] Consulted Devin through four review rounds during this second preflight, most recently up to 5834032.

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 (auto_review is disabled in .coderabbit.yml).

Comment thread src/BloomExe/Edit/EditingModel.cs
Comment thread src/BloomExe/Edit/EditingView.cs
Comment thread src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.ts
Comment thread src/BloomExe/web/PageListApi.cs
Comment thread src/BloomBrowserUI/bookEdit/js/bloomEditing.ts
Comment thread src/BloomExe/Edit/EditingStateMachine.cs
if (
SavePageInPlaceThen(pageContentFromBrowser, doBeforeSaveToDisk, forceFullSave)
!= InPlaceSaveOutcome.Declined
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@JohnThomson

Copy link
Copy Markdown
Contributor Author

[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 62117e6.

Seven findings now have their own threads above; six are fixed and resolved, one is left open for John.

Fixed this round:

  • the refusal to save a book another program had just replaced was undone by the fallback path, so the other program's work could still be overwritten — there is now a distinct Refused outcome that SaveThen will not fall back from (b42b63d);
  • leaving the Edit tab only shut the toolbox down, leaving the controls above the page and the canvas machinery mounted — it now runs the page frame's whole pageUnloading() teardown (b42b63d);
  • a save could quietly delete a book's own highlight markup nested inside a narrated sentence; the Talking Book tool now marks the spans it creates and unwraps only those (b42b63d);
  • a save requested from inside a save-in-place action stranded the editor in SavePending, so the page the command promised to go to was never shown (62117e6).

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 EditingModel.SaveThen.

Full suites green at 62117e6: C# 3145 passed / 0 failed, front-end 755 passed / 0 failed. CI (pr-automation) passing. No other comment-posting review bots are configured on this repo.

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 about:blank — the UI cannot be driven. Everything on that list still needs to be exercised by hand or on an unlocked desktop.

@JohnThomson

Copy link
Copy Markdown
Contributor Author

[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. PageThumbnailList.PageMoved passes an action that changes the page selection, which reaches PageListController.OnPageSelectedChanged and asks for another save from inside the save-in-place action. ToSavePending now refuses while that action is running (62117e6), so the navigation that follows it is no longer dropped — and the edits gathered before the move are not lost.

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 react-draggable, so the drag either does nothing or onDragStop sees unchanged grid coordinates and treats it as an ordinary click. Two attempts with three numbered pages present moved nothing and reported no error; an earlier attempt with two pages instead hit the "front matter and back matter pages must remain where they are" dialog, because the target slot sat in the column next to the Credits Page. This is now recorded in the bloom-automation skill so the next agent does not repeat it.

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 audioRecordingSpec.ts, including two added for the cases that were previously untested (a book's own highlight span nested inside a sentence the tool fixed, and a book that already carried the no-highlight class).

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>
@JohnThomson
JohnThomson force-pushed the BL-13502-save-without-reload branch from 62117e6 to 8f2e327 Compare August 26, 2026 16:54
@JohnThomson

Copy link
Copy Markdown
Contributor Author

[Claude Opus 5 from John Thomson's machine during preflight] Consulted Devin on 2026-08-20, up to commit 8f2e3277.

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: pr-automation passing. No other comment-posting review bots are configured on this repo.

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.

1 participant