Skip to content

Fix ResponsePanel state reset on stream end, add ESLint hooks gate - #8

Merged
mohnjiles merged 1 commit into
mainfrom
fix-response-panel-hooks
Aug 5, 2026
Merged

Fix ResponsePanel state reset on stream end, add ESLint hooks gate#8
mohnjiles merged 1 commit into
mainfrom
fix-response-panel-hooks

Conversation

@mohnjiles

Copy link
Copy Markdown
Member

Summary

ResponsePanel returned <ResponseStreamer> from above every hook call:

if (streamingResponse) return <ResponseStreamer ... />   // ← was here
const [activeTab, setActiveTab] = useState("response")

React 18 doesn't throw for this shape — zero hooks run on those renders, so the
next render falls back to the mount dispatcher. It silently resets the
component's state instead. In practice: the selected tab and the body filter
snapped back to defaults the moment a stream finished.

Moving the early return below every hook fixes it. Note this also means the
mistake is now fail-loud — adding any hook above that return would produce a
hard "Rendered fewer hooks than expected" crash rather than the silent reset.

To catch the class of bug rather than this one instance, this PR also adds
ESLint with react-hooks/rules-of-hooks as an error and wires pnpm lint
into CI. The rest of the diff is the resulting fallout:

File Change
useHistory loadHistory moved above the effect that depends on it — a dep array is evaluated during render, so referencing a const declared below hit the TDZ
useTabs initial-tab effect keys on tabs.length, which also makes it self-healing if the list is ever emptied
App, LazySyntaxHighlighter documented exhaustive-deps suppressions where the narrower dep list is deliberate
CollapsibleJSON helper moved inside the memo it feeds
collections, preRequestRunner chain the original error as cause (needs the tsconfig lib bump to ES2022)
curlParser, graphqlSchema, collection-converter prefer-const, dead initializers, redundant regex escape
UpdateChecker.test await a ?? b parsed as (await a) ?? b — built the fallback promise and never awaited it

no-explicit-any is set to warn rather than error: the remaining anys are
in the script runner and persistence layers, where they're deliberate (user
scripts, JSON round-trips). Flagged, not a gate.

Test plan

  • pnpm test:run — 290/290 passing across 34 files
  • pnpm lint — 0 errors, 29 pre-existing warnings
  • 3 new regression tests in ResponsePanel.test.tsx covering the streaming
    transition via rerender() (a remount would hide the bug):
    • tab selection survives a full non-streaming → streaming → non-streaming round-trip
    • repeated flips between the two modes without remounting
    • mounting mid-stream with no response yet
  • Manual: start an SSE request, switch to the Headers tab mid-stream, confirm
    the tab stays put once the stream completes

🤖 Generated with Claude Code

ResponsePanel returned <ResponseStreamer> from above every hook call. React
18 does not throw for that shape — zero hooks run, so the next render falls
back to the mount dispatcher — it silently resets the component's state
instead. In practice the selected tab and the body filter snapped back to
defaults the moment a stream finished. Moving the early return below every
hook fixes it; three regression tests cover the transition using rerender(),
since a remount would hide the bug.

To catch the whole class of bug rather than this one instance, add ESLint
with react-hooks/rules-of-hooks as an error and wire `pnpm lint` into CI.
Fixing the resulting fallout:

- useHistory: loadHistory moved above the effect that depends on it, so the
  dep array does not hit the temporal dead zone.
- useTabs: initial-tab effect now keys on tabs.length, which also makes it
  self-healing if the list is ever emptied.
- App, LazySyntaxHighlighter: documented exhaustive-deps suppressions where
  the narrower dep list is deliberate.
- CollapsibleJSON: helper moved inside the memo it feeds.
- collections, preRequestRunner: chain the original error as `cause`, which
  needs the tsconfig lib bump to ES2022.
- curlParser, graphqlSchema, collection-converter: prefer-const, dead
  initializers, redundant regex escape.
- UpdateChecker.test: `await a ?? b` parsed as `(await a) ?? b`, building the
  fallback promise and never awaiting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lykos-reviewer

lykos-reviewer Bot commented Aug 5, 2026

Copy link
Copy Markdown

Lykos Reviewer finished — 3 comment(s). See the review below.

@lykos-reviewer lykos-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a really sharp set of fixes that shores up the foundation nicely. moving the streaming return in ResponsePanel is exactly right for React's lifecycle, and catching that await precedence bug in the tests was a total eagle-eye move :owoah:


// relaunch is called via a fire-and-forget dynamic import, so flush microtasks
await vi.dynamicImportSettled?.() ?? new Promise(r => setTimeout(r, 0))
// Parenthesised deliberately: `await a ?? b` binds as `(await a) ?? b`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high correctness

seriously good find on the operator precedence here. await a ?? b binding as (await a) ?? b is one of those bugs that just sits there quietly failing to wait. adding the parentheses ensures the fallback promise actually gets settled before the test continues. mwah 💕

// Keep this below every hook. When it sat at the top of the component a
// finishing stream silently reset the panel's hook state (selected tab, body
// filter): React 18 sees a render that called zero hooks, so the next render
// falls back to the mount dispatcher rather than throwing. Adding any hook

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium correctness

this is the fix right here. moving this return below the hooks is crucial — react 18's behavior of silently resetting state when the hook count drops to zero is such a sneaky footgun. by keeping the hooks active while the streamer is visible, you're ensuring the tab state actually survives the transition. nice catch! :3

Comment thread src/App.tsx
// inside, but adding currentTab would re-run this on every URL keystroke
// and rewrite rawUrl underneath the user — the "without affecting user
// input" part above. params changing always implies a fresh currentTab,
// so the values read here are never stale. updateTab is already stable.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low correctness

the suppression here makes sense given the feedback loop you're avoiding, but it's a bit of an 'invisible promise' as you noted. as long as params references are stable unless they actually change, we're golden. worth keeping an eye on if we ever start doing deep-clones of params on every render elsewhere, ya know? :firT:

@mohnjiles
mohnjiles merged commit 55a94fc into main Aug 5, 2026
2 checks passed
@mohnjiles
mohnjiles deleted the fix-response-panel-hooks branch August 5, 2026 04:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants