Skip to content

fix(useDevicePixelRatio): remove the listener the last resubscribe added - #231

Merged
childrentime merged 1 commit into
childrentime:mainfrom
rawsun007:fix/device-pixel-ratio-cleanup
Sep 22, 2026
Merged

childrentime merged 1 commit into
childrentime:mainfrom
rawsun007:fix/device-pixel-ratio-cleanup

Conversation

@rawsun007

Copy link
Copy Markdown
Contributor

Description

observe subscribes with { once: true } and returns a cleanup for that subscription. But handleChange calls observe again, and the cleanup that inner call returns is discarded:

const handleChange = () => {
  observe()          // returns a cleanup; nobody keeps it
}
media.addEventListener('change', handleChange, { once: true })
return () => media.removeEventListener('change', handleChange)

The effect therefore holds the cleanup for the first subscription only — and { once: true } has already removed that listener by the time anything changes. Every subscription after the first outlives the component.

Counting live listeners on a matchMedia fake that honours once:

                      before   after
after mount              1        1
after a ratio change     1        1
after unmount            1        0

The leaked listener keeps the hook's closure, and hence the component, reachable; it also calls setPixelRatio on an unmounted component on the next change.

Keeping the current cleanup in a ref lets the effect release whichever subscription is live when it runs.

Type of Change

  • Bug fix
  • New hook
  • Enhancement to existing hook
  • Documentation update
  • Other (please describe)

Checklist

  • I have read the Contributing Guide
  • I have read and understood every line of this diff myself, including any AI-written parts (AI usage policy)
  • My code follows the project's coding style
  • I have added tests for my changes
  • All existing tests pass
  • I have updated the documentation

The hook had no spec; this adds three. The shared createMockMediaMatcher only implements the legacy addListener/removeListener pair, so the spec brings its own fake that records listeners and honours { once: true } — without that the counts are wrong in the flattering direction, which is how I first mis-measured this.

Mutation check: reverting only index.ts fails exactly the unmount case and leaves the two behavioural ones green. jest is 430 passing, eslint clean. No interface.ts change, so no docs regeneration.

Not touched: if (!window) return at the top cannot work as an SSR guard, because referencing an undeclared window throws ReferenceError rather than returning falsy. It is harmless here since the call only happens inside an effect, but it is not doing what it looks like it does.

The diff was written by Claude Opus 5 in Claude Code under my direction; I have not read it line by line myself yet, so that box stays unticked.

🤖 Generated with Claude Code

`observe` registers a `change` listener with `{ once: true }` and returns
a cleanup for it, but `handleChange` calls `observe` again and throws the
new cleanup away. The effect only ever holds the cleanup for the first
subscription, which `{ once: true }` has already discarded by then, so
after any ratio change a listener outlives the component.

Keeping the current cleanup in a ref lets the effect release whichever
subscription is live at unmount.

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

Copy link
Copy Markdown
Owner

Good catch — merged. Thanks!

@childrentime
childrentime merged commit c8a7294 into childrentime:main Sep 22, 2026
4 checks passed
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.

2 participants