Skip to content

Attach touchstart/touchmove/wheel listeners as passive (#428)#5165

Draft
JoviDeCroock wants to merge 1 commit into
mainfrom
JoviDeCroock/issue-428
Draft

Attach touchstart/touchmove/wheel listeners as passive (#428)#5165
JoviDeCroock wants to merge 1 commit into
mainfrom
JoviDeCroock/issue-428

Conversation

@JoviDeCroock

@JoviDeCroock JoviDeCroock commented Jul 17, 2026

Copy link
Copy Markdown
Member

Resolves #428

Matches React 18: these scroll-blocking events are registered with {passive: true} so they can't block compositor scrolling, silencing Chrome's non-passive listener violations and Lighthouse's uses-passive-event-listeners audit. preventDefault() in these handlers becomes a no-op; attach a non-passive listener via a ref when cancelation is needed.

React

Matches React 18: these scroll-blocking events are registered with
{passive: true} so they can't block compositor scrolling, silencing
Chrome's non-passive listener violations and Lighthouse's
uses-passive-event-listeners audit. preventDefault() in these handlers
becomes a no-op; attach a non-passive listener via a ref when
cancelation is needed.

+32 B brotli (4456 -> 4488).
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

📊 Tachometer Benchmark Results

Summary

duration

  • create10k: unsure 🔍 -1% - +0% (-5.30ms - +1.12ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 -0% - -0% (-0.08ms - -0.00ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -1% - +2% (-0.29ms - +1.00ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 -0% - +2% (-0.02ms - +0.27ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -4% - +1% (-2.20ms - +0.82ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -5% - +5% (-0.10ms - +0.10ms)
    preact-local vs preact-main
  • todo: unsure 🔍 -0% - +2% (-0.05ms - +0.64ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -4% - +2% (-1.26ms - +0.87ms)
    preact-local vs preact-main

usedJSHeapSize

  • create10k: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • filter-list: unsure 🔍 +0% - +0% (+0.00ms - +0.01ms)
    preact-local vs preact-main
  • hydrate1k: unsure 🔍 -1% - +5% (-0.06ms - +0.24ms)
    preact-local vs preact-main
  • many-updates: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • replace1k: unsure 🔍 -0% - +0% (-0.00ms - +0.00ms)
    preact-local vs preact-main
  • text-update: unsure 🔍 -3% - +3% (-0.04ms - +0.03ms)
    preact-local vs preact-main
  • todo: unsure 🔍 +0% - +0% (+0.00ms - +0.00ms)
    preact-local vs preact-main
  • update10th1k: unsure 🔍 -0% - +0% (-0.01ms - +0.01ms)
    preact-local vs preact-main

Results

create10k

duration

VersionAvg timevs preact-localvs preact-main
preact-local807.96ms - 812.62ms-unsure 🔍
-1% - +0%
-5.30ms - +1.12ms
preact-main810.17ms - 814.59msunsure 🔍
-0% - +1%
-1.12ms - +5.30ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local19.08ms - 19.08ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main19.08ms - 19.08msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
filter-list

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.52ms - 16.57ms-unsure 🔍
-0% - -0%
-0.08ms - -0.00ms
preact-main16.56ms - 16.61msunsure 🔍
+0% - +0%
+0.00ms - +0.08ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.56ms - 1.56ms-unsure 🔍
+0% - +0%
+0.00ms - +0.01ms
preact-main1.55ms - 1.56msunsure 🔍
-0% - -0%
-0.01ms - -0.00ms
-
hydrate1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local54.84ms - 55.85ms-unsure 🔍
-1% - +2%
-0.29ms - +1.00ms
preact-main54.59ms - 55.39msunsure 🔍
-2% - +1%
-1.00ms - +0.29ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local4.99ms - 5.27ms-unsure 🔍
-1% - +5%
-0.06ms - +0.24ms
preact-main4.98ms - 5.10msunsure 🔍
-5% - +1%
-0.24ms - +0.06ms
-
many-updates

duration

VersionAvg timevs preact-localvs preact-main
preact-local16.52ms - 16.56ms-unsure 🔍
-0% - +2%
-0.02ms - +0.27ms
preact-main16.28ms - 16.56msunsure 🔍
-2% - +0%
-0.27ms - +0.02ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.74ms - 3.75ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main3.74ms - 3.74msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
replace1k
  • Browser: chrome-headless
  • Sample size: 100
  • Built by: CI #5688
  • Commit: 9a9ce72

duration

VersionAvg timevs preact-localvs preact-main
preact-local58.71ms - 60.56ms-unsure 🔍
-4% - +1%
-2.20ms - +0.82ms
preact-main59.13ms - 61.52msunsure 🔍
-1% - +4%
-0.82ms - +2.20ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local3.01ms - 3.02ms-unsure 🔍
-0% - +0%
-0.00ms - +0.00ms
preact-main3.01ms - 3.02msunsure 🔍
-0% - +0%
-0.00ms - +0.00ms
-

run-warmup-0

VersionAvg timevs preact-localvs preact-main
preact-local29.01ms - 29.71ms-unsure 🔍
-1% - +4%
-0.15ms - +1.07ms
preact-main28.40ms - 29.40msunsure 🔍
-4% - +0%
-1.07ms - +0.15ms
-

run-warmup-1

VersionAvg timevs preact-localvs preact-main
preact-local34.60ms - 35.85ms-unsure 🔍
-2% - +3%
-0.72ms - +0.97ms
preact-main34.53ms - 35.67msunsure 🔍
-3% - +2%
-0.97ms - +0.72ms
-

run-warmup-2

VersionAvg timevs preact-localvs preact-main
preact-local30.64ms - 31.77ms-unsure 🔍
-1% - +4%
-0.31ms - +1.19ms
preact-main30.27ms - 31.26msunsure 🔍
-4% - +1%
-1.19ms - +0.31ms
-

run-warmup-3

VersionAvg timevs preact-localvs preact-main
preact-local26.88ms - 27.49ms-unsure 🔍
-1% - +2%
-0.28ms - +0.46ms
preact-main26.89ms - 27.31msunsure 🔍
-2% - +1%
-0.46ms - +0.28ms
-

run-warmup-4

VersionAvg timevs preact-localvs preact-main
preact-local24.26ms - 25.60ms-unsure 🔍
-6% - +2%
-1.57ms - +0.45ms
preact-main24.74ms - 26.24msunsure 🔍
-2% - +6%
-0.45ms - +1.57ms
-

run-final

VersionAvg timevs preact-localvs preact-main
preact-local21.61ms - 21.97ms-unsure 🔍
-1% - +2%
-0.29ms - +0.33ms
preact-main21.52ms - 22.02msunsure 🔍
-2% - +1%
-0.33ms - +0.29ms
-
text-update
  • Browser: chrome-headless
  • Sample size: 170
  • Built by: CI #5688
  • Commit: 9a9ce72

duration

VersionAvg timevs preact-localvs preact-main
preact-local2.07ms - 2.23ms-unsure 🔍
-5% - +5%
-0.10ms - +0.10ms
preact-main2.08ms - 2.21msunsure 🔍
-5% - +5%
-0.10ms - +0.10ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.02ms - 1.06ms-unsure 🔍
-3% - +3%
-0.04ms - +0.03ms
preact-main1.02ms - 1.07msunsure 🔍
-3% - +3%
-0.03ms - +0.04ms
-
todo

duration

VersionAvg timevs preact-localvs preact-main
preact-local31.86ms - 32.52ms-unsure 🔍
-0% - +2%
-0.05ms - +0.64ms
preact-main31.79ms - 32.00msunsure 🔍
-2% - +0%
-0.64ms - +0.05ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local1.27ms - 1.27ms-unsure 🔍
+0% - +0%
+0.00ms - +0.00ms
preact-main1.27ms - 1.27msunsure 🔍
-0% - -0%
-0.00ms - -0.00ms
-
update10th1k

duration

VersionAvg timevs preact-localvs preact-main
preact-local34.09ms - 35.65ms-unsure 🔍
-4% - +2%
-1.26ms - +0.87ms
preact-main34.34ms - 35.79msunsure 🔍
-3% - +4%
-0.87ms - +1.26ms
-

usedJSHeapSize

VersionAvg timevs preact-localvs preact-main
preact-local2.97ms - 2.98ms-unsure 🔍
-0% - +0%
-0.01ms - +0.01ms
preact-main2.97ms - 2.98msunsure 🔍
-0% - +0%
-0.01ms - +0.01ms
-

tachometer-reporter-action v2 for CI

@github-actions

Copy link
Copy Markdown

Size Change: +39 B (+0.25%)

Total Size: 15.9 kB

📦 View Changed
Filename Size Change
dist/preact.mjs 4.93 kB +39 B (+0.8%)
ℹ️ View Unchanged
Filename Size
compat/dist/compat.mjs 3.87 kB
debug/dist/debug.mjs 3.89 kB
devtools/dist/devtools.mjs 274 B
hooks/dist/hooks.mjs 1.55 kB
jsx-runtime/dist/jsxRuntime.mjs 861 B
test-utils/dist/testUtils.mjs 473 B

compressed-size-action

@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 98.534%. remained the same — JoviDeCroock/issue-428 into main

@rschristian

Copy link
Copy Markdown
Member

The linked issue is asking for the ability for the user to mark a listener as passive, which this PR doesn't really support, and deviating from standard behavior to instead match React (which has been criticized over this) in core seems like it might be a bit much to me; a whitelist of events that automatically get different behavior without the author opting in is, I think, unexpected outside of compat for users.

As for opting in, admittedly I'm not sure of any good solution there besides having the user rely on refs, can't really support an options object via altered prop names alone as there's way too many combinations.

@JoviDeCroock

Copy link
Copy Markdown
Member Author

I was thinking so at first as well but I couldn't come up with many cases where you don't want these interactions to be passive 😅 so I thought maybe mirroring React was sane. In Vue they make it part of the event-name https://github.com/vuejs/core/blob/main/packages/runtime-dom/src/modules/events.ts#L73

@rschristian rschristian left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same, but I can imagine there's some people out there who will want it and we generally get points on social media for not matching behavior like this 😅

I suppose I'd support this though, it's fairly limited and is trying to provide a better default, even if that default deviates. You convinced me.


As is we should probably unlink the issue though as this only partially addresses it (mostly the later comments in that thread over chrome warnings).

The naming thing seems harder if we want to combine options from the options bag (...CapturePassive?), but for single values it's probably fine, could offer it for all our handlers too like Vue

@JoviDeCroock

JoviDeCroock commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

Yes, the vue thing is also elegant in a way but thinking about the types it might be... tedious and an expensive matcher.

Gonna think some more about this in the next week.

@Minsecrus

Minsecrus commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

I think making these three events passive by default can be a reasonable default, but I'm not sure the available browser data is enough to conclude that active handlers are rare for Preact's case.

Chrome's figures for touch and wheel listeners only cover listeners registered on root targets (window, document, or body). Preact attaches event props to individual elements, where use cases such as gesture surfaces, maps/canvas zooming, custom scrolling, and wheel controls may be more concentrated. React's own discussion also describes the lack of an API for intentionally attaching an active listener as a pain point: react/react#19651

This makes me think the React-compatible default may be appropriate for preact/compat, but core should ideally retain an explicit way to request passive: false. So this seems like a useful default change, but only a partial solution to #428.

There may also be a separate compatibility concern. The PR passes an EventListenerOptions object unconditionally, while the current build targets still include Chrome 40, Safari 9, Firefox 36, and Edge 12: https://github.com/preactjs/preact/blob/main/scripts/build.mjs#L33-L39

The EventListenerOptions explainer notes that older browsers interpret an object in the third argument as capture: true, which is why feature detection is recommended: https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md#feature-detection

React performs that feature detection and falls back to the boolean capture argument when passive listeners aren't supported: https://github.com/react/react/blob/main/packages/react-dom-bindings/src/events/checkPassiveEvents.js

Are those older targets still part of Preact's runtime support policy? If so, would this need a feature-detected fallback?


Preact's test utilities already contain the same getter-based feature detection in supportsPassiveEvents(): https://github.com/preactjs/preact/blob/main/test/_util/helpers.jsx#L20-L36

It may be a useful project-local reference if a runtime fallback is needed, but an equivalent runtime check would of course need to be evaluated against the core size budget.

@JoviDeCroock

Copy link
Copy Markdown
Member Author

Our browser target has since moved to modern browsers, we haven't put an explicit definition to that yet though but IE11 is dropped so Safari becomes the bottle-neck in most cases. I can see what you're saying and maybe we should skip this, I do think that having these magic keywords on events is going to be cumbersome especially since most of our counter-parts don't seem to be doing it.

@JoviDeCroock
JoviDeCroock marked this pull request as draft July 18, 2026 06:15
@rschristian

rschristian commented Jul 18, 2026

Copy link
Copy Markdown
Member

Our browser target has since moved to modern browsers

The browser versions they list (Chrome 40, Safari 9, Firefox 36, and Edge 12) are the browser versions we decided on earlier this year, same versions listed in the upgrade guide. IIRC matching the browsers that support Symbol.for, the newest feature we were using at the time.

It's a good point though that { passive: true } wasn't supported until Chrome 51, we'd have to adjust our targets if we wanted it.


options.passive parameter defaults to true for touchstart and touchmove events

https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#browser_compatibility

TIL, do we even need to include those events here then? If modern browsers will handle that for us, and realistically that's where most users are at, we can get them for free.

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.

Support passive event listeners

4 participants