diff --git a/README.md b/README.md index b956c35..fd62e26 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 | diff --git a/tests/e2e/fixtures/LegacyActionConsumerFixture.svelte b/tests/e2e/fixtures/LegacyActionConsumerFixture.svelte new file mode 100644 index 0000000..dee415a --- /dev/null +++ b/tests/e2e/fixtures/LegacyActionConsumerFixture.svelte @@ -0,0 +1,22 @@ + + + + +
+ {intersecting ? "Element is in view" : "Element is not in view"} +
+

Exit count: {exitCount}

+ +
(intersecting = e.detail.isIntersecting)} + onexit={() => (exitCount += 1)} + data-testid="target" +> + Hello world +
diff --git a/tests/e2e/fixtures/LegacyAttachmentConsumerFixture.svelte b/tests/e2e/fixtures/LegacyAttachmentConsumerFixture.svelte new file mode 100644 index 0000000..ba68298 --- /dev/null +++ b/tests/e2e/fixtures/LegacyAttachmentConsumerFixture.svelte @@ -0,0 +1,23 @@ + + + + +
+ {intersecting ? "Element is in view" : "Element is not in view"} +
+

Exit count: {exitCount}

+ +
(intersecting = e.detail.isIntersecting)} + onexit={() => (exitCount += 1)} + data-testid="target" +> + Hello world +
diff --git a/tests/e2e/fixtures/LegacyComposableConsumerFixture.svelte b/tests/e2e/fixtures/LegacyComposableConsumerFixture.svelte new file mode 100644 index 0000000..1e7c0a9 --- /dev/null +++ b/tests/e2e/fixtures/LegacyComposableConsumerFixture.svelte @@ -0,0 +1,20 @@ + + + + +
{statusText}
+ +
+ Hello world +
diff --git a/tests/e2e/fixtures/LegacyConsumerFixture.svelte b/tests/e2e/fixtures/LegacyConsumerFixture.svelte new file mode 100644 index 0000000..92eaceb --- /dev/null +++ b/tests/e2e/fixtures/LegacyConsumerFixture.svelte @@ -0,0 +1,28 @@ + + + + +
{statusText}
+ + (exitCount += 1)} +> +
+ Exit count: {exitCount} +
+
diff --git a/tests/e2e/fixtures/LegacyGroupConsumerFixture.svelte b/tests/e2e/fixtures/LegacyGroupConsumerFixture.svelte new file mode 100644 index 0000000..bba0f28 --- /dev/null +++ b/tests/e2e/fixtures/LegacyGroupConsumerFixture.svelte @@ -0,0 +1,30 @@ + + + + +
+ {#each ids as id} +

+ Item {id} is {visible[id] ? "visible" : "not visible"} +

+ {/each} +
+ +{#each ids as id (id)} +
{ + visible = { ...visible, [id]: entry.isIntersecting }; + }, + })} + > + Item {id} +
+{/each} diff --git a/tests/e2e/fixtures/LegacyMultipleConsumerFixture.svelte b/tests/e2e/fixtures/LegacyMultipleConsumerFixture.svelte new file mode 100644 index 0000000..af92c3d --- /dev/null +++ b/tests/e2e/fixtures/LegacyMultipleConsumerFixture.svelte @@ -0,0 +1,36 @@ + + + + + + {#snippet children({ elementIntersections })} +
+
+ Item 1: {elementIntersections.get(ref1) ? "visible" : "not visible"} +
+
+ Item 2: {elementIntersections.get(ref2) ? "visible" : "not visible"} +
+
+ +
+ Item 1 +
+
+ Item 2 +
+ {/snippet} +
diff --git a/tests/unit/legacy-consumer.test.ts b/tests/unit/legacy-consumer.test.ts new file mode 100644 index 0000000..705882c --- /dev/null +++ b/tests/unit/legacy-consumer.test.ts @@ -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", ``) +// 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 `
` 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"); + }); +});