editor: collect the refund — the web editor autosaves on an idle pause
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m29s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m29s
Desktop (Tauri) / Update manifest (push) Successful in 5s
#2971's engine work was already done and its benefit was never taken up here. Both engines coalesce revision snapshots to one per editing session — `src/thoughtsync/revisions.py::should_snapshot` and `store.rs`'s namesake, the server's applied on the PATCH path AND in `sync.py`, with four integration tests covering it. So a write has cost a write, not a write plus a revision, for some time. But this editor still wrote only on `close()`. That save-on-close existed BECAUSE writes were expensive; with the reason gone, all that was left was the cost — a tab closed mid-paragraph lost the paragraph, which is the one thing a notes app must not do. Android already debounces (`BoardViewModel`); the shared Vue editor did not, so web and desktop kept paying for a trade that had been cancelled. Now: a 1s idle pause writes. EDIT MODE ONLY, deliberately. In compose, `dismiss` discards a note that was never persisted so an accidental keystroke or a type-to-compose never litters the board. An autosave there would create the row and quietly take that behaviour away. Materialising a compose on first keystroke is a separate decision (#2967), not a side effect of this one. Three details that decide whether it is safe rather than merely present: * `flush` returns without writing while a save is in flight, so an autosave landing there would silently drop everything typed since that save began. It RE-ARMS instead of skipping. * Errors are swallowed and retried on the next pause. An autosave that interrupts typing with a message is worse than one that waits, and `close` still surfaces a real failure where the person is looking. * The timer is cancelled by `close`, by `dismiss` and on unmount, so nothing fires through a component during its leave animation or after it is gone. Checked and found harmless rather than assumed: `notes.reconcile` replaces the store's item but never touches `useNoteEditor`'s `editing` ref, so the `watch(() => props.note)` that calls `setBody` does not fire on a save. Were that not true, autosaving would have reset the field and the caret every second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { useNotesStore } from "../stores/notes";
|
import { useNotesStore } from "../stores/notes";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import LabelPicker from "./LabelPicker.vue";
|
import LabelPicker from "./LabelPicker.vue";
|
||||||
@@ -165,6 +165,63 @@ async function flush(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- idle autosave ----
|
||||||
|
//
|
||||||
|
// This editor used to write ONLY on close, and the reason was cost: a body write
|
||||||
|
// snapshotted a revision, so saving often meant a version history of thirty
|
||||||
|
// snapshots of one paragraph being typed. The price was durability — a tab closed
|
||||||
|
// mid-paragraph lost the paragraph, which is the one thing a notes app must not do.
|
||||||
|
//
|
||||||
|
// That trade is gone. Both engines now coalesce snapshots to one per editing
|
||||||
|
// session (`revisions.py` and `store.rs`'s `should_snapshot`, Scribe #2971), so a
|
||||||
|
// write costs a write. Writing on an idle pause is what collects the refund; the
|
||||||
|
// Android editor already does the same.
|
||||||
|
const AUTOSAVE_MS = 1000;
|
||||||
|
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function cancelAutosave(): void {
|
||||||
|
if (autosaveTimer !== null) {
|
||||||
|
clearTimeout(autosaveTimer);
|
||||||
|
autosaveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autosave(): Promise<void> {
|
||||||
|
// `flush` returns without writing while a save is in flight, which would silently
|
||||||
|
// drop everything typed since that save began. Re-arming rather than skipping is
|
||||||
|
// what keeps that from being a lost paragraph.
|
||||||
|
if (saving.value) {
|
||||||
|
scheduleAutosave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await flush();
|
||||||
|
} catch {
|
||||||
|
// Swallowed on purpose. An autosave that interrupts typing with an error is
|
||||||
|
// worse than one that waits for the next pause, and `close` still surfaces a
|
||||||
|
// real failure at the moment the person is looking at the editor.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAutosave(): void {
|
||||||
|
cancelAutosave();
|
||||||
|
autosaveTimer = setTimeout(() => {
|
||||||
|
autosaveTimer = null;
|
||||||
|
void autosave();
|
||||||
|
}, AUTOSAVE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// EDIT mode only, deliberately. In compose, `dismiss` discards a note that was
|
||||||
|
// never persisted, so that an accidental keystroke or a type-to-compose never
|
||||||
|
// litters the board — and an autosave that created the row would take that away
|
||||||
|
// without anyone asking for it. Materialising a compose on first keystroke is a
|
||||||
|
// separate decision (Scribe #2967), not a side effect of this one.
|
||||||
|
watch(body, () => {
|
||||||
|
if (!isCreate.value) scheduleAutosave();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(cancelAutosave);
|
||||||
|
|
||||||
function resetCompose(): void {
|
function resetCompose(): void {
|
||||||
noteId.value = null;
|
noteId.value = null;
|
||||||
setBody("");
|
setBody("");
|
||||||
@@ -225,6 +282,10 @@ async function finish(): Promise<void> {
|
|||||||
|
|
||||||
// Persist (create in compose, save in edit) and close the editor.
|
// Persist (create in compose, save in edit) and close the editor.
|
||||||
async function close(): Promise<void> {
|
async function close(): Promise<void> {
|
||||||
|
// Cancelled first: a timer that fires during the leave animation would write
|
||||||
|
// through a component on its way out, after `flush` has already saved the same
|
||||||
|
// text.
|
||||||
|
cancelAutosave();
|
||||||
await flush();
|
await flush();
|
||||||
await finish();
|
await finish();
|
||||||
}
|
}
|
||||||
@@ -233,6 +294,7 @@ async function close(): Promise<void> {
|
|||||||
// commit it explicitly (Done, Ctrl/Cmd+Enter, or Shift+Enter). An existing note, or a
|
// commit it explicitly (Done, Ctrl/Cmd+Enter, or Shift+Enter). An existing note, or a
|
||||||
// compose already persisted by a rich action, closes normally (saving its text).
|
// compose already persisted by a rich action, closes normally (saving its text).
|
||||||
async function dismiss(): Promise<void> {
|
async function dismiss(): Promise<void> {
|
||||||
|
cancelAutosave();
|
||||||
if (isCreate.value) {
|
if (isCreate.value) {
|
||||||
await finish();
|
await finish();
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user