From 79dd5c6754c9cc69b120a99d3af46414e68320a5 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Sun, 5 Jul 2026 13:17:17 -0700 Subject: [PATCH] fix: unobserve `entry.target` for `once`, not the current element prop Intersection callbacks are delivered asynchronously. If the `element` prop changed between observation and delivery, a stale entry for the old element ran `unobserve(element)` against the *new* element, leaving the new element unobserved and the wiring silently dead. Unobserve the entry's own target instead. MultipleIntersectionObserver and createIntersectionGroup already used entry.target; only the single-element component was affected. --- src/IntersectionObserver.svelte | 2 +- .../fixtures/OnceElementChangeFixture.svelte | 63 +++++++++++++++++++ tests/unit/IntersectionObserver.test.ts | 31 +++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/fixtures/OnceElementChangeFixture.svelte diff --git a/src/IntersectionObserver.svelte b/src/IntersectionObserver.svelte index b86199f..19e6bd1 100644 --- a/src/IntersectionObserver.svelte +++ b/src/IntersectionObserver.svelte @@ -63,7 +63,7 @@ ), ); - if (element && once) observer?.unobserve(element); + if (once) observer?.unobserve(_entry.target); } } }, diff --git a/tests/e2e/fixtures/OnceElementChangeFixture.svelte b/tests/e2e/fixtures/OnceElementChangeFixture.svelte new file mode 100644 index 0000000..0693928 --- /dev/null +++ b/tests/e2e/fixtures/OnceElementChangeFixture.svelte @@ -0,0 +1,63 @@ + + +
+ {intersecting ? "Element is in view" : "Element is not in view"} + +
+ + +
+ A +
+
+ +
+ B +
+ + diff --git a/tests/unit/IntersectionObserver.test.ts b/tests/unit/IntersectionObserver.test.ts index 645c2f0..edf7bb8 100644 --- a/tests/unit/IntersectionObserver.test.ts +++ b/tests/unit/IntersectionObserver.test.ts @@ -4,6 +4,7 @@ import BasicFixture from "../e2e/fixtures/BasicFixture.svelte"; import EachBindingFixture from "../e2e/fixtures/EachBindingFixture.svelte"; import ElementChangeFixture from "../e2e/fixtures/ElementChangeFixture.svelte"; import ElementNullFixture from "../e2e/fixtures/ElementNullFixture.svelte"; +import OnceElementChangeFixture from "../e2e/fixtures/OnceElementChangeFixture.svelte"; import OnceFixture from "../e2e/fixtures/OnceFixture.svelte"; import RootFixture from "../e2e/fixtures/RootFixture.svelte"; import RootMarginChangeFixture from "../e2e/fixtures/RootMarginChangeFixture.svelte"; @@ -80,6 +81,36 @@ describe("IntersectionObserver", () => { ).toContain("Intersect count: 1"); }); + test("once unobserves the entry's own target, not a stale element prop, when the element changes before delivery", () => { + const rendered = render(OnceElementChangeFixture); + cleanup = rendered.cleanup; + const elementA = rendered.target.querySelector('[data-testid="el-a"]'); + const elementB = rendered.target.querySelector('[data-testid="el-b"]'); + const switchButton = rendered.target.querySelector( + '[data-testid="switch"]', + ); + if ( + !elementA || + !elementB || + !(switchButton instanceof HTMLButtonElement) + ) { + throw new Error("fixture markup missing"); + } + + const observer = MockIntersectionObserver.last(); + expect(observer.observedElements.has(elementA)).toBe(true); + + switchButton.click(); + flushSync(); + + flushSync(() => + observer.trigger([{ target: elementA, isIntersecting: true }]), + ); + + expect(observer.observedElements.has(elementB)).toBe(true); + expect(observer.observedElements.has(elementA)).toBe(false); + }); + test("skip pauses and resumes observation without losing state", () => { const rendered = render(SkipFixture); cleanup = rendered.cleanup;