Skip to content

Lazy-paint slides thumbnails + show async image backgrounds#271

Merged
hackerwins merged 2 commits into
mainfrom
slides-thumbnail-async-bg
May 21, 2026
Merged

Lazy-paint slides thumbnails + show async image backgrounds#271
hackerwins merged 2 commits into
mainfrom
slides-thumbnail-async-bg

Conversation

@hackerwins

@hackerwins hackerwins commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three UX bugs in the slides thumbnail panel, all most visible on decks with master-level image backgrounds:

  • Image backgrounds never appeared in thumbnails. renderThumbnail constructed a throwaway SlideRenderer, so the onAssetLoad callback that fires when an image finishes loading had no effect (the renderer was already GC'd). Threaded onAssetLoad through renderThumbnaildrawSlide; the panel routes it via ThumbnailScheduler so concurrent loads coalesce.
  • Initial-mount block on large decks. Building N items (div + canvas + getContext + observe) synchronously stalled the main thread for hundreds of ms on 50+ slide decks. Switched to IntersectionObserver-driven lazy paint (rootMargin: 200px) plus chunked DOM construction via requestAnimationFrame — first viewport paints in the first frame, the rest stream in 20 items per frame.
  • Whole-panel flicker on every slide edit. store.onChange was calling thumbHandle.refresh() (full DOM wipe) for every drag / color / text edit. Split the panel API:
    • refresh() — structural (rebuild DOM)
    • refreshContent() — content-only, re-snapshots ThumbState.slide/doc and schedules repaints of painted thumbs; falls back to render() when the slide order diverges from the store (catches remote peer reorder)
    • syncCurrentHighlight() — toggles .current class + border color only, used by onCurrentSlideChange

Test plan

  • pnpm verify:fast — 792/792 green
  • Unit tests added for: renderThumbnail onAssetLoad propagation, IO lazy paint, async background image repaint via scheduler, dispose during async load, current-slide switch without canvas repaint, refreshContent debounced repaint, refreshContent order-divergence fallback, chunked render with rAF, refresh-during-chunking cancels stale items, dispose-during-chunking cancels pending chunks (44 panel tests, 5 thumbnail tests)
  • Manual smoke: pnpm dev against a deck with master-level image background + ~30 slides — confirm (a) visible thumbs show the image on initial mount, (b) scrolling reveals lower thumbs which paint with the image, (c) clicking a thumb only updates the highlight (no flicker across the panel), (d) editing a shape only repaints the affected thumb

Plan + review notes in docs/tasks/active/20260521-slides-thumbnail-async-bg-todo.md.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Slide thumbnails now render lazily, painting only when scrolled into view for improved responsiveness.
  • Bug Fixes

    • Fixed thumbnails failing to update when slide background images finish loading asynchronously.
    • Content-only edits no longer trigger unnecessary full thumbnail refreshes.
  • Tests

    • Added comprehensive test coverage for lazy thumbnail rendering and asynchronous asset-loading scenarios.

Review Change Stack

Three UX bugs in the slides thumbnail panel, all visible on decks
with master-level image backgrounds:

1. Image backgrounds never showed in thumbnails on first mount. The
   renderer's onAssetLoad callback fired into a discarded
   SlideRenderer (renderThumbnail constructed one per call and threw
   it away), so when the <img> finally loaded the canvas was never
   repainted. Now renderThumbnail takes an onAssetLoad and calls
   drawSlide directly; the panel routes the callback through
   ThumbnailScheduler so concurrent loads coalesce.

2. Initial-mount block on large decks. Building N items
   (div + canvas + getContext + observe) synchronously stalled the
   main thread for ~hundreds of ms on 50+ slide decks. Switched to
   IntersectionObserver-driven lazy paint (rootMargin 200px) plus
   chunked DOM construction via requestAnimationFrame so the first
   viewport's worth of thumbs paint in the first frame and the rest
   stream in one chunk per frame.

3. Whole-panel flicker on every slide edit. store.onChange in
   slides-view.tsx was calling thumbHandle.refresh() (full DOM wipe)
   for every drag, color tweak, or text edit. Split the panel API
   into refresh() (structural — rebuild DOM), refreshContent()
   (content-only — re-snapshot ThumbState.slide/doc and schedule
   repaints of painted thumbs), and syncCurrentHighlight() (toggle
   .current class only). refreshContent also detects when the slide
   order diverges from the store (remote peer reorder with the same
   count) and falls back to render() so the DOM doesn't end up
   pointing at the wrong slides.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@hackerwins has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 45 minutes and 52 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cd668ac8-5f70-429d-a8a8-75cbcdc94255

📥 Commits

Reviewing files that changed from the base of the PR and between 4a9c416 and 07a689c.

📒 Files selected for processing (3)
  • docs/tasks/active/20260521-slides-thumbnail-async-bg-todo.md
  • packages/slides/src/view/editor/thumbnail-panel.ts
  • packages/slides/test/view/editor/thumbnail-panel.test.ts
📝 Walkthrough

Walkthrough

This PR implements async background-image thumbnail repainting and lazy rendering for the slides editor. It adds an optional onAssetLoad callback to renderThumbnail, introduces IntersectionObserver-driven lazy painting, adds a new refreshContent() API for in-place slide edits, and introduces chunked DOM construction with proper lifecycle cleanup to prevent painting after disposal.

Changes

Slides Thumbnail Async Repaint & Lazy Rendering

Layer / File(s) Summary
Design & Task Documentation
docs/tasks/active/20260521-slides-thumbnail-async-bg-todo.md
Comprehensive design document specifying the thumbnail repaint problem (async images prevent repaint after renderer teardown), planned improvements (async callbacks + IntersectionObserver lazy painting), implementation checklist, mid-implementation follow-ups (click-flicker, chunked rendering, content-only refresh), and known limitations.
Thumbnail Renderer: Async Asset Load Support
packages/slides/src/view/canvas/thumbnail.ts
renderThumbnail now accepts optional onAssetLoad callback and calls drawSlide directly instead of wrapping in SlideRenderer. Added ThumbnailScheduler.cancel() method to clear debounce timers and pending IDs for explicit cleanup on dispose.
Thumbnail Panel: State & Scheduler Setup
packages/slides/src/view/editor/thumbnail-panel.ts (types, lifecycle, scheduler)
Introduced ThumbState type tracking per-slide DOM, canvas, context, state, and painted flag. mountThumbnailPanel now sets up disposed lifecycle flag, per-slide state map, debounced ThumbnailScheduler coalescing onAssetLoad callbacks, IntersectionObserver reference, chunked-render token/cancellation guards, and syncCurrentHighlight() helper for highlight-only updates.
Lazy Rendering with IntersectionObserver
packages/slides/src/view/editor/thumbnail-panel.ts (render logic)
Updated render() to disconnect prior observer, cancel chunk work, increment render token for guard, and configure IntersectionObserver to paint only unpainted thumbs on viewport intersection. Captures render-time closure snapshot (dimensions, DPR, doc, currentId) for both sync first chunk and deferred rAF chunks. Defers thumbnail painting by storing ThumbState with painted: false until observer triggers or IO-less fallback paints.
Content Refresh & Lifecycle Management
packages/slides/src/view/editor/thumbnail-panel.ts (refreshContent, dispose)
Added refreshContent() API to ThumbnailPanelHandle for re-snapshotting thumb slide/doc and scheduling repaints only for already-painted thumbs (in-place edits without DOM rebuild). Falls back to full render() if slide order diverges. Updated dispose() to set disposed, cancel chunks, clear scheduler, disconnect observer, and clear state.
Event Optimization & Chunked Rendering
packages/slides/src/view/editor/thumbnail-panel.ts (selection, chunks)
Shift-click multi-selection now skips render() to avoid DOM rebuild for selection-only changes. Right-click menu updates skip render when only selection state changes. Added token/disposed-guard protected rAF-deferred chunk builder for large decks: sync first chunk, subsequent chunks defer. Current-slide changes use highlight-only syncCurrentHighlight() instead of full render().
SlidesView Integration
packages/frontend/src/app/slides/slides-view.tsx
Updated store-change handler to call thumbHandle.refreshContent() instead of thumbHandle.refresh() after rendering. Ensures in-place content edits trigger targeted repaint without full rebuild; reserves structural refresh() for add/remove operations to avoid one-frame blank flicker.
Thumbnail Renderer Tests: Async Image Loading
packages/slides/test/view/canvas/thumbnail.test.ts
Added clearImageCacheForTests import. Introduced FakeImage test class completing on microtask and flushMicrotasks helper. New async image background suite verifies renderThumbnail skips draw/onAssetLoad while image loads, fires exactly one callback after microtask flush, and draws on second render. Scoped fake timers to ThumbnailScheduler describe block.
Thumbnail Panel Tests: Lazy Paint, Refresh, & Chunking
packages/slides/test/view/editor/thumbnail-panel.test.ts
Added afterEach, vi, and clearImageCacheForTests imports. Four new test suites: (1) IntersectionObserver lazy paint—thumbs paint only when intersecting, prevent redundant repaints, observer cleanup on dispose; (2) refreshContent—debounced repaints without rebuild for edits, structural fallback on reorder, no-op after dispose; (3) async image repaint—thumb repaints after image loads via scheduler, suppressed post-dispose; (4) chunked rendering—first chunk syncs, remaining defer via rAF, refresh() discards stale chunks, dispose() halts appends.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

The PR introduces substantial architectural changes across the thumbnail rendering pipeline: a new state-tracking system with ThumbState, IntersectionObserver-driven lazy painting with token-guarded concurrency, scheduler-based callback coalescing, chunked DOM construction, and comprehensive test coverage for multiple interaction paths. While individual pieces are conceptually clear, the interplay between token guards, disposal safety, lazy-vs-sync painting fallbacks, and chunked rendering requires careful verification of edge cases and concurrent access patterns.

Possibly related PRs

  • wafflebase/wafflebase#261: Updates thumbnail selection event from mousedown to pointerdown in the same thumbnail-panel.ts file, affecting event handler wiring at the same interaction checkpoint.

Poem

🐰 A rabbit's ode to lazy thumbnails
Async images bowed, then came alive—
observers watching, thumbs arrive.
When slides edit gently, no flicker, no fuss,
Smart refresh knows: paint what matters to us. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title directly captures the two main improvements: lazy thumbnail painting via IntersectionObserver and async image background rendering through the onAssetLoad callback mechanism.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slides-thumbnail-async-bg

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.

@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Verification: verify:self

Result: ✅ PASS in 210.8s

Lane Status Duration
sheets:build ✅ pass 12.7s
docs:build ✅ pass 12.4s
slides:build ✅ pass 13.6s
verify:fast ✅ pass 125.5s
frontend:build ✅ pass 18.8s
verify:frontend:chunks ✅ pass 0.3s
backend:build ✅ pass 4.8s
cli:build ✅ pass 2.1s
verify:entropy ✅ pass 20.6s

Verification: verify:integration

Result: ✅ PASS

@codecov

codecov Bot commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/tasks/active/20260521-slides-thumbnail-async-bg-todo.md`:
- Around line 75-76: The design notes currently document ThumbnailScheduler
debounce as two different values; update the text so both references to
ThumbnailScheduler (including the description in the mountThumbnailPanel context
and the later mention around onFlush(ids)) use the same debounce duration
(choose either 100ms or 200ms) to avoid mismatch — ensure the single chosen
value replaces both occurrences (references: mountThumbnailPanel,
ThumbnailScheduler, onFlush(ids)).
- Around line 274-277: Update the stale limitation text that claims "Thumbnails
do not auto-refresh on canvas content edits" so it accurately reflects that the
panel can be refreshed via the existing refresh handle pattern and the
documented refreshContent() integration; replace the absolute claim with a note
that thumbnails only auto-refresh when consumers call refreshContent() (or wire
their store changes into the refresh handle) and clarify that the panel
subscribes to onCurrentSlideChange by default but will update for content edits
when refreshContent()/refresh handle is used; mirror this clarification in both
the section describing refreshContent() and the earlier limitation text to
remove the contradiction.
- Line 209: The review section has inconsistent completion state: the summary
claims all 12 plan items are done but the V2 checklist item is left unchecked
and the manual verification note is marked pending; update the review checklist
so states are consistent by either checking the V2 item and marking manual
verification as completed (if actually done) or changing the summary to reflect
the remaining items, and ensure the "V2" checklist entry and the "manual
verification" line match the final status and the total count of completed plan
items.

In `@packages/slides/src/view/editor/thumbnail-panel.ts`:
- Around line 537-572: The per-chunk restore unconditionally sets
scrollParent.scrollTop inside buildChunk which can snap the user's scroll during
multi-rAF builds; change it to only restore when the browser has actually
clamped (i.e., compare current scrollTop and only set if current <
savedScrollTop) and ensure scrollParent is non-null before comparing; update the
assignment inside buildChunk (where savedScrollTop and scrollParent are used) to
a conditional guard so user-initiated scrolls aren't overwritten across chunks.

In `@packages/slides/test/view/editor/thumbnail-panel.test.ts`:
- Around line 642-648: The test title is inaccurate: it claims to exercise
"called before first render" but the fixture helper mountThumbnailPanel runs
render synchronously, then the test calls dispose() and tests refreshContent()
after dispose; update the test name to reflect the actual behavior (e.g.
"refreshContent is a safe no-op after dispose") so it matches the exercised
path; locate the spec that uses mountThumbnailPanel(...), calls
handle.dispose(), and then handle.refreshContent(), and change the it(...)
description accordingly (referencing mountThumbnailPanel, dispose, and
refreshContent).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: eaa1a50d-6582-4e3f-b839-8fdca2bc7c06

📥 Commits

Reviewing files that changed from the base of the PR and between 08bb636 and 4a9c416.

📒 Files selected for processing (6)
  • docs/tasks/active/20260521-slides-thumbnail-async-bg-todo.md
  • packages/frontend/src/app/slides/slides-view.tsx
  • packages/slides/src/view/canvas/thumbnail.ts
  • packages/slides/src/view/editor/thumbnail-panel.ts
  • packages/slides/test/view/canvas/thumbnail.test.ts
  • packages/slides/test/view/editor/thumbnail-panel.test.ts

Comment thread docs/tasks/active/20260521-slides-thumbnail-async-bg-todo.md Outdated
Comment thread docs/tasks/active/20260521-slides-thumbnail-async-bg-todo.md Outdated
Comment thread docs/tasks/active/20260521-slides-thumbnail-async-bg-todo.md Outdated
Comment thread packages/slides/src/view/editor/thumbnail-panel.ts
Comment thread packages/slides/test/view/editor/thumbnail-panel.test.ts Outdated
Code: per-chunk scrollTop restore now only fires when the browser
clamped (scrollTop < savedScrollTop). On a 100-slide deck the rAF
build runs across multiple frames; if the user scrolled past
savedScrollTop during a between-chunk gap, the unconditional restore
would snap them back to the saved position on every frame.

Plan: reconcile two CodeRabbit-flagged inconsistencies — the
ThumbnailScheduler debounce was documented as both 100ms and 200ms
(actual value is 100ms = ASSET_LOAD_DEBOUNCE_MS), and the review
section claimed all 12 items done while V2 (manual visual) was still
unchecked. Also clarify the "no auto-refresh" limitation to reflect
the post-refreshContent() caller contract.

Test: rename "refreshContent is a no-op when state is empty (called
before first render)" — mount runs render synchronously, so the test
exercises the post-dispose path, not pre-render.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@hackerwins
hackerwins merged commit dcfc9a6 into main May 21, 2026
4 checks passed
@hackerwins
hackerwins deleted the slides-thumbnail-async-bg branch May 21, 2026 16:38
hackerwins added a commit that referenced this pull request May 24, 2026
These tasks' features merged to main (PRs #267 #271 #277 #284 #285)
but their plan checkboxes were never ticked, so the todo files
lingered in tasks/active. Added a status note citing each merge
commit, marked the checkboxes complete, and ran tasks:archive to
move them under archive/2026/05 with regenerated indexes.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
@hackerwins hackerwins mentioned this pull request May 24, 2026
2 tasks
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