Skip to content

fix(ui): stabilize series browser virtual scroll#873

Merged
alexhb1 merged 1 commit into
grimmory-tools:developfrom
alexhb1:series-fix
Apr 25, 2026
Merged

fix(ui): stabilize series browser virtual scroll#873
alexhb1 merged 1 commit into
grimmory-tools:developfrom
alexhb1:series-fix

Conversation

@alexhb1

@alexhb1 alexhb1 commented Apr 25, 2026

Copy link
Copy Markdown
Member

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

  • Fixed series browser freezing by stabilising CDK virtual scroll sizing and refresh behaviour

Summary by CodeRabbit

  • Bug Fixes

    • Fixed layout shifts caused by scrollbar appearance and disappearance in the series browser.
  • Performance Improvements

    • Enhanced virtual scrolling rendering for smoother list performance and responsive viewport handling.

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The 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

Cohort / File(s) Summary
Virtual Scroller Configuration
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.html
Changed virtual scroller from auto-sizing to fixed-size items via [itemSize]="rowHeight" property binding.
Scrollbar & Sizing Adjustments
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.scss
Added overflow: hidden auto and scrollbar-gutter: stable for scroll containment; removed explicit padding and added box-sizing: border-box to prevent layout shift.
Viewport Refresh & Measurement Logic
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts
Introduced computed rowHeight, debounced viewport refresh mechanism (pendingViewportRefresh, scheduleViewportRefresh, refreshViewport), and updated width measurement helpers to account for horizontal padding; integrated ResizeObserver and reactive effect() watchers.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • PR #309: Addresses virtual-scroller measurement and spacing inconsistencies through margin-based spacing and rem-to-px conversions for consistent height measurements.

Suggested labels

frontend, enhancement

Suggested reviewers

  • balazs-szucs

Poem

🐰 Fixed rows scroll with steady grace,
No dancing icons round the place,
With padding measured, heights now true,
The series view works just like new! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title follows conventional commit format with 'fix' type and descriptive scope/subject about stabilizing virtual scroll.
Description check ✅ Passed The PR description includes all required template sections: Description, Linked Issue, and Changes with substantive details about the fix.
Linked Issues check ✅ Passed The PR code changes directly address issue #865 by implementing virtual scroll stabilization to fix series browser freezing and responsiveness issues.
Out of Scope Changes check ✅ Passed All changes are focused on the series browser virtual scroll component; no unrelated modifications were introduced outside the stated objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
frontend/src/app/features/series-browser/components/series-browser/series-browser.component.ts (1)

113-113: Recommended: convert screenWidth and the size getters to signals/computed.

isMobile, cardWidth, cardHeight, and rowHeight are plain getters, and screenWidth is a plain field. The refreshViewportEffect and the gridColumns computed only reactively re-run because seriesScaleService.scaleFactor() happens to be read transitively — they do not track screenWidth at all. Today this works because onResize also calls scheduleViewportRefresh() and the resulting viewportWidth.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 for onResize to 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) over BehaviorSubject/Observable for 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

📥 Commits

Reviewing files that changed from the base of the PR and between e7bc5fe and f0b3f96.

📒 Files selected for processing (3)
  • frontend/src/app/features/series-browser/components/series-browser/series-browser.component.html
  • frontend/src/app/features/series-browser/components/series-browser/series-browser.component.scss
  • frontend/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.html
  • frontend/src/app/features/series-browser/components/series-browser/series-browser.component.scss
  • frontend/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.html
  • frontend/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 to CdkFixedSizeVirtualScroll in 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 auto plus scrollbar-gutter: stable prevent the scrollbar appearance from triggering width oscillations in the ResizeObserver, which is consistent with the hover-jitter symptom described in #865. box-sizing: border-box on .series-grid keeps 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 ResizeObserver callback in zone.run(...) (necessary because ResizeObserver callbacks fire outside the Angular zone). The scheduleViewportRefresh() 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 - horizontalPadding resolves as ?? (clientWidth - horizontalPadding) because ?? has the lowest precedence here, so both branches yield the content-box width.
  • scheduleViewportRefresh is idempotent via the pendingViewportRefresh guard, and the rAF callback clears it before invoking refreshViewport(), so subsequent events can re-arm it.
  • refreshViewport writes back to viewportWidth outside 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.
  • cancelAnimationFrame is correctly invoked in destroyRef.onDestroy to avoid a refresh firing on a torn-down view.

@balazs-szucs balazs-szucs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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!

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.

Series View Broken

3 participants