Fix IntersectionObserver not re-firing in Chrome after Reorder#3724
Fix IntersectionObserver not re-firing in Chrome after Reorder#3724mattgperry wants to merge 1 commit into
Conversation
…ionObserver re-fires After a Reorder.Item settles back into a layout position, the projection system removes the transform style by setting it to "none". Chrome's IntersectionObserver can fail to re-evaluate the element's intersection state when transform transitions to "none" while the underlying DOM position has moved, leaving observers stuck reporting isIntersecting: false. Forcing a single offsetWidth read after the transform is cleared flushes the cached layout/geometry and nudges Chrome to re-fire its observers. Fixes #2679
Greptile SummaryThis PR fixes a Chrome-specific bug where
Confidence Score: 4/5Safe to merge — the change is a single, well-placed layout read that fires once per completed animation with no API surface impact. The fix is minimal and correctly scoped to the exact render path that resets the projection transform. The double type cast works at runtime but bypasses TypeScript's type safety, and the dev test deviates from the project timing guideline. The type cast in Important Files Changed
Sequence DiagramsequenceDiagram
participant RA as Reorder.Item (drag ends)
participant PN as ProjectionNode.render()
participant DOM as DOM / Chrome layout
participant IO as IntersectionObserver
RA->>PN: "animation completes, hasProjected=true, no projectionDelta"
PN->>DOM: "targetStyle.transform = "none""
PN->>PN: "hasProjected = false"
PN->>DOM: read offsetWidth (layout flush)
DOM->>IO: geometry cache invalidated
IO->>RA: fires callback with isIntersecting: true
Reviews (1): Last reviewed commit: "Flush layout after projection transform ..." | Re-trigger Greptile |
| if (this.instance) { | ||
| void (this.instance as unknown as HTMLElement) | ||
| .offsetWidth | ||
| } |
There was a problem hiding this comment.
Using
instanceof HTMLElement would be more type-safe than double-casting through unknown. Since this.instance is typed as the generic I (which can be SVG or HTML), the cast to HTMLElement silently does nothing on SVG elements (.offsetWidth is undefined there). An explicit guard keeps the intent clearer and avoids the double-cast smell without changing runtime behaviour.
| if (this.instance) { | |
| void (this.instance as unknown as HTMLElement) | |
| .offsetWidth | |
| } | |
| if (this.instance instanceof HTMLElement) { | |
| void this.instance.offsetWidth | |
| } |
|
|
||
| const observer = new IntersectionObserver( | ||
| (entries) => { | ||
| const time = performance.now() |
There was a problem hiding this comment.
The project's timing guideline (CLAUDE.md) says to use
time.now() from motion-dom/src/frameloop/sync-time.ts instead of performance.now(). While this is a dev test file and the timestamp is used only for logging, keeping it consistent with the rest of the codebase avoids ambiguity about which clock source to use.
| const time = performance.now() | |
| const time = Date.now() |
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary
offsetWidthread) after the projection transform style is set back to"none"on layout-animation completion.IntersectionObserverto re-evaluate the target's visibility state, fixing the case where an observer stays stuck reportingisIntersecting: falseafter aReorder.Itemsettles into its new position.Why
The reporter (and a second user in a totally different React + d3 stack) observed that Chrome's
IntersectionObserverstops firing for an element once its bounding box changes via a projection / FLIP transform — the callback firesisIntersecting: falseonce and never re-firestrueeven when the element is plainly visible again. Firefox and Safari are unaffected.The shared symptom across both reports is that forcing layout (e.g. scrolling) re-arms Chrome's observer. Inside the projection system there is one well-defined moment where this manifests: the post-animation render path that resets
transform: noneafter a layout animation finishes. ReadingoffsetWidthright there flushes the cached geometry so Chrome reconciles its observer state.Notes
Reorder.Itemends its log withisIntersecting: true— without being able to fail against the unfixed binary in headless runs.Fixes #2679
Test plan
yarn buildyarn test(776 unit tests pass)cypress run --spec cypress/integration/reorder-intersection-observer.tsagainst React 18 dev servercypress run --config-file=cypress.react-19.json --spec cypress/integration/reorder-intersection-observer.tsagainst React 19 dev servercypress run --spec cypress/integration/drag-to-reorder.ts(regression) — React 18 + 19