Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ See [Library](#library) for the full docs on each. Try it in the [Svelte REPL](h

| Package version | Svelte version | Notes |
| :--------------- | :----------------- | :----------------------------------------- |
| [1.x](https://github.com/metonym/svelte-intersection-observer/tree/v1.2.x) | 3, 4, 5 (non-runes) | Uses `export let`, slots, and `on:` events |
| 2.x | ≥5.29 (runes mode only) | Uses `$props()`, snippets, and callback props |
| [1.x](https://github.com/metonym/svelte-intersection-observer/tree/v1.2.x) | 3, 4, 5 (legacy/non-runes) | Uses `export let`, slots, and `on:` events |
| 2.x | ≥5.29 (legacy + runes) | Uses `$props()`, snippets, and callback props |

All primitives are implemented with runes internally, but Svelte 5 lets a non-runes ("legacy") component consume them — `bind:`, callback props, snippets, and `{@attach}` all interoperate across that boundary. The one exception is [`createIntersectionObserver`](#createintersectionobserver): it exposes `intersecting`/`entry` as plain getters with no bind:/callback prop, and a legacy-mode template doesn't reactively track getter reads, so its state won't update the UI unless the consuming component is itself in runes mode.

<!-- TOC -->

Expand Down Expand Up @@ -362,6 +364,8 @@ To get intersection state without wrapping markup in a component, use `createInt

`createIntersectionObserver` takes the same options as [`intersectAttachment`](#intersectattachment) (as a function returning the options object) and reuses its underlying observer logic.

**Note**: the returned `intersecting`/`entry` are plain getters backed by runes. They only stay reactive when read from a runes-mode component — a non-runes ("legacy") consumer won't re-render when they change, since its template doesn't track getter reads. If you need this to work from a legacy component, use one of the other primitives (e.g. [`intersectAttachment`](#intersectattachment) with its `onobserve` callback) instead.

#### Return value

| Name | Description | Type |
Expand Down
22 changes: 22 additions & 0 deletions tests/e2e/fixtures/LegacyActionConsumerFixture.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<svelte:options runes={false} />

<script lang="ts">
import { intersect } from "svelte-intersection-observer";

let intersecting = false;
let exitCount = 0;
</script>

<header>
{intersecting ? "Element is in view" : "Element is not in view"}
</header>
<p data-testid="exit-count">Exit count: {exitCount}</p>

<div
use:intersect
onobserve={(e) => (intersecting = e.detail.isIntersecting)}
onexit={() => (exitCount += 1)}
data-testid="target"
>
Hello world
</div>
23 changes: 23 additions & 0 deletions tests/e2e/fixtures/LegacyAttachmentConsumerFixture.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<svelte:options runes={false} />

<script lang="ts">
// biome-ignore lint/correctness/noUnusedImports: used in {@attach}, which Biome's Svelte parser doesn't yet analyze for usages
import { intersectAttachment } from "svelte-intersection-observer";

let intersecting = false;
let exitCount = 0;
</script>

<header>
{intersecting ? "Element is in view" : "Element is not in view"}
</header>
<p data-testid="exit-count">Exit count: {exitCount}</p>

<div
{@attach intersectAttachment()}
onobserve={(e) => (intersecting = e.detail.isIntersecting)}
onexit={() => (exitCount += 1)}
data-testid="target"
>
Hello world
</div>
20 changes: 20 additions & 0 deletions tests/e2e/fixtures/LegacyComposableConsumerFixture.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<svelte:options runes={false} />

<script lang="ts">
import { createIntersectionObserver } from "svelte-intersection-observer";

const observer = createIntersectionObserver(() => ({ once: true }));

$: statusText = observer.intersecting
? "Element is in view"
: "Element is not in view";
</script>

<header>{statusText}</header>

<div
{@attach observer.attach}
data-testid="target"
>
Hello world
</div>
28 changes: 28 additions & 0 deletions tests/e2e/fixtures/LegacyConsumerFixture.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<svelte:options runes={false} />

<script lang="ts">
import IntersectionObserver from "svelte-intersection-observer";

export let element: null | HTMLDivElement = null;
let intersecting = false;
let exitCount = 0;

$: statusText = intersecting
? "Element is in view"
: "Element is not in view";
</script>

<header>{statusText}</header>

<IntersectionObserver
{element}
bind:intersecting
onexit={() => (exitCount += 1)}
>
<div
bind:this={element}
data-testid="exit-count"
>
Exit count: {exitCount}
</div>
</IntersectionObserver>
30 changes: 30 additions & 0 deletions tests/e2e/fixtures/LegacyGroupConsumerFixture.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<svelte:options runes={false} />

<script lang="ts">
import { createIntersectionGroup } from "svelte-intersection-observer";

const group = createIntersectionGroup();
const ids = [1, 2];
let visible: Record<number, boolean> = {};
</script>

<header>
{#each ids as id}
<p data-testid="item-{id}-status">
Item {id} is {visible[id] ? "visible" : "not visible"}
</p>
{/each}
</header>

{#each ids as id (id)}
<div
data-testid="item-{id}"
{@attach group.attach({
onobserve: (entry) => {
visible = { ...visible, [id]: entry.isIntersecting };
},
})}
>
Item {id}
</div>
{/each}
36 changes: 36 additions & 0 deletions tests/e2e/fixtures/LegacyMultipleConsumerFixture.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<svelte:options runes={false} />

<script lang="ts">
import { MultipleIntersectionObserver } from "svelte-intersection-observer";

let ref1: null | HTMLDivElement = null;
let ref2: null | HTMLDivElement = null;

$: elements = [ref1, ref2];
</script>

<MultipleIntersectionObserver {elements}>
{#snippet children({ elementIntersections })}
<header>
<div data-testid="item-1-status">
Item 1: {elementIntersections.get(ref1) ? "visible" : "not visible"}
</div>
<div data-testid="item-2-status">
Item 2: {elementIntersections.get(ref2) ? "visible" : "not visible"}
</div>
</header>

<div
bind:this={ref1}
data-testid="item-1"
>
Item 1
</div>
<div
bind:this={ref2}
data-testid="item-2"
>
Item 2
</div>
{/snippet}
</MultipleIntersectionObserver>
169 changes: 169 additions & 0 deletions tests/unit/legacy-consumer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { flushSync } from "svelte";
import { afterEach, describe, expect, test } from "vitest";
import LegacyActionConsumerFixture from "../e2e/fixtures/LegacyActionConsumerFixture.svelte";
import LegacyAttachmentConsumerFixture from "../e2e/fixtures/LegacyAttachmentConsumerFixture.svelte";
import LegacyComposableConsumerFixture from "../e2e/fixtures/LegacyComposableConsumerFixture.svelte";
import LegacyConsumerFixture from "../e2e/fixtures/LegacyConsumerFixture.svelte";
import LegacyGroupConsumerFixture from "../e2e/fixtures/LegacyGroupConsumerFixture.svelte";
import LegacyMultipleConsumerFixture from "../e2e/fixtures/LegacyMultipleConsumerFixture.svelte";
import { MockIntersectionObserver } from "./mock-intersection-observer";
import { render } from "./render";

// Every primitive here is implemented with runes ($props, $bindable, $state),
// but Svelte 5 lets a non-runes ("legacy", `<svelte:options runes={false}/>`)
// parent consume them. Each fixture below locks in `runes={false}` (so it
// can't silently drift into runes mode) and exercises one primitive from
// that legacy parent, confirming bind:/callback props/`{@attach}`/snippets
// and legacy `$:` reactivity all interoperate correctly across the boundary.

let cleanup: (() => void) | undefined;

afterEach(() => {
cleanup?.();
cleanup = undefined;
});

describe("legacy (non-runes) consumer", () => {
test("IntersectionObserver: bind:intersecting updates a legacy $: derived value and onexit fires", () => {
const rendered = render(LegacyConsumerFixture);
cleanup = rendered.cleanup;
const header = rendered.target.querySelector("header");
const el = rendered.target.querySelector('[data-testid="exit-count"]');
if (!header || !el) throw new Error("fixture markup missing");

expect(header.textContent).toContain("Element is not in view");

const observer = MockIntersectionObserver.last();
expect(observer.observedElements.has(el)).toBe(true);

flushSync(() => observer.trigger([{ target: el, isIntersecting: true }]));
expect(header.textContent).toContain("Element is in view");
expect(el.textContent).toContain("Exit count: 0");

flushSync(() => observer.trigger([{ target: el, isIntersecting: false }]));
expect(header.textContent).toContain("Element is not in view");
expect(el.textContent).toContain("Exit count: 1");
});

test("MultipleIntersectionObserver: snippet params reflect per-element state via a legacy $: elements array", () => {
const rendered = render(LegacyMultipleConsumerFixture);
cleanup = rendered.cleanup;
const item1 = rendered.target.querySelector('[data-testid="item-1"]');
const item2 = rendered.target.querySelector('[data-testid="item-2"]');
if (!item1 || !item2) throw new Error("fixture markup missing");

const observer = MockIntersectionObserver.last();
expect(observer.observedElements.has(item1)).toBe(true);
expect(observer.observedElements.has(item2)).toBe(true);

flushSync(() =>
observer.trigger([{ target: item1, isIntersecting: true }]),
);

expect(
rendered.target.querySelector('[data-testid="item-1-status"]')
?.textContent,
).toContain("Item 1: visible");
expect(
rendered.target.querySelector('[data-testid="item-2-status"]')
?.textContent,
).toContain("Item 2: not visible");
});

test("intersect action: use:intersect updates legacy state via onobserve/onexit callback props", () => {
const rendered = render(LegacyActionConsumerFixture);
cleanup = rendered.cleanup;
const header = rendered.target.querySelector("header");
const el = rendered.target.querySelector('[data-testid="target"]');
if (!header || !el) throw new Error("fixture markup missing");

expect(header.textContent).toContain("Element is not in view");

const observer = MockIntersectionObserver.last();
expect(observer.observedElements.has(el)).toBe(true);

flushSync(() => observer.trigger([{ target: el, isIntersecting: true }]));
expect(header.textContent).toContain("Element is in view");

flushSync(() => observer.trigger([{ target: el, isIntersecting: false }]));
expect(header.textContent).toContain("Element is not in view");
expect(
rendered.target.querySelector('[data-testid="exit-count"]')?.textContent,
).toContain("Exit count: 1");
});

test("intersectAttachment: {@attach} updates legacy state via onobserve/onexit callback props", () => {
const rendered = render(LegacyAttachmentConsumerFixture);
cleanup = rendered.cleanup;
const header = rendered.target.querySelector("header");
const el = rendered.target.querySelector('[data-testid="target"]');
if (!header || !el) throw new Error("fixture markup missing");

expect(header.textContent).toContain("Element is not in view");

const observer = MockIntersectionObserver.last();
expect(observer.observedElements.has(el)).toBe(true);

flushSync(() => observer.trigger([{ target: el, isIntersecting: true }]));
expect(header.textContent).toContain("Element is in view");

flushSync(() => observer.trigger([{ target: el, isIntersecting: false }]));
expect(header.textContent).toContain("Element is not in view");
expect(
rendered.target.querySelector('[data-testid="exit-count"]')?.textContent,
).toContain("Exit count: 1");
});

// Unlike the other primitives, `createIntersectionObserver` exposes state
// via plain getters (no bind:/callback prop) — a legacy-mode consumer reads
// those getters directly in the template. Svelte's legacy compiler wraps
// such reads in `untrack()` (dependencies are inferred syntactically from
// reassigned local `let`s, not from external reactive getters), so the
// getter's *value* is correct at every read, but the template does not
// re-render when it changes underneath a legacy parent.
test("createIntersectionObserver: side effects (unobserve on `once`) still run, but the legacy template does not re-render off the getter", () => {
const rendered = render(LegacyComposableConsumerFixture);
cleanup = rendered.cleanup;
const header = rendered.target.querySelector("header");
const el = rendered.target.querySelector('[data-testid="target"]');
if (!header || !el) throw new Error("fixture markup missing");

expect(header.textContent).toContain("Element is not in view");

const observer = MockIntersectionObserver.last();
flushSync(() => observer.trigger([{ target: el, isIntersecting: true }]));

// `once: true` still unobserves — that's driven by the composable's own
// internal event listener, not by legacy template reactivity.
expect(observer.observedElements.has(el)).toBe(false);

// But the legacy `<header>` text never updates to reflect it.
expect(header.textContent).toContain("Element is not in view");
});

test("createIntersectionGroup: one shared observer drives independent legacy state per element", () => {
const rendered = render(LegacyGroupConsumerFixture);
cleanup = rendered.cleanup;
const item1 = rendered.target.querySelector('[data-testid="item-1"]');
const item2 = rendered.target.querySelector('[data-testid="item-2"]');
if (!item1 || !item2) throw new Error("fixture markup missing");

expect(MockIntersectionObserver.instances).toHaveLength(1);
const observer = MockIntersectionObserver.last();
expect(observer.observedElements.has(item1)).toBe(true);
expect(observer.observedElements.has(item2)).toBe(true);

flushSync(() =>
observer.trigger([{ target: item1, isIntersecting: true }]),
);

expect(
rendered.target.querySelector('[data-testid="item-1-status"]')
?.textContent,
).toContain("Item 1 is visible");
expect(
rendered.target.querySelector('[data-testid="item-2-status"]')
?.textContent,
).toContain("Item 2 is not visible");
});
});