perf(right-sidebar): gate live effects, memoize lookups, always-mount tabs (#1674)
* perf(right-sidebar): gate live effects, memoize lookups, always-mount tabs
Performance fixes for the right sidebar (git/files/context tabs).
== Correctness / leak fixes (P0)
* RightSidebar: drop dead useEffect that re-nulled refs the resize
handler already nulled; collapse the redundant width/minWidth/maxWidth
triple into width + the existing --oc-right-sidebar-width variable.
* useUIStore: clamp setRightSidebarWidth to [MIN, MAX]; simplify
setRightSidebarOpen (22 lines -> 12).
* RightSidebarTabs: useRightSidebarGitSync now takes the right tab and
main tab and only polls when the right git tab is the visible consumer
and the browser is online + visible. Replaces a global poll that
fired for the lifetime of the open sidebar.
* GitView: commit-files fetch refactored to cancelled + Promise.all
(was a per-hash loop that could setState after unmount); getRemoteUrl
and refreshRemotes gated on cancelled/mountedRef; new module-scoped
mountedRef guards setIsSettingIdentity from firing after unmount.
* GitView + useGitmojiList: extract gitmoji fetch/cache into a hook
with module-level inflight promise + subscribers Set; stale-while-
revalidate from localStorage; ensureLoaded() for call-site-initiated
hydration; cancelled flag on setIsLoading to avoid the React
setState-after-unmount race.
* ProjectNotesTodoPanel: 400 ms notes debounce now cancels on blur
(was double-saving); persistProjectData chained per project through
a module-level Map<projectId, Promise> so a fast todo toggle racing
the debounced save no longer hits the server in parallel; resize
auto-adjust guards against same-value pings.
== Render fanout (P1)
* RightSidebarTabs: all three tab content components are now always
mounted with the hidden attribute. State and cache survive tab
switches. When activeMainTab === 'git' (or 'context') the matching
right tab is filtered out of the tab strip and a redirect effect
snaps any persisted-but-now-hidden right tab to 'files'. onSelect is
now a type-guarded handler instead of `as RightTab`.
* GitView: 13 separate useGitStore action selectors collapsed into one
useShallow block (one re-evaluation per store change instead of 13).
* GitView: new isGitViewActive flag (true when this instance is the
visible consumer) gates the 7 live effects — load identities, fetch
remote URL, refresh remotes, ensureAll, sessionEvents.onGitRefreshHint,
worktree bootstrap poll, default-identity auto-apply. Hidden
GitView instances no longer run these.
* GitView: gitViewSnapshots module-level Map is now backed by an
LRU wrapper (cap 20) so per-directory draft snapshots cannot leak
across hundreds of project switches. Removed the dead `unique.set`
dedup in changeEntries — GitStatus.files is already unique by path.
* SidebarFilesTree: statusByPath Map<path, FileStatus> and
badgeByDir Map<dirPath, { modified, added }> are precomputed once
per gitStatus change. Tree render is O(1) per node instead of O(N)
per node via the previous per-row find/scan. badgeByDir walks each
file's path segments and increments counters for every ancestor
dir, so total cost is O(N + total_dirs_in_files) per gitStatus
change.
* SidebarFilesTree: FileRow wrapped in React.memo with a custom
comparator. Context-menu open state moved INTO FileRow as local
state — opening a menu in one row no longer re-renders siblings.
* SidebarFilesTree: loadDirectory accepts an isCancelled predicate;
the batch-load effect for expandedPaths passes a stable predicate
so per-dir fetches stop touching state once the effect tears down.
* SidebarFilesTree: module-level fileTreeCacheByRoot Map (LRU,
cap 8 roots) hydrates childrenByDir / loadErrorsByDir /
loadedDirsRef on mount or root change. Mirror effects write state
back to the cache. Survives close-and-reopen of the right sidebar;
populated entries are dropped on unmount only when they had no
data.
== Result
Net diff: 6 files modified, 1 new (useGitmojiList.ts), 682 insertions,
283 deletions. Existing test suite baseline preserved (537 pass / 58
fail / 1 error) — no new regressions. The 58 pre-existing failures are
in unrelated chat/streaming tests and were verified via git stash on
the same branch.
Architecture assumptions, verified by manual review:
- P1.1's redirect effect snaps rightSidebarTab to 'files' whenever
activeMainTab === 'git', so the right and main GitView instances
are mutually exclusive — isGitViewActive cannot be true for both.
- The 7 gated effects plus the useRightSidebar GitSync poll cover all
cases where git state should advance: visible consumer fetches; the
poll keeps the store warm when only the right git tab is visible.
- The aborted loadDirectory predicate is sufficient because
inFlightDirsRef and loadedDirsRef dedup at the call site before
any network IO is initiated.
* fix(sidebar): always clean up inFlightDirsRef regardless of cancellation
* refactor(sidebar): deduplicate RIGHT_SIDEBAR_MIN/MAX_WIDTH constants, export from useUIStore
* docs: split right sidebar perf plan into standalone file, clean up merged master status from chat plan
* fix(git): gate GitView effects by instance visibility
---------
Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Leonid Skorobogatyy
Bohdan Triapitsyn
parent
e982bd9388
commit
9199798a14
@@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
|
||||
export type GitmojiEntry = {
|
||||
emoji: string;
|
||||
code: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type GitmojiCachePayload = {
|
||||
gitmojis: GitmojiEntry[];
|
||||
fetchedAt: number;
|
||||
version: string;
|
||||
};
|
||||
|
||||
const GITMOJI_CACHE_KEY = 'gitmojiCache';
|
||||
const GITMOJI_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 7;
|
||||
const GITMOJI_CACHE_VERSION = '1';
|
||||
const GITMOJI_SOURCE_URL =
|
||||
'https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json';
|
||||
|
||||
const isGitmojiEntry = (value: unknown): value is GitmojiEntry => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof candidate.emoji === 'string' &&
|
||||
typeof candidate.code === 'string' &&
|
||||
typeof candidate.description === 'string'
|
||||
);
|
||||
};
|
||||
|
||||
const readGitmojiCache = (): GitmojiCachePayload | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(GITMOJI_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<GitmojiCachePayload>;
|
||||
if (!parsed || parsed.version !== GITMOJI_CACHE_VERSION || typeof parsed.fetchedAt !== 'number') {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed.gitmojis)) return null;
|
||||
const gitmojis = parsed.gitmojis.filter(isGitmojiEntry);
|
||||
return { gitmojis, fetchedAt: parsed.fetchedAt, version: parsed.version };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeGitmojiCache = (gitmojis: GitmojiEntry[]) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const payload: GitmojiCachePayload = {
|
||||
gitmojis,
|
||||
fetchedAt: Date.now(),
|
||||
version: GITMOJI_CACHE_VERSION,
|
||||
};
|
||||
window.localStorage.setItem(GITMOJI_CACHE_KEY, JSON.stringify(payload));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const isGitmojiCacheFresh = (payload: GitmojiCachePayload): boolean =>
|
||||
Date.now() - payload.fetchedAt < GITMOJI_CACHE_TTL_MS;
|
||||
|
||||
// Module-level inflight + subscribers so concurrent hook instances dedupe the
|
||||
// same fetch. Subscribers receive the resolved list via setState on each hook
|
||||
// instance, so every consumer converges on the same data.
|
||||
let inflightFetch: Promise<GitmojiEntry[]> | null = null;
|
||||
const subscribers = new Set<(entries: GitmojiEntry[]) => void>();
|
||||
|
||||
const fetchGitmojiList = async (): Promise<GitmojiEntry[]> => {
|
||||
if (inflightFetch) return inflightFetch;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const response = await fetch(GITMOJI_SOURCE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load gitmojis: ${response.statusText}`);
|
||||
}
|
||||
const payload = (await response.json()) as { gitmojis?: GitmojiEntry[] };
|
||||
const gitmojis = Array.isArray(payload.gitmojis)
|
||||
? payload.gitmojis.filter(isGitmojiEntry)
|
||||
: [];
|
||||
writeGitmojiCache(gitmojis);
|
||||
subscribers.forEach((callback) => callback(gitmojis));
|
||||
return gitmojis;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load gitmoji list:', error);
|
||||
const empty: GitmojiEntry[] = [];
|
||||
subscribers.forEach((callback) => callback(empty));
|
||||
return empty;
|
||||
} finally {
|
||||
inflightFetch = null;
|
||||
}
|
||||
})();
|
||||
|
||||
inflightFetch = promise;
|
||||
return promise;
|
||||
};
|
||||
|
||||
export type UseGitmojiListResult = {
|
||||
gitmojis: GitmojiEntry[];
|
||||
isLoading: boolean;
|
||||
ensureLoaded: () => Promise<GitmojiEntry[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the gitmoji emoji catalog, hydrated from a local cache and
|
||||
* stale-while-revalidated from the upstream JSON.
|
||||
*
|
||||
* - When `enabled` is false, the hook returns an empty list and skips all IO.
|
||||
* - The first call hydrates synchronously from localStorage (no network
|
||||
* round-trip on warm starts).
|
||||
* - If the cache is missing or stale, a single background fetch is fired and
|
||||
* deduped across concurrent hook instances.
|
||||
* - Callers can invoke `ensureLoaded()` to await the next settled value when
|
||||
* they need the list synchronously (e.g. auto-suggest on commit subject).
|
||||
*/
|
||||
export const useGitmojiList = (enabled: boolean): UseGitmojiListResult => {
|
||||
const [gitmojis, setGitmojis] = React.useState<GitmojiEntry[]>(() => {
|
||||
if (!enabled) return [];
|
||||
return readGitmojiCache()?.gitmojis ?? [];
|
||||
});
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const enabledRef = React.useRef(enabled);
|
||||
enabledRef.current = enabled;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
subscribers.delete(setGitmojis);
|
||||
setGitmojis([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = readGitmojiCache();
|
||||
if (cached) {
|
||||
setGitmojis(cached.gitmojis);
|
||||
if (isGitmojiCacheFresh(cached)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
subscribers.add(setGitmojis);
|
||||
setIsLoading(true);
|
||||
let cancelled = false;
|
||||
void fetchGitmojiList().finally(() => {
|
||||
if (!cancelled && enabledRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
subscribers.delete(setGitmojis);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const ensureLoaded = React.useCallback(async (): Promise<GitmojiEntry[]> => {
|
||||
if (!enabledRef.current) return [];
|
||||
if (gitmojis.length > 0) return gitmojis;
|
||||
const cached = readGitmojiCache();
|
||||
if (cached && isGitmojiCacheFresh(cached)) {
|
||||
return cached.gitmojis;
|
||||
}
|
||||
return fetchGitmojiList();
|
||||
}, [gitmojis]);
|
||||
|
||||
return { gitmojis, isLoading, ensureLoaded };
|
||||
};
|
||||
Reference in New Issue
Block a user