Skip to content

feat(ui): collapse the compare sidebar, and remember the choice - #10718

Open
luvkapur wants to merge 3 commits into
masterfrom
feat/collapsible-compare-sidebar
Open

luvkapur wants to merge 3 commits into
masterfrom
feat/collapsible-compare-sidebar

Conversation

@luvkapur

Copy link
Copy Markdown
Member

What

CompareSidebar can now collapse to a 36px rail, and remembers whether the reader left it that way.

Reading a diff is the point of the compare view, but the 280px component list sits next to it the whole time — including after the reader has already picked what they want to look at. There was no way to get that space back.

  • the chevron in the sidebar header toggles it
  • while collapsed, the whole rail is a hit target on the way back out
  • the choice is persisted, so it survives a reload

API

Uncontrolled and persisted by default, so every surface that renders the sidebar (today: lane compare) picks the behaviour up with no wiring:

<CompareSidebar groups={groups} onSelect={onSelect} />

For a host that wants to drive it instead:

collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
collapseStorageKey?: string; // scopes the memory, for surfaces that want separate preferences

A controlled sidebar never writes a preference its owner did not ask to persist.

Two details worth naming

usePersistedToggle reads storage in an effect, not during render. Seeding useState from localStorage makes the first client render disagree with the server's, which React 18 treats as a hydration failure. The hook applies the stored value on the first commit and returns hydrated, which the sidebar uses to hold back its width transition — that is what stops a left-collapsed sidebar from visibly sliding shut on every page load. Storage failures (private mode, quota) degrade to an in-memory toggle rather than breaking the surface. The hook is exported; it is not sidebar-specific.

The component list stays mounted while collapsed and is hidden in CSS, so a collapse/expand round trip does not throw away every expanded file tree.

Testing

Six specs in compare-sidebar.spec.tsx covering the default, the toggle, the persisted round trip, key scoping, the mounted-while-collapsed guarantee, and the controlled path (including that it does not write storage).

teambit.component/ui/component-compare/component-compare - 6 passed

oxlint --deny-warnings clean on the component.

🤖 Generated with Claude Code

Reading a diff is the point of the compare view, and the 280px component
list sits next to it the whole time even once the reader has picked what
they want to look at. There was no way to get that space back.

`CompareSidebar` now collapses to a 36px rail. The chevron in its header
toggles it, and the whole rail is a hit target on the way back out.

The state is uncontrolled by default and persisted, so every surface that
renders the sidebar picks the behaviour up without wiring, and a reader
who collapses it finds it collapsed next time. `collapsed` /
`onCollapsedChange` are there for a host that wants to drive it instead;
a controlled sidebar never writes a preference its owner did not ask for.

Two details worth naming:

- `usePersistedToggle` reads storage in an effect, not during render.
  Seeding `useState` from `localStorage` makes the first client render
  disagree with the server's, which React 18 treats as a hydration
  failure. Callers get `hydrated` so they can hold back a transition
  until the stored value has landed — which is what stops a
  left-collapsed sidebar from visibly sliding shut on every load.
- the component list stays mounted while collapsed and is hidden in CSS,
  so a collapse/expand round trip does not throw away every expanded
  file tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add a persistent collapsible compare sidebar

✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Collapses the compare sidebar into a 36px rail, restoring workspace for reading diffs.
• Persists uncontrolled preferences after hydration while supporting controlled hosts and scoped
 storage keys.
• Preserves mounted file-tree state and covers collapse behavior with six focused tests.
Diagram

sequenceDiagram
  actor Reader
  participant Sidebar as Compare Sidebar
  participant Hook as Persisted Toggle
  participant Storage as Local Storage
  participant Host as Host Controller
  participant Content as Component List
  Sidebar->>Hook: Initialize preference
  Hook->>Storage: Read after commit
  Storage-->>Hook: Stored preference
  Hook-->>Sidebar: Value and hydrated
  Reader->>Sidebar: Toggle rail
  alt Controlled
    Sidebar->>Host: Notify collapse change
  else Uncontrolled
    Sidebar->>Hook: Set next value
    Hook->>Storage: Persist preference
  end
  Sidebar->>Content: Hide but keep mounted
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Host-owned collapse state
  • ➕ Keeps persistence policy explicit at each compare surface.
  • ➕ Avoids storage access inside controlled sidebar instances.
  • ➖ Requires repeated state and persistence wiring across every host.
  • ➖ Existing surfaces would not receive the feature automatically.
2. Unmount collapsed content
  • ➕ Removes hidden component-list DOM while collapsed.
  • ➕ May reduce rendering overhead for very large lists.
  • ➖ Discards expanded file-tree state on every collapse.
  • ➖ Requires lifting or separately persisting nested UI state.

Recommendation: Keep the PR's hybrid controlled/uncontrolled API and CSS-hiding strategy. It provides the feature to existing surfaces without wiring, preserves nested tree state, remains SSR-safe, and still allows hosts to take explicit control without unintended storage writes.

Files changed (5) +284 / -1

Enhancement (4) +199 / -1
compare-sidebar.module.scssStyle the collapsed sidebar rail and hydration-safe transition +87/-0

Style the collapsed sidebar rail and hydration-safe transition

• Adds the 36px collapsed layout, chevron directions, full-rail expansion target, and vertical item-count label. Width transitions activate only after persisted state hydrates, while hidden content remains mounted.

components/ui/component-compare/component-compare/compare-sidebar.module.scss

compare-sidebar.tsxAdd controlled and persisted collapse behavior +66/-1

Add controlled and persisted collapse behavior

• Introduces collapse props, an accessible header toggle, and an expandable 36px rail with component counts. Uncontrolled state persists through the new hook, while controlled state delegates changes to the host and leaves storage untouched.

components/ui/component-compare/component-compare/compare-sidebar.tsx

index.tsExport the reusable persisted-toggle hook +1/-0

Export the reusable persisted-toggle hook

• Exposes usePersistedToggle from the component-compare package for other UI preferences.

components/ui/component-compare/component-compare/index.ts

use-persisted-toggle.tsAdd an SSR-safe persisted boolean hook +45/-0

Add an SSR-safe persisted boolean hook

• Adds a reusable hook that reads localStorage after commit, reports hydration completion, and persists updates. Storage failures gracefully fall back to in-memory state.

components/ui/component-compare/component-compare/use-persisted-toggle.ts

Tests (1) +85 / -0
compare-sidebar.spec.tsxCover persisted and controlled sidebar collapse behavior +85/-0

Cover persisted and controlled sidebar collapse behavior

• Adds six tests for the expanded default, toggling, restoration, storage-key scoping, mounted content preservation, and controlled mode. The controlled test also verifies that the sidebar does not write an unsolicited preference.

components/ui/component-compare/component-compare/compare-sidebar.spec.tsx

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 18, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Sidebar slides when its scope changes 🐞 Bug ≡ Correctness ⭐ New
Description
useAnimateAfter leaves animated true when hydrated returns to false, so changing
collapseStorageKey does not disarm the width transition before the hook restores the new key.
After the first scope has armed animation, switching a mounted sidebar to a scope whose value
differs makes the 280px/36px restoration visibly slide even though persisted-state hydration is
intended to happen without a transition.
Code

components/ui/component-compare/component-compare/compare-sidebar.tsx[R282-283]

+    if (!ready || animated) return undefined;
+    const frame = requestAnimationFrame(() => setAnimated(true));
Evidence
The persisted hook marks a changed key unhydrated until its layout effect reads that key, while the
sidebar's animation helper only transitions from false to true and never resets. Because the
stylesheet applies width transitions whenever animated remains present, restoring a different
scoped value changes the width with transitions enabled.

components/ui/component-compare/component-compare/use-persisted-toggle.ts[42-47]
components/ui/component-compare/component-compare/use-persisted-toggle.ts[61-61]
components/ui/component-compare/component-compare/compare-sidebar.tsx[57-58]
components/ui/component-compare/component-compare/compare-sidebar.tsx[278-287]
components/ui/component-compare/component-compare/compare-sidebar.module.scss[42-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The animation remains armed when persistence hydration restarts for a different storage key, causing the restored width to transition visibly.

## Fix Focus Areas
- components/ui/component-compare/component-compare/compare-sidebar.tsx[57-58]
- components/ui/component-compare/component-compare/compare-sidebar.tsx[278-287]
- components/ui/component-compare/component-compare/compare-sidebar.spec.tsx[79-87]

## Recommended Fix
Tie the armed animation state to the current storage key or hydration cycle so a key change synchronously disables transitions, then re-enable them on a later animation frame after the new stored value has committed. Extend the key-change test to verify that the animation class is absent during restoration and only appears afterward.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Remembered sidebars still slide on load ✓ Resolved 🐞 Bug ≡ Correctness
Description
usePersistedToggle sets the stored value and hydrated in the same effect, while CompareSidebar
enables the width transition as soon as hydrated becomes true. When storage contains true, React
commits the collapsed width and transition class together, so the browser animates from 280px to
36px on every reload.
Code

components/ui/component-compare/component-compare/compare-sidebar.tsx[R75-77]

+        // only animate once the remembered value has been applied, so a sidebar that was left
+        // collapsed does not visibly slide shut on every page load
+        hydrated && styles.animated,
Evidence
The hook batches the stored-value and hydration updates in one effect, the sidebar immediately maps
hydration to the animation class, and that class declares transitions for the same width properties
changed by the collapsed class.

components/ui/component-compare/component-compare/use-persisted-toggle.ts[22-30]
components/ui/component-compare/component-compare/compare-sidebar.tsx[71-80]
components/ui/component-compare/component-compare/compare-sidebar.module.scss[40-50]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The stored collapse value and hydration flag are committed together, causing the transition to animate the initial restored width.
## Fix Focus Areas
- components/ui/component-compare/component-compare/compare-sidebar.tsx[71-80]
- components/ui/component-compare/component-compare/use-persisted-toggle.ts[22-30]
- components/ui/component-compare/component-compare/compare-sidebar.module.scss[40-50]
## Recommended Fix
Apply the persisted collapse state in a render without the transition class, then enable transitions in a subsequent animation frame or equivalent post-commit step. Clean up any scheduled frame when the component unmounts.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. New preference scopes inherit old state ✓ Resolved 🐞 Bug ≡ Correctness
Description
usePersistedToggle only replaces value when the newly selected storage key contains exactly
true or false, leaving the previous key’s value intact otherwise. When a mounted caller changes
collapseStorageKey to an empty or invalid scope, its uncontrolled sidebar continues displaying the
old scope’s collapse state instead of the supplied default.
Code

components/ui/component-compare/component-compare/use-persisted-toggle.ts[R24-27]

+      const stored = globalThis.localStorage?.getItem(storageKey);
+      if (stored === 'true' || stored === 'false') setValue(stored === 'true');
+    } catch {
+      // storage unavailable — keep the default
Evidence
State is initialized from the default only once, while subsequent key reads have no fallback
assignment for missing or malformed values; the sidebar directly renders this remembered value
whenever it is uncontrolled.

components/ui/component-compare/component-compare/use-persisted-toggle.ts[15-30]
components/ui/component-compare/component-compare/compare-sidebar.tsx[53-60]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Changing to a storage key without a valid saved value leaves the hook’s previous key value active.
## Fix Focus Areas
- components/ui/component-compare/component-compare/use-persisted-toggle.ts[15-30]
- components/ui/component-compare/component-compare/compare-sidebar.tsx[53-60]
## Recommended Fix
Track which storage key produced the current value and return `defaultValue` whenever it does not match the requested key. On each key change, resolve the new value to either its valid stored boolean or `defaultValue`, and include `defaultValue` in the relevant dependencies.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced: This modifies shared UI behavior, persistence, controlled-state API semantics, hydration/layout timing, accessibility interactions, and responsive styling, creating real cross-path correctness risk despite the localized scope.

Grey Divider

Tip of the day
💡 Did you know, you can type 'qodo, fix this' on a finding and the fix lands right on your PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 392c525 ⚖️ Balanced

Results up to commit aa7000f


🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)


Remediation recommended
1. Remembered sidebars still slide on load ✓ Resolved 🐞 Bug ≡ Correctness
Description
usePersistedToggle sets the stored value and hydrated in the same effect, while CompareSidebar
enables the width transition as soon as hydrated becomes true. When storage contains true, React
commits the collapsed width and transition class together, so the browser animates from 280px to
36px on every reload.
Code

components/ui/component-compare/component-compare/compare-sidebar.tsx[R75-77]

+        // only animate once the remembered value has been applied, so a sidebar that was left
+        // collapsed does not visibly slide shut on every page load
+        hydrated && styles.animated,
Evidence
The hook batches the stored-value and hydration updates in one effect, the sidebar immediately maps
hydration to the animation class, and that class declares transitions for the same width properties
changed by the collapsed class.

components/ui/component-compare/component-compare/use-persisted-toggle.ts[22-30]
components/ui/component-compare/component-compare/compare-sidebar.tsx[71-80]
components/ui/component-compare/component-compare/compare-sidebar.module.scss[40-50]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The stored collapse value and hydration flag are committed together, causing the transition to animate the initial restored width.
## Fix Focus Areas
- components/ui/component-compare/component-compare/compare-sidebar.tsx[71-80]
- components/ui/component-compare/component-compare/use-persisted-toggle.ts[22-30]
- components/ui/component-compare/component-compare/compare-sidebar.module.scss[40-50]
## Recommended Fix
Apply the persisted collapse state in a render without the transition class, then enable transitions in a subsequent animation frame or equivalent post-commit step. Clean up any scheduled frame when the component unmounts.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. New preference scopes inherit old state 🐞 Bug ≡ Correctness
Description
usePersistedToggle only replaces value when the newly selected storage key contains exactly
true or false, leaving the previous key’s value intact otherwise. When a mounted caller changes
collapseStorageKey to an empty or invalid scope, its uncontrolled sidebar continues displaying the
old scope’s collapse state instead of the supplied default.
Code

components/ui/component-compare/component-compare/use-persisted-toggle.ts[R24-27]

+      const stored = globalThis.localStorage?.getItem(storageKey);
+      if (stored === 'true' || stored === 'false') setValue(stored === 'true');
+    } catch {
+      // storage unavailable — keep the default
Evidence
State is initialized from the default only once, while subsequent key reads have no fallback
assignment for missing or malformed values; the sidebar directly renders this remembered value
whenever it is uncontrolled.

components/ui/component-compare/component-compare/use-persisted-toggle.ts[15-30]
components/ui/component-compare/component-compare/compare-sidebar.tsx[53-60]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Changing to a storage key without a valid saved value leaves the hook’s previous key value active.
## Fix Focus Areas
- components/ui/component-compare/component-compare/use-persisted-toggle.ts[15-30]
- components/ui/component-compare/component-compare/compare-sidebar.tsx[53-60]
## Recommended Fix
Track which storage key produced the current value and return `defaultValue` whenever it does not match the requested key. On each key change, resolve the new value to either its valid stored boolean or `defaultValue`, and include `defaultValue` in the relevant dependencies.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Context sources
Review mode: ⚖️ Balanced: This is a localized UI feature with persistence and controlled/uncontrolled state behavior, carrying real behavioral risk but not enough independent complexity to justify extended review.
Results up to commit 0d2af8b


🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)


Remediation recommended
1. Remembered sidebars still slide on load 🐞 Bug ≡ Correctness
Description
usePersistedToggle sets the stored value and hydrated in the same effect, while CompareSidebar
enables the width transition as soon as hydrated becomes true. When storage contains true, React
commits the collapsed width and transition class together, so the browser animates from 280px to
36px on every reload.
Code

components/ui/component-compare/component-compare/compare-sidebar.tsx[R75-77]

+        // only animate once the remembered value has been applied, so a sidebar that was left
+        // collapsed does not visibly slide shut on every page load
+        hydrated && styles.animated,
Evidence
The hook batches the stored-value and hydration updates in one effect, the sidebar immediately maps
hydration to the animation class, and that class declares transitions for the same width properties
changed by the collapsed class.

components/ui/component-compare/component-compare/use-persisted-toggle.ts[22-30]
components/ui/component-compare/component-compare/compare-sidebar.tsx[71-80]
components/ui/component-compare/component-compare/compare-sidebar.module.scss[40-50]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The stored collapse value and hydration flag are committed together, causing the transition to animate the initial restored width.

## Fix Focus Areas
- components/ui/component-compare/component-compare/compare-sidebar.tsx[71-80]
- components/ui/component-compare/component-compare/use-persisted-toggle.ts[22-30]
- components/ui/component-compare/component-compare/compare-sidebar.module.scss[40-50]

## Recommended Fix
Apply the persisted collapse state in a render without the transition class, then enable transitions in a subsequent animation frame or equivalent post-commit step. Clean up any scheduled frame when the component unmounts.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. New preference scopes inherit old state 🐞 Bug ≡ Correctness
Description
usePersistedToggle only replaces value when the newly selected storage key contains exactly
true or false, leaving the previous key’s value intact otherwise. When a mounted caller changes
collapseStorageKey to an empty or invalid scope, its uncontrolled sidebar continues displaying the
old scope’s collapse state instead of the supplied default.
Code

components/ui/component-compare/component-compare/use-persisted-toggle.ts[R24-27]

+      const stored = globalThis.localStorage?.getItem(storageKey);
+      if (stored === 'true' || stored === 'false') setValue(stored === 'true');
+    } catch {
+      // storage unavailable — keep the default
Evidence
State is initialized from the default only once, while subsequent key reads have no fallback
assignment for missing or malformed values; the sidebar directly renders this remembered value
whenever it is uncontrolled.

components/ui/component-compare/component-compare/use-persisted-toggle.ts[15-30]
components/ui/component-compare/component-compare/compare-sidebar.tsx[53-60]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Changing to a storage key without a valid saved value leaves the hook’s previous key value active.

## Fix Focus Areas
- components/ui/component-compare/component-compare/use-persisted-toggle.ts[15-30]
- components/ui/component-compare/component-compare/compare-sidebar.tsx[53-60]

## Recommended Fix
Track which storage key produced the current value and return `defaultValue` whenever it does not match the requested key. On each key change, resolve the new value to either its valid stored boolean or `defaultValue`, and include `defaultValue` in the relevant dependencies.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Context sources
Review mode: ⚖️ Balanced: This is a behavior-changing UI feature involving persistence, controlled state, SSR hydration timing, accessibility, and multiple interaction paths, warranting a complete single-pass review.

Grey Divider

Qodo Logo

Comment thread components/ui/component-compare/component-compare/compare-sidebar.tsx Outdated
Comment thread components/ui/component-compare/component-compare/use-persisted-toggle.ts Outdated
The correction ran in a passive effect, so a sidebar the reader had left
collapsed rendered once at full width and only snapped shut after the
browser had already painted that frame. Moving it to a layout effect makes
it land in the same frame.

Still not a render-time read: initialising state from `localStorage` would
make the first client render disagree with the server's, which is a
hydration failure. Rendering the default and correcting it before paint
keeps both renders identical and costs the reader nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit aa7000f

…erly

Two defects Qodo caught in review.

The restored width and the transition class landed in the same commit, so
the browser saw a width change on a newly-transitionable element and
animated 280px → 36px on every reload — the exact "slides shut on load"
the layout effect was meant to prevent. Transitions now turn on a frame
later, once the restored width is already in place, so only real toggles
animate.

`usePersistedToggle` also only replaced its value when the new key held a
valid boolean, so moving a mounted caller to a scope with nothing stored
left the previous scope's preference on screen. It now tracks which key
produced the current value and falls back to the default for any other.

Both are covered by specs that fail without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +282 to +283
if (!ready || animated) return undefined;
const frame = requestAnimationFrame(() => setAnimated(true));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Sidebar slides when its scope changes 🐞 Bug ≡ Correctness

useAnimateAfter leaves animated true when hydrated returns to false, so changing
collapseStorageKey does not disarm the width transition before the hook restores the new key.
After the first scope has armed animation, switching a mounted sidebar to a scope whose value
differs makes the 280px/36px restoration visibly slide even though persisted-state hydration is
intended to happen without a transition.
Agent Prompt
## Issue description
The animation remains armed when persistence hydration restarts for a different storage key, causing the restored width to transition visibly.

## Fix Focus Areas
- components/ui/component-compare/component-compare/compare-sidebar.tsx[57-58]
- components/ui/component-compare/component-compare/compare-sidebar.tsx[278-287]
- components/ui/component-compare/component-compare/compare-sidebar.spec.tsx[79-87]

## Recommended Fix
Tie the armed animation state to the current storage key or hydration cycle so a key change synchronously disables transitions, then re-enable them on a later animation frame after the new stored value has committed. Extend the key-change test to verify that the animation class is absent during restoration and only appears afterward.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 392c525

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant