FN-9160: Prevent draft storage quota exhaustion
Make browser draft restoration best-effort so oversized or quota-blocked writes never interrupt task creation. - cap free-text draft persistence at 64,000 bytes and warn once when restoration is unavailable - make scoped storage operations throw-safe with bounded retries and stale volatile-state reclamation - extend quota, composer, modal persistence, and chat draft coverage and document the storage policy - add a patch changeset for the operator-facing fix Files changed: .changeset/fn-9160-quick-entry-quota.md | 7 ++ docs/storage.md | 31 +++--- .../dashboard/app/components/InlineCreateCard.tsx | 25 ++++- .../dashboard/app/components/QuickEntryBox.tsx | 25 ++++- .../components/__tests__/ChatView.draft.test.tsx | 36 +++++- .../components/__tests__/InlineCreateCard.test.tsx | 20 ++++ .../components/__tests__/QuickEntryBox.test.tsx | 36 +++++- .../app/hooks/__tests__/modalPersistence.test.ts | 28 +++-- packages/dashboard/app/hooks/modalPersistence.ts | 29 ++--- .../app/utils/__tests__/projectStorage.test.ts | 88 ++++++++++++++- packages/dashboard/app/utils/projectStorage.ts | 122 +++++++++++++++++++-- 11 files changed, 384 insertions(+), 63 deletions(-) Fusion-Task-Id: FN-9160 Fusion-Task-Lineage: 63481936-91f1-4e7a-b27c-c3c5f49014dd Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9160-quick-entry-quota.md
Normal file
7
.changeset/fn-9160-quick-entry-quota.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Prevent oversized task drafts from exhausting browser storage.
|
||||
category: fix
|
||||
dev: Scoped draft writes return a persistence result, cap free text at 64,000 bytes, and reclaim stale entries after quota failures.
|
||||
@@ -322,18 +322,18 @@ High-level finding: the dashboard currently uses localStorage extensively for UX
|
||||
| `kb-dashboard-current-project` | `hooks/useCurrentProject.ts` | JSON `ProjectInfo` object (includes id/name/path/status/etc.) | project/identity | **Medium** |
|
||||
| `kb-terminal-tabs` | `hooks/useTerminalSessions.ts` | JSON array of tab objects (`id`, `sessionId`, `title`, active state, timestamp) | UI preference (operational session state) | **High** |
|
||||
| `fn-agent-tree-expanded` | `hooks/useAgentHierarchy.ts` | JSON string[] of expanded agent ids | UI preference | Low |
|
||||
| `kb-planning-last-description` | `hooks/modalPersistence.ts` (used by `PlanningModeModal`) | free-text draft | user draft | Medium |
|
||||
| `kb-subtask-last-description` | `hooks/modalPersistence.ts` (used by `SubtaskBreakdownModal`) | free-text draft | user draft | Medium |
|
||||
| `kb-mission-last-goal` | `hooks/modalPersistence.ts` (used by `MissionInterviewModal`) | free-text draft | user draft | Medium |
|
||||
| `kb-planning-last-description` | `hooks/modalPersistence.ts` (used by `PlanningModeModal`) | free-text draft (best-effort; 64,000-byte cap) | user draft | Medium |
|
||||
| `kb-subtask-last-description` | `hooks/modalPersistence.ts` (used by `SubtaskBreakdownModal`) | free-text draft (best-effort; 64,000-byte cap) | user draft | Medium |
|
||||
| `kb-mission-last-goal` | `hooks/modalPersistence.ts` (used by `MissionInterviewModal`) | free-text draft (best-effort; 64,000-byte cap) | user draft | Medium |
|
||||
| `kb-dashboard-view-mode` | `App.tsx` | enum string (`overview`/`project`) | UI preference | Low |
|
||||
| `kb-dashboard-task-view` | `App.tsx` | enum string (`board`/`list`/`agents`) | UI preference | Low |
|
||||
| `kb-dashboard-list-columns` | `components/ListView.tsx` | JSON array of visible list columns | UI preference | Low |
|
||||
| `kb-dashboard-hide-done` | `components/ListView.tsx` | boolean string (`"true"`/`"false"`) | UI preference | Low |
|
||||
| `kb-dashboard-list-collapsed` | `components/ListView.tsx` | JSON array of collapsed column ids | UI preference | Low |
|
||||
| `kb-dashboard-selected-tasks` | `components/ListView.tsx` | JSON array of selected task IDs | UI preference | **Medium** |
|
||||
| `kb-quick-entry-text` | `components/QuickEntryBox.tsx` | free-text task draft | user draft | Medium |
|
||||
| `kb-quick-entry-text` | `components/QuickEntryBox.tsx` | free-text task draft (best-effort; 64,000-byte cap) | user draft | Medium |
|
||||
| `kb-quick-entry-expanded` | `components/QuickEntryBox.tsx` (legacy cleanup via `removeItem`) | legacy bool key (no longer used) | UI preference | Low |
|
||||
| `kb-inline-create-text` | `components/InlineCreateCard.tsx` | free-text task draft | user draft | Medium |
|
||||
| `kb-inline-create-text` | `components/InlineCreateCard.tsx` | free-text task draft (best-effort; 64,000-byte cap) | user draft | Medium |
|
||||
| `fn-agent-view` | `components/AgentsView.tsx`, `components/AgentListModal.tsx` | enum string (`board`/`list`/`tree` in view; modal supports board/list) | UI preference | Medium |
|
||||
| `kb-usage-view-mode` | `components/UsageIndicator.tsx` | enum string (`used`/`remaining`) | UI preference | Low |
|
||||
| `kb-dashboard-recent-projects` | `components/ProjectOverview.tsx` | JSON array of recent project IDs | project/identity | Low |
|
||||
@@ -611,49 +611,54 @@ The PostgreSQL-era runtime writes `.fusion/project.json`. Startup reads this leg
|
||||
- **Problem:** Theme is persisted in both localStorage (`kb-dashboard-theme-mode`, `kb-dashboard-color-theme`) and backend global settings (`themeMode`, `colorTheme`), but app bootstrap uses localStorage-only theme hydration. If backend and browser cache diverge, cross-device consistency breaks.
|
||||
- **Recommended fix:** Make backend global settings the source of truth (or explicitly define local cache precedence + bidirectional sync strategy and conflict resolution).
|
||||
|
||||
2. **Project-unscoped localStorage keys in multi-project UX state**
|
||||
2. **Draft persistence quota exhaustion (#3477) — resolved**
|
||||
- **Severity:** Medium
|
||||
- **Affected:** `kb-quick-entry-text`, `kb-inline-create-text`, `kb-planning-last-description`, `kb-subtask-last-description`, `kb-mission-last-goal`
|
||||
- **Resolution:** Scoped writes are throw-safe and retry after exact-key eviction; repeated quota failures reclaim stale other-project volatile drafts and stale dashboard SWR envelopes. Free-text drafts are capped at 64,000 bytes and remain in memory when they cannot be restored, so **Clear local data** is no longer a workaround for draft-driven saturation.
|
||||
|
||||
3. **Project-unscoped localStorage keys in multi-project UX state**
|
||||
- **Severity:** High
|
||||
- **Affected:** `App.tsx`, `ListView.tsx`, `QuickEntryBox.tsx`, `InlineCreateCard.tsx`, `AgentsView.tsx`, `useTerminalSessions.ts`, `useAgentHierarchy.ts`, `UsageIndicator.tsx`
|
||||
- **Problem:** Many keys are global (`kb-dashboard-task-view`, `kb-dashboard-list-*`, `kb-dashboard-selected-tasks`, `kb-quick-entry-text`, `kb-inline-create-text`, `kb-terminal-tabs`, etc.) and are reused across projects. This can leak preferences/drafts/selections between projects unexpectedly.
|
||||
- **Recommended fix:** Namespace project-specific keys with `projectId` (e.g., `kb:{projectId}:dashboard-list-columns`). Keep only true global prefs unscoped.
|
||||
|
||||
3. **`kb-dashboard-selected-tasks` can carry stale selections across projects**
|
||||
4. **`kb-dashboard-selected-tasks` can carry stale selections across projects**
|
||||
- **Severity:** Medium
|
||||
- **Affected:** `components/ListView.tsx`
|
||||
- **Problem:** Selected task IDs persist globally. In multi-project setups with overlapping ID patterns, stale selections can reappear and affect bulk operations unexpectedly.
|
||||
- **Recommended fix:** Project-scope this key, and/or treat selection as in-memory/session-only state.
|
||||
|
||||
4. **Terminal session persistence stores operational identifiers in localStorage**
|
||||
5. **Terminal session persistence stores operational identifiers in localStorage**
|
||||
- **Severity:** Medium
|
||||
- **Affected:** `hooks/useTerminalSessions.ts` (`kb-terminal-tabs`)
|
||||
- **Problem:** Session IDs and tab metadata persist client-side and are not project-scoped. This is operational state better owned by backend/session layer; stale tabs also survive cache until cleanup logic runs.
|
||||
- **Recommended fix:** Move terminal tab/session state to server persistence (or at minimum sessionStorage + project scoping + TTL/versioning).
|
||||
|
||||
5. **Current project persistence stores full `ProjectInfo` object (includes filesystem path)**
|
||||
6. **Current project persistence stores full `ProjectInfo` object (includes filesystem path)**
|
||||
- **Severity:** Medium
|
||||
- **Affected:** `hooks/useCurrentProject.ts` (`kb-dashboard-current-project`)
|
||||
- **Problem:** Storing full project objects increases drift risk and stores more data than needed (including local path).
|
||||
- **Recommended fix:** Persist only stable `projectId`; resolve current object from backend project list each load.
|
||||
|
||||
6. **Draft persistence is local-only (device/browser-bound)**
|
||||
7. **Draft persistence is local-only (device/browser-bound)**
|
||||
- **Severity:** Medium
|
||||
- **Affected:** `modalPersistence.ts`, `QuickEntryBox.tsx`, `InlineCreateCard.tsx`
|
||||
- **Problem:** Planning/subtask/mission/task-entry drafts are lost on storage clear or browser/device switch.
|
||||
- **Recommended fix:** Keep local quick-draft behavior, but add optional server-backed drafts (short TTL) for continuity.
|
||||
|
||||
7. **Settings scope key lists drift from interfaces**
|
||||
8. **Settings scope key lists drift from interfaces**
|
||||
- **Severity:** Medium
|
||||
- **Affected:** `packages/core/src/types.ts`, `store.ts`, `routes.ts`, `SettingsModal.tsx`
|
||||
- **Problem:** `GLOBAL_SETTINGS_KEYS` (14) omits `setupComplete`, `favoriteProviders`, `favoriteModels`; `PROJECT_SETTINGS_KEYS` (52) omits 9 project interface keys (`strictScopeEnforcement`, `buildRetryCount`, `buildTimeoutMs`, `autoUnpause*`, `maintenanceIntervalMs`, `scripts`, `setupScript`). This creates scope-classification and patch-filter inconsistencies.
|
||||
- **Recommended fix:** Generate key lists from schema/interface source (or enforce parity tests) to prevent drift.
|
||||
|
||||
8. **`fn-agent-view` shared by two UIs with different supported modes**
|
||||
9. **`fn-agent-view` shared by two UIs with different supported modes**
|
||||
- **Severity:** Low
|
||||
- **Affected:** `AgentsView.tsx`, `AgentListModal.tsx`
|
||||
- **Problem:** Both share the same key, but one surface supports `tree` and the modal supports only `board/list`; behavior remains valid but coupling is implicit.
|
||||
- **Recommended fix:** Decide intentional shared behavior and document it; otherwise split keys by surface.
|
||||
|
||||
9. **Workflow steps still persisted in config JSON compatibility path (known in-progress work)**
|
||||
10. **Workflow steps still persisted in config JSON compatibility path (known in-progress work)**
|
||||
- **Severity:** Low
|
||||
- **Affected:** `config.settings/workflowSteps`, `db.ts` config table
|
||||
- **Problem (historical audit):** Workflow step storage was tied to config blob structure; **FN-1201** moved it to a dedicated table before the PostgreSQL cutover.
|
||||
|
||||
@@ -14,7 +14,7 @@ import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { applyPresetToSelection } from "../utils/modelPresets";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getScopedItem, MAX_PERSISTED_DRAFT_BYTES, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { WorkflowSelector } from "./WorkflowSelector";
|
||||
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
||||
import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews";
|
||||
@@ -153,6 +153,7 @@ export function InlineCreateCard({
|
||||
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
|
||||
const [pendingSubmit, setPendingSubmit] = useState<CreateTaskInput | null>(null);
|
||||
const justResetRef = useRef(false);
|
||||
const draftPersistenceWarningShownRef = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const agentPickerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -162,12 +163,26 @@ export function InlineCreateCard({
|
||||
setDescription(getScopedItem(STORAGE_KEY, projectId) || "");
|
||||
}, [projectId]);
|
||||
|
||||
// Persist description to localStorage whenever it changes
|
||||
/*
|
||||
FNXC:QuickAddDraftPersistence 2026-08-20-00:43:
|
||||
Inline Create keeps its React draft authoritative for submission. The localStorage restore mirror is capped and optional so a large paste never exhausts browser quota or interrupts the composer (Runfusion/Fusion#3477).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setScopedItem(STORAGE_KEY, description, projectId);
|
||||
if (description.length === 0) {
|
||||
removeScopedItem(STORAGE_KEY, projectId);
|
||||
return;
|
||||
}
|
||||
}, [description, projectId]);
|
||||
|
||||
const persisted = setScopedItem(STORAGE_KEY, description, projectId, {
|
||||
maxBytes: MAX_PERSISTED_DRAFT_BYTES,
|
||||
});
|
||||
if (persisted) {
|
||||
draftPersistenceWarningShownRef.current = false;
|
||||
} else if (!draftPersistenceWarningShownRef.current) {
|
||||
draftPersistenceWarningShownRef.current = true;
|
||||
addToast(t("tasks.draftTooLargeToSave", "Draft is too large to save in this browser — it will not be restored after a reload."), "warning");
|
||||
}
|
||||
}, [addToast, description, projectId, t]);
|
||||
|
||||
// Clear agents cache when projectId changes to prevent stale agents from leaking across projects
|
||||
useEffect(() => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
||||
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Zap, Eye, EyeOff, Play } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getScopedItem, MAX_PERSISTED_DRAFT_BYTES, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { useComposerDictation } from "../hooks/useComposerDictation";
|
||||
import { MicButton } from "./MicButton";
|
||||
@@ -184,6 +184,7 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const startIntentRef = useRef<ValidatedQuickAddWorkflow | null>(null);
|
||||
const justResetRef = useRef(false);
|
||||
const draftPersistenceWarningShownRef = useRef(false);
|
||||
const previousProjectIdRef = useRef(projectId);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||||
@@ -502,12 +503,26 @@ export function QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], avai
|
||||
setDescription(getScopedItem(STORAGE_KEY, projectId) || "");
|
||||
}, [projectId]);
|
||||
|
||||
// Persist description to localStorage whenever it changes
|
||||
/*
|
||||
FNXC:QuickAddDraftPersistence 2026-08-20-00:43:
|
||||
Quick Add's in-memory draft remains authoritative for task creation. Browser restoration is best-effort and capped so a pasted description cannot exhaust localStorage or interrupt typing (Runfusion/Fusion#3477).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setScopedItem(STORAGE_KEY, description, projectId);
|
||||
if (description.length === 0) {
|
||||
removeScopedItem(STORAGE_KEY, projectId);
|
||||
return;
|
||||
}
|
||||
}, [description, projectId]);
|
||||
|
||||
const persisted = setScopedItem(STORAGE_KEY, description, projectId, {
|
||||
maxBytes: MAX_PERSISTED_DRAFT_BYTES,
|
||||
});
|
||||
if (persisted) {
|
||||
draftPersistenceWarningShownRef.current = false;
|
||||
} else if (!draftPersistenceWarningShownRef.current) {
|
||||
draftPersistenceWarningShownRef.current = true;
|
||||
addToast(t("tasks.draftTooLargeToSave", "Draft is too large to save in this browser — it will not be restored after a reload."), "warning");
|
||||
}
|
||||
}, [addToast, description, projectId, t]);
|
||||
|
||||
// Clear agents cache when projectId changes to prevent stale agents from leaking across projects
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { ChatView } from "../ChatView";
|
||||
@@ -150,6 +150,10 @@ describe("ChatView draft persistence", () => {
|
||||
mockFetchSettings.mockResolvedValue({} as Awaited<ReturnType<typeof api.fetchSettings>>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("writes direct-session drafts to localStorage while typing", async () => {
|
||||
await renderChatView();
|
||||
|
||||
@@ -160,6 +164,36 @@ describe("ChatView draft persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the chat composer usable when its raw draft storage throws a quota error", async () => {
|
||||
const sendMessage = vi.fn();
|
||||
const draftKey = "fusion:chat-draft:direct:session-001";
|
||||
setup({ sendMessage });
|
||||
vi.spyOn(localStorage, "setItem").mockImplementation((key) => {
|
||||
if (key === draftKey) throw new DOMException("Quota exceeded", "QuotaExceededError");
|
||||
});
|
||||
|
||||
await renderChatView();
|
||||
const composer = screen.getByPlaceholderText("Type a message...");
|
||||
await expect(userEvent.type(composer, "still sends despite quota")).resolves.toBeUndefined();
|
||||
|
||||
expect(composer).toHaveValue("still sends despite quota");
|
||||
await userEvent.click(screen.getAllByTestId("chat-send-btn")[0]);
|
||||
expect(sendMessage).toHaveBeenCalledWith("still sends despite quota", [], expect.anything());
|
||||
});
|
||||
|
||||
it("keeps empty-draft removal throw-safe", async () => {
|
||||
const draftKey = "fusion:chat-draft:direct:session-001";
|
||||
await renderChatView();
|
||||
const composer = screen.getByPlaceholderText("Type a message...");
|
||||
await userEvent.type(composer, "temporary");
|
||||
vi.spyOn(localStorage, "removeItem").mockImplementation((key) => {
|
||||
if (key === draftKey) throw new DOMException("Storage disabled", "QuotaExceededError");
|
||||
});
|
||||
|
||||
await expect(userEvent.clear(composer)).resolves.toBeUndefined();
|
||||
expect(composer).toHaveValue("");
|
||||
});
|
||||
|
||||
it("restores the persisted direct-session draft when remounted", async () => {
|
||||
localStorage.setItem("fusion:chat-draft:direct:session-001", "saved draft");
|
||||
|
||||
|
||||
@@ -1898,6 +1898,26 @@ describe("InlineCreateCard workflow selection at create time (FN-7591)", () => {
|
||||
await waitFor(() => expect(props.onSubmit).toHaveBeenCalled());
|
||||
expect(selectTaskWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the inline composer submit-ready when its scoped draft storage throws", async () => {
|
||||
const projectId = "proj-9160";
|
||||
const key = `kb:${projectId}:kb-inline-create-text`;
|
||||
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-9160" } as Task);
|
||||
const addToast = vi.fn();
|
||||
vi.spyOn(localStorage, "setItem").mockImplementation((storageKey) => {
|
||||
if (storageKey === key) throw new DOMException("Quota exceeded", "QuotaExceededError");
|
||||
});
|
||||
renderCard([], { projectId, onSubmit, addToast });
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
expect(() => fireEvent.change(textarea, { target: { value: "inline task survives quota" } })).not.toThrow();
|
||||
expect(textarea).toHaveValue("inline task survives quota");
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByTestId("save-button"));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ description: "inline task survives quota" })));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { expectStableTyping } from "./typingStability.test-helpers";
|
||||
import { TASK_PRIORITIES, type Task, type TaskPriority } from "@fusion/core";
|
||||
import { checkDuplicateTasks, fetchSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../../api";
|
||||
import { useNodes } from "../../hooks/useNodes";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
import { MAX_PERSISTED_DRAFT_BYTES, scopedKey } from "../../utils/projectStorage";
|
||||
import { getPriorityColorVar } from "../../utils/priorityIndicator";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import { readAppFile } from "../../test/cssFixture";
|
||||
@@ -5460,4 +5460,38 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("quota-safe draft persistence (FN-9160)", () => {
|
||||
it("evicts an over-cap Quick Add draft and warns only once", () => {
|
||||
const addToast = vi.fn();
|
||||
localStorage.setItem(QUICK_ENTRY_STORAGE_KEY, "prior draft");
|
||||
renderQuickEntryBox({ addToast });
|
||||
|
||||
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "x".repeat(MAX_PERSISTED_DRAFT_BYTES + 1) } });
|
||||
|
||||
expect(screen.getByTestId("quick-entry-input")).toHaveValue("x".repeat(MAX_PERSISTED_DRAFT_BYTES + 1));
|
||||
expect(localStorage.getItem(QUICK_ENTRY_STORAGE_KEY)).toBeNull();
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the reported scoped Quick Add draft usable and submit-ready when storage throws", async () => {
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
const addToast = vi.fn();
|
||||
const setItem = vi.spyOn(localStorage, "setItem").mockImplementation((key) => {
|
||||
if (key === QUICK_ENTRY_STORAGE_KEY) {
|
||||
throw new DOMException("Quota exceeded", "QuotaExceededError");
|
||||
}
|
||||
});
|
||||
renderQuickEntryBox({ onCreate, addToast });
|
||||
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||
|
||||
expect(() => fireEvent.change(textarea, { target: { value: "still creates despite quota" } })).not.toThrow();
|
||||
expect(textarea).toHaveValue("still creates despite quota");
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByTestId("quick-entry-save"));
|
||||
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ description: "still creates despite quota" })));
|
||||
expect(setItem).toHaveBeenCalledWith(QUICK_ENTRY_STORAGE_KEY, "still creates despite quota");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -123,6 +123,20 @@ describe("modalPersistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded free-text persistence", () => {
|
||||
it.each([
|
||||
["planning", STORED_PLANNING_KEY, savePlanningDescription],
|
||||
["subtask", STORED_SUBTASK_KEY, saveSubtaskDescription],
|
||||
["mission", STORED_MISSION_KEY, saveMissionGoal],
|
||||
] as const)("does not persist an over-cap %s draft", (_name, key, save) => {
|
||||
const projectId = "project-a";
|
||||
localStorage.setItem(scopedKey(key, projectId), "prior value");
|
||||
|
||||
expect(() => save("x".repeat(64_001), projectId)).not.toThrow();
|
||||
expect(localStorage.getItem(scopedKey(key, projectId))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Planning active-session persistence", () => {
|
||||
it("saves, reads, and clears an active session per project", () => {
|
||||
savePlanningActiveSession("planning-123", "proj-123");
|
||||
@@ -182,7 +196,7 @@ describe("modalPersistence", () => {
|
||||
|
||||
expect(() => save(value, "project-a")).not.toThrow();
|
||||
|
||||
expect(setItem).toHaveBeenCalledTimes(2);
|
||||
expect(setItem).toHaveBeenCalledTimes(3);
|
||||
expect(removeItem).toHaveBeenCalledTimes(1);
|
||||
expect(removeItem).toHaveBeenCalledWith(planningKey);
|
||||
expect(localStorage.getItem("unrelated-key")).toBe("preserved");
|
||||
@@ -198,7 +212,7 @@ describe("modalPersistence", () => {
|
||||
});
|
||||
|
||||
expect(() => savePlanningDescription("new description", "project-a")).not.toThrow();
|
||||
expect(setItem).toHaveBeenCalledTimes(2);
|
||||
expect(setItem).toHaveBeenCalledTimes(3);
|
||||
expect(removeItem).toHaveBeenCalledWith(planningKey);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
@@ -242,11 +256,11 @@ describe("modalPersistence", () => {
|
||||
|
||||
expect(removeItem).toHaveBeenCalledWith(descriptionKey);
|
||||
expect(removeItem).toHaveBeenCalledWith(activeSessionKey);
|
||||
expect(setItem.mock.calls.filter(([key]) => key === descriptionKey)).toHaveLength(2);
|
||||
// The durable draft hand-off and the selected-session effect each retry their same active key once.
|
||||
expect(setItem.mock.calls.filter(([key]) => key === activeSessionKey)).toHaveLength(4);
|
||||
expect(setItem).toHaveBeenCalledTimes(6);
|
||||
expect(localStorage.getItem("kb:project-2:kb-planning-last-description")).toBe("other project");
|
||||
expect(setItem.mock.calls.filter(([key]) => key === descriptionKey)).toHaveLength(3);
|
||||
// The shared seam makes a final write after its bounded reclaim attempt for each persistence call.
|
||||
expect(setItem.mock.calls.filter(([key]) => key === activeSessionKey)).toHaveLength(6);
|
||||
expect(setItem).toHaveBeenCalledTimes(9);
|
||||
expect(localStorage.getItem("kb:project-2:kb-planning-last-description")).toBeNull();
|
||||
expect(localStorage.getItem("unrelated-key")).toBe("preserved");
|
||||
expect(screen.queryByText("Quota exceeded")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getScopedItem, MAX_PERSISTED_DRAFT_BYTES, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
// Storage keys — each modal type has independent storage
|
||||
export const STORED_PLANNING_KEY = "kb-planning-last-description";
|
||||
@@ -12,27 +12,16 @@ export const STORED_GITHUB_IMPORT_KEY = "kb-dashboard-github-import-state";
|
||||
/*
|
||||
FNXC:PlanningStorage 2026-08-06-14:56:
|
||||
Planning storage is optional restoration state, never a prerequisite for durable draft creation or streaming. On a write failure, evict only this exact project-scoped key, retry once, then swallow cleanup or retry failures so planning continues from React and server state.
|
||||
*/
|
||||
function savePlanningItem(baseKey: string, value: string, projectId?: string): void {
|
||||
try {
|
||||
setScopedItem(baseKey, value, projectId);
|
||||
} catch {
|
||||
try {
|
||||
removeScopedItem(baseKey, projectId);
|
||||
} catch {
|
||||
// Best-effort eviction; ignore cleanup failures so planning continues.
|
||||
}
|
||||
|
||||
try {
|
||||
setScopedItem(baseKey, value, projectId);
|
||||
} catch {
|
||||
// Best-effort retry; ignore storage failures so planning continues.
|
||||
}
|
||||
}
|
||||
FNXC:PlanningStorage 2026-08-20-00:43:
|
||||
FN-9160 moves that retry ladder into the shared scoped-storage seam, which also reclaims stale volatile entries after repeated quota failures. Free-text modal drafts pass its byte cap; small session identifiers and JSON view state retain their existing behavior.
|
||||
*/
|
||||
function savePlanningItem(baseKey: string, value: string, projectId?: string, maxBytes?: number): void {
|
||||
setScopedItem(baseKey, value, projectId, maxBytes === undefined ? undefined : { maxBytes });
|
||||
}
|
||||
|
||||
export function savePlanningDescription(description: string, projectId?: string): void {
|
||||
savePlanningItem(STORED_PLANNING_KEY, description, projectId);
|
||||
savePlanningItem(STORED_PLANNING_KEY, description, projectId, MAX_PERSISTED_DRAFT_BYTES);
|
||||
}
|
||||
|
||||
export function getPlanningDescription(projectId?: string): string {
|
||||
@@ -62,7 +51,7 @@ export function clearPlanningActiveSession(projectId?: string): void {
|
||||
// Subtask persistence
|
||||
|
||||
export function saveSubtaskDescription(description: string, projectId?: string): void {
|
||||
setScopedItem(STORED_SUBTASK_KEY, description, projectId);
|
||||
setScopedItem(STORED_SUBTASK_KEY, description, projectId, { maxBytes: MAX_PERSISTED_DRAFT_BYTES });
|
||||
}
|
||||
|
||||
export function getSubtaskDescription(projectId?: string): string {
|
||||
@@ -76,7 +65,7 @@ export function clearSubtaskDescription(projectId?: string): void {
|
||||
// Mission persistence
|
||||
|
||||
export function saveMissionGoal(goal: string, projectId?: string): void {
|
||||
setScopedItem(STORED_MISSION_KEY, goal, projectId);
|
||||
setScopedItem(STORED_MISSION_KEY, goal, projectId, { maxBytes: MAX_PERSISTED_DRAFT_BYTES });
|
||||
}
|
||||
|
||||
export function getMissionGoal(projectId?: string): string {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
GLOBAL_STORAGE_KEYS,
|
||||
MAX_PERSISTED_DRAFT_BYTES,
|
||||
PROJECT_STORAGE_KEYS,
|
||||
VOLATILE_DRAFT_STORAGE_KEYS,
|
||||
getScopedItem,
|
||||
removeScopedItem,
|
||||
scopedKey,
|
||||
@@ -14,6 +16,11 @@ describe("projectStorage", () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("scopedKey", () => {
|
||||
it("returns scoped key when projectId is provided", () => {
|
||||
expect(scopedKey("kb-dashboard-list-columns", "proj-abc")).toBe(
|
||||
@@ -135,18 +142,93 @@ describe("projectStorage", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("setScopedItem is a no-op when localStorage.setItem is unavailable", () => {
|
||||
it("returns false when localStorage.setItem is unavailable", () => {
|
||||
vi.stubGlobal("window", { localStorage: {} });
|
||||
expect(() => setScopedItem("kb-dashboard-list-columns", "value", "proj-abc")).not.toThrow();
|
||||
expect(setScopedItem("kb-dashboard-list-columns", "value", "proj-abc")).toBe(false);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("evicts an over-cap draft instead of persisting it", () => {
|
||||
const key = scopedKey("kb-quick-entry-text", "proj-abc");
|
||||
localStorage.setItem(key, "previous draft");
|
||||
|
||||
expect(setScopedItem("kb-quick-entry-text", "x".repeat(MAX_PERSISTED_DRAFT_BYTES + 1), "proj-abc", {
|
||||
maxBytes: MAX_PERSISTED_DRAFT_BYTES,
|
||||
})).toBe(false);
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it("persists a draft exactly at its byte cap", () => {
|
||||
const value = "x".repeat(MAX_PERSISTED_DRAFT_BYTES);
|
||||
expect(setScopedItem("kb-quick-entry-text", value, "proj-abc", {
|
||||
maxBytes: MAX_PERSISTED_DRAFT_BYTES,
|
||||
})).toBe(true);
|
||||
expect(getScopedItem("kb-quick-entry-text", "proj-abc")).toBe(value);
|
||||
});
|
||||
|
||||
it("evicts the same key and retries after an initial quota failure", () => {
|
||||
const key = scopedKey("kb-quick-entry-text", "proj-abc");
|
||||
const nativeSetItem = localStorage.setItem.bind(localStorage);
|
||||
const setItem = vi.spyOn(localStorage, "setItem")
|
||||
.mockImplementationOnce(() => { throw new DOMException("Quota exceeded", "QuotaExceededError"); })
|
||||
.mockImplementation((nextKey, value) => nativeSetItem(nextKey, value));
|
||||
|
||||
expect(setScopedItem("kb-quick-entry-text", "draft", "proj-abc")).toBe(true);
|
||||
expect(setItem).toHaveBeenCalledTimes(2);
|
||||
expect(localStorage.getItem(key)).toBe("draft");
|
||||
});
|
||||
|
||||
it("reclaims other-project volatile drafts and stale SWR entries before a final retry", () => {
|
||||
const nativeSetItem = localStorage.setItem.bind(localStorage);
|
||||
localStorage.setItem(scopedKey("kb-quick-entry-text", "other-project"), "old draft");
|
||||
localStorage.setItem(scopedKey("kb-inline-create-text", "other-project"), "old inline draft");
|
||||
localStorage.setItem(scopedKey("kb-quick-entry-text", "keep-project"), "keep draft");
|
||||
localStorage.setItem(scopedKey("kb-dashboard-list-columns", "other-project"), "keep preference");
|
||||
localStorage.setItem("kb-dashboard-tasks-cache:old", JSON.stringify({ savedAt: 0, data: [] }));
|
||||
const setItem = vi.spyOn(localStorage, "setItem")
|
||||
.mockImplementationOnce(() => { throw new DOMException("Quota exceeded", "QuotaExceededError"); })
|
||||
.mockImplementationOnce(() => { throw new DOMException("Quota exceeded", "QuotaExceededError"); })
|
||||
.mockImplementation((key, value) => nativeSetItem(key, value));
|
||||
|
||||
expect(setScopedItem("kb-quick-entry-text", "fresh", "keep-project")).toBe(true);
|
||||
expect(setItem).toHaveBeenCalledTimes(3);
|
||||
expect(localStorage.getItem(scopedKey("kb-quick-entry-text", "other-project"))).toBeNull();
|
||||
expect(localStorage.getItem(scopedKey("kb-inline-create-text", "other-project"))).toBeNull();
|
||||
expect(localStorage.getItem(scopedKey("kb-quick-entry-text", "keep-project"))).toBe("fresh");
|
||||
expect(localStorage.getItem(scopedKey("kb-dashboard-list-columns", "other-project"))).toBe("keep preference");
|
||||
expect(localStorage.getItem("kb-dashboard-tasks-cache:old")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns false instead of throwing when storage remains full", () => {
|
||||
vi.spyOn(localStorage, "setItem").mockImplementation(() => {
|
||||
throw new DOMException("Quota exceeded", "QuotaExceededError");
|
||||
});
|
||||
|
||||
expect(() => expect(setScopedItem("kb-quick-entry-text", "draft", "proj-abc")).toBe(false)).not.toThrow();
|
||||
});
|
||||
|
||||
it("declares every volatile draft key as project-scoped storage", () => {
|
||||
expect(PROJECT_STORAGE_KEYS).toEqual(expect.arrayContaining(VOLATILE_DRAFT_STORAGE_KEYS));
|
||||
});
|
||||
|
||||
it("removeScopedItem is a no-op when localStorage.removeItem is unavailable", () => {
|
||||
vi.stubGlobal("window", { localStorage: {} });
|
||||
expect(() => removeScopedItem("kb-dashboard-list-columns", "proj-abc")).not.toThrow();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps scoped reads and cleanup throw-safe when browser storage is blocked", () => {
|
||||
vi.spyOn(localStorage, "getItem").mockImplementation(() => {
|
||||
throw new DOMException("Storage disabled", "SecurityError");
|
||||
});
|
||||
vi.spyOn(localStorage, "removeItem").mockImplementation(() => {
|
||||
throw new DOMException("Storage disabled", "SecurityError");
|
||||
});
|
||||
|
||||
expect(getScopedItem("kb-quick-entry-text", "proj-abc")).toBeNull();
|
||||
expect(() => removeScopedItem("kb-quick-entry-text", "proj-abc")).not.toThrow();
|
||||
});
|
||||
|
||||
it("has no overlap between global and project-scoped keys", () => {
|
||||
const globalSet = new Set(GLOBAL_STORAGE_KEYS);
|
||||
const overlap = PROJECT_STORAGE_KEYS.filter((key) => globalSet.has(key));
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import { pruneStaleCacheEntries } from "./swrCache";
|
||||
|
||||
export const MAX_PERSISTED_DRAFT_BYTES = 64_000;
|
||||
|
||||
export const VOLATILE_DRAFT_STORAGE_KEYS = [
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"kb-planning-last-description",
|
||||
"kb-subtask-last-description",
|
||||
"kb-mission-last-goal",
|
||||
] as const;
|
||||
|
||||
export const GLOBAL_STORAGE_KEYS: string[] = [
|
||||
"kb-dashboard-theme-mode",
|
||||
"kb-dashboard-color-theme",
|
||||
@@ -62,20 +74,110 @@ export function getScopedItem(baseKey: string, projectId?: string): string | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
return getItem.call(window.localStorage, scopedKey(baseKey, projectId));
|
||||
try {
|
||||
return getItem.call(window.localStorage, scopedKey(baseKey, projectId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setScopedItem(baseKey: string, value: string, projectId?: string): void {
|
||||
function getStorage(): Storage | null {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const setItem = window.localStorage?.setItem;
|
||||
if (typeof setItem !== "function") {
|
||||
return;
|
||||
try {
|
||||
return window.localStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeStorageKey(storage: Storage, key: string): boolean {
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reclaims optional restoration state only after a scoped write hits storage quota.
|
||||
* The sweep is deliberately bounded and preserves the active project's drafts.
|
||||
*/
|
||||
export function reclaimScopedStorageQuota(options?: { keepProjectId?: string }): number {
|
||||
const storage = getStorage();
|
||||
if (!storage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
setItem.call(window.localStorage, scopedKey(baseKey, projectId), value);
|
||||
let removed = 0;
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
const maxEntries = Math.min(storage.length, 1_000);
|
||||
for (let index = 0; index < maxEntries; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key !== null) keys.push(key);
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const isOtherProjectDraft = VOLATILE_DRAFT_STORAGE_KEYS.some((draftKey) => (
|
||||
key.startsWith("kb:")
|
||||
&& key.endsWith(`:${draftKey}`)
|
||||
&& key !== scopedKey(draftKey, options?.keepProjectId)
|
||||
));
|
||||
if (isOtherProjectDraft && removeStorageKey(storage, key)) {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Storage enumeration is best-effort; stale SWR pruning can still reclaim space.
|
||||
}
|
||||
|
||||
try {
|
||||
removed += pruneStaleCacheEntries();
|
||||
} catch {
|
||||
// A blocked storage implementation must not make draft persistence throw.
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ProjectStorage 2026-08-20-00:43:
|
||||
Issue #3477 reported `kb:<projectId>:kb-quick-entry-text` exhausting localStorage. Draft persistence is optional restoration state, so writes must never throw or saturate the origin: capped callers skip oversized values and quota failures evict, reclaim stale volatile state, then fail closed.
|
||||
*/
|
||||
export function setScopedItem(
|
||||
baseKey: string,
|
||||
value: string,
|
||||
projectId?: string,
|
||||
options?: { maxBytes?: number },
|
||||
): boolean {
|
||||
const storage = getStorage();
|
||||
if (!storage || typeof storage.setItem !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = scopedKey(baseKey, projectId);
|
||||
if (typeof options?.maxBytes === "number" && new TextEncoder().encode(value).length > options.maxBytes) {
|
||||
removeStorageKey(storage, key);
|
||||
return false;
|
||||
}
|
||||
|
||||
const tryWrite = (): boolean => {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (tryWrite()) return true;
|
||||
removeStorageKey(storage, key);
|
||||
if (tryWrite()) return true;
|
||||
reclaimScopedStorageQuota({ keepProjectId: projectId });
|
||||
return tryWrite();
|
||||
}
|
||||
|
||||
export function removeScopedItem(baseKey: string, projectId?: string): void {
|
||||
@@ -88,5 +190,9 @@ export function removeScopedItem(baseKey: string, projectId?: string): void {
|
||||
return;
|
||||
}
|
||||
|
||||
removeItem.call(window.localStorage, scopedKey(baseKey, projectId));
|
||||
try {
|
||||
removeItem.call(window.localStorage, scopedKey(baseKey, projectId));
|
||||
} catch {
|
||||
// Storage removal is also optional restoration cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user