From 4765657423da9e540573ef9a7735f2e2883fb128 Mon Sep 17 00:00:00 2001 From: Ammar Ahmed <40239442+ammarahm-ed@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:55:06 +0500 Subject: [PATCH] mobile: fix editor cross contamination (#10157) 1. Use noteId defined in editorMessage when saving content, never use tabId since it can point to a different note since a tab can load a different note while a note save message is coming across the bridge. 2. If saving fails and editor saves save payloads in localStorage, add the noteId to them so we know which note the content belongs to. 3. Pending content saves must keep noteId and edit time so when we save them later, they save into the correct note and if the content is newer, we skip saving. 4. Fix the debounce key so cross contamination can never occur in new notes between two tabs. --- .../mobile/app/screens/editor/tiptap/types.ts | 2 + .../editor/tiptap/use-editor-events.tsx | 14 ++- .../app/screens/editor/tiptap/use-editor.ts | 116 ++++++++++++++---- .../src/hooks/useEditorController.ts | 47 ++++--- .../editor-mobile/src/utils/pending-saves.ts | 16 ++- 5 files changed, 141 insertions(+), 54 deletions(-) diff --git a/apps/mobile/app/screens/editor/tiptap/types.ts b/apps/mobile/app/screens/editor/tiptap/types.ts index 803020a79..6778cc7b8 100644 --- a/apps/mobile/app/screens/editor/tiptap/types.ts +++ b/apps/mobile/app/screens/editor/tiptap/types.ts @@ -93,6 +93,8 @@ export type SavePayload = { ignoreEdit: boolean; tabId: string; pendingChanges?: boolean; + sourceNoteId?: string; + pendingChangesAt?: number; }; export type AppState = { diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx b/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx index 276d2c0a5..716963271 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx +++ b/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx @@ -411,16 +411,20 @@ export const useEditorEvents = ( .getState() .getNoteIdForTab(editorMessage.tabId); + const saveNoteId = editorMessage.noteId || noteId; + switch (editorMessage.type) { case EditorEvents.content: DatabaseLogger.log("EditorEvents.content"); editor.saveContent({ type: editorMessage.type, content: editorMessage.value.html as string, - noteId: noteId, + noteId: saveNoteId, + sourceNoteId: editorMessage.noteId, tabId: editorMessage.tabId, ignoreEdit: (editorMessage.value as ContentMessage).ignoreEdit, - pendingChanges: editorMessage.value?.pendingChanges + pendingChanges: editorMessage.value?.pendingChanges, + pendingChangesAt: editorMessage.value?.pendingChangesAt }); break; case EditorEvents.title: @@ -428,10 +432,12 @@ export const useEditorEvents = ( editor.saveContent({ type: editorMessage.type, title: editorMessage.value?.title as string, - noteId: noteId, + noteId: saveNoteId, + sourceNoteId: editorMessage.noteId, tabId: editorMessage.tabId, ignoreEdit: false, - pendingChanges: editorMessage.value?.pendingChanges + pendingChanges: editorMessage.value?.pendingChanges, + pendingChangesAt: editorMessage.value?.pendingChangesAt }); break; case EditorEvents.logger: diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor.ts b/apps/mobile/app/screens/editor/tiptap/use-editor.ts index 830331751..3e24ee06a 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor.ts +++ b/apps/mobile/app/screens/editor/tiptap/use-editor.ts @@ -263,26 +263,63 @@ export const useEditor = ( ignoreEdit, sessionHistoryId: currentSessionHistoryId, tabId, - pendingChanges + pendingChanges, + sourceNoteId, + pendingChangesAt }: SavePayload) => { if (currentNotes.current[id as string]?.readonly || readonly) return; + + if (sourceNoteId && id && sourceNoteId !== id) { + DatabaseLogger.error( + new Error( + `Refused to save content of note ${sourceNoteId} into note ${id}` + ) + ); + return; + } + try { if (id && !(await db.notes?.note(id))) { - await reset(tabId); - useTabStore.getState().updateTab(tabId, { - session: { - noteId: undefined, - noteLocked: undefined, - locked: undefined, - readonly: undefined, - scrollTop: undefined, - selection: undefined, - spellCheckDisabled: false - } - }); + if (useTabStore.getState().getNoteIdForTab(tabId) === id) { + await reset(tabId); + useTabStore.getState().updateTab(tabId, { + session: { + noteId: undefined, + noteLocked: undefined, + locked: undefined, + readonly: undefined, + scrollTop: undefined, + selection: undefined, + spellCheckDisabled: false + } + }); + } return; } let note = id ? await db.notes?.note(id) : undefined; + + // A restored pending change can be older than what is already in the + // db (it was saved on another device, or the save actually went + // through and only the acknowledgement was lost). Applying it would + // roll the note back, so verify it is still the newest edit. Content + // and title are compared separately so that a newer title doesn't + // discard pending content, and vice versa. + if (pendingChanges && pendingChangesAt && note) { + const dateEdited = data + ? note.contentId + ? (await db.content?.get(note.contentId))?.dateEdited + : undefined + : note.dateEdited; + + if (dateEdited && dateEdited > pendingChangesAt) { + DatabaseLogger.log( + `Discarding stale pending ${ + data ? "content" : "title" + } for note ${id}: edited at ${dateEdited}, change captured at ${pendingChangesAt}` + ); + return id; + } + } const locked = note && (await db.vaults.itemExists(note)); if (note?.conflicted) { @@ -321,6 +358,9 @@ export const useEditor = ( let saved = false; setTimeout(() => { if (saved) return; + // Don't report progress on a tab that has moved on to another note. + if (id && useTabStore.getState().getNoteIdForTab(tabId) !== id) + return; commands.setStatus( getFormattedDate(note ? note.dateEdited : Date.now(), "date-time"), strings.saving(), @@ -436,14 +476,26 @@ export const useEditor = ( } } - if ( - id && - id === useTabStore.getState().getCurrentNoteId() && - pendingChanges - ) { - postMessage(NativeEvents.title, title || note?.title, tabId); - postMessage(NativeEvents.html, data, tabId); - currentNotes.current[id] = note; + if (id && pendingChanges) { + if (data) { + currentContents.current[id] = { + data: data, + type: "tiptap", + noteId: id + }; + } + lastContentChangeTime.current[id] = Date.now(); + + // Push the restored change into the editor only if the note is + // actually open in a tab, and only into that tab. + const noteTabId = useTabStore.getState().getTabForNote(id); + if (noteTabId !== undefined) { + postMessage(NativeEvents.title, title || note?.title, noteTabId); + if (data) { + postMessage(NativeEvents.html, { data: data }, noteTabId); + } + currentNotes.current[id] = note; + } } if (!saveCount.current[tabId]) { @@ -935,7 +987,9 @@ export const useEditor = ( ignoreEdit, noteId, tabId, - pendingChanges + pendingChanges, + sourceNoteId, + pendingChangesAt }: { noteId?: string; title?: string; @@ -944,6 +998,8 @@ export const useEditor = ( ignoreEdit: boolean; tabId: string; pendingChanges?: boolean; + sourceNoteId?: string; + pendingChangesAt?: number; }) => { DatabaseLogger.log( `saveContent... title: ${!!title}, content: ${!!content}, noteId: ${noteId}` @@ -971,7 +1027,10 @@ export const useEditor = ( return; } - if (noteId) { + // A restored pending change is not a live edit: it may still be + // discarded as stale by saveNote, so it must not claim to be the newest + // content until it is actually written. + if (noteId && !pendingChanges) { lastContentChangeTime.current[noteId] = Date.now(); localTabState.current?.setEditTime(noteId, Date.now()); localTabState?.current?.set(tabId, { @@ -979,7 +1038,7 @@ export const useEditor = ( }); } - if (type === EditorEvents.content && noteId) { + if (type === EditorEvents.content && noteId && !pendingChanges) { currentContents.current[noteId as string] = { data: content, type: "tiptap", @@ -995,12 +1054,15 @@ export const useEditor = ( ignoreEdit, sessionHistoryId: noteId ? editorSessionHistory.get(noteId) : undefined, tabId: tabId, - pendingChanges + pendingChanges, + sourceNoteId, + pendingChangesAt }; + withTimer( - noteId || "newnote", + `${noteId || tabId}:${type}`, () => { - if (!params.id) { + if (!params.id && !params.sourceNoteId) { params.id = useTabStore.getState().getNoteIdForTab(tabId); } if (onChange && params.data) { diff --git a/packages/editor-mobile/src/hooks/useEditorController.ts b/packages/editor-mobile/src/hooks/useEditorController.ts index 169b5d6fc..5755b94da 100644 --- a/packages/editor-mobile/src/hooks/useEditorController.ts +++ b/packages/editor-mobile/src/hooks/useEditorController.ts @@ -153,18 +153,17 @@ export function useEditorController({ const titleChange = useCallback(async (title: string) => { if (!isReactNative()) return; const currentSessionId = globalThis.sessionId; - post( - EditorEvents.contentchange, - undefined, - tabRef.current.id, - tabRef.current.session?.noteId - ); + const editedAt = Date.now(); + + const tabId = tabRef.current.id; + const noteId = tabRef.current.session?.noteId; + post(EditorEvents.contentchange, undefined, tabId, noteId); const params = [ { title }, - tabRef.current.id, - tabRef.current.session?.noteId, + tabId, + noteId, currentSessionId, 1000 ]; @@ -186,12 +185,12 @@ export function useEditorController({ `Saving title failed, setting pending request ${pendingTitleIds.length}` ); if (params[2]) { - pendingSaveRequests.setTitle(params); + pendingSaveRequests.setTitle(params, editedAt); } const element = document.getElementById("editor-saving-failed-overlay"); if (element) { element.style.display = "flex"; - editors[tabRef.current.id]?.commands?.blur(); + editors[tabId]?.commands?.blur(); element.focus(); } }); @@ -217,26 +216,36 @@ export function useEditorController({ return; } const currentSessionId = globalThis.sessionId; - post( - EditorEvents.contentchange, - undefined, - tabRef.current.id, - tabRef.current.session?.noteId - ); + const tabId = tabRef.current.id; + const noteId = tabRef.current.session?.noteId; + post(EditorEvents.contentchange, undefined, tabId, noteId); if (!editor) return; if (typeof timers.current.change === "number") { clearTimeout(timers.current?.change); } timers.current.change = setTimeout(async () => { + if (tabRef.current.session?.noteId !== noteId) { + logger( + "info", + `Edit discarded, tab ${tabId} moved from note ${noteId} to ${tabRef.current.session?.noteId}` + ); + return; + } + if (editorControllers[tabId]?.loading) { + logger("info", "Edit discarded, tab is in loading state"); + return; + } + + const editedAt = Date.now(); htmlContentRef.current = editor.getHTML(); const params = [ { html: htmlContentRef.current, ignoreEdit: ignoreEdit }, - tabRef.current.id, - tabRef.current.session?.noteId, + tabId, + noteId, currentSessionId, 5000 ]; @@ -262,7 +271,7 @@ export function useEditorController({ }` ); if (params[2]) { - pendingSaveRequests.setContent(params); + pendingSaveRequests.setContent(params, editedAt); } const element = document.getElementById( diff --git a/packages/editor-mobile/src/utils/pending-saves.ts b/packages/editor-mobile/src/utils/pending-saves.ts index b81703aa1..71c7a9fb4 100644 --- a/packages/editor-mobile/src/utils/pending-saves.ts +++ b/packages/editor-mobile/src/utils/pending-saves.ts @@ -23,13 +23,14 @@ class PendingSaveRequests { static TITLES = "pendingTitles"; static CONTENT = "pendingContents"; - async setTitle(value: any) { + async setTitle(value: any, editedAt: number) { const pendingTitles = JSON.parse( this.get(PendingSaveRequests.TITLES) || "[]" ); (pendingTitles as any[]).push({ id: randId("title-pending"), + editedAt, params: value }); return localStorage.setItem( @@ -45,13 +46,14 @@ class PendingSaveRequests { return pendingTitles; } - async setContent(value: any) { + async setContent(value: any, editedAt: number) { const pendingContents = JSON.parse( this.get(PendingSaveRequests.CONTENT) || "[]" ); (pendingContents as any[]).push({ id: randId("content-pending"), + editedAt, params: value }); return localStorage.setItem( @@ -118,7 +120,10 @@ class PendingSaveRequests { const pendingTitles = await this.getPendingTitles(); this.remove(PendingSaveRequests.TITLES); for (const pending of pendingTitles) { - if (pending.params[0]) pending.params[0].pendingChanges = true; + if (pending.params[0]) { + pending.params[0].pendingChanges = true; + pending.params[0].pendingChangesAt = pending.editedAt; + } await postAsyncWithTimeout(EditorEvents.title, ...pending.params); } }; @@ -127,7 +132,10 @@ class PendingSaveRequests { const pendingContents = await this.getPendingContent(); this.remove(PendingSaveRequests.CONTENT); for (const pending of pendingContents) { - if (pending.params[0]) pending.params[0].pendingChanges = true; + if (pending.params[0]) { + pending.params[0].pendingChanges = true; + pending.params[0].pendingChangesAt = pending.editedAt; + } await postAsyncWithTimeout(EditorEvents.content, ...pending.params); } };