|
| 1 | +--- |
| 2 | +title: Reader pane scrollbars need pane-owned scroll roots and full-height placeholders |
| 3 | +date: 2026-04-19 |
| 4 | +category: ui-bugs |
| 5 | +module: article reader |
| 6 | +problem_type: ui_bug |
| 7 | +component: react_component |
| 8 | +symptoms: |
| 9 | + - the desktop reader could fall back toward page-level scrolling instead of keeping the article list and detail panes independently scrollable |
| 10 | + - the left pane depended on a fragile `sm:h-full` height chain, while the right pane still used native `overflow-auto`, so scrollbar behavior was asymmetric and hard to discover |
| 11 | + - after the detail pane moved into the shared scroll wrapper, empty and unavailable states could lose vertical centering unless the placeholder path also kept a full-height flex chain |
| 12 | +root_cause: scope_issue |
| 13 | +resolution_type: code_fix |
| 14 | +severity: medium |
| 15 | +related_components: |
| 16 | + - next_page |
| 17 | + - testing_framework |
| 18 | +tags: |
| 19 | + [ |
| 20 | + article-reader, |
| 21 | + dual-pane, |
| 22 | + scroll-area, |
| 23 | + scrollbar, |
| 24 | + overflow, |
| 25 | + placeholder-state, |
| 26 | + flex-height-chain, |
| 27 | + ] |
| 28 | +--- |
| 29 | + |
| 30 | +# Reader pane scrollbars need pane-owned scroll roots and full-height placeholders |
| 31 | + |
| 32 | +## Problem |
| 33 | + |
| 34 | +The desktop reader is designed as a fixed-height dual-pane surface, but the original height and overflow chain let scrolling drift away from the panes themselves. The list pane depended on an ambient height assumption, the detail pane used native `overflow-auto`, and the later detail refactor showed that placeholder states also needed the same full-height contract as real article content. |
| 35 | + |
| 36 | +## Symptoms |
| 37 | + |
| 38 | +- On desktop, the page could become the effective scroll container instead of the list pane and detail pane owning scroll independently. |
| 39 | +- The two panes did not share the same scrollbar contract, so the list pane and detail pane behaved differently under overflow. |
| 40 | +- Empty or unavailable detail states risked sitting inside a scroll wrapper without enough height context to stay vertically centered. |
| 41 | + |
| 42 | +## What Didn't Work |
| 43 | + |
| 44 | +- Leaving the detail pane on native `overflow-auto` while the list pane used the shared `ScrollArea` kept behavior asymmetric and made scrollbar visibility depend on OS/browser defaults. |
| 45 | +- Relying on `sm:h-full` inside the list pane did not create a reliable height chain in a fixed shell; it only worked when ancestor sizing happened to line up. |
| 46 | +- Moving placeholder content under the new scroll wrapper without also restoring a `min-h-full` + `flex-1` chain would keep the scroll root but break the previous centered empty-state layout. |
| 47 | + |
| 48 | +## Solution |
| 49 | + |
| 50 | +Make the reader shell and both panes explicit about scroll ownership, then preserve the same height contract for placeholder states. |
| 51 | + |
| 52 | +The shell now owns a fixed viewport boundary, and the shared primitive is a flex-owned scroll root with stable structural hooks: |
| 53 | + |
| 54 | +```tsx |
| 55 | +<main className="h-dvh min-h-dvh overflow-hidden ..."> |
| 56 | + <section className="... h-[calc(100dvh-1rem-2px)] ... overflow-hidden ..."> |
| 57 | + <ArticleList ... /> |
| 58 | + <ArticleDetailPane ... /> |
| 59 | + </section> |
| 60 | +</main> |
| 61 | +``` |
| 62 | + |
| 63 | +```tsx |
| 64 | +const ScrollArea = React.forwardRef( |
| 65 | + ...({ className, children, type = "always", ...props }, ref) => ( |
| 66 | + <ScrollAreaPrimitive.Root |
| 67 | + data-slot="scroll-area" |
| 68 | + type={type} |
| 69 | + className={cn( |
| 70 | + "group/scroll-area relative flex min-h-0 flex-col overflow-hidden", |
| 71 | + className, |
| 72 | + )} |
| 73 | + {...props} |
| 74 | + > |
| 75 | + <ScrollAreaPrimitive.Viewport |
| 76 | + data-slot="scroll-area-viewport" |
| 77 | + className="min-h-0 w-full flex-1 rounded-[inherit]" |
| 78 | + > |
| 79 | + {children} |
| 80 | + </ScrollAreaPrimitive.Viewport> |
| 81 | + <ScrollBar /> |
| 82 | + </ScrollAreaPrimitive.Root> |
| 83 | + ), |
| 84 | +); |
| 85 | +``` |
| 86 | + |
| 87 | +The detail pane now uses the same shared scroll root as the list pane, and placeholder states keep a full-height flex chain so centering survives the refactor: |
| 88 | + |
| 89 | +```tsx |
| 90 | +<ScrollArea data-testid="article-detail-scroll-area" className="min-h-0 flex-1"> |
| 91 | + <div |
| 92 | + className={cn( |
| 93 | + "px-5 py-5 lg:px-7 lg:py-7 xl:px-8 xl:py-8", |
| 94 | + isPlaceholderState && "flex min-h-full flex-col", |
| 95 | + )} |
| 96 | + > |
| 97 | + <div |
| 98 | + className={cn( |
| 99 | + "mx-auto w-full max-w-5xl", |
| 100 | + isPlaceholderState && "flex min-h-full flex-1 flex-col", |
| 101 | + )} |
| 102 | + > |
| 103 | + {content} |
| 104 | + </div> |
| 105 | + </div> |
| 106 | +</ScrollArea> |
| 107 | +``` |
| 108 | + |
| 109 | +Verification was split across SSR and browser tests: |
| 110 | + |
| 111 | +- `apps/web/app/page.spec.tsx` asserts the fixed shell classes, the two scroll roots, and the placeholder-state full-height wrappers. |
| 112 | +- `apps/web/e2e/home.spec.ts` proves `scrollHeight > clientHeight`, independent `scrollTop` changes for each pane, stable headers during pane scrolling, and preserved detail scroll roots for pending and unavailable states. |
| 113 | + |
| 114 | +## Why This Works |
| 115 | + |
| 116 | +The bug was not a single missing class; it was a broken ownership boundary. The shell is supposed to be a clipped reading surface, so the panes must own overflow explicitly. Once both panes use the same `ScrollArea` contract and their bodies opt into `min-h-0 flex-1`, the browser no longer needs to guess where scrolling belongs. The follow-up placeholder fix works for the same reason: empty and unavailable states now inherit the same full-height flex chain as the normal detail body, so layout does not collapse when content is replaced by a centered fallback. |
| 117 | + |
| 118 | +## Prevention |
| 119 | + |
| 120 | +- In fixed-height reader or dashboard layouts, keep headers outside the scroll body and make the pane body the explicit scroll root. |
| 121 | +- Do not rely on `h-full` alone inside nested flex shells; preserve the full `overflow-hidden` -> `min-h-0` -> `flex-1` chain from shell to viewport to content wrapper. |
| 122 | +- When moving empty, pending, or unavailable states under a shared scroll wrapper, preserve their full-height layout path and add assertions for the wrapper classes that centering depends on. |
| 123 | +- Keep stable scroll-root selectors such as `data-testid="article-list-scroll-area"` and `data-slot="scroll-area-viewport"` so browser tests can verify real overflow behavior instead of only markup. |
| 124 | + |
| 125 | +## Related Issues |
| 126 | + |
| 127 | +- Paired implementation plans: |
| 128 | + - `docs/en/plans/2026-04-19-001-fix-reader-pane-scrollbars-plan.md` |
| 129 | + - `docs/zh-Hans/plans/2026-04-19-001-fix-reader-pane-scrollbars-plan.md` |
| 130 | +- Related learning: `docs/en/solutions/logic-errors/article-summary-retryable-failures-must-not-clear-existing-summary-2026-04-18.md` — the reader should keep a stable visible structure even when summary content is pending or unavailable. |
| 131 | +- GitHub issue search via `gh issue list --search "reader scrollbar article detail scroll" --state all --limit 5` returned no related issues. |
0 commit comments