fix(ui): stabilize series browser virtual scroll#873
Conversation
📝 WalkthroughWalkthroughThe series browser switches from auto-sizing to fixed-size virtual scrolling with a computed row height. A debounced viewport refresh mechanism is added, triggered by ResizeObserver, window resize, and reactive data changes. CSS updates improve scrollbar handling and element sizing consistency by adjusting overflow, scrollbar-gutter, and box-sizing properties. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts (1)
113-113: Recommended: convertscreenWidthand the size getters to signals/computed.
isMobile,cardWidth,cardHeight, androwHeightare plain getters, andscreenWidthis a plain field. TherefreshViewportEffectand thegridColumnscomputed only reactively re-run becauseseriesScaleService.scaleFactor()happens to be read transitively — they do not trackscreenWidthat all. Today this works becauseonResizealso callsscheduleViewportRefresh()and the resultingviewportWidth.set(...)change re-triggers downstream computeds, but that's an indirect coupling that's easy to break in a future change.Promoting these to a
signal/computed()chain makes the reactive graph explicit, lets the template's[itemSize]="rowHeight"participate properly in signal-based change detection, and removes one reason foronResizeto manually poke the refresh pipeline.♻️ Sketch of the refactor
- screenWidth = window.innerWidth; + private readonly screenWidth = signal(window.innerWidth); @@ - `@HostListener`('window:resize') - onResize(): void { - this.screenWidth = window.innerWidth; - this.scheduleViewportRefresh(); - } + `@HostListener`('window:resize') + onResize(): void { + this.screenWidth.set(window.innerWidth); + this.scheduleViewportRefresh(); + } @@ - get isMobile(): boolean { - return this.screenWidth <= 767; - } - - get cardWidth(): number { - const base = this.isMobile - ? SeriesBrowserComponent.MOBILE_BASE_WIDTH - : SeriesBrowserComponent.BASE_WIDTH; - return Math.round(base * this.seriesScaleService.scaleFactor()); - } - - get cardHeight(): number { - const base = this.isMobile - ? SeriesBrowserComponent.MOBILE_BASE_HEIGHT - : SeriesBrowserComponent.BASE_HEIGHT; - return Math.round(base * this.seriesScaleService.scaleFactor()); - } - - get rowHeight(): number { - return this.cardHeight + SeriesBrowserComponent.ROW_TOP_PADDING; - } + readonly isMobile = computed(() => this.screenWidth() <= 767); + readonly cardWidth = computed(() => { + const base = this.isMobile() + ? SeriesBrowserComponent.MOBILE_BASE_WIDTH + : SeriesBrowserComponent.BASE_WIDTH; + return Math.round(base * this.seriesScaleService.scaleFactor()); + }); + readonly cardHeight = computed(() => { + const base = this.isMobile() + ? SeriesBrowserComponent.MOBILE_BASE_HEIGHT + : SeriesBrowserComponent.BASE_HEIGHT; + return Math.round(base * this.seriesScaleService.scaleFactor()); + }); + readonly rowHeight = computed(() => + this.cardHeight() + SeriesBrowserComponent.ROW_TOP_PADDING + );Template/effect call sites become
cardWidth(),cardHeight(),rowHeight(),isMobile().Based on learnings: "Prefer Angular signals (e.g.,
signal,computed) overBehaviorSubject/Observablefor state where signals/computed values fit the use case" (balazs-szucs, PR 385).Also applies to: 123-143, 145-159
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts` at line 113, screenWidth is a plain field and the size getters (isMobile, cardWidth, cardHeight, rowHeight) are plain getters so the reactive graph doesn't explicitly track screen size; convert screenWidth into a signal (e.g., screenWidth = signal(window.innerWidth)) and convert the getters into computed signals (computed(() => ...)) so refreshViewportEffect and gridColumns explicitly depend on screenWidth (and seriesScaleService.scaleFactor() stays as before); update template and any callers to use the computed call form (e.g., rowHeight()) and remove the need for onResize to manually poke scheduleViewportRefresh/viewportWidth.set by updating the screenWidth signal in onResize instead.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In
`@frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts`:
- Line 113: screenWidth is a plain field and the size getters (isMobile,
cardWidth, cardHeight, rowHeight) are plain getters so the reactive graph
doesn't explicitly track screen size; convert screenWidth into a signal (e.g.,
screenWidth = signal(window.innerWidth)) and convert the getters into computed
signals (computed(() => ...)) so refreshViewportEffect and gridColumns
explicitly depend on screenWidth (and seriesScaleService.scaleFactor() stays as
before); update template and any callers to use the computed call form (e.g.,
rowHeight()) and remove the need for onResize to manually poke
scheduleViewportRefresh/viewportWidth.set by updating the screenWidth signal in
onResize instead.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro
Run ID: 9b78557d-5f30-4b78-b841-a2456e888e04
📒 Files selected for processing (3)
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.htmlfrontend/src/app/features/series-browser/components/series-browser/series-browser.component.scssfrontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
- GitHub Check: Test Suite / Backend Tests
- GitHub Check: Test Suite / Frontend Tests
- GitHub Check: Analyze (javascript-typescript)
- GitHub Check: Analyze (java-kotlin)
🧰 Additional context used
📓 Path-based instructions (4)
frontend/src/**/*.{ts,tsx,html,scss}
📄 CodeRabbit inference engine (AGENTS.md)
Use 2-space indentation in TypeScript, HTML, and SCSS in frontend code
Files:
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.htmlfrontend/src/app/features/series-browser/components/series-browser/series-browser.component.scssfrontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts
frontend/src/**/*.{ts,tsx,html}
📄 CodeRabbit inference engine (AGENTS.md)
frontend/src/**/*.{ts,tsx,html}: Follow frontend/eslint.config.js: component selectors use app-, directive selectors use app, and any is disallowed in frontend code
Put user-facing strings in Transloco files under frontend/src/i18n/
Files:
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.htmlfrontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts
frontend/src/**/*.component.ts
📄 CodeRabbit inference engine (AGENTS.md)
Keep Angular code on standalone components. Do not add NgModules in frontend code
Files:
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts
frontend/src/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
Prefer inject() over constructor injection in frontend code
Files:
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts
🧠 Learnings (4)
📚 Learning: 2026-04-22T01:56:39.495Z
Learnt from: CR
Repo: grimmory-tools/grimmory PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-22T01:56:39.495Z
Learning: Applies to frontend/src/**/*.component.ts : Keep Angular code on standalone components. Do not add NgModules in frontend code
Applied to files:
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts
📚 Learning: 2026-04-05T21:16:01.715Z
Learnt from: balazs-szucs
Repo: grimmory-tools/grimmory PR: 385
File: frontend/src/app/app.component.ts:55-56
Timestamp: 2026-04-05T21:16:01.715Z
Learning: When reviewing code in the Grimmory frontend (Angular), prefer modern Angular patterns. Specifically: (1) Prefer `DestroyRef` with `takeUntilDestroyed(destroyRef)` for teardown in Angular v16+ instead of manually tracking `Subscription` arrays and calling `unsubscribe()` in `ngOnDestroy()`. (2) Prefer `inject()` for dependency injection over constructor injection where appropriate. (3) Prefer Angular signals (e.g., `signal`, `computed`) over `BehaviorSubject`/`Observable` for state where signals/computed values fit the use case. Flag older patterns when they can be replaced with these modern equivalents without changing behavior.
Applied to files:
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts
📚 Learning: 2026-04-07T09:28:09.587Z
Learnt from: balazs-szucs
Repo: grimmory-tools/grimmory PR: 393
File: frontend/src/app/features/readers/pdf-reader/pdf-reader.component.ts:255-263
Timestamp: 2026-04-07T09:28:09.587Z
Learning: In this Angular frontend (under frontend/src/app/), flag manual resource management/cleanup patterns when there is an Angular v16+ automatic alternative. Examples to prefer: (1) Instead of manually pairing document/window event listeners with stored cleanup functions (e.g., add/removeEventListener with mouseMoveCleanup/documentClickCleanup/keydownCleanup/touchCleanup fields), register teardown via DestroyRef.onDestroy(cleanupFn) (or equivalent Angular v16+ teardown mechanism). (2) Instead of storing Subscriptions in fields and explicitly unsubscribing in ngOnDestroy (e.g., annotationSaveSubscription/annotationCacheSubscription), use takeUntilDestroyed(destroyRef) (piped into the observable) or other Angular v16+ primitives. (3) If teardown is lifecycle-coupled and can be automated via DestroyRef/takeUntilDestroyed/signals (or other Angular v16+ mechanisms), prefer the automated approach over manual ngOnDestroy cleanup. Raise a review finding for the manual pattern and recommend the aut...
Applied to files:
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts
📚 Learning: 2026-04-11T03:55:57.229Z
Learnt from: zachyale
Repo: grimmory-tools/grimmory PR: 439
File: frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts:178-196
Timestamp: 2026-04-11T03:55:57.229Z
Learning: In this Angular frontend (frontend/src/app/), prefer the team’s reactive i18n “signal/computed” pattern: (1) For individual reactive translated strings, use `translateSignal()` from `jsverse/transloco`. (2) For option/label arrays that must update on language switch, create a single `activeLang` signal with `toSignal(t.langChanges$, { initialValue: t.getActiveLang() })`, then derive the arrays as `computed()` signals that read `activeLang()`. This should avoid manual `langChanges$` subscriptions and any `ngOnDestroy` subscription cleanup; prefer this over subscribing in `ngOnInit` when implementing reactive localization.
Applied to files:
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts
🔇 Additional comments (4)
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.html (1)
80-80: LGTM — fixed-size binding aligns with the component change.
[itemSize]="rowHeight"correctly pairs with the switch toCdkFixedSizeVirtualScrollin the component class. The buffer settings remain unchanged.frontend/src/app/features/series-browser/components/series-browser/series-browser.component.scss (1)
106-114: LGTM — overflow containment and stable gutter address the jitter/freeze symptoms.
overflow: hidden autoplusscrollbar-gutter: stableprevent the scrollbar appearance from triggering width oscillations in the ResizeObserver, which is consistent with the hover-jitter symptom described in#865.box-sizing: border-boxon.series-gridkeeps the padded grid within the measured viewport width.Also applies to: 140-140
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts (2)
73-89: LGTM — viewport setter correctly handles re-mounts.The setter disconnects any prior observer before creating a new one, performs an initial synchronous measurement, and wraps the
ResizeObservercallback inzone.run(...)(necessary becauseResizeObservercallbacks fire outside the Angular zone). ThescheduleViewportRefresh()rAF debouncing dedups overlapping resize/data events.
218-249: LGTM — measurement and refresh helpers are sound.A few things worth calling out as correct (so they aren't broken later):
- Operator precedence in
entry?.contentRect.width ?? el.clientWidth - horizontalPaddingresolves as?? (clientWidth - horizontalPadding)because??has the lowest precedence here, so both branches yield the content-box width.scheduleViewportRefreshis idempotent via thependingViewportRefreshguard, and the rAF callback clears it before invokingrefreshViewport(), so subsequent events can re-arm it.refreshViewportwrites back toviewportWidthoutside the effect's synchronous execution (inside rAF), and signal equality short-circuits no-op writes — so there's no risk of an effect↔write feedback loop.cancelAnimationFrameis correctly invoked indestroyRef.onDestroyto avoid a refresh firing on a torn-down view.
balazs-szucs
left a comment
There was a problem hiding this comment.
I have an idea for a clean refactor which should help all the browser pages but that's probably for another day :)
Agreed :)
Approved, but make TODO note, so that you do not forget it :)
Thank you!
Description
Linked Issue: Fixes #865
Not the cleanest, its a workaround with the existing CDK browser and layout to stop the immediate regression. I have an idea for a clean refactor which should help all the browser pages but that's probably for another day :)
Changes
Summary by CodeRabbit
Bug Fixes
Performance Improvements