From a86e5fb5276cc27dd00fffb5e453e74a787f3a53 Mon Sep 17 00:00:00 2001 From: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Tue, 11 Aug 2026 12:36:11 -0400 Subject: [PATCH] feat(desktop): auto-reload stale renderer when the user is away Long-lived renderer sessions accumulate heap and timer churn until GC pauses queue behind input, degrading scroll and click latency. The Cmd+R clean-teardown reload clears it without touching the native engine or its child agents, but relies on the user noticing and acting. Add an idle backstop that performs the same reload automatically once a session is ~12h old and the user is provably away (OS idle >= 30 min and the window unfocused). It withholds the reload while any reload-destroyed volatile work is outstanding: a starting/active huddle, a foreground or background media upload including its completion settling window, queued local file attachments a draft cannot serialize, or any in-flight send/mutation/native operation. Persisted draft text survives via localStorage, so it is deliberately not a blocker. The reload primitive is one idempotent requestRendererReload() shared by Cmd+R and the backstop: a module-level in-flight promise collapses overlapping triggers so the native WebSocket teardown never runs twice, teardown is bounded to 500 ms, and a rejecting or hung teardown is swallowed so the reload always runs -- a rejection can no longer win the race and strand the renderer on a stale heap. All fire/withhold logic is a pure, exhaustively tested predicate; the hook only wires live signals, arms after the session-age threshold, and serializes overlapping checks. Signals are read imperatively at check time (once a minute) so the hook holds no reactive subscription and never re-renders. The controller mounts inside the ready main-window shell only, guarded so Huddle companion windows and every onboarding/blocking/reset/ keyring/relaunch screen are ineligible by construction. Outstanding volatile work is tracked through one app-global registry (volatileWorkRegistry) that owns acquire/release; the native multi-file picker seam registers at the choke point via withVolatileWork so every caller is covered by construction. The deferred paperclip path is deliberately left unregistered: useFilePicker's hidden input has no cancel signal, so acquiring on open risks a hold that never releases. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/App.tsx | 2 + desktop/src/app/useIdleAutoReload.ts | 107 +++ desktop/src/app/useReloadShortcut.ts | 12 +- .../src/features/forum/ui/ForumComposer.tsx | 8 + .../lib/backgroundMediaUploadStore.ts | 34 + .../features/messages/lib/useMediaUpload.ts | 20 + .../MessageComposerDraftImagePersist.test.mjs | 101 +++ .../messages/ui/useDraftPersistSnapshot.ts | 54 +- desktop/src/shared/api/tauri.ts | 6 +- desktop/src/shared/api/tauriMedia.ts | 26 +- .../shared/lib/idleAutoReloadPolicy.test.mjs | 273 ++++++++ .../src/shared/lib/idleAutoReloadPolicy.ts | 155 +++++ .../src/shared/lib/reloadRenderer.test.mjs | 117 ++++ desktop/src/shared/lib/reloadRenderer.ts | 64 ++ .../shared/lib/volatileWorkRegistry.test.mjs | 284 ++++++++ .../src/shared/lib/volatileWorkRegistry.ts | 127 ++++ .../shared/lib/volatileWorkWiring.test.mjs | 647 ++++++++++++++++++ .../e2e/composer-selection-formatting.spec.ts | 11 +- 18 files changed, 2018 insertions(+), 30 deletions(-) create mode 100644 desktop/src/app/useIdleAutoReload.ts create mode 100644 desktop/src/shared/lib/idleAutoReloadPolicy.test.mjs create mode 100644 desktop/src/shared/lib/idleAutoReloadPolicy.ts create mode 100644 desktop/src/shared/lib/reloadRenderer.test.mjs create mode 100644 desktop/src/shared/lib/reloadRenderer.ts create mode 100644 desktop/src/shared/lib/volatileWorkRegistry.test.mjs create mode 100644 desktop/src/shared/lib/volatileWorkRegistry.ts create mode 100644 desktop/src/shared/lib/volatileWorkWiring.test.mjs diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 0f311f3a650..cddb1c301ef 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -20,6 +20,7 @@ import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { CommunityThemeController } from "@/shared/theme/CommunityThemeController"; import { useReloadShortcut } from "@/app/useReloadShortcut"; +import { IdleAutoReloadController } from "@/app/useIdleAutoReload"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; @@ -283,6 +284,7 @@ function AppReady({ } > + {huddleWindowChannelId() === null ? : null} diff --git a/desktop/src/app/useIdleAutoReload.ts b/desktop/src/app/useIdleAutoReload.ts new file mode 100644 index 00000000000..e6a99ceab49 --- /dev/null +++ b/desktop/src/app/useIdleAutoReload.ts @@ -0,0 +1,107 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { getOsIdleSeconds } from "@/shared/api/osIdle"; +import { invokeTauri } from "@/shared/api/tauri"; +import { isAppFocused } from "@/shared/lib/useDocumentVisible"; +import { + createSerializedIdleReloadCheck, + IDLE_RELOAD_SESSION_AGE_MS, + normalizeOsIdleMs, + type IdleAutoReloadReaders, +} from "@/shared/lib/idleAutoReloadPolicy"; +import { requestRendererReload } from "@/shared/lib/reloadRenderer"; +import { isAnyVolatileWorkPending } from "@/shared/lib/volatileWorkRegistry"; + +/** Wall-clock ms when this renderer module first loaded — the session-age + * anchor. `location.reload()` reloads the module, so a fired reload resets this + * to ~now and re-arms the backstop automatically on the fresh page. */ +const SESSION_LOADED_AT_MS = Date.now(); + +/** How often the backstop re-evaluates once the session is old enough to arm. */ +const CHECK_INTERVAL_MS = 60_000; + +/** True while a huddle is anything but fully idle. Unknown/errored → true so a + * live call is never dropped by a reload. */ +async function readHuddleActive(): Promise { + try { + const state = await invokeTauri<{ phase?: string }>("get_huddle_state"); + return (state?.phase ?? "idle") !== "idle"; + } catch { + return true; + } +} + +/** + * Idle auto-reload backstop. Once the renderer session is ~12h old, checks + * once a minute whether the user is provably away (OS idle + window unfocused) + * with no volatile work outstanding, and if so performs the same clean-teardown + * reload as Cmd+R — discarding the accumulated renderer heap and timers while + * the native engine and its child agents keep running. + * + * The volatile-work blocker withholds the reload during a huddle, a foreground + * or background media upload (including its completion settling window), queued + * local attachments a draft cannot serialize, or any in-flight + * send/mutation/native operation (`queryClient.isMutating()` — sends, media + * pickers, install/import/save all run as mutations). Persisted draft text + * survives reload via `localStorage`, so it is deliberately not a blocker. + * + * All fire/withhold logic lives in the pure, exhaustively tested policy module. + * This hook only wires live signals, arms after the session-age threshold, and + * serializes overlapping checks. Every signal is read imperatively at check + * time (once a minute), so the hook holds no reactive subscription and never + * re-renders. + * + * Mounted via {@link IdleAutoReloadController} — a null-rendering leaf inside + * the main ready shell only. Huddle companion windows and every onboarding / + * blocking / reset / keyring / relaunch screen are ineligible by construction. + */ +export function useIdleAutoReload(): void { + const queryClient = useQueryClient(); + + React.useEffect(() => { + let disposed = false; + let intervalId: number | undefined; + let armTimerId: number | undefined; + + const readers: IdleAutoReloadReaders = { + now: Date.now, + sessionLoadedAtMs: SESSION_LOADED_AT_MS, + isAppFocused, + getOsIdleMs: () => normalizeOsIdleMs(getOsIdleSeconds), + getHuddleActive: readHuddleActive, + isVolatileWorkPending: () => + isAnyVolatileWorkPending() || queryClient.isMutating() > 0, + reload: () => requestRendererReload(), + }; + + const check = createSerializedIdleReloadCheck(readers); + + const startInterval = () => { + if (disposed) return; + check(); + intervalId = window.setInterval(check, CHECK_INTERVAL_MS); + }; + + const msUntilArm = + SESSION_LOADED_AT_MS + IDLE_RELOAD_SESSION_AGE_MS - Date.now(); + if (msUntilArm <= 0) { + startInterval(); + } else { + armTimerId = window.setTimeout(startInterval, msUntilArm); + } + + return () => { + disposed = true; + if (intervalId !== undefined) window.clearInterval(intervalId); + if (armTimerId !== undefined) window.clearTimeout(armTimerId); + }; + }, [queryClient]); +} + +/** Null-rendering mount point for {@link useIdleAutoReload}. Kept as its own + * leaf so nothing in the app tree re-renders on its account. */ +export function IdleAutoReloadController(): null { + useIdleAutoReload(); + return null; +} diff --git a/desktop/src/app/useReloadShortcut.ts b/desktop/src/app/useReloadShortcut.ts index fb93f77f6f6..0e8b5cb9095 100644 --- a/desktop/src/app/useReloadShortcut.ts +++ b/desktop/src/app/useReloadShortcut.ts @@ -1,9 +1,7 @@ import * as React from "react"; -import { closeAllWebSockets } from "@/shared/api/relayWebSocketClose"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; - -const RELOAD_TEARDOWN_TIMEOUT_MS = 500; +import { requestRendererReload } from "@/shared/lib/reloadRenderer"; /** Reloads the webview after bounded native WebSocket teardown. */ export function useReloadShortcut() { @@ -19,13 +17,7 @@ export function useReloadShortcut() { } event.preventDefault(); - await Promise.race([ - closeAllWebSockets(), - new Promise((resolve) => - window.setTimeout(resolve, RELOAD_TEARDOWN_TIMEOUT_MS), - ), - ]); - window.location.reload(); + await requestRendererReload(); } window.addEventListener("keydown", handleKeyDown); diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 6204186abe9..e46f6564b50 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -21,6 +21,7 @@ import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomple import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; +import { useRegisterVolatileWork } from "@/shared/lib/volatileWorkRegistry"; import { DropdownMenu, DropdownMenuContent, @@ -55,6 +56,13 @@ export function ForumComposer({ const contentRef = React.useRef(content); contentRef.current = content; + // The forum composer has no draft persistence, so unsent text lives only in + // renderer memory and a reload discards it. Register it with the app-global + // volatile-work registry so the idle backstop withholds the reload while a + // draft is unsent; the hold releases when the text clears or the forum view + // (and this composer) unmounts, so a closed view never leaks its key. + useRegisterVolatileWork(content.trim().length > 0); + const [isCompactExpanded, setIsCompactExpanded] = React.useState(!compact); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts index ace711985ad..fb8539504ff 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -2,6 +2,7 @@ import * as React from "react"; import type { BlobDescriptor } from "@/shared/api/tauri"; import { cancelMediaUpload, uploadMediaFile } from "@/shared/api/tauriMedia"; +import { registerVolatileWorkPredicate } from "@/shared/lib/volatileWorkRegistry"; import { type BackgroundMediaUploadPhase, isNativeMediaUploadPhase, @@ -336,6 +337,39 @@ export function takeQueuedAttachmentsForDraft( return attachments; } +/** + * True while any local attachment is queued for a deferred upload. These are + * `File` objects retained only in memory (they cannot be serialized into a + * persisted draft), so a renderer reload destroys them — the idle backstop + * withholds the reload until the queue drains via the registered predicate + * below. + */ +function hasQueuedDraftAttachments(): boolean { + for (const attachments of queuedAttachmentsByDraftKey.values()) { + if (attachments.length > 0) return true; + } + return false; +} + +/** + * True while any background media upload is in flight, including the + * `isCompleting` settling window between the last byte and `onComplete` + * resolving. Mirrors the `isUploading` snapshot without a React subscription so + * the idle backstop can read it at check time. + */ +function isBackgroundMediaUploadInFlight(): boolean { + return snapshot.isUploading; +} + +// Register this store's reload-destroyed state with the app-global volatile- +// work registry: an in-flight background upload (its settling window included) +// or a local file retained off-channel for a deferred upload (the retention +// map, in-memory only). The idle backstop reads this at check time and +// withholds the reload while either holds. +registerVolatileWorkPredicate( + () => isBackgroundMediaUploadInFlight() || hasQueuedDraftAttachments(), +); + function subscribe(listener: () => void): () => void { listeners.add(listener); return () => listeners.delete(listener); diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index 4b9d2c6cce7..b4f2f4e1991 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -6,6 +6,7 @@ import { uploadMediaBytes, } from "@/shared/api/tauri"; import { uploadMediaFile } from "@/shared/api/tauriMedia"; +import { useRegisterVolatileWork } from "@/shared/lib/volatileWorkRegistry"; import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots"; import { useFilePicker } from "./useFilePicker"; @@ -622,6 +623,19 @@ export function useMediaUpload({ const handlePaperclip = React.useCallback(async () => { if (queueUntilSend) { + // Deliberately NOT registered with the volatile-work registry. Unlike the + // native picker (`pickAndUploadMedia`), this path opens `useFilePicker`'s + // hidden ``: `input.click()` returns synchronously and + // the dialog is owned by the webview. There is no reliable dismissal + // signal — `useFilePicker`'s own comment notes "Cancel emits no `change`" + // — so acquiring on open would risk a hold that never releases when the + // user cancels, and a leaked key blocks the idle backstop forever (worse + // than the window it would protect). The window is safe to leave + // unregistered: no reload-destroyed state exists until `change` fires + // (which routes into `queueFiles`/`uploadFiles`, both already covered by + // the registered `queuedAttachments`/`uploadingCount` hold), and a user + // interacting with an open dialog is not OS-idle, so the backstop's + // 30-minute idle gate cannot fire mid-dialog anyway. openFilePicker({ multiple: true }, (files) => { queueFiles(files.filter(shouldQueueFile)); uploadFiles(files.filter((file) => !shouldQueueFile(file))); @@ -894,6 +908,12 @@ export function useMediaUpload({ * already-cleared composer. */ const isUploading = uploadingCount > 0; + // Foreground uploads in flight and local files queued for a deferred send + // both live only in this hook's state — a renderer reload discards them. Hold + // the app-global volatile-work registry while either is outstanding so the + // idle backstop withholds the reload; the hold releases when the composer + // drains or unmounts. + useRegisterVolatileWork(isUploading || queuedAttachments.length > 0); const queuedPreviews = React.useMemo( () => queuedAttachments.map((attachment) => ({ diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index 46b5d91d5b2..82571799a1d 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -179,6 +179,13 @@ function installDOMShim() { configurable: true, }); } + // The draft-persist hook registers a window-level `pagehide` listener; back + // window.{add,remove}EventListener/dispatchEvent with an event target so the + // listener binds cleanly and the pagehide flush can be dispatched in tests. + const windowET = new MinimalEventTarget(); + globalThis.addEventListener = windowET.addEventListener.bind(windowET); + globalThis.removeEventListener = windowET.removeEventListener.bind(windowET); + globalThis.dispatchEvent = windowET.dispatchEvent.bind(windowET); if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { Object.defineProperty(globalThis, "navigator", { value: { userAgent: "node" }, @@ -291,6 +298,24 @@ async function mountStrictMode(Comp) { }; } +// Plain (non-StrictMode) mount: no simulate-unmount, so the draft-key-change +// cleanup never fires while mounted. Used to isolate the pagehide flush as the +// sole persist path. +async function mountPlain(Comp) { + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render(React.createElement(Comp)); + }); + return { + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + // ── Tests ───────────────────────────────────────────────────────────────────── /** @@ -662,3 +687,79 @@ test("discarding_a_draft_drops_its_retained_local_files", () => { assert.deepEqual(takeQueuedAttachmentsForDraft("chan-deleted"), []); }); + +/** + * Regression test: typed-but-unpersisted composer text survives a + * pagehide-then-reload cycle (the idle auto-reload backstop and Cmd+R). + * + * ── Background ──────────────────────────────────────────────────────────────── + * `location.reload()` navigates the webview without running React effect + * cleanup, and the draft-key-change cleanup is otherwise the ONLY path that + * persists live editor text. So text typed since the last key change — the + * common case: the user is mid-message when the backstop or Cmd+R fires — + * would be lost. `pagehide` fires before the reload, so the hook flushes the + * CURRENT draft there. + * + * This mounts the REAL `useDraftPersistLifecycle` on a key with NO saved draft, + * types content into the live editor (never triggering a key change), dispatches + * `pagehide`, and asserts the store now holds that text — proving the flush + * captures work the cleanup never saw. + */ +test("pagehide_flush_persists_typed_but_unsaved_composer_text_before_reload", async () => { + const DRAFT_KEY = "chan-pagehide-flush"; + setupStore("pubkey-pagehide-flush"); + // No draft seeded: the live editor text has never been persisted. + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "precondition: empty store", + ); + + // Live editor content the user is typing; the cleanup has not run for it. + const editorContent = "half-written message"; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: () => {}, + clearContent: () => {}, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + }); + return null; + } + + const handle = await mountPlain(HarnessComposer); + + // Still unsaved after mount — mount does not persist an unchanged key, and a + // plain mount runs no cleanup, so only the pagehide flush can persist here. + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "mount alone must not persist the live editor text", + ); + + // The reload path fires pagehide before location.reload(). + await act(async () => { + globalThis.dispatchEvent({ type: "pagehide" }); + }); + + const persisted = loadDraftEntry(DRAFT_KEY); + assert.ok(persisted, "pagehide flush must persist the current draft"); + assert.equal( + persisted.content, + "half-written message", + "typed-but-unsaved composer text must survive the reload", + ); + + await handle.unmount(); +}); diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 14dae33adbc..4977808e3ac 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -116,6 +116,46 @@ export function useDraftPersistLifecycle({ // cleanup always reads the latest value during normal mounted operation. pendingImetaForPersistRef.current = livePendingImeta; + // Persist a draft's volatile composer state — live editor text, queued local + // files, imeta, spoilers, and mention routing. The key/channelId are passed + // explicitly so the draft-key-change cleanup persists the OUTGOING draft + // while the pagehide flush persists the CURRENT one; both read the same live + // editor content and imeta ref, so the write is identical either way. + const persistDraftSnapshot = ( + draftKey: string, + draftChannelId: string | null | undefined, + ) => { + const queuedAttachments = getQueuedAttachments?.() ?? []; + if (queuedAttachments.length > 0) { + saveQueuedAttachmentsForDraft?.(draftKey, queuedAttachments); + } + const content = syncComposerContentFromEditor(); + persistDraft( + draftKey, + content, + draftChannelId ?? draftKey, + [...pendingImetaForPersistRef.current], + [...spoileredAttachmentUrlsRef.current], + getMentionRefs(content), + ); + }; + + // location.reload() (the idle backstop and Cmd+R) and window close do not run + // React effect cleanup, so the live editor text — otherwise persisted only by + // the draft-key-change cleanup below — would be lost. pagehide fires before + // all three, so flush the current draft synchronously there. A ref keeps the + // mount-once listener bound to the live key/channelId without re-subscribing. + // Mirrors the pagehide flush in useObservedUnreadPersistence. + const flushCurrentDraftRef = React.useRef<() => void>(() => {}); + flushCurrentDraftRef.current = () => { + if (effectiveDraftKey) persistDraftSnapshot(effectiveDraftKey, channelId); + }; + React.useEffect(() => { + const onPageHide = () => flushCurrentDraftRef.current(); + window.addEventListener("pagehide", onPageHide); + return () => window.removeEventListener("pagehide", onPageHide); + }, []); + // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { // The outgoing draft is persisted by the cleanup below, which runs before @@ -156,19 +196,7 @@ export function useDraftPersistLifecycle({ return () => { if (effectiveDraftKey) { - const queuedAttachments = getQueuedAttachments?.() ?? []; - if (queuedAttachments.length > 0) { - saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); - } - const content = syncComposerContentFromEditor(); - persistDraft( - effectiveDraftKey, - content, - channelId ?? effectiveDraftKey, - [...pendingImetaForPersistRef.current], - [...spoileredAttachmentUrlsRef.current], - getMentionRefs(content), - ); + persistDraftSnapshot(effectiveDraftKey, channelId); } }; }, [effectiveDraftKey]); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 8eb626a81dd..819c1fad19b 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -593,11 +593,6 @@ export async function uploadMedia( isTemp, }); } -export async function pickAndUploadMedia( - progressId?: string, -): Promise { - return invokeTauri("pick_and_upload_media", { progressId }); -} export async function uploadMediaBytes( data: number[], filename?: string, @@ -612,6 +607,7 @@ export async function uploadMediaBytes( } export { editMessage } from "@/shared/api/editMessage"; +export { pickAndUploadMedia } from "@/shared/api/tauriMedia"; export async function deleteMessage( channelId: string, diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index daedebde5cf..82a011b43ce 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -1,5 +1,6 @@ import { invoke as invokeTauriRaw, isTauri } from "@tauri-apps/api/core"; import { type BlobDescriptor, invokeTauri } from "./tauri"; +import { withVolatileWork } from "@/shared/lib/volatileWorkRegistry"; function encodeRawIpcHeader(value: string): string { const bytes = new TextEncoder().encode(value); @@ -46,7 +47,30 @@ export async function cancelMediaUpload(progressId: string): Promise { * reach the relay. Resolves to `null` when the user cancels the dialog. */ export async function pickAndUploadImage(): Promise { - return invokeTauri("pick_and_upload_image", {}); + // Same reload-destroyed window as `pickAndUploadMedia`: an open OS dialog + // then a Rust-side upload. Hold the volatile-work registry across it (release + // on resolve or cancel) so the idle backstop withholds the reload. + return withVolatileWork("pick-and-upload-image", () => + invokeTauri("pick_and_upload_image", {}), + ); +} + +/** + * Open the native multi-file picker and upload the chosen files, reporting + * per-file progress under `progressId`. Rust performs the upload; this resolves + * once every selected file has landed. + */ +export async function pickAndUploadMedia( + progressId?: string, +): Promise { + // The native picker returns immediately from JS but holds an open OS dialog + // and then a Rust-side upload — reload-destroyed work that no other signal + // (`uploadingCount`, mutations) covers for every caller. Hold the app-global + // volatile-work registry across the whole operation (release on resolve or + // reject) so the idle backstop withholds the reload while the picker is open. + return withVolatileWork("pick-and-upload-media", () => + invokeTauri("pick_and_upload_media", { progressId }), + ); } /** diff --git a/desktop/src/shared/lib/idleAutoReloadPolicy.test.mjs b/desktop/src/shared/lib/idleAutoReloadPolicy.test.mjs new file mode 100644 index 00000000000..4f8ac5b94cb --- /dev/null +++ b/desktop/src/shared/lib/idleAutoReloadPolicy.test.mjs @@ -0,0 +1,273 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createSerializedIdleReloadCheck, + IDLE_RELOAD_OS_IDLE_MS, + IDLE_RELOAD_SESSION_AGE_MS, + normalizeOsIdleMs, + runIdleReloadCheck, + shouldIdleReload, +} from "./idleAutoReloadPolicy.ts"; + +// ── shouldIdleReload gate matrix ───────────────────────────────────────────── +// +// SAFETY-CRITICAL: a wrong "true" navigates the webview out from under the +// user. The all-green baseline is the ONLY fire case; each never-fire row +// flips exactly one input away from green to prove that gate alone holds. + +/** All gates open, session old, user away — the only true case. */ +function greenInputs(overrides = {}) { + return { + osIdleMs: IDLE_RELOAD_OS_IDLE_MS, + sessionAgeMs: IDLE_RELOAD_SESSION_AGE_MS, + appFocused: false, + volatileWorkPending: false, + ...overrides, + }; +} + +test("shouldIdleReload_all_gates_green_returns_true", () => { + assert.equal(shouldIdleReload(greenInputs()), true); +}); + +const NEVER_FIRE_ROWS = [ + ["volatile work pending blocks", { volatileWorkPending: true }], + ["focused app blocks", { appFocused: true }], + ["young session blocks", { sessionAgeMs: IDLE_RELOAD_SESSION_AGE_MS - 1 }], + ["null idle (unsupported platform) blocks", { osIdleMs: null }], + ["below idle threshold blocks", { osIdleMs: IDLE_RELOAD_OS_IDLE_MS - 1 }], +]; + +for (const [name, override] of NEVER_FIRE_ROWS) { + test(`shouldIdleReload_${name.replace(/\s+/g, "_")}_returns_false`, () => { + assert.equal(shouldIdleReload(greenInputs(override)), false); + }); +} + +test("shouldIdleReload_exact_thresholds_are_inclusive", () => { + assert.equal( + shouldIdleReload( + greenInputs({ + osIdleMs: IDLE_RELOAD_OS_IDLE_MS, + sessionAgeMs: IDLE_RELOAD_SESSION_AGE_MS, + }), + ), + true, + ); +}); + +test("shouldIdleReload_volatile_work_beats_all_other_green_gates", () => { + // Every precondition green but volatile work outstanding → still false. + assert.equal( + shouldIdleReload(greenInputs({ volatileWorkPending: true })), + false, + ); +}); + +// ── runIdleReloadCheck orchestration ───────────────────────────────────────── + +const HOUR = 60 * 60 * 1000; + +/** Readers wired to fire: old session, away, all clear. */ +function greenReaders(overrides = {}) { + const calls = { reload: 0 }; + const base = { + now: () => 100 * HOUR, + sessionLoadedAtMs: 100 * HOUR - IDLE_RELOAD_SESSION_AGE_MS, + isAppFocused: () => false, + getOsIdleMs: async () => IDLE_RELOAD_OS_IDLE_MS, + getHuddleActive: async () => false, + isVolatileWorkPending: () => false, + reload: async () => { + calls.reload += 1; + }, + }; + return { readers: { ...base, ...overrides }, calls }; +} + +test("runIdleReloadCheck_fires_reload_when_all_conditions_hold", async () => { + const { readers, calls } = greenReaders(); + assert.equal(await runIdleReloadCheck(readers), true); + assert.equal(calls.reload, 1); +}); + +test("runIdleReloadCheck_young_session_skips_before_any_ipc", async () => { + let osIdleReads = 0; + let huddleReads = 0; + const { readers, calls } = greenReaders({ + sessionLoadedAtMs: 100 * HOUR - (IDLE_RELOAD_SESSION_AGE_MS - 1), + getOsIdleMs: async () => { + osIdleReads += 1; + return IDLE_RELOAD_OS_IDLE_MS; + }, + getHuddleActive: async () => { + huddleReads += 1; + return false; + }, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); + // A present-enough session must not incur the per-minute IPC reads. + assert.equal(osIdleReads, 0); + assert.equal(huddleReads, 0); +}); + +test("runIdleReloadCheck_focused_user_skips_before_any_ipc", async () => { + let osIdleReads = 0; + const { readers, calls } = greenReaders({ + isAppFocused: () => true, + getOsIdleMs: async () => { + osIdleReads += 1; + return IDLE_RELOAD_OS_IDLE_MS; + }, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); + assert.equal(osIdleReads, 0); +}); + +test("runIdleReloadCheck_huddle_active_blocks_reload", async () => { + const { readers, calls } = greenReaders({ + getHuddleActive: async () => true, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); +}); + +test("runIdleReloadCheck_volatile_work_blocks_reload", async () => { + // Stands in for a foreground/background upload, queued local attachment, or + // in-flight send/mutation/native operation — the hook folds all into one read. + const { readers, calls } = greenReaders({ + isVolatileWorkPending: () => true, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); +}); + +test("runIdleReloadCheck_null_os_idle_blocks_reload", async () => { + const { readers, calls } = greenReaders({ + getOsIdleMs: async () => null, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); +}); + +// ── normalizeOsIdleMs — the extracted reader normalizer ────────────────────── +// +// The MINOR finding: prove a rejecting getOsIdleSeconds withholds the reload +// this tick, then a later successful read lets it fire. The normalizer is the +// seam that collapses all three "cannot confirm away" outcomes to null; it was +// extracted to the pure module precisely so this path tests without the hook's +// React/timer/IPC weight. + +test("normalizeOsIdleMs_seconds_convert_to_milliseconds", async () => { + assert.equal(await normalizeOsIdleMs(async () => 42), 42_000); +}); + +test("normalizeOsIdleMs_null_reader_stays_null", async () => { + assert.equal(await normalizeOsIdleMs(async () => null), null); +}); + +test("normalizeOsIdleMs_rejecting_reader_collapses_to_null", async () => { + // A failed idle IPC must never surface as a numeric idle the policy could + // read as "away" — it collapses to null, which shouldIdleReload withholds on. + assert.equal( + await normalizeOsIdleMs(async () => { + throw new Error("idle IPC failed"); + }), + null, + ); +}); + +test("idle_read_rejection_withholds_this_tick_then_retry_fires", async () => { + // The full MINOR contract through the real normalizer + orchestrator: the + // first tick's idle read rejects → normalizeOsIdleMs → null → withheld; the + // next tick's read succeeds → the reload fires. No stuck state between ticks. + let attempt = 0; + const getOsIdleSeconds = async () => { + attempt += 1; + if (attempt === 1) throw new Error("transient idle IPC failure"); + return IDLE_RELOAD_OS_IDLE_MS / 1000; + }; + const { readers, calls } = greenReaders({ + getOsIdleMs: () => normalizeOsIdleMs(getOsIdleSeconds), + }); + + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0, "a rejected idle read must withhold this tick"); + + assert.equal(await runIdleReloadCheck(readers), true); + assert.equal(calls.reload, 1, "the next tick's successful read must fire"); +}); + +test("runIdleReloadCheck_user_returns_during_ipc_await_blocks_reload", async () => { + // Focus flips to true while the async idle/huddle reads are in flight; the + // post-await re-read of the synchronous focus signal must catch it. + let focused = false; + const { readers, calls } = greenReaders({ + isAppFocused: () => focused, + getOsIdleMs: async () => { + focused = true; // user touched the machine mid-check + return IDLE_RELOAD_OS_IDLE_MS; + }, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); +}); + +test("runIdleReloadCheck_volatile_work_appears_during_ipc_await_blocks_reload", async () => { + let busy = false; + const { readers, calls } = greenReaders({ + isVolatileWorkPending: () => busy, + getHuddleActive: async () => { + busy = true; // a send/upload began mid-check + return false; + }, + }); + assert.equal(await runIdleReloadCheck(readers), false); + assert.equal(calls.reload, 0); +}); + +// ── createSerializedIdleReloadCheck ────────────────────────────────────────── + +test("serialized_check_drops_ticks_while_a_check_is_pending", async () => { + let releaseIdle; + let osIdleReads = 0; + const { readers, calls } = greenReaders({ + getOsIdleMs: () => { + osIdleReads += 1; + return new Promise((resolve) => { + releaseIdle = () => resolve(IDLE_RELOAD_OS_IDLE_MS); + }); + }, + }); + const check = createSerializedIdleReloadCheck(readers); + check(); // starts a check that parks on the idle read + check(); // second tick lands while the first is pending — dropped + check(); + assert.equal(osIdleReads, 1); + releaseIdle(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls.reload, 1); +}); + +test("serialized_check_runs_again_after_the_previous_check_settles", async () => { + let osIdleReads = 0; + const { readers, calls } = greenReaders({ + getOsIdleMs: async () => { + osIdleReads += 1; + return IDLE_RELOAD_OS_IDLE_MS; + }, + reload: async () => { + calls.reload += 1; + }, + }); + const check = createSerializedIdleReloadCheck(readers); + check(); + await new Promise((resolve) => setTimeout(resolve, 0)); + check(); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Both ticks ran to completion because each settled before the next. + assert.equal(osIdleReads, 2); +}); diff --git a/desktop/src/shared/lib/idleAutoReloadPolicy.ts b/desktop/src/shared/lib/idleAutoReloadPolicy.ts new file mode 100644 index 00000000000..2e350653341 --- /dev/null +++ b/desktop/src/shared/lib/idleAutoReloadPolicy.ts @@ -0,0 +1,155 @@ +/** + * Idle auto-reload backstop — the pure decision core. + * + * A long-lived renderer session accumulates heap and timer churn until GC + * pauses queue behind input (the multi-GB, multi-hour sessions that motivate + * this feature). The Cmd+R clean-teardown reload discards that state without + * touching the native engine or its child agents. This backstop performs the + * same reload automatically, but ONLY when the user is provably away and the + * session is old enough to have accumulated the churn — never during use. + * + * SAFETY: the reload navigates the webview (`location.reload()`), so a wrong + * "fire" interrupts the user or drops in-flight work. Every gate below is + * load-bearing; the test matrix enumerates them exhaustively. When any signal + * is unknown (e.g. `osIdleMs === null` on platforms without an idle API), the + * policy withholds the reload rather than guessing. + */ +export type IdleAutoReloadInputs = { + /** + * Milliseconds since the last OS-wide user input, or null where the platform + * exposes no idle API. Null is treated as "cannot confirm away" — never fire. + */ + osIdleMs: number | null; + /** Milliseconds since this renderer session loaded. */ + sessionAgeMs: number; + /** True while the window is visible AND focused; the reload waits for away. */ + appFocused: boolean; + /** + * True while any volatile, reload-destroyed work is outstanding: a huddle + * starting/active, a foreground or background media upload (including its + * completion settling window), queued local attachments a draft cannot + * serialize, or an in-flight send/mutation/native operation. One blocker so + * a new volatile-work class is guarded by extending its single reader, not by + * threading another gate through this predicate. + */ + volatileWorkPending: boolean; +}; + +/** OS idle the reload waits for: long enough that the user has clearly stepped + * away rather than paused to read. */ +export const IDLE_RELOAD_OS_IDLE_MS = 30 * 60 * 1000; + +/** Minimum renderer session age before the backstop arms: below this the heap + * and timer churn have not grown enough to be worth a reload. */ +export const IDLE_RELOAD_SESSION_AGE_MS = 12 * 60 * 60 * 1000; + +/** + * Decide whether the idle backstop should reload the renderer now. + * + * Returns true only when the session is old, the user is provably away, and no + * volatile work is outstanding. Any red gate returns false. + */ +export function shouldIdleReload(inputs: IdleAutoReloadInputs): boolean { + const { osIdleMs, sessionAgeMs, appFocused, volatileWorkPending } = inputs; + + // The single volatile-work blocker: anything a reload would destroy. + if (volatileWorkPending) return false; + + // Preconditions: only reload a stale session whose user has stepped away. + if (appFocused) return false; + if (sessionAgeMs < IDLE_RELOAD_SESSION_AGE_MS) return false; + if (osIdleMs === null) return false; + return osIdleMs >= IDLE_RELOAD_OS_IDLE_MS; +} + +/** + * Normalize a raw OS-idle read into the milliseconds the policy consumes. + * + * The platform reader ({@link getOsIdleSeconds}) reports seconds, null where no + * idle API exists, and rejects when the IPC call fails. All three "cannot + * confirm away" outcomes collapse to null so the policy withholds the reload + * rather than guessing. Pure and reader-injected so the rejection path is + * testable without importing the hook (React, timers, IPC). + */ +export async function normalizeOsIdleMs( + getOsIdleSeconds: () => Promise, +): Promise { + try { + const seconds = await getOsIdleSeconds(); + return seconds === null ? null : seconds * 1000; + } catch { + return null; + } +} + +/** + * Injectable signal sources for {@link runIdleReloadCheck}. The hook wires + * these to live app state; tests wire them to fixtures — no React, timers, or + * IPC required to exercise the orchestration. + */ +export type IdleAutoReloadReaders = { + now: () => number; + /** Wall-clock ms when this renderer session loaded. */ + sessionLoadedAtMs: number; + isAppFocused: () => boolean; + /** Async OS idle read; resolves null where unknown/unsupported. */ + getOsIdleMs: () => Promise; + /** Async huddle read; resolves true when a call is in progress OR unknown. */ + getHuddleActive: () => Promise; + /** Synchronous read of non-huddle volatile work (uploads, queued files, + * in-flight sends/mutations/native operations). */ + isVolatileWorkPending: () => boolean; + reload: () => Promise; +}; + +/** + * Gather the current signals and reload the renderer iff {@link shouldIdleReload} + * says so. Returns whether the reload fired. + * + * The session-age and focus gates run before the async IPC reads so a present + * user never triggers per-minute huddle/idle queries. The synchronous signals + * are re-read when the decision is made so state that changed during the IPC + * awaits (the user returning or starting work mid-check) still blocks the + * reload. + */ +export async function runIdleReloadCheck( + readers: IdleAutoReloadReaders, +): Promise { + const sessionAgeMs = readers.now() - readers.sessionLoadedAtMs; + if (sessionAgeMs < IDLE_RELOAD_SESSION_AGE_MS) return false; + if (readers.isAppFocused()) return false; + + const osIdleMs = await readers.getOsIdleMs(); + const huddleActive = await readers.getHuddleActive(); + + const fire = shouldIdleReload({ + osIdleMs, + sessionAgeMs: readers.now() - readers.sessionLoadedAtMs, + appFocused: readers.isAppFocused(), + volatileWorkPending: huddleActive || readers.isVolatileWorkPending(), + }); + if (!fire) return false; + + await readers.reload(); + return true; +} + +/** + * Wrap {@link runIdleReloadCheck} so overlapping interval ticks collapse to one + * in-flight check: a tick that lands while the previous check is still awaiting + * its IPC reads (or reload) is dropped. Keeps the per-minute poll from stacking + * concurrent huddle/idle queries. Pure — no React or timers — so the collapse + * is exercised directly in tests. + */ +export function createSerializedIdleReloadCheck( + readers: IdleAutoReloadReaders, +): () => void { + let running = false; + return () => { + if (running) return; + running = true; + void runIdleReloadCheck(readers).finally(() => { + running = false; + }); + }; +} diff --git a/desktop/src/shared/lib/reloadRenderer.test.mjs b/desktop/src/shared/lib/reloadRenderer.test.mjs new file mode 100644 index 00000000000..5db0d7e674b --- /dev/null +++ b/desktop/src/shared/lib/reloadRenderer.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test } from "node:test"; + +import { + RELOAD_TEARDOWN_TIMEOUT_MS, + requestRendererReload, + resetRendererReloadForTests, +} from "./reloadRenderer.ts"; + +// requestRendererReload takes injectable `reload`/`closeSockets`, so the +// teardown/timeout race and idempotence run headless without navigating the +// runner. Only `window.setTimeout` needs a stub for the timeout arm. +let originalWindow; + +beforeEach(() => { + originalWindow = globalThis.window; + globalThis.window = { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (id) => clearTimeout(id), + }; +}); + +afterEach(() => { + globalThis.window = originalWindow; + resetRendererReloadForTests(); +}); + +test("requestRendererReload_tears_down_sockets_then_reloads", async () => { + let reloadCount = 0; + let closeCalls = 0; + let resolveClose; + const done = requestRendererReload({ + reload: () => { + reloadCount += 1; + }, + closeSockets: () => { + closeCalls += 1; + return new Promise((resolve) => { + resolveClose = resolve; + }); + }, + }); + // Teardown started; reload waits for the race to settle. + assert.equal(closeCalls, 1); + assert.equal(reloadCount, 0); + resolveClose(); + await done; + assert.equal(reloadCount, 1); +}); + +test("requestRendererReload_reloads_even_when_teardown_hangs_past_timeout", async () => { + let reloadCount = 0; + // Never resolve the teardown; only the timeout can win the race. + await requestRendererReload({ + reload: () => { + reloadCount += 1; + }, + closeSockets: () => new Promise(() => {}), + }); + assert.equal(reloadCount, 1); + assert.ok(RELOAD_TEARDOWN_TIMEOUT_MS > 0); +}); + +test("requestRendererReload_reloads_even_when_teardown_rejects", async () => { + let reloadCount = 0; + // A rejecting teardown must not win the race and strand the stale renderer. + await requestRendererReload({ + reload: () => { + reloadCount += 1; + }, + closeSockets: () => Promise.reject(new Error("socket wedged")), + }); + assert.equal(reloadCount, 1); +}); + +test("requestRendererReload_collapses_concurrent_triggers_to_one", async () => { + let reloadCount = 0; + let closeCalls = 0; + let resolveClose; + const overrides = { + reload: () => { + reloadCount += 1; + }, + closeSockets: () => { + closeCalls += 1; + return new Promise((resolve) => { + resolveClose = resolve; + }); + }, + }; + // Cmd+R and the idle backstop fire while the first teardown is mid-flight. + const first = requestRendererReload(overrides); + const second = requestRendererReload(overrides); + assert.equal(first, second); + assert.equal(closeCalls, 1); + resolveClose(); + await Promise.all([first, second]); + // Teardown ran once; the webview navigated once. + assert.equal(closeCalls, 1); + assert.equal(reloadCount, 1); +}); + +test("requestRendererReload_stays_collapsed_after_settling", async () => { + let reloadCount = 0; + const overrides = { + reload: () => { + reloadCount += 1; + }, + closeSockets: () => Promise.resolve(), + }; + await requestRendererReload(overrides); + assert.equal(reloadCount, 1); + // In production location.reload() discards the module; the guard is never + // reset, so a settled reload cannot start a second on the dying page. + await requestRendererReload(overrides); + assert.equal(reloadCount, 1); +}); diff --git a/desktop/src/shared/lib/reloadRenderer.ts b/desktop/src/shared/lib/reloadRenderer.ts new file mode 100644 index 00000000000..849dffb919b --- /dev/null +++ b/desktop/src/shared/lib/reloadRenderer.ts @@ -0,0 +1,64 @@ +import { closeAllWebSockets } from "@/shared/api/relayWebSocketClose"; + +/** Upper bound on the graceful WebSocket teardown before the webview reloads. */ +export const RELOAD_TEARDOWN_TIMEOUT_MS = 500; + +/** + * The single reload in progress. Overlapping triggers (a second Cmd+R, or the + * idle backstop firing while Cmd+R is mid-teardown) collapse onto it so the + * native WebSocket teardown never runs twice. Never cleared in production — + * `location.reload()` discards the module — so a settled reload cannot start a + * second one on the dying page. + */ +let inFlightReload: Promise | null = null; + +type RequestRendererReloadOverrides = { + /** Navigate the webview. Injected in tests so the runner never reloads. */ + reload?: () => void; + /** Bounded native teardown. Injected to exercise the rejection/timeout path. */ + closeSockets?: () => Promise; +}; + +/** + * Reload the webview after a bounded native WebSocket teardown, exactly once + * per navigation. + * + * Shared by the Cmd+R shortcut (`useReloadShortcut`) and the idle backstop + * (`useIdleAutoReload`): both need the same clean-teardown path so the native + * engine and its child agents survive while the renderer's heap and timers are + * discarded. Only the backstop applies eligibility guards; this primitive is + * pure execution. + * + * The reload is unconditional. Teardown is attempted for at most + * `RELOAD_TEARDOWN_TIMEOUT_MS`; a hung socket loses to the timeout and a + * rejecting teardown is logged and swallowed, so the reload always runs in the + * `finally`. A rejection must never win the race and strand the renderer on a + * stale heap. + */ +export function requestRendererReload({ + reload = () => window.location.reload(), + closeSockets = closeAllWebSockets, +}: RequestRendererReloadOverrides = {}): Promise { + if (inFlightReload) return inFlightReload; + inFlightReload = (async () => { + try { + await Promise.race([ + closeSockets().catch((err) => + console.debug("requestRendererReload teardown rejected:", err), + ), + new Promise((resolve) => + window.setTimeout(resolve, RELOAD_TEARDOWN_TIMEOUT_MS), + ), + ]); + } finally { + reload(); + } + })(); + return inFlightReload; +} + +/** Clear the module-level in-flight reload between tests (production reloads + * the page, so the guard is never reset there). */ +export function resetRendererReloadForTests(): void { + inFlightReload = null; +} diff --git a/desktop/src/shared/lib/volatileWorkRegistry.test.mjs b/desktop/src/shared/lib/volatileWorkRegistry.test.mjs new file mode 100644 index 00000000000..31de72db493 --- /dev/null +++ b/desktop/src/shared/lib/volatileWorkRegistry.test.mjs @@ -0,0 +1,284 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// The registry hook uses React.useId + React.useEffect, so the mounted-lifecycle +// tests need a DOM. The imperative acquire/release/predicate surface is pure. +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +async function loadRegistry() { + const mod = await import("./volatileWorkRegistry.ts"); + mod._resetVolatileWorkForTests(); + return mod; +} + +afterEach(async () => { + const { _resetVolatileWorkForTests } = await import( + "./volatileWorkRegistry.ts" + ); + _resetVolatileWorkForTests(); +}); + +// ── acquire / release ──────────────────────────────────────────────────────── + +test("isAnyVolatileWorkPending_no_registrations_returns_false", async () => { + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +test("acquireVolatileWork_holds_until_released", async () => { + const { acquireVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + const release = acquireVolatileWork("op-1"); + assert.equal(isAnyVolatileWorkPending(), true); + release(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +test("acquireVolatileWork_same_key_twice_is_idempotent", async () => { + // Two acquisitions of the same key collapse to one hold; the key is either + // held or not, so a single release clears it (no refcount drift). + const { acquireVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + const releaseA = acquireVolatileWork("op-dup"); + const releaseB = acquireVolatileWork("op-dup"); + assert.equal(isAnyVolatileWorkPending(), true); + releaseA(); + assert.equal(isAnyVolatileWorkPending(), false); + // The second release is a harmless no-op on an already-clear key. + releaseB(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +test("acquireVolatileWork_distinct_keys_each_hold_independently", async () => { + const { acquireVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + const releaseA = acquireVolatileWork("op-a"); + const releaseB = acquireVolatileWork("op-b"); + releaseA(); + // b still outstanding — a concurrent picker/upload must keep blocking. + assert.equal(isAnyVolatileWorkPending(), true); + releaseB(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +// ── withVolatileWork (bounded async seam) ───────────────────────────────────── + +test("withVolatileWork_holds_while_running_releases_on_resolve", async () => { + const { withVolatileWork, isAnyVolatileWorkPending } = await loadRegistry(); + let releaseInner; + const pending = withVolatileWork( + "op", + () => new Promise((resolve) => (releaseInner = () => resolve("done"))), + ); + // The hold is live while the wrapped operation is in flight. + assert.equal(isAnyVolatileWorkPending(), true); + releaseInner(); + assert.equal(await pending, "done", "the wrapped result passes through"); + assert.equal(isAnyVolatileWorkPending(), false, "resolve releases the hold"); +}); + +test("withVolatileWork_releases_on_reject", async () => { + // A failed picker/upload must not leak its hold — the reload would be blocked + // forever with nothing to surface it. + const { withVolatileWork, isAnyVolatileWorkPending } = await loadRegistry(); + await assert.rejects( + withVolatileWork("op", async () => { + throw new Error("picker failed"); + }), + ); + assert.equal(isAnyVolatileWorkPending(), false, "reject releases the hold"); +}); + +test("withVolatileWork_concurrent_calls_hold_independently", async () => { + // Two overlapping operations (composer paperclip + custom-emoji upload) must + // get distinct keys so one settling does not release the other's hold. + const { withVolatileWork, isAnyVolatileWorkPending } = await loadRegistry(); + let releaseA; + let releaseB; + const a = withVolatileWork( + "op", + () => new Promise((resolve) => (releaseA = resolve)), + ); + const b = withVolatileWork( + "op", + () => new Promise((resolve) => (releaseB = resolve)), + ); + assert.equal(isAnyVolatileWorkPending(), true); + releaseA(); + await a; + assert.equal( + isAnyVolatileWorkPending(), + true, + "the second operation's hold must survive the first settling", + ); + releaseB(); + await b; + assert.equal(isAnyVolatileWorkPending(), false); +}); + +// ── predicates ─────────────────────────────────────────────────────────────── + +test("registerVolatileWorkPredicate_reports_pending_on_demand", async () => { + const { registerVolatileWorkPredicate, isAnyVolatileWorkPending } = + await loadRegistry(); + let busy = false; + registerVolatileWorkPredicate(() => busy); + assert.equal(isAnyVolatileWorkPending(), false); + busy = true; + assert.equal(isAnyVolatileWorkPending(), true); + busy = false; + assert.equal(isAnyVolatileWorkPending(), false); +}); + +test("registerVolatileWorkPredicate_unregister_stops_reads", async () => { + const { registerVolatileWorkPredicate, isAnyVolatileWorkPending } = + await loadRegistry(); + const unregister = registerVolatileWorkPredicate(() => true); + assert.equal(isAnyVolatileWorkPending(), true); + unregister(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +test("isAnyVolatileWorkPending_holder_alone_is_enough", async () => { + // A held key blocks even when every predicate reports idle. + const { + acquireVolatileWork, + registerVolatileWorkPredicate, + isAnyVolatileWorkPending, + } = await loadRegistry(); + registerVolatileWorkPredicate(() => false); + const release = acquireVolatileWork("op-solo"); + assert.equal(isAnyVolatileWorkPending(), true); + release(); + assert.equal(isAnyVolatileWorkPending(), false); +}); + +// ── useRegisterVolatileWork (mounted lifecycle) ──────────────────────────────── + +test("useRegisterVolatileWork_holds_while_active_true", async () => { + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + renderHook(() => useRegisterVolatileWork(true)); + assert.equal(isAnyVolatileWorkPending(), true); + } finally { + cleanup(); + } +}); + +test("useRegisterVolatileWork_active_false_does_not_hold", async () => { + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + renderHook(() => useRegisterVolatileWork(false)); + assert.equal(isAnyVolatileWorkPending(), false); + } finally { + cleanup(); + } +}); + +test("useRegisterVolatileWork_releases_when_active_flips_false", async () => { + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + const { rerender } = renderHook( + ({ active }) => useRegisterVolatileWork(active), + { initialProps: { active: true } }, + ); + assert.equal(isAnyVolatileWorkPending(), true); + rerender({ active: false }); + assert.equal( + isAnyVolatileWorkPending(), + false, + "clearing work while mounted must stop blocking", + ); + } finally { + cleanup(); + } +}); + +test("useRegisterVolatileWork_releases_on_unmount", async () => { + // A component torn down mid-work (the forum view closing with unsent text) + // must not leak its key — a leaked key blocks the reload forever. + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + const { unmount } = renderHook(() => useRegisterVolatileWork(true)); + assert.equal(isAnyVolatileWorkPending(), true); + unmount(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "unmount must release the pending key", + ); + } finally { + cleanup(); + } +}); + +test("useRegisterVolatileWork_two_mounts_use_distinct_keys", async () => { + // Two composers active at once must not collide on one key: releasing one + // must leave the other's hold intact. + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + const first = renderHook(() => useRegisterVolatileWork(true)); + const second = renderHook(() => useRegisterVolatileWork(true)); + assert.equal(isAnyVolatileWorkPending(), true); + first.unmount(); + assert.equal( + isAnyVolatileWorkPending(), + true, + "the second mount's key must survive the first's unmount", + ); + second.unmount(); + assert.equal(isAnyVolatileWorkPending(), false); + } finally { + cleanup(); + } +}); + +test("useRegisterVolatileWork_strict_mode_settles_to_single_hold", async () => { + // StrictMode double-invokes the effect (acquire → release → acquire). Because + // the key is stable across the replay and the registry is keyed, the final + // state is a single hold that a single unmount fully releases. + const React = await import("react"); + const { cleanup, renderHook } = await import("@testing-library/react"); + const { useRegisterVolatileWork, isAnyVolatileWorkPending } = + await loadRegistry(); + try { + const { unmount } = renderHook(() => useRegisterVolatileWork(true), { + wrapper: ({ children }) => + React.createElement(React.StrictMode, null, children), + }); + assert.equal(isAnyVolatileWorkPending(), true); + unmount(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "StrictMode replay must not leave a residual hold after unmount", + ); + } finally { + cleanup(); + } +}); diff --git a/desktop/src/shared/lib/volatileWorkRegistry.ts b/desktop/src/shared/lib/volatileWorkRegistry.ts new file mode 100644 index 00000000000..9f77108af69 --- /dev/null +++ b/desktop/src/shared/lib/volatileWorkRegistry.ts @@ -0,0 +1,127 @@ +import * as React from "react"; + +/** + * App-global registry of volatile, reload-destroyed work. + * + * The idle auto-reload backstop (`useIdleAutoReload`) navigates the webview + * (`location.reload()`), discarding the renderer heap. Any work that lives only + * in renderer memory — a hook-local queue of local files, unsent composer text a + * draft cannot serialize, an open native picker whose result has not returned — + * would be lost if the reload fired mid-flight. Each such source registers here; + * {@link isAnyVolatileWorkPending} folds them into the single blocker the policy + * reads, so a new volatile-work class is guarded by registering it, not by + * threading another gate through the reload predicate. + * + * Two registration shapes, because volatile work comes in two lifetimes: + * + * - {@link registerVolatileWorkPredicate} — a module-level store (e.g. the + * background upload store) whose pending state is read on demand. Registered + * once at import; never released. + * - {@link acquireVolatileWork} / {@link useRegisterVolatileWork} — a bounded + * operation or a mounted component's live state. Acquiring returns a release + * handle; the work counts as pending until it is released. A leaked handle + * blocks the backstop forever and nothing surfaces it, so release is wired to + * both unmount and the `active` flag flipping false (see the hook). + */ + +/** + * Live acquire handles, keyed so repeated acquisition from one owner (a + * component key, an operation) collapses to a single hold. StrictMode double- + * invokes effects (acquire → release → acquire); a keyed map is idempotent + * under that replay where a bare counter would drift. + */ +const holders = new Set(); + +/** On-demand predicates contributed by module-level stores. */ +const predicates = new Set<() => boolean>(); + +/** + * Register a predicate read at reload-check time. Returns an unregister fn for + * symmetry and tests; module-level stores register once and never unregister. + */ +export function registerVolatileWorkPredicate( + predicate: () => boolean, +): () => void { + predicates.add(predicate); + return () => { + predicates.delete(predicate); + }; +} + +/** + * Mark a bounded volatile operation as in progress under `key`, returning a + * release handle. Repeated acquisition under the same key is idempotent (the + * key is either held or not); the returned release clears that key. Callers own + * exactly one release call per acquire — a native-picker wrapper releases in a + * `finally`, a component effect releases on cleanup. + */ +export function acquireVolatileWork(key: string): () => void { + holders.add(key); + return () => { + holders.delete(key); + }; +} + +/** Monotonic suffix so overlapping {@link withVolatileWork} calls hold distinct + * keys (e.g. the composer paperclip and a custom-emoji upload running at once). */ +let workSeq = 0; + +/** + * Run an async operation while holding a volatile-work key, releasing it once + * the operation settles — resolve OR reject. The single seam for bounded + * reload-destroyed work (native pickers, IPC uploads): the `finally` cannot + * leak the hold, and a per-call key keeps concurrent operations independent. + */ +export async function withVolatileWork( + label: string, + run: () => Promise, +): Promise { + const release = acquireVolatileWork(`${label}-${workSeq++}`); + try { + return await run(); + } finally { + release(); + } +} + +/** + * True while any registered source has volatile work outstanding — a held + * acquire key or a predicate that reports pending. Read imperatively by the + * idle backstop at check time; holds no subscription. + */ +export function isAnyVolatileWorkPending(): boolean { + if (holders.size > 0) return true; + for (const predicate of predicates) { + if (predicate()) return true; + } + return false; +} + +/** + * Register a mounted component's live volatile state with the registry. + * + * While `active` is true the component holds a unique per-mount key (from + * {@link React.useId}, so two composers never collide); when `active` flips + * false the hold releases, and unmount releases it too. Both paths matter: a + * component that clears its work but stays mounted must stop blocking, and a + * component torn down mid-work (the forum view closing with unsent text) must + * not leak its key. + * + * StrictMode double-invokes this effect (acquire → release → acquire); because + * the key is stable across the replay and the registry is keyed, the final + * state is a single hold, matching production's single invoke. + */ +export function useRegisterVolatileWork(active: boolean): void { + const key = React.useId(); + React.useEffect(() => { + if (!active) return; + const release = acquireVolatileWork(key); + return release; + }, [active, key]); +} + +/** Clear all registrations. Tests only — production never resets. */ +export function _resetVolatileWorkForTests(): void { + holders.clear(); + predicates.clear(); +} diff --git a/desktop/src/shared/lib/volatileWorkWiring.test.mjs b/desktop/src/shared/lib/volatileWorkWiring.test.mjs new file mode 100644 index 00000000000..787795e9fe8 --- /dev/null +++ b/desktop/src/shared/lib/volatileWorkWiring.test.mjs @@ -0,0 +1,647 @@ +/** + * Mounted production-wiring tests for the volatile-work registry. + * + * ── What this proves ───────────────────────────────────────────────────────── + * The registry itself is unit-tested in volatileWorkRegistry.test.mjs. This + * file proves the OTHER half of the CRITICAL finding: that each real source + * Thufir enumerated actually registers its reload-destroyed work, by mounting + * the REAL production code (not a fixture with the blocker hand-set) and + * asserting `isAnyVolatileWorkPending()` flips as the source's live state does. + * + * Covered sources, each empty → active → drained: + * - ForumComposer's unsent draft: full mount (real tiptap editor, router, + * providers); empty composer never blocks, real editor input blocks, and + * unmount releases (the forum view closing must not leak its key). + * - useMediaUpload, BOTH branches: the production composer takes the + * `deferUploadsUntilSend` branch (queued local videos), the non-deferred + * path is a foreground upload in flight. A mounted test on only one branch + * would "prove" coverage the other path never gets. + * - The native-picker choke points `pickAndUploadMedia` (tauri.ts) and + * `pickAndUploadImage` (tauriMedia.ts): the open-dialog + Rust-upload window + * blocks, and the `finally` releases on both resolve and reject. Testing at + * the choke point (not the caller) is what covers every picker caller — + * CustomEmojiSettingsCard and useSendFeedback have no uploadingCount. + * - backgroundMediaUploadStore's registered predicate: an off-channel + * retained local file (the in-memory `queuedAttachmentsByDraftKey` map) and + * an in-flight background task both block; draining either releases. + * + * ── DOM + process-exit note ────────────────────────────────────────────────── + * The full ForumComposer mount pulls in tiptap/ProseMirror + TanStack Router, + * whose deep tree leaves ref'd timers alive after unmount (visible only in + * `process.getActiveResourcesInfo()`, not `_getActiveHandles()`). Left alone + * they keep the event loop alive and hang `pnpm test`. We track every timer + * created after setup and clear the survivors in `after()`, so this file exits + * cleanly. node:test runs each file in its own process, so the timer shims and + * the module-level store predicate cannot leak into other test files. + */ + +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// Real timer fns captured before the tracking shims replace the globals. +const realSetTimeout = globalThis.setTimeout; +const realClearTimeout = globalThis.clearTimeout; +const realSetInterval = globalThis.setInterval; +const realClearInterval = globalThis.clearInterval; + +// Every timer created after setup, cleared in after() to release the ref'd +// timers the tiptap/router tree leaves behind. +const liveTimeouts = new Set(); +const liveIntervals = new Set(); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +// Swappable Tauri IPC handler; tests that drive the bridge replace it. +let invokeImpl = async () => undefined; + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + DOMParser: dom.window.DOMParser, + Node: dom.window.Node, + Range: dom.window.Range, + File: dom.window.File, + Blob: dom.window.Blob, + FileList: dom.window.FileList, + localStorage: dom.window.localStorage, + self: globalThis, + scrollTo: () => {}, + }); + globalThis.setTimeout = (...args) => { + const id = realSetTimeout(...args); + liveTimeouts.add(id); + return id; + }; + globalThis.clearTimeout = (id) => { + liveTimeouts.delete(id); + return realClearTimeout(id); + }; + globalThis.setInterval = (...args) => { + const id = realSetInterval(...args); + liveIntervals.add(id); + return id; + }; + globalThis.clearInterval = (id) => { + liveIntervals.delete(id); + return realClearInterval(id); + }; + const raf = (fn) => globalThis.setTimeout(() => fn(Date.now()), 0); + const caf = (id) => globalThis.clearTimeout(id); + globalThis.requestAnimationFrame = raf; + globalThis.cancelAnimationFrame = caf; + dom.window.requestAnimationFrame = raf; + dom.window.cancelAnimationFrame = caf; + if (!globalThis.MutationObserver) { + globalThis.MutationObserver = dom.window.MutationObserver; + } + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + // jsdom's createObjectURL rejects a File; the composer only needs an opaque + // handle, so stub it (and revoke) on both realms. + globalThis.URL.createObjectURL = () => "blob:test"; + globalThis.URL.revokeObjectURL = () => {}; + dom.window.URL.createObjectURL = () => "blob:test"; + dom.window.URL.revokeObjectURL = () => {}; + const tauriInternals = { + invoke: (cmd, args, opts) => invokeImpl(cmd, args, opts), + transformCallback: () => 0, + }; + globalThis.__TAURI_INTERNALS__ = tauriInternals; + dom.window.__TAURI_INTERNALS__ = tauriInternals; + // The Tauri event plugin's _unlisten() reads this global on teardown; the + // upload hooks register a byte-progress listener whose cleanup runs on + // unmount, so without the stub every mounted-hook test throws there. + const tauriEventPluginInternals = { + unregisterListener: () => {}, + }; + globalThis.__TAURI_EVENT_PLUGIN_INTERNALS__ = tauriEventPluginInternals; + dom.window.__TAURI_EVENT_PLUGIN_INTERNALS__ = tauriEventPluginInternals; + // This jsdom build ships File/Blob without arrayBuffer(); uploadMediaFile + // awaits it before the IPC, so polyfill it to reach the mocked invoke. + if (!dom.window.Blob.prototype.arrayBuffer) { + dom.window.Blob.prototype.arrayBuffer = async function arrayBuffer() { + return new ArrayBuffer(this.size ?? 0); + }; + } +}); + +after(() => { + for (const id of liveTimeouts) realClearTimeout(id); + for (const id of liveIntervals) realClearInterval(id); + liveTimeouts.clear(); + liveIntervals.clear(); + dom.window.close(); +}); + +// React core needs no DOM; react-dom is imported dynamically inside the mount +// helper so the timer shims installed in before() are active before its +// scheduler binds. +const React = await import("react"); + +/** Wait `ms` on the real (untracked) timer so the wait itself is never a + * survivor we clear. */ +function realDelay(ms) { + return new Promise((resolve) => realSetTimeout(resolve, ms)); +} + +/** Mount a hook in a thin harness, exposing its latest return value. */ +async function mountHook(useHook) { + const { createRoot } = await import("react-dom/client"); + const { act } = await import("react"); + const ref = { current: null }; + function Harness() { + ref.current = useHook(); + return null; + } + const container = dom.window.document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render(React.createElement(Harness)); + }); + return { + ref, + act, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +/** Mount the real ForumComposer inside the providers + router it needs. */ +async function mountForumComposer() { + const { createRoot } = await import("react-dom/client"); + const { act } = await import("react"); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { createRouter, createRootRoute, RouterProvider, createMemoryHistory } = + await import("@tanstack/react-router"); + const { ForumComposer } = await import( + "@/features/forum/ui/ForumComposer.tsx" + ); + const { CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + ); + const { TooltipProvider } = await import("@/shared/ui/tooltip.tsx"); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(["identity"], { pubkey: "c".repeat(64) }); + const rootRoute = createRootRoute({ + component: () => + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + CommunitiesProvider, + null, + React.createElement( + TooltipProvider, + null, + React.createElement(ForumComposer, { + placeholder: "Post to the forum", + onSubmit: () => {}, + }), + ), + ), + ), + }); + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); + + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render(React.createElement(RouterProvider, { router })); + }); + await act(async () => { + await realDelay(150); + }); + return { + container, + act, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + }; +} + +async function loadRegistry() { + return import("@/shared/lib/volatileWorkRegistry.ts"); +} + +// ── ForumComposer unsent draft ─────────────────────────────────────────────── + +test("forum_composer_empty_draft_does_not_block_reload", async () => { + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + const view = await mountForumComposer(); + const editor = view.container.querySelector( + '.ProseMirror, [contenteditable="true"]', + ); + assert.ok(editor, "the real tiptap editor must mount"); + assert.equal( + isAnyVolatileWorkPending(), + false, + "an empty forum composer holds no reload-destroyed work", + ); + await view.unmount(); +}); + +test("forum_composer_unsent_text_blocks_then_unmount_releases", async () => { + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + const view = await mountForumComposer(); + const editor = view.container.querySelector( + '.ProseMirror, [contenteditable="true"]', + ); + assert.ok(editor, "the real tiptap editor must mount"); + + // Type into the real editor; the content flows into ForumComposer's state, + // which drives useRegisterVolatileWork. + await view.act(async () => { + editor.textContent = "an unsent forum reply"; + editor.dispatchEvent(new dom.window.InputEvent("input", { bubbles: true })); + }); + await view.act(async () => { + await realDelay(30); + }); + assert.equal( + isAnyVolatileWorkPending(), + true, + "unsent forum text must block the idle reload", + ); + + // The forum view closing with unsent text must not leak its key. + await view.unmount(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "unmounting the forum composer must release its pending key", + ); +}); + +// ── useMediaUpload — deferred (production composer) branch ──────────────────── + +test("media_upload_deferred_queued_video_blocks_then_clear_releases", async () => { + const { useMediaUpload } = await import( + "@/features/messages/lib/useMediaUpload.ts" + ); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + const handle = await mountHook(() => + useMediaUpload({ deferUploadsUntilSend: true }), + ); + // A video on the deferred branch is queued locally (never uploaded until + // send) — reload-destroyed state with no in-flight upload behind it. + const video = new dom.window.File([new Uint8Array(8)], "clip.mp4", { + type: "video/mp4", + }); + await handle.act(async () => { + await handle.ref.current.uploadFile(video); + }); + assert.equal( + handle.ref.current.queuedAttachments.length, + 1, + "the video must be queued locally, not uploaded", + ); + assert.equal( + isAnyVolatileWorkPending(), + true, + "a queued local attachment must block the reload", + ); + + await handle.act(async () => { + handle.ref.current.clearQueuedAttachments(); + }); + assert.equal( + isAnyVolatileWorkPending(), + false, + "clearing the queue drains the block", + ); + await handle.unmount(); +}); + +// ── useMediaUpload — non-deferred (foreground upload) branch ────────────────── + +test("media_upload_foreground_in_flight_blocks_then_completion_releases", async () => { + const { useMediaUpload } = await import( + "@/features/messages/lib/useMediaUpload.ts" + ); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + let resolveUpload; + invokeImpl = (cmd) => { + if (cmd === "upload_media_bytes_raw") { + return new Promise((resolve) => { + resolveUpload = () => + resolve({ url: "u", sha256: "s", size: 8, type: "image/png" }); + }); + } + return undefined; + }; + + const handle = await mountHook(() => + useMediaUpload({ deferUploadsUntilSend: false }), + ); + const image = new dom.window.File([new Uint8Array(8)], "pic.png", { + type: "image/png", + }); + await handle.act(async () => { + handle.ref.current.uploadFile(image).catch(() => {}); + await realDelay(20); + }); + assert.equal( + handle.ref.current.isUploading, + true, + "the foreground upload must be in flight", + ); + assert.equal( + isAnyVolatileWorkPending(), + true, + "an in-flight foreground upload must block the reload", + ); + + await handle.act(async () => { + resolveUpload(); + await realDelay(20); + }); + assert.equal( + isAnyVolatileWorkPending(), + false, + "a completed upload drains the block", + ); + await handle.unmount(); + invokeImpl = async () => undefined; +}); + +test("media_upload_unmount_while_queued_releases", async () => { + const { useMediaUpload } = await import( + "@/features/messages/lib/useMediaUpload.ts" + ); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + const handle = await mountHook(() => + useMediaUpload({ deferUploadsUntilSend: true }), + ); + const video = new dom.window.File([new Uint8Array(8)], "clip.mp4", { + type: "video/mp4", + }); + await handle.act(async () => { + await handle.ref.current.uploadFile(video); + }); + assert.equal(isAnyVolatileWorkPending(), true, "queued video blocks"); + + // A composer torn down with a queued attachment must not leak its key. + await handle.unmount(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "unmounting a composer with a queued attachment must release its key", + ); +}); + +// ── Native-picker choke points ──────────────────────────────────────────────── + +test("pick_and_upload_media_open_dialog_blocks_then_release_on_resolve", async () => { + const { pickAndUploadMedia } = await import("@/shared/api/tauri.ts"); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + let resolvePick; + invokeImpl = (cmd) => { + if (cmd === "pick_and_upload_media") { + return new Promise((resolve) => { + resolvePick = () => resolve([]); + }); + } + return undefined; + }; + + const pending = pickAndUploadMedia("progress-id"); + await realDelay(5); + assert.equal( + isAnyVolatileWorkPending(), + true, + "the open picker + Rust upload window must block the reload", + ); + resolvePick(); + await pending; + assert.equal( + isAnyVolatileWorkPending(), + false, + "the finally must release when the picker resolves", + ); + invokeImpl = async () => undefined; +}); + +test("pick_and_upload_media_release_on_reject", async () => { + const { pickAndUploadMedia } = await import("@/shared/api/tauri.ts"); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + let rejectPick; + invokeImpl = (cmd) => { + if (cmd === "pick_and_upload_media") { + return new Promise((_resolve, reject) => { + rejectPick = () => reject(new Error("picker failed")); + }); + } + return undefined; + }; + + const pending = pickAndUploadMedia("progress-id"); + await realDelay(5); + assert.equal(isAnyVolatileWorkPending(), true, "open picker blocks"); + rejectPick(); + await assert.rejects(pending); + assert.equal( + isAnyVolatileWorkPending(), + false, + "the finally must release even when the picker rejects", + ); + invokeImpl = async () => undefined; +}); + +test("pick_and_upload_image_open_dialog_blocks_then_release_on_resolve", async () => { + const { pickAndUploadImage } = await import("@/shared/api/tauriMedia.ts"); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + let resolvePick; + invokeImpl = (cmd) => { + if (cmd === "pick_and_upload_image") { + return new Promise((resolve) => { + resolvePick = () => resolve(null); + }); + } + return undefined; + }; + + const pending = pickAndUploadImage(); + await realDelay(5); + assert.equal( + isAnyVolatileWorkPending(), + true, + "the image picker choke point (feedback, no uploadingCount) must block", + ); + resolvePick(); + await pending; + assert.equal( + isAnyVolatileWorkPending(), + false, + "the finally must release when the image picker resolves", + ); + invokeImpl = async () => undefined; +}); + +// ── backgroundMediaUploadStore registered predicate ─────────────────────────── + +test("background_store_retained_offchannel_file_blocks_then_taking_releases", async () => { + const store = await import( + "@/features/messages/lib/backgroundMediaUploadStore.ts" + ); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + // A local file retained after the user leaves its channel lives only in the + // in-memory queuedAttachmentsByDraftKey map — the clearest reload-loss case. + const attachment = { + file: new dom.window.File([new Uint8Array(4)], "note.pdf", { + type: "application/pdf", + }), + id: 1, + spoilered: false, + }; + store.saveQueuedAttachmentsForDraft("chan-away", [attachment]); + assert.equal( + isAnyVolatileWorkPending(), + true, + "an off-channel retained local file must block the reload", + ); + + const taken = store.takeQueuedAttachmentsForDraft("chan-away"); + assert.equal(taken.length, 1, "the retained file is returned when taken"); + assert.equal( + isAnyVolatileWorkPending(), + false, + "draining the retention map releases the block", + ); +}); + +test("background_store_in_flight_task_blocks_then_completion_releases", async () => { + const store = await import( + "@/features/messages/lib/backgroundMediaUploadStore.ts" + ); + const { isAnyVolatileWorkPending } = await loadRegistry(); + assert.equal( + isAnyVolatileWorkPending(), + false, + "precondition: registry idle", + ); + + let resolveUpload; + invokeImpl = (cmd) => { + if (cmd === "upload_media_bytes_raw") { + return new Promise((resolve) => { + resolveUpload = () => + resolve({ url: "u", sha256: "s", size: 4, type: "image/png" }); + }); + } + return undefined; + }; + + let completed = false; + const attachment = { + file: new dom.window.File([new Uint8Array(4)], "pic.png", { + type: "image/png", + }), + id: 2, + spoilered: false, + }; + store.enqueueBackgroundMediaUpload({ + attachments: [attachment], + onComplete: async () => { + completed = true; + }, + onError: () => {}, + }); + await realDelay(20); + assert.equal( + isAnyVolatileWorkPending(), + true, + "an in-flight background upload must block the reload", + ); + + resolveUpload(); + await realDelay(20); + assert.equal(completed, true, "the background upload completes"); + assert.equal( + isAnyVolatileWorkPending(), + false, + "the settled background task releases the block", + ); + store.resetBackgroundMediaUploads(); + invokeImpl = async () => undefined; +}); diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index 202ec968698..1b3eeec3167 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -417,7 +417,16 @@ test("partial list-item selections snap to whole items for block formats", async ], }[format]; expect(structure).toEqual(expected); - await page.reload(); + // Clear the composer before the next iteration navigates. The pagehide + // draft flush persists live editor text on navigation-away, so leaving the + // formatted content in place would restore it into the next iteration's + // composer. `fill("")` does not reliably empty the ProseMirror editor, so + // select-all + Backspace clears it the way a user would; the emptied editor + // then flushes to a cleared draft. (This replaces a bare page.reload(), + // which the flush now defeats — the next iteration's openGeneral already + // provides the fresh page.) + await input.press("ControlOrMeta+a"); + await input.press("Backspace"); } });