fix(useIntersectionObserver): preserve observer across renders when options are structurally equal - #467
Conversation
…ptions are structurally equal
🦋 Changeset detectedLatest commit: 84e4621 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
🟢 Approval recommended
The change is narrowly scoped, SSR-safe, and includes targeted tests validating both the new “no recreate on structural equality” behavior and the intended recreation cases.
Pull request overview
This PR improves useIntersectionObserver performance and behavior stability by preventing unnecessary IntersectionObserver re-creation when callers pass inline options objects that are structurally unchanged (especially important for frequently re-rendering consumers like useImpressionRef).
Changes:
- Preserve
IntersectionObserverInitoptions across renders usingusePreservedReferencewith a custom comparator (areIntersectionOptionsEqual). - Add tests to ensure the observer does recreate when
root/rootMargin/thresholdchange, and does not recreate for value-equalthresholdarrays with new references. - Add a patch changeset documenting the behavior change.
File summaries
| File | Description |
|---|---|
| packages/react-simplikit/src/hooks/useIntersectionObserver/useIntersectionObserver.ts | Preserves options by structural equality to avoid re-instantiating the observer on referential-only changes. |
| packages/react-simplikit/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.ts | Adds regression tests covering observer recreation rules for option changes vs value-equal threshold arrays. |
| .changeset/light-jobs-grin.md | Patch notes for the behavior change in useIntersectionObserver. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #467 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 67 67
Lines 2199 2204 +5
Branches 709 711 +2
=========================================
+ Hits 2199 2204 +5 🚀 New features to boost your workflow:
|
hyesungoh
left a comment
There was a problem hiding this comment.
Thanks for your contribution! please take a look at comments below 🙏
|
|
||
| function areIntersectionOptionsEqual(a: IntersectionObserverInit, b: IntersectionObserverInit): boolean { | ||
| return ( | ||
| a.root === b.root && a.rootMargin === b.rootMargin && JSON.stringify(a.threshold) === JSON.stringify(b.threshold) |
There was a problem hiding this comment.
Could you add a short comment here explaining why root is compared by reference while threshold goes through JSON.stringify? The reasoning is in the PR description, but it would be nice to have it right next to the code. 👍
There was a problem hiding this comment.
@hyesungoh
Thanks for the feedback! Pushed a fix in 84e4621.
Let me know if this reads clearly, or if you'd frame it differently — happy to iterate further. 😀
…n areIntersectionOptionsEqual
Overview
useIntersectionObserverrecreates itsIntersectionObserveron every render where the caller passes an inlineoptionsobject, even whenroot/rootMargin/thresholdhaven't actually changed. This is becauseoptionsis passed directly into theuseMemo/useRefEffectdependency arrays, and an inline object literal gets a new reference every render.useImpressionRefhits this on every re-render since it always builds a fresh options object internally ({ rootMargin, threshold: areaThreshold }), so this affects any frequently re-rendering component using it (list items, ads, etc.).Fix
Wrapped
optionswithusePreservedReferenceand a custom equality function,areIntersectionOptionsEqual. A custom comparator was needed instead of the defaultJSON.stringifycomparison because:rootis a DOM node, andJSON.stringifyon a DOM node always produces"{}"— so the default comparator would treat any two differentrootelements as equal, silently keeping the observer on a stale root.thresholdcan benumber[], and an inline array gets a new reference every render — so a naive===comparison would defeat the fix for that case.rootis compared by reference;rootMargin/thresholdare compared by value.Verification
mainand pass with this fix.yarn test:coverage— 100% maintained.Checklist
yarn run fixto format and lint the code and docs?yarn run test:coverageto make sure there is no uncovered line?