Skip to content

perf(web): cut the render cascade and fix motion defects - #34

Merged
PunGrumpy merged 12 commits into
mainfrom
perf/optimizer-rendering-and-motion
Aug 29, 2026
Merged

PunGrumpy merged 12 commits into
mainfrom
perf/optimizer-rendering-and-motion

Conversation

@PunGrumpy

Copy link
Copy Markdown
Owner

A rendering and motion pass over apps/web, split into one commit per finding.

Rendering

  • Context cascade. useOptimizer returned a fresh object literal every render, so the provider's useMemo never held and every consumer re-rendered on any state change. Dragging the quality slider reconciled all 50 queue rows, the top bar and the controls panel per pointer event. Now split into separate state and actions contexts; actions are stable for the provider's lifetime. OptimizerQueueItem reads no context at all and is memoized.
  • Divider drag. Moving the divider replaced the jobs array on every pointer move. It is presentation state, so useCompareSlider owns it and commits once the gesture ends.
  • WebP encoding. canvas.toDataURL blocked the main thread for the whole encode plus a base64 round trip. Replaced with OffscreenCanvas.convertToBlob, falling back to canvas.toBlob.
  • Concurrency. addFiles and applyOptions fired the entire queue at once. Capped at four, with generations claimed up front so a superseded batch bails instead of racing.
  • Thumbnails. Queue rows rendered the original blob into a 32px box. Ingest now emits a 64px WebP from the decode it already performs.
  • Directory drops no longer rebuild the accumulator on every batch.

Motion

  • Wheel zoom no longer restarts a 300ms transform transition per tick.
  • The compare handle's hover cue was on a pointer-events-none element and never fired.
  • The status badge no longer paints a red "API OFFLINE" flash on every load.
  • Spinners keep moving under reduced motion, slowed rather than stopped.
  • Durations normalized to 150ms feedback / 300ms state / 800ms reveal.

Notes for review

Two changes go slightly beyond the findings and are worth a look:

  1. OptimizerPreview is now keyed by job id. Each image gets its own divider state, and zoom/pan reset when you switch images rather than carrying across. That is a behaviour change, and I think an improvement, but it is a judgement call.
  2. Extracting useImageTransform and useCompareSlider was not on the original list. The other changes pushed OptimizerPreview past react-doctor's giant-component threshold, and the baseline was clean, so I split it rather than leave a regression.

One doctor override was added for effect-raf-loop-needs-cancel. The reveal loop does cancel, but through a ref the rule cannot follow, and the ref is required so pointer handlers can abort the sweep mid-flight. Documented in doctor.config.ts alongside the two existing exceptions.

Left alone as out of scope: when a browser cannot encode WebP it silently returns PNG that we still label outputFormat: "webp". That predates this branch and applies to both toDataURL and toBlob.

Verification

bun run typecheck, bun test (43 pass), bun run build and ultracite check all pass. React Doctor is 100/100, unchanged from the baseline on main.

🤖 Generated with Claude Code

PunGrumpy and others added 12 commits August 29, 2026 02:25
Hover and focus feedback was split between 150ms and 200ms, and six
transitions relied on Tailwind's implicit default rather than stating a
duration. Settle on one scale: 150ms for feedback, 300ms for state and
layout changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status badge seeded its state to "offline", so every page load
painted a red "API OFFLINE" badge and then animated it to green once
the probe resolved. Start from a neutral "checking" state instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reduced-motion block clamped tw-animate-css entrance keyframes but
left `animate-spin` at full speed. Stopping a spinner outright would
strand the user, since it is the only signal that work is still running,
so slow the rotation to 2s instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
readAllEntries recursed and rebuilt the accumulator with a spread on
every step, making a dropped directory O(n^2) in the number of files.
Drain the readEntries cursor in a loop instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
canvas.toDataURL blocks the main thread for the whole encode and then
pays a base64 round trip through fetch() to get a Blob back. At the
16384px dimension limit that is a multi-second freeze.

Prefer OffscreenCanvas.convertToBlob, which hands the encode to a
browser-managed thread, and fall back to the async canvas.toBlob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every queue row rendered the original blob into a 32px box, so a full
queue held one full-resolution decode per row. A 50-image queue of
6000x4000 sources is several gigabytes of decoded bitmap for thumbnails.

readPreview reuses the decode already needed for dimensions to emit a
64px WebP thumbnail, and the queue falls back to the original blob when
that encode fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
addFiles fired every ingested job at once, and applyOptions did the same
for the whole queue on every settings change. At the 50-file limit that
is 50 concurrent multipart bodies of up to 20MB on the API path, or 50
back-to-back canvas encodes on the browser path.

Run at most four at a time. startBatch claims a generation for every job
up front, so a later batch supersedes this one wholesale and jobs still
queued bail before starting instead of racing it.

That also makes removedIdsRef redundant: removeJob and clearAll already
drop the generation entry, which marks any in-flight run stale. Dropping
the set fixes a slow leak, since ids were only cleared when a stale run
happened to resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
useOptimizer returned a fresh object literal every render, so the
provider's useMemo recomputed every time and every consumer re-rendered
on any state change. Dragging the quality slider reconciled all 50 queue
rows, the top bar and the controls panel on every pointer event.

Return separate state and actions objects behind separate contexts.
Every action is stable for the provider's lifetime, so dispatch-only
consumers never re-render. Memoize isProcessing, totalOriginal and
totalCompressed, which the state object needs to stay stable, and hoist
the search needle out of the per-job filter.

OptimizerQueueItem now reads no context at all: it takes targetFormat as
a prop and is wrapped in memo, so a queue reconciles only the row whose
job actually changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dragging the divider called onSliderChange on every pointer move, which
replaced the jobs array, recomputed filteredJobs and pushed a new context
value through the whole app. The divider position is presentation state,
so useCompareSlider owns it and commits the resting value once the
gesture ends.

The 800ms reveal sweep now re-renders only the preview subtree instead of
driving the job list at 60fps, and its reduced-motion path runs the same
loop with no duration rather than scheduling a frame that did nothing.
usePrefersReducedMotion subscribes to the media query, so toggling the OS
setting mid-session takes effect without a reload.

Keying OptimizerPreview by job id gives each image its own divider state,
and resets zoom and pan when you switch images instead of carrying them
across. The drag rect is measured once per gesture, since re-measuring
while the divider moves forces a synchronous layout every frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both viewport images carried a 300ms transform transition, suppressed
during divider drags and pans but not during wheel zoom. Every wheel tick
restarted the transition, so the zoom lagged the cursor and felt rubbery.

Transform now carries an animate flag: button zoom and reset ease, wheel
and pan do not. useImageTransform also accumulates wheel ticks and pan
deltas in refs and settles them into one state update per frame, since
wheel events fire faster than the screen refreshes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The handle carried hover:scale-105 and a hover border colour, but it is
pointer-events-none, so it never received hover and the cue was dead CSS.
The whole viewport is the drag surface, so key the cue off hovering the
viewport with group-hover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cut the promotional feature copy ("Beautiful", "Modern", "powered by"),
drop the em dash separators and horizontal rules, use sentence case
headings, and rewrite the API notes in active voice.

Two accuracy fixes along the way: the Next.js version said 16.2+ while
package.json pins ^16.3.0, and the project tree never listed
packages/core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pigo Ready Ready Preview Aug 29, 2026 2:33am
pigo-api Ready Ready Preview Aug 29, 2026 2:33am

@changeset-bot

changeset-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 80c83b0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 80c83b0.

@PunGrumpy
PunGrumpy merged commit 48ed2ea into main Aug 29, 2026
10 checks passed
@PunGrumpy
PunGrumpy deleted the perf/optimizer-rendering-and-motion branch August 29, 2026 02:36
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