Private user notes can lose typed text (store echo reverts draft; close within debounce drops save) #18

Closed
opened 2026-09-12 01:50:53 -04:00 by jared · 0 comments
Owner

Severity: high · Type: bug · Confidence: high

Location: src/app/components/user-profile/UserRoomProfile.tsx:212-234; underlying store: src/app/hooks/createAccountDataListStore.ts:95-107
src/app/components/user-profile/UserRoomProfile.tsx:212-234

Problem

UserPrivateNotes has useEffect(() => setDraft(getNote(userId)), [getNote, userId]) with no "dirty" guard. getNote is a useCallback over the notes object from useUserNotes(), whose identity changes on every store notification — and enqueueWrite notifies listeners twice per save: once optimistically when the debounced write is enqueued, and again when the account-data echo for that same write arrives via sync. Each notification re-fires the effect and calls setDraft(getNote(userId)). If the user keeps typing after the ~800ms debounce fired but before that save's own echo lands (a normal debounce-then-keep-typing pattern), the echo's resync reverts the textarea to the older, just-saved value, silently discarding whatever was typed in the interim. Note this is a private, per-user account-data note (io.lotus.user_notes), not room-shared state, and it renders via a plain <textarea> with no dangerouslySetInnerHTML — no XSS concern here, just data loss.

Second mechanism (related finding): UserPrivateNotes debounces saves with setTimeout(..., 800) and stores the pending timer in saveTimer.current. The unmount cleanup effect (useEffect(() => () => clearTimeout(saveTimer.current), [])) only cancels the pending timer — it never flushes the latest draft value to setNote() before cancelling. If the user types a note and closes the user-profile panel (e.g. clicking elsewhere, pressing Escape, navigating away) inside the 800ms window, the debounced write is cancelled and the note is never persisted to io.lotus.user_notes account data — the typed text is lost with no warning, and re-opening the profile shows the old (or empty) note.

How to trigger

Open a user's private note, type a phrase, pause ~800ms so autosave fires, then immediately resume typing more text before the network round-trip completes.

Also: Open a user's profile, type a private note, and close the profile drawer within 800ms of the last keystroke (a very plausible interaction — type a short note then immediately click away). The note is not saved.

Suggested fix

Track a per-note "dirty" ref (mirroring the statusDirtyRef/pendingAppliedRef pattern already used by the adjacent ProfileStatus component specifically to avoid this class of clobber) and skip the resync effect while the user has unsaved local edits.

Also: In the unmount cleanup, if a save is pending, synchronously call setNote(userId, draft) (fire-and-forget is fine) before/instead of just clearTimeout.


Filed from the September 2026 client audit (branch lotus @ 4bea4895).

**Severity:** high · **Type:** bug · **Confidence:** high **Location:** `src/app/components/user-profile/UserRoomProfile.tsx:212-234`; underlying store: `src/app/hooks/createAccountDataListStore.ts:95-107` `src/app/components/user-profile/UserRoomProfile.tsx:212-234` ### Problem `UserPrivateNotes` has `useEffect(() => setDraft(getNote(userId)), [getNote, userId])` with no "dirty" guard. `getNote` is a `useCallback` over the `notes` object from `useUserNotes()`, whose identity changes on every store notification — and `enqueueWrite` notifies listeners twice per save: once optimistically when the debounced write is enqueued, and again when the account-data echo for that same write arrives via sync. Each notification re-fires the effect and calls `setDraft(getNote(userId))`. If the user keeps typing after the ~800ms debounce fired but before that save's own echo lands (a normal debounce-then-keep-typing pattern), the echo's resync reverts the textarea to the older, just-saved value, silently discarding whatever was typed in the interim. Note this is a private, per-user account-data note (`io.lotus.user_notes`), not room-shared state, and it renders via a plain `<textarea>` with no `dangerouslySetInnerHTML` — no XSS concern here, just data loss. **Second mechanism (related finding):** `UserPrivateNotes` debounces saves with `setTimeout(..., 800)` and stores the pending timer in `saveTimer.current`. The unmount cleanup effect (`useEffect(() => () => clearTimeout(saveTimer.current), [])`) only cancels the pending timer — it never flushes the latest `draft` value to `setNote()` before cancelling. If the user types a note and closes the user-profile panel (e.g. clicking elsewhere, pressing Escape, navigating away) inside the 800ms window, the debounced write is cancelled and the note is never persisted to `io.lotus.user_notes` account data — the typed text is lost with no warning, and re-opening the profile shows the old (or empty) note. ### How to trigger Open a user's private note, type a phrase, pause ~800ms so autosave fires, then immediately resume typing more text before the network round-trip completes. Also: Open a user's profile, type a private note, and close the profile drawer within 800ms of the last keystroke (a very plausible interaction — type a short note then immediately click away). The note is not saved. ### Suggested fix Track a per-note "dirty" ref (mirroring the `statusDirtyRef`/`pendingAppliedRef` pattern already used by the adjacent `ProfileStatus` component specifically to avoid this class of clobber) and skip the resync effect while the user has unsaved local edits. Also: In the unmount cleanup, if a save is pending, synchronously call `setNote(userId, draft)` (fire-and-forget is fine) before/instead of just `clearTimeout`. --- --- _Filed from the September 2026 client audit (branch `lotus` @ 4bea4895)._
jared added this to the Audit 2026-09 · High milestone 2026-09-12 01:50:53 -04:00
jared added the bugpriority: higharea: settings labels 2026-09-12 01:50:53 -04:00
jared self-assigned this 2026-09-12 01:50:53 -04:00
jared closed this issue 2026-09-12 14:59:48 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
2026-10-14
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#18