Skip to content

Commit e63d0af

Browse files
authored
Chore: Enhance spreadsheet state management with debouncing and schema validation (#177)
1 parent 00d84ce commit e63d0af

4 files changed

Lines changed: 109 additions & 13 deletions

File tree

‎app/components/assessment/SpreadsheetModalInner.tsx‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ import {
1111
loadSpreadsheetState,
1212
persistSpreadsheetState,
1313
} from "@/app/lib/assessment/results";
14+
import { SPREADSHEET_STATE_DEBOUNCE_MS } from "@/app/lib/assessment/constants";
1415
import type { UniverAPI } from "@/app/lib/types/assessment";
1516

17+
// Univer command types: 0=COMMAND, 1=OPERATION, 2=MUTATION. Only mutations change state.
18+
const UNIVER_MUTATION_TYPE = 2;
19+
1620
interface SpreadsheetModalInnerProps {
1721
runId: number;
1822
title: string;
@@ -50,20 +54,42 @@ export default function SpreadsheetModalInner({
5054
api.createUniverSheet(saved ?? buildSpreadsheetWorkbookData(headers, rows));
5155

5256
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
53-
const cmdDisposable = api.onCommandExecuted(() => {
57+
let lastSerialized: string | null = null;
58+
59+
const flushNow = () => {
60+
if (debounceTimer) {
61+
clearTimeout(debounceTimer);
62+
debounceTimer = null;
63+
}
64+
try {
65+
const snapshot = api.getActiveWorkbook()?.save();
66+
if (!snapshot) return;
67+
const serialized = JSON.stringify(snapshot);
68+
if (serialized === lastSerialized) return; // dedup unchanged saves
69+
lastSerialized = serialized;
70+
persistSpreadsheetState(runId, snapshot);
71+
} catch {
72+
// serialization failed or storage unavailable — keep in-memory state
73+
}
74+
};
75+
76+
const cmdDisposable = api.onCommandExecuted((info) => {
77+
// Skip non-mutating commands (selection, scroll, focus, etc.)
78+
if (info.type !== UNIVER_MUTATION_TYPE) return;
5479
if (debounceTimer) clearTimeout(debounceTimer);
55-
debounceTimer = setTimeout(() => {
56-
try {
57-
const snapshot = api.getActiveWorkbook()?.save();
58-
if (snapshot) persistSpreadsheetState(runId, snapshot);
59-
} catch {
60-
// silently skip — storage quota exceeded or unavailable
61-
}
62-
}, 1500);
80+
debounceTimer = setTimeout(flushNow, SPREADSHEET_STATE_DEBOUNCE_MS);
6381
});
6482

83+
const handleVisibility = () => {
84+
if (document.visibilityState === "hidden") flushNow();
85+
};
86+
window.addEventListener("beforeunload", flushNow);
87+
document.addEventListener("visibilitychange", handleVisibility);
88+
6589
return () => {
66-
if (debounceTimer) clearTimeout(debounceTimer);
90+
window.removeEventListener("beforeunload", flushNow);
91+
document.removeEventListener("visibilitychange", handleVisibility);
92+
flushNow(); // flush pending edits on unmount
6793
cmdDisposable.dispose();
6894
api.dispose?.();
6995
univerRef.current = null;

‎app/lib/assessment/constants.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export const ASSESSMENT_CONFIG_VERSION_PAGE_SIZE = 8;
1515

1616
export const RESULTS_POLL_INTERVAL_MS = 60_000;
1717
export const SPREADSHEET_STATE_STORAGE_PREFIX = "kaapi_sheet_state_";
18+
export const SPREADSHEET_STATE_SCHEMA_VERSION = 1;
19+
export const SPREADSHEET_STATE_DEBOUNCE_MS = 800;
1820
export const SPREADSHEET_PREVIEW_ROW_LIMIT = 5000;
1921

2022
export const MAX_DATASET_FILE_BYTES = 5 * 1024 * 1024;

‎app/lib/assessment/results.ts‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import type {
33
AssessmentRun,
44
ResultTone,
55
ResultsCounts,
6+
SpreadsheetStateEnvelope,
67
StatusFilter,
78
} from "@/app/lib/types/assessment";
89
import {
910
ACTIVE_ASSESSMENT_STATUSES,
1011
COMPLETED_ASSESSMENT_STATUSES,
1112
FAILED_ASSESSMENT_STATUSES,
13+
SPREADSHEET_STATE_SCHEMA_VERSION,
1214
SPREADSHEET_STATE_STORAGE_PREFIX,
1315
} from "@/app/lib/assessment/constants";
1416

@@ -77,14 +79,66 @@ export function spreadsheetStorageKey(runId: number): string {
7779
export function loadSpreadsheetState(runId: number): object | null {
7880
try {
7981
const raw = localStorage.getItem(spreadsheetStorageKey(runId));
80-
return raw ? (JSON.parse(raw) as object) : null;
82+
if (!raw) return null;
83+
const parsed = JSON.parse(raw) as Partial<SpreadsheetStateEnvelope>;
84+
if (parsed?.v !== SPREADSHEET_STATE_SCHEMA_VERSION || !parsed.data) {
85+
localStorage.removeItem(spreadsheetStorageKey(runId));
86+
return null;
87+
}
88+
return parsed.data;
8189
} catch {
8290
return null;
8391
}
8492
}
8593

94+
// Evict oldest spreadsheet-state entries until below `keep` count.
95+
function evictOldestSpreadsheetStates(keep: number): void {
96+
const entries: Array<{ key: string; ts: number }> = [];
97+
for (let i = 0; i < localStorage.length; i++) {
98+
const key = localStorage.key(i);
99+
if (!key?.startsWith(SPREADSHEET_STATE_STORAGE_PREFIX)) continue;
100+
try {
101+
const raw = localStorage.getItem(key);
102+
if (!raw) continue;
103+
const parsed = JSON.parse(raw) as Partial<SpreadsheetStateEnvelope>;
104+
entries.push({ key, ts: parsed?.ts ?? 0 });
105+
} catch {
106+
// malformed — treat as oldest so it gets dropped first
107+
entries.push({ key, ts: 0 });
108+
}
109+
}
110+
entries.sort((a, b) => a.ts - b.ts);
111+
const toDrop = Math.max(0, entries.length - keep);
112+
for (let i = 0; i < toDrop; i++) {
113+
localStorage.removeItem(entries[i].key);
114+
}
115+
}
116+
86117
export function persistSpreadsheetState(runId: number, data: object): void {
87-
localStorage.setItem(spreadsheetStorageKey(runId), JSON.stringify(data));
118+
const envelope: SpreadsheetStateEnvelope = {
119+
v: SPREADSHEET_STATE_SCHEMA_VERSION,
120+
ts: Date.now(),
121+
data,
122+
};
123+
const key = spreadsheetStorageKey(runId);
124+
const payload = JSON.stringify(envelope);
125+
try {
126+
localStorage.setItem(key, payload);
127+
} catch (err) {
128+
// Quota exceeded — drop oldest sheets (keep current) and retry once
129+
const isQuota =
130+
err instanceof DOMException &&
131+
(err.name === "QuotaExceededError" ||
132+
err.name === "NS_ERROR_DOM_QUOTA_REACHED");
133+
if (!isQuota) return;
134+
try {
135+
localStorage.removeItem(key);
136+
evictOldestSpreadsheetStates(5);
137+
localStorage.setItem(key, payload);
138+
} catch {
139+
// still failing — give up silently; in-memory state remains intact
140+
}
141+
}
88142
}
89143

90144
type SpreadsheetCellEntry = { v: string | number; t: number; s?: object };

‎app/lib/types/assessment.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,27 @@ export interface ResultsCounts {
272272
failed: number;
273273
}
274274

275+
export type UniverCommandInfo = {
276+
id: string;
277+
type?: number;
278+
params?: unknown;
279+
};
280+
275281
export type UniverAPI = {
276282
dispose?: () => void;
277-
onCommandExecuted: (cb: () => void) => { dispose: () => void };
283+
onCommandExecuted: (cb: (info: UniverCommandInfo) => void) => {
284+
dispose: () => void;
285+
};
278286
getActiveWorkbook: () => { save: () => object } | null;
279287
createUniverSheet: (d: object) => void;
280288
};
281289

290+
export type SpreadsheetStateEnvelope = {
291+
v: number;
292+
ts: number;
293+
data: object;
294+
};
295+
282296
export interface AssessmentResultsPreview {
283297
runId: number;
284298
title: string;

0 commit comments

Comments
 (0)