* perf(stores): defer safeStorage writes off the interaction path Session switches funnel every persisted store slice through safeStorage.setItem, and doing those large JSON.stringify writes synchronously blocked the main thread for over a second. Add a write-behind buffer that: - Defers each setItem/removeItem to a later task via setTimeout(0) so the click-to-paint path is not blocked. - Coalesces repeated writes to the same key into a single backing flush. - Serves pending values from memory so read-after-write stays consistent within the deferral window. - Flushes synchronously on pagehide/beforeunload/visibilitychange/freeze so deferred state survives tab close, reload, and the mobile freeze lifecycle. Adds a test covering write deferral, coalescing, and pending read serving. * fix(stores): defer persisted JSON serialization * fix(stores): defer direct safeStorage writes --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
227 lines
7.1 KiB
TypeScript
227 lines
7.1 KiB
TypeScript
import { create } from 'zustand';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
|
|
|
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation';
|
|
|
|
export interface InlineCommentDraft {
|
|
id: string;
|
|
sessionKey: string; // sessionId or 'draft' for new sessions
|
|
source: InlineCommentSource;
|
|
fileLabel: string; // filename or 'plan'
|
|
startLine: number;
|
|
endLine: number;
|
|
side?: 'original' | 'modified'; // diff only
|
|
code: string;
|
|
language: string;
|
|
text: string;
|
|
createdAt: number;
|
|
}
|
|
|
|
interface InlineCommentDraftState {
|
|
drafts: Record<string, InlineCommentDraft[]>; // sessionKey -> drafts
|
|
}
|
|
|
|
interface InlineCommentDraftActions {
|
|
addDraft: (draft: Omit<InlineCommentDraft, 'id' | 'createdAt'>) => void;
|
|
updateDraft: (sessionKey: string, draftId: string, updates: Partial<Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'>>) => void;
|
|
removeDraft: (sessionKey: string, draftId: string) => void;
|
|
clearDrafts: (sessionKey: string) => void;
|
|
getDrafts: (sessionKey: string) => InlineCommentDraft[];
|
|
consumeDrafts: (sessionKey: string) => InlineCommentDraft[];
|
|
getDraftCount: (sessionKey: string) => number;
|
|
hasDrafts: (sessionKey: string) => boolean;
|
|
}
|
|
|
|
type InlineCommentDraftStore = InlineCommentDraftState & InlineCommentDraftActions;
|
|
|
|
const isValidSource = (value: unknown): value is InlineCommentSource =>
|
|
value === 'diff' || value === 'plan' || value === 'file' || value === 'preview-console' || value === 'preview-annotation';
|
|
|
|
const isValidSide = (value: unknown): value is 'original' | 'modified' =>
|
|
value === 'original' || value === 'modified';
|
|
|
|
const toPositiveLine = (value: unknown): number | null => {
|
|
const parsed = typeof value === 'number' ? value : Number(value);
|
|
if (!Number.isFinite(parsed)) return null;
|
|
return Math.max(1, Math.floor(parsed));
|
|
};
|
|
|
|
const sanitizeDraft = (input: unknown): InlineCommentDraft | null => {
|
|
if (!input || typeof input !== 'object') return null;
|
|
const draft = input as Partial<InlineCommentDraft>;
|
|
|
|
if (typeof draft.sessionKey !== 'string' || draft.sessionKey.trim().length === 0) return null;
|
|
if (!isValidSource(draft.source)) return null;
|
|
|
|
const startLine = toPositiveLine(draft.startLine);
|
|
const endLine = toPositiveLine(draft.endLine);
|
|
if (!startLine || !endLine) return null;
|
|
|
|
const id = typeof draft.id === 'string' && draft.id.trim().length > 0
|
|
? draft.id
|
|
: `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
return {
|
|
id,
|
|
sessionKey: draft.sessionKey,
|
|
source: draft.source,
|
|
fileLabel: typeof draft.fileLabel === 'string' ? draft.fileLabel : 'unknown',
|
|
startLine,
|
|
endLine,
|
|
side: isValidSide(draft.side) ? draft.side : undefined,
|
|
code: typeof draft.code === 'string' ? draft.code : '',
|
|
language: typeof draft.language === 'string' ? draft.language : 'text',
|
|
text: typeof draft.text === 'string' ? draft.text : '',
|
|
createdAt: Number.isFinite(draft.createdAt) ? Number(draft.createdAt) : Date.now(),
|
|
};
|
|
};
|
|
|
|
const sanitizeDraftMap = (input: unknown): Record<string, InlineCommentDraft[]> => {
|
|
if (!input || typeof input !== 'object') return {};
|
|
|
|
const entries = Object.entries(input as Record<string, unknown>);
|
|
const result: Record<string, InlineCommentDraft[]> = {};
|
|
|
|
for (const [sessionKey, sessionDrafts] of entries) {
|
|
if (!Array.isArray(sessionDrafts)) continue;
|
|
|
|
const sanitized = sessionDrafts
|
|
.map(sanitizeDraft)
|
|
.filter((draft): draft is InlineCommentDraft => Boolean(draft))
|
|
.filter((draft) => draft.sessionKey === sessionKey);
|
|
|
|
if (sanitized.length > 0) {
|
|
result[sessionKey] = sanitized;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
|
devtools(
|
|
persist(
|
|
(set, get) => ({
|
|
drafts: {},
|
|
|
|
addDraft: (draft) => {
|
|
const id = `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
const newDraft: InlineCommentDraft = {
|
|
...draft,
|
|
id,
|
|
createdAt: Date.now(),
|
|
};
|
|
|
|
set((state) => {
|
|
const currentDrafts = state.drafts[draft.sessionKey] ?? [];
|
|
return {
|
|
drafts: {
|
|
...state.drafts,
|
|
[draft.sessionKey]: [...currentDrafts, newDraft],
|
|
},
|
|
};
|
|
});
|
|
|
|
return id;
|
|
},
|
|
|
|
updateDraft: (sessionKey, draftId, updates) => {
|
|
set((state) => {
|
|
const currentDrafts = state.drafts[sessionKey] ?? [];
|
|
const newDrafts = currentDrafts.map((draft) => {
|
|
if (draft.id !== draftId) {
|
|
return draft;
|
|
}
|
|
return {
|
|
...draft,
|
|
...updates,
|
|
};
|
|
});
|
|
|
|
return {
|
|
drafts: {
|
|
...state.drafts,
|
|
[sessionKey]: newDrafts,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
removeDraft: (sessionKey, draftId) => {
|
|
set((state) => {
|
|
const currentDrafts = state.drafts[sessionKey] ?? [];
|
|
const newDrafts = currentDrafts.filter((d) => d.id !== draftId);
|
|
|
|
if (newDrafts.length === 0) {
|
|
const { [sessionKey]: _removed, ...rest } = state.drafts;
|
|
void _removed;
|
|
return { drafts: rest };
|
|
}
|
|
|
|
return {
|
|
drafts: {
|
|
...state.drafts,
|
|
[sessionKey]: newDrafts,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
clearDrafts: (sessionKey) => {
|
|
set((state) => {
|
|
const { [sessionKey]: _removed, ...rest } = state.drafts;
|
|
void _removed;
|
|
return { drafts: rest };
|
|
});
|
|
},
|
|
|
|
getDrafts: (sessionKey) => {
|
|
return get().drafts[sessionKey] ?? [];
|
|
},
|
|
|
|
consumeDrafts: (sessionKey) => {
|
|
const drafts = get().drafts[sessionKey] ?? [];
|
|
if (drafts.length === 0) return [];
|
|
|
|
// Sort by creation time to maintain order
|
|
const sortedDrafts = [...drafts].sort((a, b) => a.createdAt - b.createdAt);
|
|
|
|
// Clear drafts after consuming
|
|
set((state) => {
|
|
const { [sessionKey]: _removed, ...rest } = state.drafts;
|
|
void _removed;
|
|
return { drafts: rest };
|
|
});
|
|
|
|
return sortedDrafts;
|
|
},
|
|
|
|
getDraftCount: (sessionKey) => {
|
|
return (get().drafts[sessionKey] ?? []).length;
|
|
},
|
|
|
|
hasDrafts: (sessionKey) => {
|
|
return (get().drafts[sessionKey] ?? []).length > 0;
|
|
},
|
|
}),
|
|
{
|
|
name: 'openchamber-inline-comment-drafts',
|
|
storage: createDeferredSafeJSONStorage(),
|
|
version: 1,
|
|
migrate: (persistedState: unknown) => {
|
|
if (!persistedState || typeof persistedState !== 'object') {
|
|
return { drafts: {} };
|
|
}
|
|
|
|
const state = persistedState as { drafts?: unknown };
|
|
return {
|
|
drafts: sanitizeDraftMap(state.drafts),
|
|
};
|
|
},
|
|
}
|
|
),
|
|
{ name: 'inline-comment-draft-store' }
|
|
)
|
|
);
|