4.9 KiB
GitHub Work Item Drawer: Fix Reopen Flash
Symptom
After #1655, reopening a GH issue/PR drawer can still flash a loading/skeleton frame before cached content appears.
Verified root cause
GitHubItemDialog currently paints from component state (details, loading, error) that is updated in an effect (src/renderer/src/components/GitHubItemDialog.tsx:2480+).
Current sequence on close/reopen of the same item:
- Close path runs effect branch with no
workItemand callssetDetails(null). - Reopen renders once with
details === null. - Effect reads
workItemDetailsCacheand then callssetDetails(painted). - Second render shows cached content.
With sheet animation, that first empty paint is visible as a flash.
Refactor direction
Move drawer paint state to the module cache and subscribe with useSyncExternalStore. The drawer should read the cache entry synchronously during render.
This is not optional if we want reopen to paint cached content on first render.
Required cache-store changes
Add a store subscription layer around workItemDetailsCache:
subscribeWorkItemDetailsCache(listener)notifyWorkItemDetailsCache()
Call notifyWorkItemDetailsCache() after every actual cache mutation:
touchWorkItemDetailsCacheinvalidateWorkItemDetailsCacheForKeyinvalidateWorkItemDetailsCacheByMatch
Also increment workItemDetailsCacheGeneration on all invalidations, not only invalidateWorkItemDetailsCacheByMatch. Otherwise an in-flight fetch launched before exact-key invalidation can repopulate stale data.
useSyncExternalStore contract details
Use:
const cachedEntry = useSyncExternalStore(
subscribeWorkItemDetailsCache,
() => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined)
)
Important constraints:
getSnapshotmust return a referentially stable value when store data has not changed.- Returning the map entry object is valid only if writes replace entry identity (
delete+setwith a new entry object). - Do not build fresh wrapper objects in
getSnapshot(that would violate stability and can cause render loops).
Component state changes
Remove component-local details/loading/error state for work-item details.
Derive view state from cachedEntry:
details = cachedEntry?.details ?? null- blocking
loading = !!cachedEntry?.pending && !cachedEntry?.details - blocking
error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null
Keep stale-on-error behavior by preserving details in cache while setting error.
Effect responsibilities after refactor
The main effect should:
- handle item-change bookkeeping (
prevItemIdRef,optimisticCommentsRef,setTab) - decide freshness and in-flight dedupe
- launch fetch and write results/errors/pending back into cache
The effect should not call setDetails, setLoading, or setError for work-item details.
Optimistic comment race and merge rules
appendOptimisticComment must write through cache only (no local setDetails).
Rules:
- push comment into
optimisticCommentsRef - patch cache entry comments (if present), mark stale (
fetchedAt: 0), notify - derive rendered comments from cached details plus optimistic refs
Dependency pitfall:
- do not depend on a freshly-created array from
optimisticCommentsRef.currentinuseMemo - memo should key off stable signals (cache entry identity); cache notifications will re-render after optimistic writes anyway
This avoids unnecessary recomputation and avoids accidental infinite rerender patterns from unstable deps.
Invalidation while drawer is open
When invalidateWorkItemDetailsCacheByMatch deletes the current key while drawer is mounted:
- subscription notifies immediately
- render sees
cachedEntry === undefined - UI falls back to loading/empty state and effect triggers refetch
This is expected and preferred over showing stale state.
Fetch race constraints (must keep)
Keep current protections:
- per-key in-flight dedupe via
entry.pending - generation guard to block stale writes after invalidation
- stale-on-null handling (
result === nullmust not wipe existing valid cached details)
Add one missing consistency rule:
- if fetch resolves after invalidation and key was dropped, do not recreate entry unless generation still matches launch generation.
Scope of deletion
Delete only the work-item-detail local state and call sites:
useState<GitHubWorkItemDetails | null>(details)useState<boolean>(loading) for this data pathuseState<string | null>(error) for this data path- corresponding
setDetails/setLoading/setErrorcalls in the details effect and optimistic comment append path
Do not touch unrelated loading/error state in other subviews.
Expected result
Reopen of a cached item paints content on first render (no flash). Background refetch still runs when stale, and cross-window/local invalidations remain authoritative.