tenseleyflow/claudex / 1ed01e3

Browse files

fix: hoist useEffect above early return (rules of hooks v2)

Authored by espadonne
SHA
1ed01e3c4be1eb9e3c42208509dc1c2daafc68cd
Parents
d47e25d
Tree
a75e3d0

1 changed file

StatusFile+-
M src/components/ViewerPane.tsx 32 42
src/components/ViewerPane.tsxmodified
@@ -52,11 +52,10 @@ export function ViewerPane() {
5252
     return false;
5353
   }, [detail, inFlightTurns]);
5454
 
55
-  // Build the claude argv for terminal mode. Must live above the
56
-  // early returns below — React's rules of hooks require every
57
-  // hook to run in the same order on every render, so an early
58
-  // return followed by a later `useMemo` triggers the
59
-  // "Rendered more hooks than during the previous render" crash.
55
+  // ALL HOOKS MUST RUN BEFORE ANY EARLY RETURN. React's rules of
56
+  // hooks require a fixed call order per render, and an early
57
+  // return sitting above a later hook violates it with
58
+  // "Rendered more hooks than during the previous render".
6059
   const claudeArgs = useMemo<string[]>(() => {
6160
     if (!headerSummary) return [];
6261
     if (headerSummary.source === "archive") return [];
@@ -66,6 +65,23 @@ export function ViewerPane() {
6665
     return ["--resume", headerSummary.id];
6766
   }, [headerSummary]);
6867
 
68
+  // Lazy detail load for terminal-default sessions that the user
69
+  // has just toggled to cards mode. Computed pre-return so the
70
+  // hook order stays stable whether or not headerSummary is set.
71
+  const lazyLoadTarget = useMemo<SessionSummary | null>(() => {
72
+    if (!headerSummary) return null;
73
+    if (headerSummary.source === "archive") return null;
74
+    const mode = viewerMode.get(headerSummary.id) ?? "cards";
75
+    if (mode !== "cards") return null;
76
+    if (loading) return null;
77
+    if (detail && detail.summary.id === headerSummary.id) return null;
78
+    return headerSummary;
79
+  }, [headerSummary, viewerMode, loading, detail]);
80
+  useEffect(() => {
81
+    if (!lazyLoadTarget) return;
82
+    void ensureDetailFor(lazyLoadTarget);
83
+  }, [lazyLoadTarget, ensureDetailFor]);
84
+
6985
   if (!headerSummary) {
7086
     return (
7187
       <Shell subtitle="" loading={false}>
@@ -77,32 +93,23 @@ export function ViewerPane() {
7793
     );
7894
   }
7995
 
80
-  const showingSkeleton = pendingSummary !== null && detail === null;
81
-
8296
   const summary = headerSummary;
8397
   const messages = detail?.messages ?? [];
8498
   const isArchive = summary.source === "archive";
8599
   const mode = viewerMode.get(summary.id) ?? "cards";
86100
   const hasLivePty = ptyIds.has(summary.id);
87101
 
88
-  // Lazy-load detail if the user is in cards mode for a session
89
-  // whose body we skipped fetching on click (because it defaulted
90
-  // to terminal mode). Only fires when the detail we need genuinely
91
-  // isn't already loaded or in flight.
92
-  const needsLazyCardsLoad =
93
-    mode === "cards" &&
94
-    !isArchive &&
95
-    !loading &&
96
-    (detail === null || detail.summary.id !== summary.id);
97
-  useEffect(() => {
98
-    if (!needsLazyCardsLoad) return;
99
-    void ensureDetailFor(summary);
100
-  }, [needsLazyCardsLoad, summary, ensureDetailFor]);
102
+  // Only show the indeterminate stripe when we're showing
103
+  // *cached* content while a background refresh runs. On a cold
104
+  // load the CardsSkeleton body is its own loading indicator, so
105
+  // a second stripe on top of it would just be noise.
106
+  const refreshing =
107
+    loading && detail !== null && detail.summary.id === summary.id;
101108
 
102109
   return (
103110
     <Shell
104111
       subtitle={summary.title}
105
-      loading={loading}
112
+      loading={refreshing}
106113
       right={
107114
         <div className="flex items-center gap-2 text-[10px] text-fg-3">
108115
           {isArchive ? (
@@ -142,12 +149,8 @@ export function ViewerPane() {
142149
     >
143150
       {!isArchive && mode === "terminal" && summary.cwd ? (
144151
         // key forces a fresh mount per session — xterm state is
145
-        // bound to sessionId so switching sessions must teardown
146
-        // and re-open (the backend PTY keeps running regardless).
147
-        //
148
-        // Suspense fallback renders while the lazy chunk downloads
149
-        // on first use. Once loaded, the chunk is cached so
150
-        // subsequent mounts are instant.
152
+        // bound to sessionId so the backend PTY keeps running
153
+        // regardless.
151154
         <Suspense fallback={<TerminalLoading />}>
152155
           <TerminalPane
153156
             key={summary.id}
@@ -156,9 +159,9 @@ export function ViewerPane() {
156159
             claudeArgs={claudeArgs}
157160
           />
158161
         </Suspense>
159
-      ) : showingSkeleton ? (
162
+      ) : !detail || detail.summary.id !== summary.id ? (
160163
         <CardsSkeleton />
161
-      ) : detail ? (
164
+      ) : (
162165
         <div className="flex h-full flex-col">
163166
           <div className="min-h-0 flex-1">
164167
             <MessageTimeline messages={messages} autoFollow={hasActiveTurn} />
@@ -170,16 +173,11 @@ export function ViewerPane() {
170173
             <ChatInput detail={detail} />
171174
           )}
172175
         </div>
173
-      ) : (
174
-        <CardsSkeleton />
175176
       )}
176177
     </Shell>
177178
   );
178179
 }
179180
 
180
-/** Tiny placeholder for the lazy TerminalPane chunk. Only visible
181
- *  on the first-ever terminal mount in a session — subsequent
182
- *  mounts reuse the cached module and render instantly. */
183181
 function TerminalLoading() {
184182
   return (
185183
     <div className="flex h-full items-center justify-center text-[11px] text-fg-3">
@@ -188,10 +186,6 @@ function TerminalLoading() {
188186
   );
189187
 }
190188
 
191
-/** Shimmer placeholder shown while a session detail is loading.
192
- *  Six ghost message cards, sized similarly to real messages, with
193
- *  a subtle animation. Keeps the viewer feeling alive instead of
194
- *  showing a frozen "loading…" string. */
195189
 function CardsSkeleton() {
196190
   return (
197191
     <div className="flex h-full flex-col gap-4 overflow-hidden p-4">
@@ -262,10 +256,6 @@ function Shell({
262256
     <div className="flex h-full flex-col overflow-hidden">
263257
       <PaneHeader title="Viewer" subtitle={subtitle} right={right} />
264258
       {loading && (
265
-        // Indeterminate progress stripe pinned under the header.
266
-        // Tells the user "something is in flight" without the
267
-        // jarring "loading session…" centered text that used to
268
-        // replace the whole body on every click.
269259
         <div
270260
           aria-hidden
271261
           className="h-0.5 w-full shrink-0 bg-gradient-to-r from-transparent via-accent/60 to-transparent"