Skip to content

Commit bbf9fc8

Browse files
committed
fix: rebuild createIntersectionGroup observer when shared options change
getSharedOptions() was only read when the observer was first created, so with more than one node attached, changing root/rootMargin/threshold never rebuilt the shared observer — and because the re-run skipped the getter entirely, the reactive dependency was lost for good. The README promised reactivity here. Every node's attachment now reads the shared options, compares them against the live observer's config (root by identity), and the first re-run after a change disconnects, rebuilds, and re-observes all registered nodes. Re-observing is idempotent, so the remaining per-node re-runs are no-ops.
1 parent 28a7d3c commit bbf9fc8

5 files changed

Lines changed: 110 additions & 9 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ A bare `intersect`/`intersectAttachment` inside an `#each` block creates one nat
405405
{/each}
406406
```
407407

408-
`root`/`rootMargin`/`threshold` configure the one shared observer, so they're passed once to `createIntersectionGroup` itself (as a function, for the same reactive-dependency-tracking reason `intersectAttachment` takes one) rather than per element:
408+
`root`/`rootMargin`/`threshold` configure the one shared observer, so they're passed once to `createIntersectionGroup` itself (as a function) rather than per element:
409409

410410
```js
411411
const group = createIntersectionGroup(() => ({
@@ -415,6 +415,8 @@ const group = createIntersectionGroup(() => ({
415415
}));
416416
```
417417

418+
Shared options are reactive: when `root`, `rootMargin`, or `threshold` changes, the group rebuilds its single shared observer and re-observes every element. Note that elements whose `once` has already fired are re-observed as well.
419+
418420
`once`, `skip`, `onobserve`, and `onintersect` are the only options that make sense per element, so those are what `group.attach(...)` accepts.
419421

420422
#### Signature

src/intersect.svelte.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,10 @@ export interface IntersectionGroup {
136136
* Creates a group of elements backed by a single shared `IntersectionObserver`
137137
* instance, for use with `{@attach}` inside an `#each` block. Brings
138138
* `MultipleIntersectionObserver`'s single-observer performance benefit to the
139-
* action/attachment API.
139+
* action/attachment API. Changing shared options rebuilds the single shared
140+
* observer and re-observes every element in the group; elements whose `once`
141+
* already fired are re-observed as well, so their callbacks may fire again
142+
* under the new config.
140143
*/
141144
export function createIntersectionGroup(
142145
getSharedOptions?: () => IntersectGroupSharedOptions,

src/intersect.svelte.js

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,20 @@ export function intersectAttachment(getOptions = () => ({})) {
9292
*
9393
* `root`/`rootMargin`/`threshold` are shared across every node in the group
9494
* (they configure the one underlying observer); only `once`/`skip` and the
95-
* `onobserve`/`onintersect` callbacks are meaningful per node.
95+
* `onobserve`/`onintersect` callbacks are meaningful per node. Changing
96+
* shared options rebuilds the single shared observer and re-observes every
97+
* element in the group; elements whose `once` already fired are re-observed
98+
* as well, so their callbacks may fire again under the new config.
9699
* @param {() => import("./intersect.svelte.d.ts").IntersectGroupSharedOptions} [getSharedOptions]
97100
* @returns {import("./intersect.svelte.d.ts").IntersectionGroup}
98101
*/
99102
export function createIntersectionGroup(getSharedOptions = () => ({})) {
100103
/** @type {IntersectionObserver | undefined} */
101104
let observer;
102105

106+
/** Key of the shared options `observer` was last built from. */
107+
let configKey = "";
108+
103109
/** @type {Map<Element, import("./intersect.svelte.d.ts").IntersectGroupNodeOptions>} */
104110
const callbacks = new Map();
105111

@@ -128,17 +134,30 @@ export function createIntersectionGroup(getSharedOptions = () => ({})) {
128134
*/
129135
function attach(nodeOptions = {}) {
130136
return (/** @type {Element} */ node) => {
131-
if (!observer && typeof IntersectionObserver !== "undefined") {
132-
const {
133-
root = null,
134-
rootMargin = "0px",
135-
threshold = 0,
136-
} = getSharedOptions();
137+
const {
138+
root = null,
139+
rootMargin = "0px",
140+
threshold = 0,
141+
} = getSharedOptions();
142+
const key = `${rootMargin}|${JSON.stringify(threshold)}`;
143+
const rootChanged =
144+
observer !== null && observer !== undefined && observer.root !== root;
145+
146+
if (
147+
typeof IntersectionObserver !== "undefined" &&
148+
(!observer || key !== configKey || rootChanged)
149+
) {
150+
observer?.disconnect();
137151
observer = new IntersectionObserver(handleEntries, {
138152
root,
139153
rootMargin,
140154
threshold,
141155
});
156+
configKey = key;
157+
158+
for (const [el, opts] of callbacks) {
159+
if (!opts.skip) observer.observe(el);
160+
}
142161
}
143162

144163
callbacks.set(node, nodeOptions);
@@ -151,6 +170,7 @@ export function createIntersectionGroup(getSharedOptions = () => ({})) {
151170
if (callbacks.size === 0) {
152171
observer?.disconnect();
153172
observer = undefined;
173+
configKey = "";
154174
}
155175
};
156176
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script lang="ts">
2+
import { createIntersectionGroup } from "svelte-intersection-observer";
3+
4+
const margins = ["0px", "100px", "200px"];
5+
let step = $state(0);
6+
let rootMargin = $derived(margins[step]);
7+
8+
const group = createIntersectionGroup(() => ({ rootMargin }));
9+
const ids = [1, 2];
10+
</script>
11+
12+
<header>
13+
<p>Root margin: {rootMargin}</p>
14+
<button
15+
data-testid="grow-root-margin"
16+
onclick={() => (step = Math.min(step + 1, margins.length - 1))}
17+
>
18+
Grow root margin
19+
</button>
20+
</header>
21+
22+
{#each ids as id (id)}
23+
<div
24+
data-testid="item-{id}"
25+
{@attach group.attach()}
26+
>
27+
Item {id}
28+
</div>
29+
{/each}

tests/unit/create-intersection-group.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { flushSync } from "svelte";
22
import { createIntersectionGroup } from "svelte-intersection-observer";
33
import { afterEach, describe, expect, test, vi } from "vitest";
4+
import GroupSharedOptionsChangeFixture from "../e2e/fixtures/GroupSharedOptionsChangeFixture.svelte";
45
import IntersectionGroupFixture from "../e2e/fixtures/IntersectionGroupFixture.svelte";
56
import { MockIntersectionObserver } from "./mock-intersection-observer";
67
import { render } from "./render";
@@ -172,4 +173,50 @@ describe("createIntersectionGroup (component integration)", () => {
172173
?.textContent,
173174
).toContain("Observer count: 1");
174175
});
176+
177+
test("rebuilds the shared observer and re-observes every node when shared options change", () => {
178+
const rendered = render(GroupSharedOptionsChangeFixture);
179+
cleanup = rendered.cleanup;
180+
const item1 = rendered.target.querySelector('[data-testid="item-1"]');
181+
const item2 = rendered.target.querySelector('[data-testid="item-2"]');
182+
const button = rendered.target.querySelector<HTMLButtonElement>(
183+
'[data-testid="grow-root-margin"]',
184+
);
185+
if (!item1 || !item2 || !button) throw new Error("fixture markup missing");
186+
187+
expect(MockIntersectionObserver.instances).toHaveLength(1);
188+
const firstObserver = MockIntersectionObserver.last();
189+
190+
flushSync(() => button.click());
191+
192+
expect(MockIntersectionObserver.instances).toHaveLength(2);
193+
expect(firstObserver.disconnected).toBe(true);
194+
195+
const secondObserver = MockIntersectionObserver.last();
196+
expect(secondObserver.options.rootMargin).toBe("100px");
197+
expect(secondObserver.observedElements.has(item1)).toBe(true);
198+
expect(secondObserver.observedElements.has(item2)).toBe(true);
199+
});
200+
201+
test("keeps tracking shared options reactively across repeated changes", () => {
202+
const rendered = render(GroupSharedOptionsChangeFixture);
203+
cleanup = rendered.cleanup;
204+
const item1 = rendered.target.querySelector('[data-testid="item-1"]');
205+
const item2 = rendered.target.querySelector('[data-testid="item-2"]');
206+
const button = rendered.target.querySelector<HTMLButtonElement>(
207+
'[data-testid="grow-root-margin"]',
208+
);
209+
if (!item1 || !item2 || !button) throw new Error("fixture markup missing");
210+
211+
flushSync(() => button.click());
212+
expect(MockIntersectionObserver.last().options.rootMargin).toBe("100px");
213+
214+
flushSync(() => button.click());
215+
216+
expect(MockIntersectionObserver.instances).toHaveLength(3);
217+
const thirdObserver = MockIntersectionObserver.last();
218+
expect(thirdObserver.options.rootMargin).toBe("200px");
219+
expect(thirdObserver.observedElements.has(item1)).toBe(true);
220+
expect(thirdObserver.observedElements.has(item2)).toBe(true);
221+
});
175222
});

0 commit comments

Comments
 (0)