Skip to content

Fix IntersectionObserver not re-firing in Chrome after Reorder#3724

Open
mattgperry wants to merge 1 commit into
mainfrom
fix/issue-2679-reorder-intersection-observer
Open

Fix IntersectionObserver not re-firing in Chrome after Reorder#3724
mattgperry wants to merge 1 commit into
mainfrom
fix/issue-2679-reorder-intersection-observer

Conversation

@mattgperry
Copy link
Copy Markdown
Collaborator

Summary

  • Force a single layout flush (offsetWidth read) after the projection transform style is set back to "none" on layout-animation completion.
  • This nudges Chrome's IntersectionObserver to re-evaluate the target's visibility state, fixing the case where an observer stays stuck reporting isIntersecting: false after a Reorder.Item settles into its new position.

Why

The reporter (and a second user in a totally different React + d3 stack) observed that Chrome's IntersectionObserver stops firing for an element once its bounding box changes via a projection / FLIP transform — the callback fires isIntersecting: false once and never re-fires true even 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: none after a layout animation finishes. Reading offsetWidth right there flushes the cached geometry so Chrome reconciles its observer state.

Notes

  • This is a workaround for an upstream Chrome quirk, not a fix in Chrome itself. The bug does not reproduce in Electron (Cypress), so the included regression test asserts the desired behaviour — every observed Reorder.Item ends its log with isIntersecting: true — without being able to fail against the unfixed binary in headless runs.
  • The added read happens at most once per item per completed layout animation (the same render pass already mutates the element's style), so the cost is negligible.
  • No public API change.

Fixes #2679

Test plan

  • yarn build
  • yarn test (776 unit tests pass)
  • cypress run --spec cypress/integration/reorder-intersection-observer.ts against React 18 dev server
  • cypress run --config-file=cypress.react-19.json --spec cypress/integration/reorder-intersection-observer.ts against React 19 dev server
  • cypress run --spec cypress/integration/drag-to-reorder.ts (regression) — React 18 + 19
  • Verify in Chrome with the original CodeSandbox repro (requires manual run by maintainer)

…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-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 12, 2026

Greptile Summary

This PR fixes a Chrome-specific bug where IntersectionObserver callbacks stop firing after a Reorder.Item completes its layout animation — the fix reads offsetWidth once immediately after transform: none is written, forcing Chrome to flush its cached geometry and re-evaluate observer state.

  • Core fix (create-projection-node.ts): a single offsetWidth read is inserted in the one well-defined post-animation render path where hasProjected is cleared and the projection transform is removed; the read is gated so it fires at most once per completed animation.
  • Test page (reorder-intersection-observer.tsx): new dev component that mounts 5 Reorder.Item elements under an IntersectionObserver and serialises every callback entry into a DOM attribute for Cypress to read.
  • Cypress spec (reorder-intersection-observer.ts): regression test that drags an item, waits for the animation to settle, and asserts every item's final log entry has isIntersecting: true; as documented, the test cannot fail in Electron/headless CI because the Chrome quirk doesn't reproduce there.

Confidence Score: 4/5

Safe 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 create-projection-node.ts (lines 2003–2006) is the only line worth a second look.

Important Files Changed

Filename Overview
packages/motion-dom/src/projection/node/create-projection-node.ts Adds a single offsetWidth read after transform: none is applied to flush Chrome's layout and re-arm IntersectionObserver; guarded by this.instance check but uses a double type cast that could be replaced with instanceof HTMLElement.
dev/react/src/tests/reorder-intersection-observer.tsx New dev test component that renders 5 Reorder items with an IntersectionObserver; uses performance.now() where the project guideline prefers the frame-synced time.now() utility.
packages/framer-motion/cypress/integration/reorder-intersection-observer.ts New Cypress regression test that simulates a drag reorder and asserts every item ends with isIntersecting: true; correctly documented as a behavioral spec that cannot fail in Electron but validates the fix in real Chrome.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "Flush layout after projection transform ..." | Re-trigger Greptile

Comment on lines +2003 to +2006
if (this.instance) {
void (this.instance as unknown as HTMLElement)
.offsetWidth
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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.

Suggested change
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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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.

Suggested change
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!

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.

[BUG] IntersectionObserver not triggering in Chrome with Reorder component

1 participant