perf(textarea-autosize): eliminate layout thrashing, cache computed style, replace mousemove with ResizeObserver#48
perf(textarea-autosize): eliminate layout thrashing, cache computed style, replace mousemove with ResizeObserver#48mattcosta7 with Copilot wants to merge 8 commits into
Conversation
…move with ResizeObserver
…me x/y, ResizeObserver guard, idempotent mousedown)
…rformance perf(autosize): fix maxHeight regression under min-height CSS; add Vitest browser test suite
| @@ -0,0 +1,180 @@ | |||
| import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' | |||
There was a problem hiding this comment.
@copilot we should run this in ci - generate a workflow for it.
There was a problem hiding this comment.
Pull request overview
This PR optimizes the autosize() hot path to reduce forced synchronous reflows during typing and to avoid always-on pointer-move work, while adding a browser-based test suite to guard regressions.
Changes:
- Reworks
sizeToFitto batch DOM reads before writes and caches border/box-sizing-derived values across keystrokes. - Replaces the always-on
mousemoveresize detection withResizeObserver(and a gated mouse fallback). - Adds Vitest browser-mode tests (Playwright/Chromium) and related configuration.
Show a summary per file
| File | Description |
|---|---|
src/index.js |
Refactors sizing logic to reduce layout thrashing; adds caching and resize detection via ResizeObserver with fallback cleanup. |
test/autosize.test.js |
Adds browser tests covering grow/shrink, min-height handling, user drag-resize disabling, form reset re-enable, and unsubscribe cleanup. |
vitest.config.js |
Enables Vitest browser mode using the Playwright provider (Chromium headless). |
package.json |
Switches test scripts to Vitest and adds Vitest/Playwright devDependencies. |
package-lock.json |
Locks the newly introduced test/tooling dependency graph. |
tsconfig.json |
Adds skipLibCheck to reduce TS compile overhead. |
.gitignore |
Ignores test screenshot output directory. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 4/8 changed files
- Comments generated: 1
- Review effort level: Low
| "eslint": "^7.21.0", | ||
| "eslint-plugin-github": "^4.1.2", | ||
| "typescript": "^4.2.3" | ||
| "playwright": "^1.61.1", | ||
| "typescript": "^4.2.3", | ||
| "vitest": "^4.1.9" |
| @@ -1,2 +1,3 @@ | |||
| dist/ | |||
| node_modules/ | |||
| test/__screenshots__/ | |||
There was a problem hiding this comment.
Looks like .vitest-attachments/8ee3…png actually got committed and this rule doesn't cover that dir, mind deleting the png and ignoring the dir too?
| test/__screenshots__/ | |
| test/__screenshots__/ | |
| .vitest-attachments/ |
| textarea.style.maxHeight = '' | ||
| } | ||
| }) | ||
| resizeObserver.observe(textarea) |
There was a problem hiding this comment.
It seems like resize detection is async now, so does an input firing mid-drag re-run sizeToFit and re-sync height before the observer notices, where the old synchronous mousemove would've latched it?
Three hot-path performance issues in
autosize()caused forced synchronous reflows on every keystroke and unnecessary work on every mouse movement.Changes
1. Batch DOM reads in
sizeToFitAll layout reads (
overflowOffset,getComputedStyle, container heights) now happen before any writes. Previously,textarea.style.maxHeightwas written beforegetComputedStyle(container).heightwas read, forcing 2–3 synchronous layout recalculations per keystroke. The only unavoidable write-then-read is theheight='auto'→scrollHeightmeasure pattern; it's now isolated with a comment explaining why.Before (interleaved read/write/read):
After (all reads first, then writes):
2. Cache
getComputedStyleborder/box-sizing valuescachedBorderAddOn(derived fromborderTopWidth,borderBottomWidth,boxSizing) is computed once and reused across keystrokes. The library-trackedheightstring is also reused for themaxHeightcalculation, avoiding a secondgetComputedStyle(textarea)call on steady-state keystrokes. Cache is invalidated on external resize and form reset.3. Replace always-on
mousemovewithResizeObserverResizeObserverfires only when dimensions actually change. It comparestextarea.style.heightagainst the last library-written value; a mismatch means the user dragged the resize handle → setsisUserResized = true, clearsmaxHeight, then goes idle.ResizeObserver): Themousemovehandler is gated behindmousedown/mouseup(withmouseupondocumentto handle pointer release outside the element), keeping it off the hot path during normal use.unsubscribe()callscleanupResizeDetection(), which disconnects the observer or removes all registered listeners.