perf(web): cut the render cascade and fix motion defects - #34
Merged
Merged
Conversation
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>
|
Contributor
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A rendering and motion pass over
apps/web, split into one commit per finding.Rendering
useOptimizerreturned a fresh object literal every render, so the provider'suseMemonever 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.OptimizerQueueItemreads no context at all and is memoized.useCompareSliderowns it and commits once the gesture ends.canvas.toDataURLblocked the main thread for the whole encode plus a base64 round trip. Replaced withOffscreenCanvas.convertToBlob, falling back tocanvas.toBlob.addFilesandapplyOptionsfired the entire queue at once. Capped at four, with generations claimed up front so a superseded batch bails instead of racing.Motion
pointer-events-noneelement and never fired.Notes for review
Two changes go slightly beyond the findings and are worth a look:
OptimizerPreviewis 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.useImageTransformanduseCompareSliderwas not on the original list. The other changes pushedOptimizerPreviewpast 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 indoctor.config.tsalongside 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 bothtoDataURLandtoBlob.Verification
bun run typecheck,bun test(43 pass),bun run buildandultracite checkall pass. React Doctor is 100/100, unchanged from the baseline onmain.🤖 Generated with Claude Code