Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -283,6 +284,7 @@ function AppReady({
}
>
<KnownAgentPubkeysProvider>
{huddleWindowChannelId() === null ? <IdleAutoReloadController /> : null}
<RouterProvider router={router} />
</KnownAgentPubkeysProvider>
</EncryptedBackupProvider>
Expand Down
107 changes: 107 additions & 0 deletions desktop/src/app/useIdleAutoReload.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
12 changes: 2 additions & 10 deletions desktop/src/app/useReloadShortcut.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -19,13 +17,7 @@ export function useReloadShortcut() {
}

event.preventDefault();
await Promise.race([
closeAllWebSockets(),
new Promise<void>((resolve) =>
window.setTimeout(resolve, RELOAD_TEARDOWN_TIMEOUT_MS),
),
]);
window.location.reload();
await requestRendererReload();
}

window.addEventListener("keydown", handleKeyDown);
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/features/forum/ui/ForumComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
34 changes: 34 additions & 0 deletions desktop/src/features/messages/lib/backgroundMediaUploadStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions desktop/src/features/messages/lib/useMediaUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 type="file">`: `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)));
Expand Down Expand Up @@ -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<UploadingAttachmentPreview[]>(
() =>
queuedAttachments.map((attachment) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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();
});
Loading
Loading