fix(md-notebook): cancel autosave timer on unmount - #3101
Conversation
|
The requested code-review race fix and one-commit PR Hygiene correction are present in the latest commit. The refreshed suite has two remaining root blockers: (1) Screenshot Evidence needs the maintainer-only |
UX Review (Fable 5, fork) — ✅ PASSUX-level review of The change is entirely behavioral — an unmount teardown that flushes the pending autosave instead of leaking (or dropping) it. No user-facing strings, markup, layout, or flows are added or altered; the base file already has the UX-Verdict: PASS Pure teardown fix with zero rendered surface: edits typed just before in-app navigation now persist instead of silently vanishing — strictly less data loss, nothing new to comprehend. [UX-REVIEWED] c7dbbaf |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of The design is well-grounded: all Design-Verdict: PASS Correctly rejects the naive cancel-only fix; send-on-unmount with tracked in-flight writes follows the file's own [DESIGN-REVIEWED] c7dbbaf |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've verified the crux. No new grounded defect emerges in the diff. No findings. [OPUS-REVIEWED] c7dbbaf |
5944fb8 to
40b8968
Compare
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
1 similar comment
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
|
🤖 Kiro Crew [operator: bolichen97]: This PR has been inactive for 7+ days with failing CI. I've assessed the blockers and they appear resolvable — I'll push fixes directly to this branch as a co-author. Assessment: The single red frontend shard is the unrelated If you'd prefer I don't touch this PR, add the |
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
1 similar comment
|
👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated. Missing sections:
Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle. |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All claims verified against the base: the First-Principles-Verdict: PASS Cancels the one leaked timer in the file at cause level — teardown now owns the debounce's lifecycle — while preserving the write that timer was load-bearing for. What this change shipsIntent: stop the autosave debounce from firing after the Notes page is gone, without losing the edit it was about to save — a FIX.
Sibling count: grepped [FIRST-PRINCIPLES-REVIEWED] c7dbbaf |
33f4216 to
c057510
Compare
|
Round 1 dispositioned, pushed as GPT BLOCKING (teardown save races note moves) -- confirmed and fixedReproduced before touching anything. Worth stating plainly why this is a regression this PR owns rather than a pre-existing property: on the base the same race exists, but the write is 1000ms late, and that delay is usually enough for the move response to land and repoint Took the second half of your own recommendation -- serialize it -- rather than the first. Reverting the flush would restore the data loss the PR exists to fix. The in-flight set was only ever tracking saves; it now tracks anything that retargets That fix also surfaced a second hazard in the same span, so it is closed in the same round: the flush used to read
The new test asserts two writes, not one, and that is deliberate. The second is First Principles CONCERNS -- one premise conceded, subtraction declined with evidenceYou are right that "it updates React state, and those setters would run against a component that is already gone" is not a defect under React 18, and that my guard's comment leaned on the bypass to justify itself. That comment is now replaced with the actual reason. The actual reason also answers the proposed subtraction. So the second write site earns its existence: it is the one that writes a SNAPSHOT rather than a live ref. The Two of us were within one round of deleting the mechanism that turned out to be load-bearing, which is worth recording in case a later reader reaches for the same subtraction. |
…king its timer MdNotebookPage armed a 1000ms autosave debounce and never cleared it on unmount, so the callback fired against a component that was gone. In the suite that landed in whichever test was running by then, hitting the file-shared api.saveNote mock. The pending edit is SENT rather than cancelled: in-app navigation does not fire beforeunload, so inside the debounce window that timer is the only thing that would have persisted what the user typed. Same call the minsTimer teardown in this file already makes for the auto-sync interval. The flush waits for any request that retargets pathRef before it writes: an in-flight save so the write carries the mtime that save produced, and an in-flight move because that is what repoints pathRef onto the note's new path. Writing before a move lands would address the path the move is vacating and lose the edit to a swallowed ESTALE. Closes kirodotdev#2984
c057510 to
c7dbbaf
Compare
|
Round 2 dispositioned, pushed as GPT BLOCKING (settled save failures bypass the flush) -- fixed as prescribedConfirmed by reading the failure path rather than the happy one. A save that FAILS leaves the exact state my gate did not cover: Took the prescribed fix verbatim:
One thing worth stating so the scope is honest: unlike round 1's move race, this is not a regression this PR introduced. On the base a failed save loses the edit on navigation too, because there is no teardown flush at all. It is an incompleteness in the mechanism I am adding, which is a fair thing to block on -- the feature's whole claim is that a pending edit survives leaving the page, and it did not hold on the failure path. I also checked the one case where writing here could be undesirable rather than merely useless. If the failure was Round 1 sat in the move/retarget span and this one in the teardown gate, so they are separate causes rather than the same finding recurring -- no escalation warranted yet. Local gates on the new head: |
|
Review-ready at What changed since the takeover, in one place:
Every fix mechanism is mutation-verified; the table is in the description. Two asks from #2984 were deliberately not built ( Not merging -- that is the maintainer's call. |
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: cancel the md-notebook autosave timer on unmount, fixing a leaked timer; UI-lifecycle only, no auth or input change. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
Problem / Motivation
MdNotebookPagearms a 1000ms autosave debounce on every keystroke and never clears it when the page unmounts. The callback outlives the component and fires about a second later against a page that is gone.In the test suite that lands inside whichever test is running by then, hitting the file-shared
api.saveNotemock. #2964 proved it with a stack capture showing the secondsaveNotearriving vialistOnTimeoutfrom a prior test's unmounted component, and had to drop an otherwise useful call-count assertion because of it.Why it matters
The user-facing stake is the opposite of what the leak looks like. Leaving Notes by in-app navigation does not fire
beforeunload, so inside that 1s window the pending timer is the ONLY thing that would ever have written the edit to disk. The bug is not that a stale write happens -- the write carries the newest buffer -- it is that a correct write is riding on an uncancelled timer, and the obvious "fix" of clearing it would silently lose whatever the user typed in the last second before navigating away.For the suite the cost is concrete: a real cross-test race, and an assertion the project wanted but could not keep.
What changed (motivation -> approach -> change)
Observed symptom: a
saveNotecall appears in a test that never made one.Root cause: the
window.setTimeoutarmed ineditoutlives the component, because no teardown effect clears it.Why the obvious change is wrong: cancelling alone is not sufficient, because that timer is load-bearing for in-app navigation. This file had already answered the same question once -- the
minsTimerteardown effect clears its timer and then SENDS the pending auto-sync interval, with a comment saying a value the user watched land in the field must not be silently discarded.The change does the same for the editor buffer, in one teardown effect:
flushSaveclears the debounce on entry and releases its tracking infinally, but leaves the buffer dirty and re-arms nothing, since only a keystroke arms the debounce.mtimethat save produced. Sending alongside its last retry would carry a supersededmtime, come backESTALE, and lose the edit -- the outcome the effect exists to prevent. That wait is bounded by the retry loop it waits on (at most 3 sequentialsaveNotecalls) and holds no timer, listener or interval, only a promise closure, so unlike the debounce it replaces it cannot run component logic at an arbitrary later time.pathRefonto the note's new path, so writing before it lands addresses the path the move is vacating and the edit is lost to a swallowedESTALE. Registered through the retarget, not merely around the request, because it is the retarget that makes the wait sufficient.contentRefat disk content -- a move reopens the note at its new path -- which would write the file back unchanged and drop the edit.flushSave, and not because of its state setters: React 18 makes a post-unmountsetStatea silent no-op. The reason is thatflushSavere-readscontentRef.currentlive on every retry attempt, so it is exactly the path that would send those disk bytes back and clear the dirty flag against them. It also mutatesmtimeRefandsaveTimerthat a still-runningrelocatereads.targetsSameNote(deletingRef.current, ...)before writing. This is defense in depth, not a live hazard, and the code comment says so:removeNoteflushes and bails while still dirty BEFORE it armsdeletingRef, andeditandmarkDirty-- the only two sites that set the dirty flag -- both refuse while a delete is in flight, so the state is unreachable today. It is kept local because this write bypassesflushSave, and with it theESTALEbranch that recognises the backend refusing to resurrect a deleted note; the callers swallow that rejection, so without the check this write's safety would rest entirely on three invariants held in two other functions.Tests
Three tests in
MdNotebookPageCoverage.test.tsx:mtime.Mutation-verified rather than assumed:
edit's delete guard, keep the teardown guardexpected "vi.fn()" to not be called at all, but actually been called 1 times"One.md", the path the rename vacateddirtyRefterm from the teardown gateexpected 2nd "vi.fn()" call ... but called only 1 times-- the edit after a failed save is never writtenThe second row is why the guard is kept: without it, typing during an in-flight delete and then navigating away does POST a write that resurrects the note. The last two rows are why the assertion #2964 dropped is safe to keep now, and they make it the regression pin for this issue -- delete the cancel and the suite goes red instead of silent.
Gates:
tsc --noEmitclean;eslintclean on both changed files (one pre-existing a11y warning elsewhere in the file); 317 of 317 green across the 12 md-notebook test files. Rebased onto currentmain(146 commits, zero conflicts), which clears the base-owned reds this branch had been sitting on --Frontend Tests (3)was failing insrc/i18n/style/hiStyle.test.tson an auto-research string, andBackend Testsshard 4 was red on three platforms for a diff containing zero Python.Manual verification
Why no screenshot: nothing rendered changes -- the diff is one unmount-teardown effect, comments and tests, with no markup, style, layout or copy touched; it makes an autosave that already happened happen deliberately.
N/A -- unit coverage sufficient. The change has no rendered surface: it makes an autosave that already happened happen deliberately, and the two states worth checking (does the edit reach disk, does a second call follow) are exactly what the mutation table above measures. There is no visual variant a screenshot could show.
Notes and decisions
Two things #2984 asked for that turned out not to be worth building, recorded as decisions rather than omissions.
A
mountDirtyFakeTimershelper is no longer needed. Post-fix the timer is cancelled at unmount, so none of the 8 real-timermountDirtysites leaks one. What they do instead is issue the teardownsaveNotesynchronously during cleanup, recorded against the test that owns it, which cannot cross into the next test. The helper would be machinery with nothing left to protect.The
savesInFlightRefSetplus manually resolved deferred is more ceremony than a counter, butflushSaveruns can overlap, so a set is the overlap-safe shape, and restructuring a delicate 100-line save function to save four lines is risk without payoff.Related Issues
Closes #2984
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)