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");
}
});