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 5 commits 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 |
…13502) Gathering a page's content for a save used to **wreck the live page**. The browser stripped the editing markup out of the real DOM: it detached the toolbox tool, removed origami layout mode and the text-box labels, killed the niceScroll bars, and rewrote every bloom-editable's innerHTML with CKEditor's cleaned-up data. What was left could be saved but not edited -- which is exactly what the SavedAndStripped state records, and why EVERY save had to end by navigating somewhere. getBodyContentForSavePage() now CLONES the body and cleans the clone. Nothing at all happens to the live page. Getting there needed: - comicaljs 0.4.1, for Comical.exportSvgToCopiesOfParents -- the non-destructive counterpart of stopEditing(), added for this in comical-js#120. Moving off 0.3.106 also surfaced four pre-existing type errors: its declarations used a bare `from "bubbleSpec"` specifier, so BubbleSpec had silently been `any`. - Our own niceScroll cleanup for the clone, instead of asking the live scrollbars to remove themselves. It handles the rails and cursors, the alignment classes addScrollbarsToPage() moves aside (the part that would have been real data loss), and the three inline styles niceScroll sets without recording. - One ITool.removeToolMarkup(pageOrClone), used two ways rather than duplicated: the save path calls it on a clone, and detachFromPage() calls it on the live page. detachCurrentTool() logs an error if an override forgets its super call, because the symptom otherwise shows up much later as tool markup saved into a book. - CKEditor's data read from the live editors and written INTO the clone. - No blurring of the active element, so the cursor stays where the user left it. On top of that, SaveThen now takes the current page's content when the caller can send it, and does the whole save in one step instead of asking the browser and waiting for the answer on a separate API. Every command the page list initiates does this -- page click, duplicate, delete, paste, reorder -- as do Change Layout, importing a video, and converting a field to a derived one. Those three keep their reload, which is doing a second job for them: they have restructured the page in ways that have never been through SetupElements. Copy Page no longer reloads at all. It was reloading the very page it was copying, purely to recover from the destructive save. Removing the round trip also closes a real hole: while C# sat in SavePending waiting for the browser, a second page click was silently discarded. Safety: - Everything that gathers page content waits on one gate, whenNoActiveDelays(), so a save cannot read a page mid-change. The synchronous gather is not exported anywhere, so there is no way around it. That gate also means the *command* does not start mid-change: C# is not asked to duplicate or delete anything until the page has settled. - SaveThen is the only way in, so the rule that only a Declined outcome may fall back lives in one place. Getting that wrong deletes a page twice -- which it did, once, during development. Reviewed by Devin over several rounds, which found six real defects in this work that neither the tests nor driving the UI had caught: the reader tools' editing highlight being saved into the book; a save that never happened reported as successful; toolbox tools no longer shut down when leaving a page (and, later, when leaving the tab); a page-list command that could vanish silently if the page frame navigated; "leave the editor blank" ignored; and balloon data rewritten on pages where it used to be left alone. All are fixed, each with a documented and resolved thread on the PR. Also from review: the Talking Book tool's cleanup now undoes the audio highlighting structurally rather than restoring a snapshot taken when playback started, so typing done during playback is no longer thrown away -- and the phrase-delimiter enshrouding survives it, which the snapshot restore had been destroying. New tests: 30 for the editing state machine, 9 for the delay gate, 9 for niceScroll cleanup, 6 for the audio highlighting undo, 4 for reader-markup removal, 1 for a refused save. src/BloomExe/Edit/SavingWithoutReloading.md explains the design, what has been converted, what has not and why, and the risks to watch when converting more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c5d0e0f to
37d206b
Compare
Dropping the 100ms deferral before a thumbnail context-menu command went too far: I removed the deferral itself, not just the delay, and ran the command inline in the API handler. That is unsafe. "Duplicate Many Times" and "Choose Different Layout" open MODAL dialogs, and those dialogs' content is served by this same Bloom server -- while the handler still holds the API sync lock, because it has not returned. Running them inline invites a deadlock. So the command is queued again, with BeginInvoke rather than a delayed Task: we are already on the UI thread, so that just puts it after the current message. It returns at once, we reply, the lock is released, and then the command runs. The 100ms window that could have left the user's latest typing out of the snapshot is still gone, which was the actual point. Found in this run's own local review, before it reached anyone else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**An id that is not a valid CSS identifier would abort the whole save.**
undoHighlightingFixes looked its elements up with `querySelector("#" + id)`,
which THROWS for an id that is not a valid CSS identifier -- a legacy one
starting with a digit, say. That pattern is older than this branch, but its blast
radius is not: it now runs inside the save's clone cleanup, so a throw would
abort gathering the page and we would post an error string instead of the user's
content. It now compares the id property, which cannot throw whatever the id
looks like.
**The AI image editor stayed silent on two of the three ways its save can
fail.** The check added earlier covers C# refusing, but not the page frame having
gone away (the optional call then yields nothing at all) and not the request
itself failing. All three now say the same thing, which is the part that matters
to the user: the book on disk still has the old image. Two tests.
**A test kept a comment claiming an assertion it no longer had.** The
partial-failure case in the AI editor tests still said "What did land still gets
saved" but had lost the assertion when the save moved off postThatMightNavigate.
Restored, against the current API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| IPage page = PageFromId(pageId); | ||
|
|
||
| if (page != null) | ||
| { |
There was a problem hiding this comment.
[Devin] Investigate: Dropping the 100ms delay relies on the API lock being released before the queued command runs
The thumbnail context-menu command is queued with BeginInvoke and runs after this handler returns. The comment says "we reply, the lock is released, and then the command runs" — Devin points out that ordering is not actually guaranteed.
The handler runs on the UI thread via Invoke from a server thread. When it returns, the server thread resumes and releases the API sync lock — but the UI thread is free to start pumping the queued command in the meantime. For "Duplicate Many Times" and "Choose Different Layout" that command opens a modal dialog whose content this same server has to serve, so in principle it could ask for content while the lock is still held.
Assessment: real, but the race is between a few microseconds (a thread-pool continuation calling Release) and at least a whole HTTP round trip (a dialog opening and fetching its content). It is very unlikely to lose. Note the old code's Task.Delay(100) made this safe by accident rather than by design — nobody wrote it down as the reason, which is exactly why it is worth recording now.
Left for a decision. The delay was removed at John's request to close a window in which typing could miss the snapshot, so putting it back is his call, not mine.
| } | ||
|
|
||
| public void CopyPage(IPage page) | ||
| public void CopyPage(IPage page, string pageContentFromBrowser = null) |
There was a problem hiding this comment.
[Devin] Investigate: Copy Page no longer selects the page it copied
Copy Page used to end by navigating to the page it copied. It no longer navigates at all — that is the deliberate change that makes copying stop reloading the page.
Devin's point is that those are only the same thing when the page you right-clicked IS the page you are on. A right-click does not select a page, so you can right-click a different thumbnail and choose Copy Page; the old behaviour would then have moved you to that page, and the new one leaves you where you are.
Assessment: arguably an improvement — a copy quietly moving you to another page is the more surprising behaviour of the two, and nothing about the copy needs you to be there. But it is a visible change to what the user sees, so it should be a choice rather than a side effect.
Left for a decision.
The message for a save that did not happen asserted that Bloom had declined. It cannot know that: a failed request does not reject, because bloomApi's wrapAxios catches it (and reports the network error itself), so it arrives looking exactly like a refusal. The message now says what is true -- the page was not saved -- and leaves the cause open. Found by Devin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They resolved playwright through a hard-coded C:/github/BloomDesktop, so they threw immediately for anyone whose repo lives elsewhere -- which is everyone but me. They now locate the repo from their own file position, and take the CDP port from BLOOM_CDP_PORT when the launcher picked a different one. Found by Devin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
[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 ( |
Gathering a page's content for a save used to wreck the live page: the browser stripped the
editing markup out of the real DOM (detached the toolbox tool, removed origami layout mode and
text-box labels, killed the niceScroll bars, rewrote every
bloom-editablewith CKEditor's cleaneddata). What was left could be saved but not edited — which is exactly what the
SavedAndStrippedstate records, and why every save had to end by navigating somewhere.
This makes the gather non-destructive, and then starts spending that.
The core change
getBodyContentForSavePage()now clones the body and cleans the clone. Nothing at all happensto the live page. Getting there needed:
Comical.exportSvgToCopiesOfParents— the non-destructive counterpart ofstopEditing(), added for this in comical-js#120. (Moving off 0.3.106 also surfaced fourpre-existing type errors: 0.3.106's declarations used a bare
from "bubbleSpec"specifier, soBubbleSpechad silently beenany.)niceScrollCleanup.ts) for the clone, instead of asking the livescrollbars to remove themselves.
ITool.removeToolMarkup(pageOrClone)used two ways rather than a duplicated pair, with aconsole error if a
detachFromPageoverride forgets itssupercall — because the symptomotherwise shows up much later, as tool markup saved into a book.
What that buys, so far
SaveThennow takes the current page's content when the caller can send it, and does the wholesave in one step instead of asking the browser and waiting for the answer on a separate API. Every
command the page list initiates does this: page click, duplicate, delete, paste, reorder, plus
Change Layout / import video / convert-to-derived-field.
Copy Page no longer reloads at all — it was reloading the very page it was copying, purely to
recover from the destructive save.
Removing the round trip also closes a real hole: while C# sat in
SavePendingwaiting for thebrowser, a second page click was silently discarded.
Safety
whenNoActiveDelays()(
pageContentDelays.ts), so a save can't read a page mid-change. The synchronous gather is nolonger exported anywhere, so there is no way around it.
SaveThenis the only way in; the rule that only aDeclinedoutcome may fall back lives in oneplace. Getting that wrong deletes a page twice — which it did, once, during development.
Testing
Driven against a real book in a running Bloom: every command saves the page's unsaved typing,
lands on a fully set-up page (CKEditor attached, canvas elements present), reaches disk, and never
fires
editView/pageContent. Holding a delay stops a command from starting at all; a delay thatnever clears lets it through after the 4s cap with a warning.
New unit tests: 21 for the state machine, 9 for the delay gate, 9 for niceScroll cleanup.
Note for reviewers
This is deliberately not merging into 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 — because
what conflicts is a changed line, not an added one.
src/BloomExe/Edit/SavingWithoutReloading.mdexplains that, what has been converted, and what is left.
Ref: https://issues.bloomlibrary.org/youtrack/issue/BL-13502
Devin review
This change is