mattermost-mobile-upstream/app/components/post_draft/editor_guard.ts
Guillermo Vayá 5cb2fbedb8 feat(drafts): Phase 3.3 — composer race guards + fail-closed BoR send guard
Guard the composer against draft resurrection now that removeDraft enqueues a
durable DELETE, and fail closed on unreconstructable burn-on-read sends.

- Add per-composer editor_guard module (keyed by serverUrl:channelId:rootId):
  tracks genuine unsaved edits + a generation bumped on Send/Delete. Shared by
  the save site and the invalidation sites; avoids prop-drilling.
- post_input: route blur/background/unmount through guardedSave, which no-ops
  when the editor is clean and discards its write (via a captured-generation
  staleness check) if Send/Delete invalidated the editor mid-save. Genuine
  typing marks dirty; an external/remote value change resets the baseline clean;
  unmount clears the entry.
- draft_handler.clearDraft and deleteDraftConfirmation invalidate the editor so a
  pending lifecycle save cannot recreate a just-sent/deleted draft.
- handleDraftUpdate discards stale markdown-image metadata when the editor
  generation changed during the async parse.
- handle_send_message: fail-closed burn-on-read guard on both send paths — an
  enabled BoR send with invalid/missing durations shows a localized error, keeps
  the draft, and never creates a downgraded plain post. Adds one en.json string.

Checks: npm run tsc clean; eslint --quiet clean; 126 Jest tests pass (editor
guard, handle_send_message incl. BoR fail-closed, draft actions, draft utils).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 17:38:30 +02:00

123 lines
3.9 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Per-composer race guard registry.
//
// Local Draft edits/deletes are routed through a durable outbox. Once a Send or
// Delete clears the draft, a still-pending blur / app-background / unmount save
// can re-run a draft update with stale editor text, resurrecting the draft and
// coalescing an upsert over the durable DELETE. This module tracks, per composer
// key, whether the editor holds a genuine unsaved user edit and a monotonically
// increasing generation that Send/Delete bump so late lifecycle saves become
// no-ops (both via `shouldSaveEditor` and via the captured-generation staleness
// check).
type EditorGuardState = {
// Bumped on every Send/Delete. Lifecycle saves capture the generation before
// the async write and discard their result if it changed in the meantime.
generation: number;
// True only after a genuine user keystroke; cleared once saved or when an
// external/remote value change updates the baseline.
hasUnsavedEdit: boolean;
// Last value known to be persisted (or externally provided). Kept for
// completeness/debugging and to reason about clean-vs-dirty state.
baseline: string;
};
const registry = new Map<string, EditorGuardState>();
/**
* Build the registry key shared by the save site and the invalidation sites.
*/
export function buildEditorKey(serverUrl: string, channelId: string, rootId: string): string {
return `${serverUrl}:${channelId}:${rootId}`;
}
/**
* Lazily create (generation:0, hasUnsavedEdit:false, baseline:'') and return the
* state for a key.
*/
function getOrCreate(key: string): EditorGuardState {
let state = registry.get(key);
if (!state) {
state = {generation: 0, hasUnsavedEdit: false, baseline: ''};
registry.set(key, state);
}
return state;
}
/**
* Mark the editor dirty in response to a genuine user edit.
*/
export function markEditorEdited(key: string): void {
getOrCreate(key).hasUnsavedEdit = true;
}
/**
* Record a successful save: clear the dirty flag and advance the baseline.
*/
export function markEditorSaved(key: string, value: string): void {
const state = getOrCreate(key);
state.hasUnsavedEdit = false;
state.baseline = value;
}
/**
* Update the baseline from an EXTERNAL/remote value change (channel switch,
* clearDraft, remote update). Marks the editor clean but does NOT signal a
* user edit, so a later untouched blur/unmount will not resave stale text.
*/
export function resetEditorBaseline(key: string, value: string): void {
const state = getOrCreate(key);
state.hasUnsavedEdit = false;
state.baseline = value;
}
/**
* Invalidate the editor on Send/Delete: bump the generation (so captured
* generations become stale) and clear the dirty flag (so a subsequent lifecycle
* save is a no-op and cannot resurrect the draft).
*/
export function invalidateEditor(key: string): void {
const state = getOrCreate(key);
state.generation += 1;
state.hasUnsavedEdit = false;
}
/**
* Whether a lifecycle save should proceed (true only when there is a genuine
* unsaved user edit).
*/
export function shouldSaveEditor(key: string): boolean {
return getOrCreate(key).hasUnsavedEdit;
}
/**
* Capture the current generation for a later staleness check.
*/
export function captureEditorGeneration(key: string): number {
return getOrCreate(key).generation;
}
/**
* Whether the generation changed since it was captured (or the key is absent).
*/
export function isEditorGenerationStale(key: string, captured: number): boolean {
const state = registry.get(key);
if (!state) {
return true;
}
return state.generation !== captured;
}
/**
* Remove the entry entirely (call on unmount). A subsequent access lazily
* recreates a fresh entry.
*/
export function clearEditor(key: string): void {
registry.delete(key);
}