From e1fe5e6e3e9886c7fccc5850f95753188a1be310 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:50:02 -0400 Subject: [PATCH 1/6] Ported to Solid 2.0 --- .../restore-make-intersection-observer.md | 5 ++ packages/intersection-observer/README.md | 38 +++++++++ packages/intersection-observer/package.json | 8 +- packages/intersection-observer/src/index.ts | 42 +++++----- .../intersection-observer/test/index.test.ts | 12 ++- .../intersection-observer/vitest.config.ts | 44 +++++++++++ pnpm-lock.yaml | 79 +++++++++++++++---- 7 files changed, 182 insertions(+), 46 deletions(-) create mode 100644 .changeset/restore-make-intersection-observer.md create mode 100644 packages/intersection-observer/vitest.config.ts diff --git a/.changeset/restore-make-intersection-observer.md b/.changeset/restore-make-intersection-observer.md new file mode 100644 index 000000000..0f6adadef --- /dev/null +++ b/.changeset/restore-make-intersection-observer.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/intersection-observer": patch +--- + +Remove `@deprecated` tag from `makeIntersectionObserver` and add it to the README. The deprecation was never documented and the primitive remains useful for imperative, non-reactive use cases. diff --git a/packages/intersection-observer/README.md b/packages/intersection-observer/README.md index e76f47484..3324cb85e 100644 --- a/packages/intersection-observer/README.md +++ b/packages/intersection-observer/README.md @@ -10,6 +10,7 @@ A range of IntersectionObserver API utilities great for different types of use cases: +- [`makeIntersectionObserver`](#makeintersectionobserver) - A non-reactive, imperative wrapper around the IntersectionObserver API. - [`createIntersectionObserver`](#createintersectionobserver) - A reactive observer primitive. - [`createViewportObserver`](#createviewportobserver) - More advanced tracker that creates a store of element signals. - [`createVisibilityObserver`](#createvisibilityobserver) - Basic visibility observer using a signal. @@ -24,6 +25,43 @@ pnpm add @solid-primitives/intersection-observer yarn add @solid-primitives/intersection-observer ``` +## `makeIntersectionObserver` + +A non-reactive, imperative wrapper around the native IntersectionObserver API. Useful when you need full manual control over observation lifecycle without integrating into a Solid reactive scope. + +```ts +import { makeIntersectionObserver } from "@solid-primitives/intersection-observer"; + +const { add, remove, start, stop, reset, instance } = makeIntersectionObserver( + [el1, el2], + entries => { + entries.forEach(e => console.log(e.isIntersecting)); + }, + { threshold: 0.5 }, +); + +add(el3); +remove(el1); +stop(); // disconnects the observer +``` + +### Definition + +```ts +function makeIntersectionObserver( + elements: Element[], + onChange: IntersectionObserverCallback, + options?: IntersectionObserverInit, +): { + add: (el: Element) => void; + remove: (el: Element) => void; + start: () => void; + reset: () => void; + stop: () => void; + instance: IntersectionObserver; +}; +``` + ## `createIntersectionObserver` ```tsx diff --git a/packages/intersection-observer/package.json b/packages/intersection-observer/package.json index 746525e23..c4f618e7c 100644 --- a/packages/intersection-observer/package.json +++ b/packages/intersection-observer/package.json @@ -42,7 +42,7 @@ "scripts": { "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", - "vitest": "vitest -c ../../configs/vitest.config.ts", + "vitest": "vitest -c vitest.config.ts", "test": "pnpm run vitest", "test:ssr": "pnpm run vitest --mode ssr" }, @@ -54,12 +54,14 @@ ], "devDependencies": { "@solid-primitives/range": "workspace:^", - "solid-js": "^1.9.7" + "@solidjs/web": "2.0.0-experimental.16", + "solid-js": "2.0.0-experimental.16" }, "dependencies": { "@solid-primitives/utils": "workspace:^" }, "peerDependencies": { - "solid-js": "^1.6.12" + "@solidjs/web": "2.0.0-experimental.16", + "solid-js": "2.0.0-experimental.16" } } diff --git a/packages/intersection-observer/src/index.ts b/packages/intersection-observer/src/index.ts index fbf571980..63112699a 100644 --- a/packages/intersection-observer/src/index.ts +++ b/packages/intersection-observer/src/index.ts @@ -1,5 +1,4 @@ import { - onMount, onCleanup, createSignal, createEffect, @@ -8,7 +7,7 @@ import { DEV, } from "solid-js"; import type { JSX, Accessor } from "solid-js"; -import { isServer } from "solid-js/web"; +import { isServer } from "@solidjs/web"; import { access, type FalsyValue, @@ -64,9 +63,6 @@ function observe(el: Element, instance: IntersectionObserver): void { instance.observe(el); } -/** - * @deprecated Please use native {@link IntersectionObserver}, or {@link createIntersectionObserver} instead. - */ export function makeIntersectionObserver( elements: Element[], onChange: IntersectionObserverCallback, @@ -125,16 +121,13 @@ export function createIntersectionObserver( const io = new IntersectionObserver(onChange, options); onCleanup(() => io.disconnect()); - createEffect((p: Element[]) => { - const list = elements(); - handleDiffArray( - list, - p, - el => observe(el, io), - el => io.unobserve(el), - ); - return list; - }, []); + createEffect( + () => elements(), + (list: Element[], prev: Element[] = []) => { + handleDiffArray(list, prev, el => observe(el, io), el => io.unobserve(el)); + }, + [] as Element[], + ); } /** @@ -206,7 +199,8 @@ export function createViewportObserver(...a: any) { remove(el); }; const start = () => initial.forEach(([el, cb]) => addEntry(el, cb)); - onMount(start); + // onMount equivalent: run start() once after the reactive scope initialises + createEffect(() => {}, () => { start(); }); return [addEntry, { remove: removeEntry, start, stop, instance }]; } @@ -276,13 +270,15 @@ export function createVisibilityObserver( let prevEl: Element | FalsyValue; if (!(element instanceof Element)) { - createEffect(() => { - const el = element(); - if (el === prevEl) return; - if (prevEl) removeEntry(prevEl); - if (el) addEntry(el, callback); - prevEl = el; - }); + createEffect( + () => element(), + (el: Element | FalsyValue) => { + if (el === prevEl) return; + if (prevEl) removeEntry(prevEl); + if (el) addEntry(el, callback); + prevEl = el; + }, + ); } else addEntry(element, callback); onCleanup(() => prevEl && removeEntry(prevEl)); diff --git a/packages/intersection-observer/test/index.test.ts b/packages/intersection-observer/test/index.test.ts index fd7baded6..9b80ef1c9 100644 --- a/packages/intersection-observer/test/index.test.ts +++ b/packages/intersection-observer/test/index.test.ts @@ -1,4 +1,4 @@ -import { createRoot, createSignal } from "solid-js"; +import { createRoot, createSignal, flush } from "solid-js"; import { describe, test, expect, beforeEach } from "vitest"; import { @@ -434,8 +434,9 @@ describe("createViewportObserver", () => { let cbEntry: any; - // the correct usage - const [props] = createSignal((e: any) => (cbEntry = e)); + // the correct usage (plain accessor, since createSignal(fn) is the compute overload in Solid 2.0) + const callback = (e: any) => (cbEntry = e); + const props = () => callback; observe(el, props); // the incorrect usage (just shouldn't cause an error) @@ -512,10 +513,12 @@ describe("createVisibilityObserver", () => { const instance = _getLastIOInstance(); instance.__TEST__onChange({ isIntersecting: true }); + flush(); expect(isVisible(), "signal returns incorrect value").toBe(true); instance.__TEST__onChange({ isIntersecting: false }); + flush(); expect(isVisible(), "signal returns incorrect value").toBe(false); @@ -531,14 +534,17 @@ describe("createVisibilityObserver", () => { const instance = _getLastIOInstance(); instance.__TEST__onChange(); + flush(); expect(isVisible()).toBe(true); instance.__TEST__onChange(); + flush(); expect(isVisible()).toBe(true); goalValue = false; instance.__TEST__onChange(); + flush(); expect(isVisible()).toBe(false); dispose(); diff --git a/packages/intersection-observer/vitest.config.ts b/packages/intersection-observer/vitest.config.ts new file mode 100644 index 000000000..6cb7e6828 --- /dev/null +++ b/packages/intersection-observer/vitest.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from "vitest/config"; +import solidPlugin from "vite-plugin-solid"; + +// solid-js 2.0 removed the "solid-js/web" sub-package in favour of "@solidjs/web". +// @solid-primitives/utils still imports from "solid-js/web", so alias it here until +// that package is upgraded. +export default defineConfig(({ mode }) => { + const testSSR = mode === "test:ssr" || mode === "ssr"; + + return { + plugins: [ + solidPlugin({ + hot: false, + solid: { generate: testSSR ? "ssr" : "dom", omitNestedClosingTags: false }, + }), + ], + resolve: { + conditions: testSSR + ? ["@solid-primitives/source", "node"] + : ["@solid-primitives/source", "browser", "development"], + alias: { + "solid-js/web": new URL( + "./node_modules/@solidjs/web/dist/web.js", + import.meta.url, + ).pathname, + }, + }, + test: { + watch: false, + isolate: false, + passWithNoTests: true, + environment: testSSR ? "node" : "jsdom", + transformMode: { + web: [/\.[jt]sx$/], + }, + ...(testSSR + ? { include: ["test/server.test.{ts,tsx}"] } + : { + include: ["test/*.test.{ts,tsx}"], + exclude: ["test/server.test.{ts,tsx}"], + }), + }, + }; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecadfdb95..38c8edd45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -464,9 +464,12 @@ importers: '@solid-primitives/range': specifier: workspace:^ version: link:../range + '@solidjs/web': + specifier: 2.0.0-experimental.16 + version: 2.0.0-experimental.16(solid-js@2.0.0-experimental.16) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-experimental.16 + version: 2.0.0-experimental.16 packages/jsx-tokenizer: dependencies: @@ -1048,10 +1051,10 @@ importers: version: link:../packages/utils '@solidjs/meta': specifier: ^0.29.3 - version: 0.29.4(solid-js@1.9.7) + version: 0.29.4(solid-js@2.0.0-experimental.16) '@solidjs/router': specifier: ^0.13.1 - version: 0.13.6(solid-js@1.9.7) + version: 0.13.6(solid-js@2.0.0-experimental.16) clsx: specifier: ^2.0.0 version: 2.1.1 @@ -1078,13 +1081,13 @@ importers: version: 1.77.8 solid-dismiss: specifier: ^1.7.121 - version: 1.8.2(solid-js@1.9.7) + version: 1.8.2(solid-js@2.0.0-experimental.16) solid-icons: specifier: ^1.1.0 - version: 1.1.0(solid-js@1.9.7) + version: 1.1.0(solid-js@2.0.0-experimental.16) solid-tippy: specifier: ^0.2.1 - version: 0.2.1(solid-js@1.9.7)(tippy.js@6.3.7) + version: 0.2.1(solid-js@2.0.0-experimental.16)(tippy.js@6.3.7) tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -2587,11 +2590,19 @@ packages: peerDependencies: solid-js: ^1.5.3 + '@solidjs/signals@0.11.3': + resolution: {integrity: sha512-udMfutYPOlcxKUmc5+n1QtarsxOiAlC6LJY2TqFyaMwdXgo+reiYUcYGDlOiAPXfCLE0lavZHQ/6GT5pJbXKBA==} + '@solidjs/start@1.1.4': resolution: {integrity: sha512-ma1TBYqoTju87tkqrHExMReM5Z/+DTXSmi30CCTavtwuR73Bsn4rVGqm528p4sL2koRMfAuBMkrhuttjzhL68g==} peerDependencies: vinxi: ^0.5.3 + '@solidjs/web@2.0.0-experimental.16': + resolution: {integrity: sha512-TcgvLKOYN2cIExX307SRxvd1aXj5eLaQUlGDLdQoQBqDHhuDdeZUfKLYrn/JOzWs1u5DYNOc+gR0SaG17vYsng==} + peerDependencies: + solid-js: ^2.0.0-experimental.16 + '@speed-highlight/core@1.2.7': resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} @@ -5892,10 +5903,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.3.2: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + engines: {node: '>=10'} + serve-placeholder@2.0.2: resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==} @@ -6001,6 +6022,9 @@ packages: solid-js@1.9.7: resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} + solid-js@2.0.0-experimental.16: + resolution: {integrity: sha512-zZ1dU7cR0EnvLnrYiRLQbCFiDw5blLdlqmofgLzKUYE1TCMWDcisBlSwz0Ez8l4yXB4adbdhtaYCuynH4xSq9A==} + solid-refresh@0.6.3: resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} peerDependencies: @@ -8576,18 +8600,20 @@ snapshots: dependencies: solid-js: 1.9.7 - '@solidjs/meta@0.29.4(solid-js@1.9.7)': + '@solidjs/meta@0.29.4(solid-js@2.0.0-experimental.16)': dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-experimental.16 - '@solidjs/router@0.13.6(solid-js@1.9.7)': + '@solidjs/router@0.13.6(solid-js@2.0.0-experimental.16)': dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-experimental.16 '@solidjs/router@0.8.4(solid-js@1.9.7)': dependencies: solid-js: 1.9.7 + '@solidjs/signals@0.11.3': {} + '@solidjs/start@1.1.4(solid-js@1.9.7)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))': dependencies: '@tanstack/server-functions-plugin': 1.121.0(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) @@ -8611,6 +8637,12 @@ snapshots: - supports-color - vite + '@solidjs/web@2.0.0-experimental.16(solid-js@2.0.0-experimental.16)': + dependencies: + seroval: 1.3.2 + seroval-plugins: 1.3.2(seroval@1.3.2) + solid-js: 2.0.0-experimental.16 + '@speed-highlight/core@1.2.7': {} '@supabase/auth-js@2.67.3': @@ -12441,8 +12473,14 @@ snapshots: dependencies: seroval: 1.3.2 + seroval-plugins@1.5.2(seroval@1.5.2): + dependencies: + seroval: 1.5.2 + seroval@1.3.2: {} + seroval@1.5.2: {} + serve-placeholder@2.0.2: dependencies: defu: 6.1.4 @@ -12557,13 +12595,13 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - solid-dismiss@1.8.2(solid-js@1.9.7): + solid-dismiss@1.8.2(solid-js@2.0.0-experimental.16): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-experimental.16 - solid-icons@1.1.0(solid-js@1.9.7): + solid-icons@1.1.0(solid-js@2.0.0-experimental.16): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-experimental.16 solid-js@1.9.7: dependencies: @@ -12571,6 +12609,13 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.2(seroval@1.3.2) + solid-js@2.0.0-experimental.16: + dependencies: + '@solidjs/signals': 0.11.3 + csstype: 3.1.3 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + solid-refresh@0.6.3(solid-js@1.9.7): dependencies: '@babel/generator': 7.27.5 @@ -12580,9 +12625,9 @@ snapshots: transitivePeerDependencies: - supports-color - solid-tippy@0.2.1(solid-js@1.9.7)(tippy.js@6.3.7): + solid-tippy@0.2.1(solid-js@2.0.0-experimental.16)(tippy.js@6.3.7): dependencies: - solid-js: 1.9.7 + solid-js: 2.0.0-experimental.16 tippy.js: 6.3.7 solid-transition-group@0.2.3(solid-js@1.9.7): From 16a45a91566e3301e2410f37fe347361eea6e197 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:26:26 -0400 Subject: [PATCH 2/6] Proposing additional changes --- ...design-intersection-observer-primitives.md | 49 +++++ packages/audio/dev/index.tsx | 4 +- packages/geolocation/dev/client.tsx | 2 +- packages/intersection-observer/README.md | 78 ++++---- packages/intersection-observer/src/index.ts | 179 +++++++++++------- .../intersection-observer/test/index.test.ts | 114 +++++++++-- .../intersection-observer/vitest.config.ts | 6 +- 7 files changed, 295 insertions(+), 137 deletions(-) create mode 100644 .changeset/redesign-intersection-observer-primitives.md diff --git a/.changeset/redesign-intersection-observer-primitives.md b/.changeset/redesign-intersection-observer-primitives.md new file mode 100644 index 000000000..42d0f20c8 --- /dev/null +++ b/.changeset/redesign-intersection-observer-primitives.md @@ -0,0 +1,49 @@ +--- +"@solid-primitives/intersection-observer": major +--- + +Redesign `createIntersectionObserver` and `createVisibilityObserver` for Solid 2.0 reactivity. + +## `createIntersectionObserver` — breaking changes + +**Removed** the `onChange` callback parameter. **Added** a store array as the return value. + +```ts +// Before +createIntersectionObserver(elements, entries => console.log(entries), options); + +// After +const entries = createIntersectionObserver(elements, options); +createEffect(() => entries.forEach(e => console.log(e.isIntersecting))); +``` + +**Why a store instead of a signal or callback?** +Each element's intersection state changes independently. With a plain signal the entire array would be a single reactive dependency — any element's change re-runs every computation reading it. A store array gives per-slot granularity: reading `entries[i].isIntersecting` only re-runs the computation that reads slot `i`, not those reading other slots. + +**Implementation details worth noting:** +- Entries are frozen (`Object.freeze`) before being stored. Solid 2.0's store proxies any non-frozen object — including DOM elements — which would break reference equality on `entry.target`. Freezing prevents that. +- Array length is updated explicitly in the produce function. Solid 2.0's store tracks length in a separate override map; a bare index assignment (`draft[idx] = value`) does not advance `draft.length` on its own. + +**Reactive options (new):** +`options` may now be a reactive accessor (`MaybeAccessor`). When the accessor's value changes, the observer is disconnected and recreated with the new options, and all currently tracked elements are re-observed. + +The reactive branch uses a closure over `io` rather than threading state through the effect's return value. Solid 2.0's `EffectFunction` must return `void | (() => void)` (a cleanup function), so the previous-observer reference is held in the outer `let io` variable and mutated on each options change. + +## `createVisibilityObserver` — breaking changes + +**Removed** the curried factory pattern. The element is now the first argument. + +```ts +// Before +const useVisibilityObserver = createVisibilityObserver({ threshold: 0.8 }); +const visible = useVisibilityObserver(() => el); + +// After +const visible = createVisibilityObserver(() => el, { threshold: 0.8 }); +``` + +**Why flatten the API?** +The factory existed so one `IntersectionObserver` instance could be shared across multiple elements with the same options. In practice most callers observed a single element, making the two-call pattern more confusing than useful. Users who need to observe many elements efficiently should use `createViewportObserver` or `createIntersectionObserver`. + +**`runWithOwner` (bug fix):** +Signal writes from the `IntersectionObserver` callback now use `runWithOwner` to bind them to the reactive scope that created the observer. Without this, Solid 2.0 warns "A Signal was written to in an owned scope" and writes may not commit correctly when the callback fires outside the owner's execution context. diff --git a/packages/audio/dev/index.tsx b/packages/audio/dev/index.tsx index b9dea4595..6df2ad096 100644 --- a/packages/audio/dev/index.tsx +++ b/packages/audio/dev/index.tsx @@ -76,7 +76,7 @@ const App: Component = () => {
-
(ref = el)} /> +
(ref = el)} />
{location()?.latitude}, {location()?.longitude}
diff --git a/packages/intersection-observer/README.md b/packages/intersection-observer/README.md index 3324cb85e..689f93513 100644 --- a/packages/intersection-observer/README.md +++ b/packages/intersection-observer/README.md @@ -64,26 +64,31 @@ function makeIntersectionObserver( ## `createIntersectionObserver` +Returns a **store array** of `IntersectionObserverEntry` objects — one slot per element, updated in place when that element's intersection state changes. Because the return value is a Solid store, reading `entries[i].isIntersecting` only re-runs the computation that reads it, not every computation that reads the array. + ```tsx import { createIntersectionObserver } from "@solid-primitives/intersection-observer"; -const [targets, setTargets] = createSignal([some_element]); +const [targets, setTargets] = createSignal([]); + +const entries = createIntersectionObserver(targets, { threshold: 0.5 }); -createIntersectionObserver(els, entries => { +createEffect(() => { entries.forEach(e => console.log(e.isIntersecting)); });
setTargets(p => [...p, el])} />; ``` +`options` may be a reactive accessor — if the options object changes, the observer is disconnected and recreated with the new options, and all currently tracked elements are re-observed. + ### Definition ```ts function createIntersectionObserver( elements: Accessor, - onChange: IntersectionObserverCallback, - options?: IntersectionObserverInit, -): void; + options?: MaybeAccessor, +): readonly IntersectionObserverEntry[]; ``` ## `createViewportObserver` @@ -121,51 +126,42 @@ function createViewportObserver( ## `createVisibilityObserver` -Creates reactive signal that changes when a single element's visibility changes. - -### How to use it +Creates a reactive signal that changes when a single element's visibility changes. Takes the element to observe directly — the previous curried factory pattern has been removed. -`createVisibilityObserver` takes a `IntersectionObserverInit` object as the first argument. Use it to set thresholds, margins, and other options. - -- `root` — The Element or Document whose bounds are used as the bounding box when testing for intersection. -- `rootMargin` — A string which specifies a set of offsets to add to the root's bounding_box when calculating intersections, effectively shrinking or growing the root for calculation purposes. -- `threshold` — Either a single number or an array of numbers between 0.0 and 1.0, specifying a ratio of intersection area to total bounding box area for the observed target. -- `initialValue` — Initial value of the signal _(default: false)_ +The element may be a reactive accessor (`() => el`) or a plain DOM element. Passing a falsy accessor value removes the element from the observer. -It returns a configured _"use"_ function for creating a visibility signal for a single element. The passed element can be a **reactive signal** or a DOM element. Returning a falsy value will remove the element from the observer. +Signal writes from the `IntersectionObserver` callback are wrapped in `runWithOwner` to correctly bind writes to the reactive scope that created the observer. ```tsx import { createVisibilityObserver } from "@solid-primitives/intersection-observer"; let el: HTMLDivElement | undefined; -const useVisibilityObserver = createVisibilityObserver({ threshold: 0.8 }); - // make sure that you pass the element reference in a thunk if it is undefined initially -const visible = useVisibilityObserver(() => el); +const visible = createVisibilityObserver(() => el, { threshold: 0.8 });
{visible() ? "Visible" : "Hidden"}
; ``` -You can use this shorthand when creating a visibility signal for a single element: - -```tsx -let el: HTMLDivElement | undefined; - -const visible = createVisibilityObserver({ threshold: 0.8 })(() => el); +Options accepted in addition to `IntersectionObserverInit`: -
{visible() ? "Visible" : "Hidden"}
; -``` +- `initialValue` — Initial value of the signal _(default: false)_ ### Setter callback -`createVisibilityObserver` takes a setter callback as the second argument. It is called when the element's intersection changes. The callback should return a boolean value indicating whether the element is visible — it'll be assigned to the signal. +`createVisibilityObserver` accepts an optional setter callback as the third argument. It is called when the element's intersection changes and should return a boolean indicating whether the element is visible. ```ts -const useVisibilityObserver = createVisibilityObserver({ threshold: 0.8 }, entry => { - // do some calculations on the intersection entry - return entry.isIntersecting; -}); +let el: HTMLDivElement | undefined; + +const visible = createVisibilityObserver( + () => el, + { threshold: 0.8 }, + entry => { + // do some calculations on the intersection entry + return entry.isIntersecting; + }, +); ``` **Exported modifiers** @@ -177,7 +173,10 @@ It provides information about element occurrence in the viewport — `"Entering" ```tsx import { createVisibilityObserver, withOccurrence } from "@solid-primitives/intersection-observer"; -const useVisibilityObserver = createVisibilityObserver( +let el: HTMLDivElement | undefined; + +const visible = createVisibilityObserver( + () => el, { threshold: 0.8 }, withOccurrence((entry, { occurrence }) => { console.log(occurrence); // => "Entering" | "Leaving" | "Inside" | "Outside" @@ -193,7 +192,10 @@ It provides information about element direction on the screen — `"Left"`, `"Ri ```ts import { createVisibilityObserver, withDirection } from "@solid-primitives/intersection-observer"; -const useVisibilityObserver = createVisibilityObserver( +let el: HTMLDivElement | undefined; + +const visible = createVisibilityObserver( + () => el, { threshold: 0.8 }, withDirection((entry, { directionY, directionX, visible }) => { if (!entry.isIntersecting && directionY === "Top" && visible) { @@ -207,11 +209,11 @@ const useVisibilityObserver = createVisibilityObserver( ### Definition ```ts -function createViewportObserver( - elements: MaybeAccessor, - callback: EntryCallback, - options?: IntersectionObserverInit, -): CreateViewportObserverReturnValue; +function createVisibilityObserver( + element: Accessor | Element, + options?: IntersectionObserverInit & { initialValue?: boolean }, + setter?: MaybeAccessor, +): Accessor; ``` ## Demo diff --git a/packages/intersection-observer/src/index.ts b/packages/intersection-observer/src/index.ts index 63112699a..ef72a7b03 100644 --- a/packages/intersection-observer/src/index.ts +++ b/packages/intersection-observer/src/index.ts @@ -2,8 +2,10 @@ import { onCleanup, createSignal, createEffect, + createStore, untrack, - type Setter, + getOwner, + runWithOwner, DEV, } from "solid-js"; import type { JSX, Accessor } from "solid-js"; @@ -95,39 +97,85 @@ export function makeIntersectionObserver( } /** - * Creates a reactive Intersection Observer primitive. + * Creates a reactive Intersection Observer primitive. Returns a store array of + * {@link IntersectionObserverEntry} objects — one slot per element, updated in + * place whenever that element's intersection state changes. Because the return + * value is a store, reading `entries[i].isIntersecting` only re-runs the + * computation that reads it, not every computation that reads the array. * - * @param elements - A list of elements to watch - * @param onChange - An event handler that returns an array of observer entires - * @param options - IntersectionObserver constructor options: + * @param elements - A reactive list of elements to watch + * @param options - IntersectionObserver constructor options (may be a reactive + * accessor; changing it disconnects and recreates the observer): * - `root` — The Element or Document whose bounds are used as the bounding box when testing for intersection. - * - `rootMargin` — A string which specifies a set of offsets to add to the root's bounding_box when calculating intersections, effectively shrinking or growing the root for calculation purposes. - * - `threshold` — Either a single number or an array of numbers between 0.0 and 1.0, specifying a ratio of intersection area to total bounding box area for the observed target. + * - `rootMargin` — A string which specifies a set of offsets to add to the root's bounding_box when calculating intersections. + * - `threshold` — Either a single number or an array of numbers between 0.0 and 1.0. * * @example * ```tsx - * const createIntersectionObserver(els, entries => - * console.log(entries) - * ); + * const entries = createIntersectionObserver(elements); + * createEffect(() => console.log(entries[0]?.isIntersecting)); * ``` */ export function createIntersectionObserver( elements: Accessor, - onChange: IntersectionObserverCallback, - options?: IntersectionObserverInit, -): void { - if (isServer) return; + options?: MaybeAccessor, +): readonly IntersectionObserverEntry[] { + if (isServer) return []; + + const [entries, setEntries] = createStore([]); + const indexMap = new Map(); + let nextIdx = 0; + let trackedEls: Element[] = []; + + // Stable callback — never recreated, safe to share across IO instances. + // Store writes (setEntries) are applied synchronously and do not need + // runWithOwner — that is only needed for signal writes from external callbacks. + const ioCallback: IntersectionObserverCallback = newEntries => { + for (const entry of newEntries) { + let idx = indexMap.get(entry.target); + if (idx === undefined) { + idx = nextIdx++; + indexMap.set(entry.target, idx); + } + // Freeze the entry so Solid's store does not recursively proxy it — + // isWrappable returns false for frozen objects, which keeps DOM element + // references (entry.target) unwrapped and referentially stable. + // Also update length explicitly: Solid 2.0 tracks array length in an + // override map, so a bare index assignment does not advance it on its own. + const frozen = Object.freeze({ ...entry }); + setEntries(draft => { + draft[idx] = frozen as any; + if (idx >= (draft.length as number)) draft.length = idx + 1; + }); + } + }; - const io = new IntersectionObserver(onChange, options); + // Create the initial IO synchronously so the element effect below can use it + // immediately on its first deferred run. + let io = new IntersectionObserver(ioCallback, untrack(() => access(options))); onCleanup(() => io.disconnect()); + if (typeof options === "function") { + // Reactive options: recreate the IO whenever options change and re-observe + // all currently tracked elements. `io` is a closure variable so the effect + // always disconnects whichever instance is current before replacing it. + createEffect(options, (opts: IntersectionObserverInit) => { + io.disconnect(); + io = new IntersectionObserver(ioCallback, opts); + trackedEls.forEach(el => observe(el, io)); + }); + } + createEffect( () => elements(), (list: Element[], prev: Element[] = []) => { handleDiffArray(list, prev, el => observe(el, io), el => io.unobserve(el)); + trackedEls = list; }, [] as Element[], ); + + return entries; } /** @@ -210,81 +258,68 @@ export type VisibilitySetter = ( ) => boolean; /** - * Creates reactive signal that changes when a single element's visibility changes. + * Creates a reactive signal that changes when a single element's visibility changes. * - * @param options - A Primitive and IntersectionObserver constructor options: - * - `root` — The Element or Document whose bounds are used as the bounding box when testing for intersection. - * - `rootMargin` — A string which specifies a set of offsets to add to the root's bounding_box when calculating intersections, effectively shrinking or growing the root for calculation purposes. - * - `threshold` — Either a single number or an array of numbers between 0.0 and 1.0, specifying a ratio of intersection area to total bounding box area for the observed target. - * - `initialValue` — Initial value of the signal *(default: false)* + * Takes the element to observe directly, removing the curried factory pattern of + * the previous API. The element may be a reactive accessor or a plain DOM element. * - * @returns A configured *"use"* function for creating a visibility signal for a single element. The passed element can be a **reactive signal** or a DOM element. Returning a falsy value will remove the element from the observer. - * ```ts - * (element: Accessor | Element) => Accessor - * ``` + * Signal writes from the IntersectionObserver callback are wrapped in + * `runWithOwner` to avoid "Signal written to an owned scope" warnings in + * Solid 2.0 when the IO fires outside the reactive owner. + * + * @param element - The element to observe; may be `Accessor` or a plain `Element`. + * @param options - IntersectionObserver constructor options plus `initialValue` for the signal. + * @param setter - Optional custom setter callback that controls the signal value. * * @example * ```tsx * let el: HTMLDivElement | undefined - * const useVisibilityObserver = createVisibilityObserver({ threshold: 0.8 }) - * const visible = useVisibilityObserver(() => el) + * const visible = createVisibilityObserver(() => el, { threshold: 0.8 }) *
{ visible() ? "Visible" : "Hidden" }
* ``` */ export function createVisibilityObserver( - options?: IntersectionObserverInit & { - initialValue?: boolean; - }, + element: Accessor | Element, + options?: IntersectionObserverInit & { initialValue?: boolean }, setter?: MaybeAccessor, -): (element: Accessor | Element) => Accessor { - if (isServer) { - return () => () => false; - } +): Accessor { + if (isServer) return () => options?.initialValue ?? false; - const callbacks = new WeakMap(); + const owner = getOwner()!; + const [isVisible, setVisible] = createSignal(options?.initialValue ?? false); - const io = new IntersectionObserver((entries, instance) => { - for (const entry of entries) callbacks.get(entry.target)?.(entry, instance); + // access(setter) is called once here — for factory setters like withOccurrence, + // this creates the per-element closure (with prevIntersecting etc.) exactly once. + const setterFn = setter ? access(setter) : null; + const entryCallback: EntryCallback = setterFn + ? entry => runWithOwner(owner, () => setVisible(setterFn(entry, { visible: untrack(isVisible) }))) + : entry => runWithOwner(owner, () => setVisible(entry.isIntersecting)); + + const io = new IntersectionObserver((newEntries, instance) => { + for (const entry of newEntries) entryCallback(entry, instance); }, options); onCleanup(() => io.disconnect()); - function removeEntry(el: Element) { - io.unobserve(el); - callbacks.delete(el); - } - function addEntry(el: Element, callback: EntryCallback) { - observe(el, io); - callbacks.set(el, callback); + let prevEl: Element | FalsyValue; + + if (!(element instanceof Element)) { + createEffect( + () => element(), + (el: Element | FalsyValue) => { + if (el === prevEl) return; + if (prevEl) { io.unobserve(prevEl); } + if (el) observe(el, io); + prevEl = el; + }, + ); + } else { + observe(element, io); + prevEl = element; } - const getCallback: (get: Accessor, set: Setter) => EntryCallback = setter - ? (get, set) => { - const setterRef = access(setter); - return entry => set(setterRef(entry, { visible: untrack(get) })); - } - : (_, set) => entry => set(entry.isIntersecting); - - return element => { - const [isVisible, setVisible] = createSignal(options?.initialValue ?? false); - const callback = getCallback(isVisible, setVisible); - let prevEl: Element | FalsyValue; - - if (!(element instanceof Element)) { - createEffect( - () => element(), - (el: Element | FalsyValue) => { - if (el === prevEl) return; - if (prevEl) removeEntry(prevEl); - if (el) addEntry(el, callback); - prevEl = el; - }, - ); - } else addEntry(element, callback); - - onCleanup(() => prevEl && removeEntry(prevEl)); - - return isVisible; - }; + onCleanup(() => { if (prevEl) io.unobserve(prevEl); }); + + return isVisible; } export enum Occurrence { diff --git a/packages/intersection-observer/test/index.test.ts b/packages/intersection-observer/test/index.test.ts index 9b80ef1c9..8ca81840e 100644 --- a/packages/intersection-observer/test/index.test.ts +++ b/packages/intersection-observer/test/index.test.ts @@ -1,8 +1,9 @@ -import { createRoot, createSignal, flush } from "solid-js"; +import { createRoot, createSignal, createEffect, flush } from "solid-js"; import { describe, test, expect, beforeEach } from "vitest"; import { makeIntersectionObserver, + createIntersectionObserver, createViewportObserver, createVisibilityObserver, withOccurrence, @@ -229,6 +230,88 @@ describe("makeIntersectionObserver", () => { }); }); +describe("createIntersectionObserver", () => { + let div!: HTMLDivElement; + let img!: HTMLImageElement; + + beforeEach(() => { + div = document.createElement("div"); + img = document.createElement("img"); + }); + + test("creates a new IntersectionObserver instance", () => { + const previousInstanceCount = intersectionObserverInstances.length; + createRoot(dispose => { + createIntersectionObserver(() => [div]); + dispose(); + }); + expect(intersectionObserverInstances.length).toBe(previousInstanceCount + 1); + }); + + test("returns a store array of entries", () => { + createRoot(dispose => { + const entries = createIntersectionObserver(() => [div, img]); + expect(Array.isArray(entries)).toBe(true); + dispose(); + }); + }); + + test("store is updated when IO fires", () => { + createRoot(dispose => { + const [els] = createSignal([div]); + const entries = createIntersectionObserver(els); + const instance = _getLastIOInstance(); + + // flush() lets the deferred element effect run so div is actually observed + flush(); + + instance.__TEST__onChange({ isIntersecting: true }); + flush(); + + expect(entries.length).toBe(1); + expect(entries[0]?.isIntersecting).toBe(true); + + instance.__TEST__onChange({ isIntersecting: false }); + flush(); + + expect(entries[0]?.isIntersecting).toBe(false); + + dispose(); + }); + }); + + test("each element occupies its own store slot", () => { + createRoot(dispose => { + const entries = createIntersectionObserver(() => [div, img]); + const instance = _getLastIOInstance(); + + flush(); // let the element effect observe both elements + + instance.__TEST__onChange({ isIntersecting: true }); + flush(); + + expect(entries.length).toBe(2); + expect(entries[0]?.target).toBe(div); + expect(entries[1]?.target).toBe(img); + // Each slot tracks its own element independently + expect(entries[0]?.isIntersecting).toBe(true); + expect(entries[1]?.isIntersecting).toBe(true); + + dispose(); + }); + }); + + test("options are passed to IntersectionObserver", () => { + createRoot(dispose => { + const options: IntersectionObserverInit = { threshold: 0.5 }; + createIntersectionObserver(() => [div], options); + const instance = _getLastIOInstance(); + expect(instance.options).toBe(options); + dispose(); + }); + }); +}); + describe("createViewportObserver", () => { let div!: HTMLDivElement; let img!: HTMLImageElement; @@ -462,8 +545,7 @@ describe("createVisibilityObserver", () => { test("creates a new IntersectionObserver instance", () => { const previousInstanceCount = intersectionObserverInstances.length; createRoot(dispose => { - const useVisibilityObserver = createVisibilityObserver(); - useVisibilityObserver(div); + createVisibilityObserver(div); dispose(); }); const newInstanceCount = intersectionObserverInstances.length; @@ -477,8 +559,7 @@ describe("createVisibilityObserver", () => { root: div, rootMargin: "10px 10px 10px 10px", }; - const useVisibilityObserver = createVisibilityObserver(options); - useVisibilityObserver(div); + createVisibilityObserver(div, options); const instance = _getLastIOInstance(); expect(instance.options, "options in IntersectionObserver don't match").toBe(options); @@ -489,16 +570,11 @@ describe("createVisibilityObserver", () => { test("returns signal", () => { createRoot(dispose => { - const useVisibilityObserver = createVisibilityObserver(); - const isVisible = useVisibilityObserver(div); + const isVisible = createVisibilityObserver(div); expect(isVisible(), "signal doesn't return default initialValue").toBe(false); - const options = { - initialValue: true, - }; - const useVisibilityObserver2 = createVisibilityObserver(options); - const isVisible2 = useVisibilityObserver2(div); + const isVisible2 = createVisibilityObserver(div, { initialValue: true }); expect(isVisible2(), "signal doesn't return custom initialValue").toBe(true); @@ -508,8 +584,7 @@ describe("createVisibilityObserver", () => { test("signal changes state when intersection changes", () => { createRoot(dispose => { - const useVisibilityObserver = createVisibilityObserver(); - const isVisible = useVisibilityObserver(div); + const isVisible = createVisibilityObserver(div); const instance = _getLastIOInstance(); instance.__TEST__onChange({ isIntersecting: true }); @@ -529,8 +604,7 @@ describe("createVisibilityObserver", () => { test("setter callback dictates the signal value", () => createRoot(dispose => { let goalValue = true; - const useVisibilityObserver = createVisibilityObserver({}, _ => goalValue); - const isVisible = useVisibilityObserver(div); + const isVisible = createVisibilityObserver(div, {}, _ => goalValue); const instance = _getLastIOInstance(); instance.__TEST__onChange(); @@ -561,14 +635,14 @@ describe("withOccurrence", () => { test("returns correct occurrence value", () => createRoot(dispose => { let lastOccurrence: any; - const useVisibilityObserver = createVisibilityObserver( + createVisibilityObserver( + div, {}, withOccurrence((e, { occurrence }) => { lastOccurrence = occurrence; return e.isIntersecting; }), ); - useVisibilityObserver(div); const instance = _getLastIOInstance(); instance.__TEST__onChange({ isIntersecting: false }); @@ -599,7 +673,8 @@ describe("withDirection", () => { let lastDirectionX: any; let lastDirectionY: any; - const useVisibilityObserver = createVisibilityObserver( + createVisibilityObserver( + div, {}, withDirection((e, { directionX, directionY }) => { lastDirectionX = directionX; @@ -607,7 +682,6 @@ describe("withDirection", () => { return e.isIntersecting; }), ); - useVisibilityObserver(div); const instance = _getLastIOInstance(); instance.__TEST__onChange({ diff --git a/packages/intersection-observer/vitest.config.ts b/packages/intersection-observer/vitest.config.ts index 6cb7e6828..c49c1a2c4 100644 --- a/packages/intersection-observer/vitest.config.ts +++ b/packages/intersection-observer/vitest.config.ts @@ -19,10 +19,8 @@ export default defineConfig(({ mode }) => { ? ["@solid-primitives/source", "node"] : ["@solid-primitives/source", "browser", "development"], alias: { - "solid-js/web": new URL( - "./node_modules/@solidjs/web/dist/web.js", - import.meta.url, - ).pathname, + "solid-js/web": new URL("./node_modules/@solidjs/web/dist/web.js", import.meta.url) + .pathname, }, }, test: { From 9e012020b9804f05c36acafbd2c83d81a16b34e7 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:32:42 -0400 Subject: [PATCH 3/6] Prefer WeakMap instead of Map here. --- packages/intersection-observer/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/intersection-observer/src/index.ts b/packages/intersection-observer/src/index.ts index ef72a7b03..401886c0f 100644 --- a/packages/intersection-observer/src/index.ts +++ b/packages/intersection-observer/src/index.ts @@ -123,7 +123,7 @@ export function createIntersectionObserver( if (isServer) return []; const [entries, setEntries] = createStore([]); - const indexMap = new Map(); + const indexMap = new WeakMap(); let nextIdx = 0; let trackedEls: Element[] = []; From 6d34aeb6468829ec7c0482f29da3e48722613140 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:41:10 -0400 Subject: [PATCH 4/6] Improving tests and adapting to Solid 2.0 better --- ...design-intersection-observer-primitives.md | 78 ++++- packages/intersection-observer/README.md | 53 ++- packages/intersection-observer/src/index.ts | 318 ++++++++++-------- .../intersection-observer/test/index.test.ts | 67 +++- .../intersection-observer/test/server.test.ts | 28 +- .../intersection-observer/vitest.config.ts | 8 +- 6 files changed, 366 insertions(+), 186 deletions(-) diff --git a/.changeset/redesign-intersection-observer-primitives.md b/.changeset/redesign-intersection-observer-primitives.md index 42d0f20c8..0f8790d5d 100644 --- a/.changeset/redesign-intersection-observer-primitives.md +++ b/.changeset/redesign-intersection-observer-primitives.md @@ -2,10 +2,30 @@ "@solid-primitives/intersection-observer": major --- -Redesign `createIntersectionObserver` and `createVisibilityObserver` for Solid 2.0 reactivity. +Redesign intersection-observer primitives for Solid 2.0 async reactivity. ## `createIntersectionObserver` — breaking changes +**Return type changed** from a plain store array to a tuple `[entries, isVisible]`. + +```ts +// Before +const entries = createIntersectionObserver(elements, options); + +// After +const [entries, isVisible] = createIntersectionObserver(elements, options); +``` + +**Added `isVisible(el)` helper** — a pending-aware accessor that throws `NotReadyError` until the first observation fires for a given element, then returns `entry.isIntersecting` reactively. Integrates with `` for a natural loading fallback: + +```tsx +const [entries, isVisible] = createIntersectionObserver(targets); + +Checking…

}> +

Visible!

+
+``` + **Removed** the `onChange` callback parameter. **Added** a store array as the return value. ```ts @@ -13,24 +33,50 @@ Redesign `createIntersectionObserver` and `createVisibilityObserver` for Solid 2 createIntersectionObserver(elements, entries => console.log(entries), options); // After -const entries = createIntersectionObserver(elements, options); +const [entries] = createIntersectionObserver(elements, options); createEffect(() => entries.forEach(e => console.log(e.isIntersecting))); ``` **Why a store instead of a signal or callback?** Each element's intersection state changes independently. With a plain signal the entire array would be a single reactive dependency — any element's change re-runs every computation reading it. A store array gives per-slot granularity: reading `entries[i].isIntersecting` only re-runs the computation that reads slot `i`, not those reading other slots. -**Implementation details worth noting:** -- Entries are frozen (`Object.freeze`) before being stored. Solid 2.0's store proxies any non-frozen object — including DOM elements — which would break reference equality on `entry.target`. Freezing prevents that. -- Array length is updated explicitly in the produce function. Solid 2.0's store tracks length in a separate override map; a bare index assignment (`draft[idx] = value`) does not advance `draft.length` on its own. - **Reactive options (new):** `options` may now be a reactive accessor (`MaybeAccessor`). When the accessor's value changes, the observer is disconnected and recreated with the new options, and all currently tracked elements are re-observed. -The reactive branch uses a closure over `io` rather than threading state through the effect's return value. Solid 2.0's `EffectFunction` must return `void | (() => void)` (a cleanup function), so the previous-observer reference is held in the outer `let io` variable and mutated on each options change. +## `createViewportObserver` — breaking changes + +**Removed `use:` directive support.** The `add` function now supports a curried ref form instead, which is nearly as terse and compatible with Solid 2.0 (RFC 07 removed the `use:` directive namespace): + +```tsx +// Before +const [intersectionObserver] = createViewportObserver() +
console.log(e.isIntersecting)}>
+ +// After +const [add] = createViewportObserver() +
console.log(e.isIntersecting))}>
+``` + +`add(callback)` returns a `(el) => void` ref callback. The imperative `add(el, callback)` form is unchanged. ## `createVisibilityObserver` — breaking changes +**Pending state (new):** When `initialValue` is omitted, `visible()` now throws `NotReadyError` until the first `IntersectionObserver` callback fires, integrating with `` for a loading fallback. Previously the signal defaulted to `false`. + +```tsx +// Before — returned false immediately +const visible = createVisibilityObserver(() => el); +visible() // false + +// After — pending until first IO fires +const visible = createVisibilityObserver(() => el); +visible() // throws NotReadyError (caught by ) + +// Opt out with initialValue: +const visible = createVisibilityObserver(() => el, { initialValue: false }); +visible() // false immediately +``` + **Removed** the curried factory pattern. The element is now the first argument. ```ts @@ -42,8 +88,18 @@ const visible = useVisibilityObserver(() => el); const visible = createVisibilityObserver(() => el, { threshold: 0.8 }); ``` -**Why flatten the API?** -The factory existed so one `IntersectionObserver` instance could be shared across multiple elements with the same options. In practice most callers observed a single element, making the two-call pattern more confusing than useful. Users who need to observe many elements efficiently should use `createViewportObserver` or `createIntersectionObserver`. +**Removed `runWithOwner`:** Signal writes from the `IntersectionObserver` callback no longer use `runWithOwner`. Solid 2.0 allows signal writes from external callbacks without owner binding. + +## `Occurrence`, `DirectionX`, `DirectionY` — breaking changes + +Converted from TypeScript `enum` to `const` objects with type aliases. This makes them tree-shakeable and avoids TypeScript enum pitfalls. -**`runWithOwner` (bug fix):** -Signal writes from the `IntersectionObserver` callback now use `runWithOwner` to bind them to the reactive scope that created the observer. Without this, Solid 2.0 warns "A Signal was written to in an owned scope" and writes may not commit correctly when the callback fires outside the owner's execution context. +```ts +// Before (enum) +import { Occurrence } from "@solid-primitives/intersection-observer"; +Occurrence.Entering // works, but enum carries runtime overhead + +// After (const object) +import { Occurrence } from "@solid-primitives/intersection-observer"; +Occurrence.Entering // "Entering" — plain string, tree-shakeable +``` diff --git a/packages/intersection-observer/README.md b/packages/intersection-observer/README.md index 689f93513..4347c1292 100644 --- a/packages/intersection-observer/README.md +++ b/packages/intersection-observer/README.md @@ -64,19 +64,27 @@ function makeIntersectionObserver( ## `createIntersectionObserver` -Returns a **store array** of `IntersectionObserverEntry` objects — one slot per element, updated in place when that element's intersection state changes. Because the return value is a Solid store, reading `entries[i].isIntersecting` only re-runs the computation that reads it, not every computation that reads the array. +Returns a tuple of: +- A **store array** of `IntersectionObserverEntry` objects — one slot per element, updated in place when that element's intersection state changes. Reading `entries[i].isIntersecting` only re-runs the computation that reads slot `i`. +- **`isVisible(el)`** — a pending-aware helper that throws `NotReadyError` until the first observation fires for that element, then returns `entry.isIntersecting` reactively. Integrates with `` for a natural loading fallback. ```tsx import { createIntersectionObserver } from "@solid-primitives/intersection-observer"; const [targets, setTargets] = createSignal([]); -const entries = createIntersectionObserver(targets, { threshold: 0.5 }); +const [entries, isVisible] = createIntersectionObserver(targets, { threshold: 0.5 }); +// entries — reactive store, fine-grained per-element tracking: createEffect(() => { entries.forEach(e => console.log(e.isIntersecting)); }); +// isVisible — integrates with for pending state: +Checking…

}> +

Visible!

+
+
setTargets(p => [...p, el])} />; ``` @@ -88,23 +96,30 @@ createEffect(() => { function createIntersectionObserver( elements: Accessor, options?: MaybeAccessor, -): readonly IntersectionObserverEntry[]; +): readonly [ + entries: readonly IntersectionObserverEntry[], + isVisible: (el: Element) => boolean, +]; ``` ## `createViewportObserver` This primitive comes with a number of flexible options. You can specify a callback at the root with an array of elements or individual callbacks for individual elements. +The `add` function has two forms: +- `add(el, callback)` — imperative: register an element with its callback directly. +- `add(callback)` — curried ref form: returns a `(el) => void` ref callback for use as `ref={add(e => ...)}` in JSX. + ```tsx import { createViewportObserver } from '@solid-primitives/intersection-observer'; // Basic usage: const [add, { remove, start, stop, instance }] = createViewportObserver(els, e => {...}); -add(el, e => console.log(e.isIntersecting)) +add(el, e => console.log(e.isIntersecting)); -// Directive usage: -const [intersectionObserver] = createViewportObserver() -
console.log(e.isIntersecting)}>
+// Ref usage (replaces old use: directive): +const [add] = createViewportObserver(); +
console.log(e.isIntersecting))}>
``` ### Definition @@ -130,22 +145,34 @@ Creates a reactive signal that changes when a single element's visibility change The element may be a reactive accessor (`() => el`) or a plain DOM element. Passing a falsy accessor value removes the element from the observer. -Signal writes from the `IntersectionObserver` callback are wrapped in `runWithOwner` to correctly bind writes to the reactive scope that created the observer. +When `initialValue` is omitted, `visible()` throws `NotReadyError` until the first `IntersectionObserver` callback fires — integrating naturally with `` for a loading fallback: ```tsx import { createVisibilityObserver } from "@solid-primitives/intersection-observer"; let el: HTMLDivElement | undefined; -// make sure that you pass the element reference in a thunk if it is undefined initially const visible = createVisibilityObserver(() => el, { threshold: 0.8 }); -
{visible() ? "Visible" : "Hidden"}
; +// Pending until first IO fires — shows fallback in the meantime: +Checking…

}> + Hidden

}> +

Visible!

+
+
+``` + +Provide `initialValue` to opt out of the pending state and start with a known value: + +```tsx +const visible = createVisibilityObserver(() => el, { initialValue: false }); +// visible() === false immediately, no pending state +
{visible() ? "Visible" : "Hidden"}
``` Options accepted in addition to `IntersectionObserverInit`: -- `initialValue` — Initial value of the signal _(default: false)_ +- `initialValue` — Opt-in initial value; when omitted, `visible()` throws `NotReadyError` until the first observation. ### Setter callback @@ -168,7 +195,7 @@ const visible = createVisibilityObserver( #### `withOccurrence` -It provides information about element occurrence in the viewport — `"Entering"`, `"Leaving"`, `"Inside"` or `"Outside"`. +Provides information about element occurrence in the viewport — `"Entering"`, `"Leaving"`, `"Inside"` or `"Outside"`. ```tsx import { createVisibilityObserver, withOccurrence } from "@solid-primitives/intersection-observer"; @@ -187,7 +214,7 @@ const visible = createVisibilityObserver( #### `withDirection` -It provides information about element direction on the screen — `"Left"`, `"Right"`, `"Top"`, `"Bottom"` or `"None"`. +Provides information about element direction on the screen — `"Left"`, `"Right"`, `"Top"`, `"Bottom"` or `"None"`. ```ts import { createVisibilityObserver, withDirection } from "@solid-primitives/intersection-observer"; diff --git a/packages/intersection-observer/src/index.ts b/packages/intersection-observer/src/index.ts index 401886c0f..2b09e211b 100644 --- a/packages/intersection-observer/src/index.ts +++ b/packages/intersection-observer/src/index.ts @@ -4,12 +4,11 @@ import { createEffect, createStore, untrack, - getOwner, - runWithOwner, + NotReadyError, DEV, } from "solid-js"; -import type { JSX, Accessor } from "solid-js"; -import { isServer } from "@solidjs/web"; +import type { Accessor } from "solid-js"; +import { isServer } from "solid-js/web"; import { access, type FalsyValue, @@ -17,6 +16,9 @@ import { handleDiffArray, } from "@solid-primitives/utils"; +// Sentinel for the "not yet observed" pending state. +const NOT_SET: unique symbol = Symbol(); + export type AddIntersectionObserverEntry = (el: Element) => void; export type RemoveIntersectionObserverEntry = (el: Element) => void; @@ -24,10 +26,15 @@ export type EntryCallback = ( entry: IntersectionObserverEntry, instance: IntersectionObserver, ) => void; -export type AddViewportObserverEntry = ( - el: Element, - callback: MaybeAccessor, -) => void; + +/** + * Curried ref-callback form: `add(callback)` returns `(el) => void` for use as + * a Solid `ref`. Direct imperative form: `add(el, callback)`. + */ +export type AddViewportObserverEntry = { + (el: Element, callback: MaybeAccessor): void; + (callback: MaybeAccessor): (el: Element) => void; +}; export type RemoveViewportObserverEntry = (el: Element) => void; export type CreateViewportObserverReturnValue = [ @@ -40,21 +47,7 @@ export type CreateViewportObserverReturnValue = [ }, ]; -declare module "solid-js" { - namespace JSX { - interface Directives { - intersectionObserver: true | EntryCallback; - } - } -} - -// This ensures the `JSX` import won't fall victim to tree shaking before -// TypesScript can use it -export type E = JSX.Element; - function observe(el: Element, instance: IntersectionObserver): void { - // Elements with 'display: "contents"' don't work with IO, even if they are visible by users - // (https://github.com/solidjs-community/solid-primitives/issues/116) if (DEV && el instanceof HTMLElement && el.style.display === "contents") { // eslint-disable-next-line no-console console.warn( @@ -97,39 +90,42 @@ export function makeIntersectionObserver( } /** - * Creates a reactive Intersection Observer primitive. Returns a store array of - * {@link IntersectionObserverEntry} objects — one slot per element, updated in - * place whenever that element's intersection state changes. Because the return - * value is a store, reading `entries[i].isIntersecting` only re-runs the - * computation that reads it, not every computation that reads the array. - * - * @param elements - A reactive list of elements to watch - * @param options - IntersectionObserver constructor options (may be a reactive - * accessor; changing it disconnects and recreates the observer): - * - `root` — The Element or Document whose bounds are used as the bounding box when testing for intersection. - * - `rootMargin` — A string which specifies a set of offsets to add to the root's bounding_box when calculating intersections. - * - `threshold` — Either a single number or an array of numbers between 0.0 and 1.0. + * Creates a reactive Intersection Observer primitive. Returns a tuple of: + * - A store array of {@link IntersectionObserverEntry} objects, one slot per + * element, updated in place whenever that element's intersection state changes. + * - `isVisible(el)` — a pending-aware accessor that throws `NotReadyError` until + * the first observation fires for that element (integrates with ``), + * then returns `entry.isIntersecting` reactively. * * @example * ```tsx - * const entries = createIntersectionObserver(elements); - * createEffect(() => console.log(entries[0]?.isIntersecting)); + * const [entries, isVisible] = createIntersectionObserver(elements); + * + * // In JSX — Loading shows fallback until first observation: + * Checking…

}> + *

Visible!

+ *
* ``` */ export function createIntersectionObserver( elements: Accessor, options?: MaybeAccessor, -): readonly IntersectionObserverEntry[] { - if (isServer) return []; +): readonly [ + entries: readonly IntersectionObserverEntry[], + isVisible: (el: Element) => boolean, +] { + if (isServer) { + const isVisible = (_el: Element): boolean => { + throw new NotReadyError("IntersectionObserver not available on server"); + }; + return [[], isVisible] as const; + } const [entries, setEntries] = createStore([]); const indexMap = new WeakMap(); let nextIdx = 0; let trackedEls: Element[] = []; - // Stable callback — never recreated, safe to share across IO instances. - // Store writes (setEntries) are applied synchronously and do not need - // runWithOwner — that is only needed for signal writes from external callbacks. const ioCallback: IntersectionObserverCallback = newEntries => { for (const entry of newEntries) { let idx = indexMap.get(entry.target); @@ -137,28 +133,18 @@ export function createIntersectionObserver( idx = nextIdx++; indexMap.set(entry.target, idx); } - // Freeze the entry so Solid's store does not recursively proxy it — - // isWrappable returns false for frozen objects, which keeps DOM element - // references (entry.target) unwrapped and referentially stable. - // Also update length explicitly: Solid 2.0 tracks array length in an - // override map, so a bare index assignment does not advance it on its own. const frozen = Object.freeze({ ...entry }); setEntries(draft => { draft[idx] = frozen as any; - if (idx >= (draft.length as number)) draft.length = idx + 1; + if (idx >= draft.length) draft.length = idx + 1; }); } }; - // Create the initial IO synchronously so the element effect below can use it - // immediately on its first deferred run. let io = new IntersectionObserver(ioCallback, untrack(() => access(options))); onCleanup(() => io.disconnect()); if (typeof options === "function") { - // Reactive options: recreate the IO whenever options change and re-observe - // all currently tracked elements. `io` is a closure variable so the effect - // always disconnects whichever instance is current before replacing it. createEffect(options, (opts: IntersectionObserverInit) => { io.disconnect(); io = new IntersectionObserver(ioCallback, opts); @@ -175,27 +161,36 @@ export function createIntersectionObserver( [] as Element[], ); - return entries; + // Reads the entry for the given element from the store. Throws NotReadyError + // until the IO has fired for that element — integrates with . + // When called inside a reactive scope, tracks the store slot reactively. + const isVisible = (el: Element): boolean => { + const idx = indexMap.get(el); + if (idx === undefined || !entries[idx]) + throw new NotReadyError("Element has not yet been observed"); + return entries[idx]!.isIntersecting; + }; + + return [entries, isVisible] as const; } /** - * Creates a more advanced viewport observer for complex tracking with multiple objects in a single IntersectionObserver instance. + * Creates a more advanced viewport observer for complex tracking with multiple + * objects in a single IntersectionObserver instance. * - * @param elements - A list of elements to watch - * @param callback - Element intersection change event handler - * @param options - IntersectionObserver constructor options: - * - `root` — The Element or Document whose bounds are used as the bounding box when testing for intersection. - * - `rootMargin` — A string which specifies a set of offsets to add to the root's bounding_box when calculating intersections, effectively shrinking or growing the root for calculation purposes. - * - `threshold` — Either a single number or an array of numbers between 0.0 and 1.0, specifying a ratio of intersection area to total bounding box area for the observed target. + * The `add` function has two forms: + * - `add(el, callback)` — imperative: register element directly. + * - `add(callback)` — returns a ref callback `(el) => void` for use as + * `ref={add(e => ...)}` in JSX. Replaces the old `use:intersectionObserver` directive. * * @example * ```tsx * const [add, { remove, start, stop, instance }] = createViewportObserver(els, (e) => {...}); * add(el, e => console.log(e.isIntersecting)) * - * // directive usage: - * const [intersectionObserver] = createViewportObserver() - *
console.log(e.isIntersecting)}>
+ * // ref usage (replaces old use: directive): + * const [add] = createViewportObserver() + *
console.log(e.isIntersecting))}>
* ``` */ export function createViewportObserver( @@ -229,25 +224,37 @@ export function createViewportObserver(...a: any) { options = a[1]; } } else options = a[0]; + const callbacks = new WeakMap>(); const onChange: IntersectionObserverCallback = (entries, instance) => entries.forEach(entry => { const cb = callbacks.get(entry.target)?.(entry, instance); - // Additional check to prevent errors when the user - // use "observe" directive without providing a callback cb instanceof Function && cb(entry, instance); }); + const { add, remove, stop, instance } = makeIntersectionObserver([], onChange, options); - const addEntry: AddViewportObserverEntry = (el, callback) => { - add(el); - callbacks.set(el, callback); + + const addEntry: AddViewportObserverEntry = ( + elOrCallback: Element | MaybeAccessor, + callback?: MaybeAccessor, + ): any => { + if (elOrCallback instanceof Element) { + add(elOrCallback); + callbacks.set(elOrCallback, callback!); + } else { + // Curried ref form: add(callback) → ref callback (el) => void + return (el: Element) => { + add(el); + callbacks.set(el, elOrCallback); + }; + } }; + const removeEntry: RemoveViewportObserverEntry = el => { callbacks.delete(el); remove(el); }; const start = () => initial.forEach(([el, cb]) => addEntry(el, cb)); - // onMount equivalent: run start() once after the reactive scope initialises createEffect(() => {}, () => { start(); }); return [addEntry, { remove: removeEntry, start, stop, instance }]; } @@ -260,40 +267,66 @@ export type VisibilitySetter = ( /** * Creates a reactive signal that changes when a single element's visibility changes. * - * Takes the element to observe directly, removing the curried factory pattern of - * the previous API. The element may be a reactive accessor or a plain DOM element. + * When `initialValue` is omitted, `visible()` throws `NotReadyError` until the + * first IntersectionObserver callback fires — integrates with `` for a + * natural loading fallback: * - * Signal writes from the IntersectionObserver callback are wrapped in - * `runWithOwner` to avoid "Signal written to an owned scope" warnings in - * Solid 2.0 when the IO fires outside the reactive owner. + * ```tsx + * const visible = createVisibilityObserver(() => el) * - * @param element - The element to observe; may be `Accessor` or a plain `Element`. - * @param options - IntersectionObserver constructor options plus `initialValue` for the signal. - * @param setter - Optional custom setter callback that controls the signal value. + * Checking…

}> + * Hidden

}> + *

Visible!

+ *
+ *
+ * ``` + * + * Provide `initialValue` to opt out of the pending state and start with a known value: * - * @example * ```tsx - * let el: HTMLDivElement | undefined - * const visible = createVisibilityObserver(() => el, { threshold: 0.8 }) - *
{ visible() ? "Visible" : "Hidden" }
+ * const visible = createVisibilityObserver(() => el, { initialValue: false }) + * // visible() === false immediately * ``` + * + * @param element - The element to observe; may be `Accessor` or a plain `Element`. + * @param options - IntersectionObserver options plus optional `initialValue`. + * @param setter - Optional custom setter controlling the signal value. */ export function createVisibilityObserver( element: Accessor | Element, options?: IntersectionObserverInit & { initialValue?: boolean }, setter?: MaybeAccessor, ): Accessor { - if (isServer) return () => options?.initialValue ?? false; + if (isServer) { + if (options?.initialValue !== undefined) return () => options.initialValue!; + return () => { + throw new NotReadyError("Visibility not yet observed"); + }; + } + + // rawVisible tracks the actual observed value; NOT_SET means "first IO hasn't fired yet". + const [rawVisible, setRawVisible] = createSignal( + options?.initialValue !== undefined ? options.initialValue : NOT_SET, + ); - const owner = getOwner()!; - const [isVisible, setVisible] = createSignal(options?.initialValue ?? false); + // Plain accessor — reading rawVisible() inside a reactive scope is tracked normally. + // Throwing from a plain function (not a computed signal) avoids caching issues + // when called outside a reactive scope between state transitions. + const visible = (): boolean => { + const val = rawVisible(); + if (val === NOT_SET) throw new NotReadyError("Visibility not yet observed"); + return val; + }; - // access(setter) is called once here — for factory setters like withOccurrence, - // this creates the per-element closure (with prevIntersecting etc.) exactly once. + // access(setter) called once so factory setters (withOccurrence, withDirection) + // create their per-element closure exactly once. const setterFn = setter ? access(setter) : null; const entryCallback: EntryCallback = setterFn - ? entry => runWithOwner(owner, () => setVisible(setterFn(entry, { visible: untrack(isVisible) }))) - : entry => runWithOwner(owner, () => setVisible(entry.isIntersecting)); + ? entry => { + const prev = untrack(rawVisible); + setRawVisible(setterFn(entry, { visible: prev === NOT_SET ? false : prev })); + } + : entry => setRawVisible(entry.isIntersecting); const io = new IntersectionObserver((newEntries, instance) => { for (const entry of newEntries) entryCallback(entry, instance); @@ -307,7 +340,7 @@ export function createVisibilityObserver( () => element(), (el: Element | FalsyValue) => { if (el === prevEl) return; - if (prevEl) { io.unobserve(prevEl); } + if (prevEl) io.unobserve(prevEl); if (el) observe(el, io); prevEl = el; }, @@ -317,17 +350,22 @@ export function createVisibilityObserver( prevEl = element; } - onCleanup(() => { if (prevEl) io.unobserve(prevEl); }); + onCleanup(() => { + if (prevEl) io.unobserve(prevEl); + }); - return isVisible; + return visible; } -export enum Occurrence { - Entering = "Entering", - Leaving = "Leaving", - Inside = "Inside", - Outside = "Outside", -} +// ─── Occurrence ─────────────────────────────────────────────────────────────── + +export const Occurrence = { + Entering: "Entering", + Leaving: "Leaving", + Inside: "Inside", + Outside: "Outside", +} as const; +export type Occurrence = (typeof Occurrence)[keyof typeof Occurrence]; /** * Calculates the occurrence of an element in the viewport. @@ -336,9 +374,6 @@ export function getOccurrence( isIntersecting: boolean, prevIsIntersecting: boolean | undefined, ): Occurrence { - if (isServer) { - return Occurrence.Outside; - } return isIntersecting ? prevIsIntersecting ? Occurrence.Inside @@ -349,30 +384,26 @@ export function getOccurrence( } /** - * A visibility setter factory function. It provides information about element occurrence in the viewport — `"Entering"`, `"Leaving"`, `"Inside"` or `"Outside"`. - * @param setter - A function that sets the occurrence of an element in the viewport. - * @returns A visibility setter function. + * A visibility setter factory providing occurrence context — `"Entering"`, + * `"Leaving"`, `"Inside"`, or `"Outside"`. + * * @example * ```ts - * const useVisibilityObserver = createVisibilityObserver( - * { threshold: 0.8 }, - * withOccurrence((entry, { occurrence }) => { - * console.log(occurrence); - * return entry.isIntersecting; - * }) + * const visible = createVisibilityObserver(el, { threshold: 0.8 }, + * withOccurrence((entry, { occurrence }) => { + * console.log(occurrence); + * return entry.isIntersecting; + * }) * ); * ``` */ export function withOccurrence( setter: MaybeAccessor>, ): () => VisibilitySetter { - if (isServer) { - return () => () => false; - } + if (isServer) return () => () => false; return () => { let prevIntersecting: boolean | undefined; const cb = access(setter); - return (entry, ctx) => { const { isIntersecting } = entry; const occurrence = getOccurrence(isIntersecting, prevIntersecting); @@ -382,35 +413,32 @@ export function withOccurrence( }; } -export enum DirectionX { - Left = "Left", - Right = "Right", - None = "None", -} +// ─── Direction ──────────────────────────────────────────────────────────────── -export enum DirectionY { - Top = "Top", - Bottom = "Bottom", - None = "None", -} +export const DirectionX = { + Left: "Left", + Right: "Right", + None: "None", +} as const; +export type DirectionX = (typeof DirectionX)[keyof typeof DirectionX]; + +export const DirectionY = { + Top: "Top", + Bottom: "Bottom", + None: "None", +} as const; +export type DirectionY = (typeof DirectionY)[keyof typeof DirectionY]; /** - * Calculates the direction of an element in the viewport. The direction is calculated based on the element's rect, it's previous rect and the `isIntersecting` flag. - * @returns A direction string: `"Left"`, `"Right"`, `"Top"`, `"Bottom"` or `"None"`. + * Calculates the scroll direction of an element based on bounding rect changes. */ export function getDirection( rect: DOMRectReadOnly, prevRect: DOMRectReadOnly | undefined, intersecting: boolean, ): { directionX: DirectionX; directionY: DirectionY } { - if (isServer) { - return { - directionX: DirectionX.None, - directionY: DirectionY.None, - }; - } - let directionX = DirectionX.None; - let directionY = DirectionY.None; + let directionX: DirectionX = DirectionX.None; + let directionY: DirectionY = DirectionY.None; if (!prevRect) return { directionX, directionY }; if (rect.top < prevRect.top) directionY = intersecting ? DirectionY.Bottom : DirectionY.Top; else if (rect.top > prevRect.top) directionY = intersecting ? DirectionY.Top : DirectionY.Bottom; @@ -421,19 +449,16 @@ export function getDirection( } /** - * A visibility setter factory function. It provides information about element direction on the screen — `"Left"`, `"Right"`, `"Top"`, `"Bottom"` or `"None"`. - * @param setter - A function that sets the occurrence of an element in the viewport. - * @returns A visibility setter function. + * A visibility setter factory providing scroll direction context — `"Left"`, + * `"Right"`, `"Top"`, `"Bottom"`, or `"None"`. + * * @example * ```ts - * const useVisibilityObserver = createVisibilityObserver( - * { threshold: 0.8 }, - * withDirection((entry, { directionY, directionX, visible }) => { - * if (!entry.isIntersecting && directionY === "Top" && visible) { - * return true; - * } - * return entry.isIntersecting; - * }) + * const visible = createVisibilityObserver(el, { threshold: 0.8 }, + * withDirection((entry, { directionY, directionX, visible }) => { + * if (!entry.isIntersecting && directionY === "Top" && visible) return true; + * return entry.isIntersecting; + * }) * ); * ``` */ @@ -442,13 +467,10 @@ export function withDirection( VisibilitySetter >, ): () => VisibilitySetter { - if (isServer) { - return () => () => false; - } + if (isServer) return () => () => false; return () => { let prevBounds: DOMRectReadOnly | undefined; const cb = access(callback); - return (entry, ctx) => { const { boundingClientRect } = entry; const direction = getDirection(boundingClientRect, prevBounds, entry.isIntersecting); diff --git a/packages/intersection-observer/test/index.test.ts b/packages/intersection-observer/test/index.test.ts index 8ca81840e..e3d8574de 100644 --- a/packages/intersection-observer/test/index.test.ts +++ b/packages/intersection-observer/test/index.test.ts @@ -250,7 +250,7 @@ describe("createIntersectionObserver", () => { test("returns a store array of entries", () => { createRoot(dispose => { - const entries = createIntersectionObserver(() => [div, img]); + const [entries] = createIntersectionObserver(() => [div, img]); expect(Array.isArray(entries)).toBe(true); dispose(); }); @@ -259,7 +259,7 @@ describe("createIntersectionObserver", () => { test("store is updated when IO fires", () => { createRoot(dispose => { const [els] = createSignal([div]); - const entries = createIntersectionObserver(els); + const [entries] = createIntersectionObserver(els); const instance = _getLastIOInstance(); // flush() lets the deferred element effect run so div is actually observed @@ -282,7 +282,7 @@ describe("createIntersectionObserver", () => { test("each element occupies its own store slot", () => { createRoot(dispose => { - const entries = createIntersectionObserver(() => [div, img]); + const [entries] = createIntersectionObserver(() => [div, img]); const instance = _getLastIOInstance(); flush(); // let the element effect observe both elements @@ -310,6 +310,29 @@ describe("createIntersectionObserver", () => { dispose(); }); }); + + test("isVisible throws before first observation, returns boolean after", () => { + createRoot(dispose => { + const [, isVisible] = createIntersectionObserver(() => [div]); + const instance = _getLastIOInstance(); + + flush(); // let the element effect observe div + + expect(() => isVisible(div), "should throw NotReadyError before first IO").toThrow(); + + instance.__TEST__onChange({ isIntersecting: true }); + flush(); + + expect(isVisible(div), "should return true after intersecting").toBe(true); + + instance.__TEST__onChange({ isIntersecting: false }); + flush(); + + expect(isVisible(div), "should return false when not intersecting").toBe(false); + + dispose(); + }); + }); }); describe("createViewportObserver", () => { @@ -533,6 +556,31 @@ describe("createViewportObserver", () => { dispose(); }); }); + + test("add(callback) returns a ref callback for JSX ref usage", () => { + createRoot(dispose => { + const [add, { instance }] = createViewportObserver(); + let cbEntry: IntersectionObserverEntry | undefined; + + // curried form: add(callback) → (el) => void — used as ref={add(e => ...)} + const refCb = add((e: IntersectionObserverEntry) => { + cbEntry = e; + }); + refCb(div); // simulate JSX ref binding + + expect( + (instance as IntersectionObserver).elements[0], + "element wasn't observed via ref callback", + ).toBe(div); + + (instance as IntersectionObserver).__TEST__onChange({ isIntersecting: true }); + + expect(cbEntry?.target, "callback should fire with correct target").toBe(div); + expect(cbEntry?.isIntersecting).toBe(true); + + dispose(); + }); + }); }); describe("createVisibilityObserver", () => { @@ -568,15 +616,20 @@ describe("createVisibilityObserver", () => { }); }); - test("returns signal", () => { + test("returns signal — pending until first observation", () => { createRoot(dispose => { const isVisible = createVisibilityObserver(div); - expect(isVisible(), "signal doesn't return default initialValue").toBe(false); + expect( + () => isVisible(), + "should throw NotReadyError before first observation", + ).toThrow(); - const isVisible2 = createVisibilityObserver(div, { initialValue: true }); + const isVisibleFalse = createVisibilityObserver(div, { initialValue: false }); + expect(isVisibleFalse(), "should return false with initialValue: false").toBe(false); - expect(isVisible2(), "signal doesn't return custom initialValue").toBe(true); + const isVisibleTrue = createVisibilityObserver(div, { initialValue: true }); + expect(isVisibleTrue(), "should return true with initialValue: true").toBe(true); dispose(); }); diff --git a/packages/intersection-observer/test/server.test.ts b/packages/intersection-observer/test/server.test.ts index 5fc1e2dd5..6688e2dd4 100644 --- a/packages/intersection-observer/test/server.test.ts +++ b/packages/intersection-observer/test/server.test.ts @@ -13,17 +13,35 @@ describe("API works in SSR", () => { test("createIntersectionObserver() - SSR", () => { const el = vi.fn(() => []); - const cb = vi.fn(() => {}); - expect(() => createIntersectionObserver(el, cb)).not.toThrow(); + const options = vi.fn(() => ({})); + expect(() => createIntersectionObserver(el, options)).not.toThrow(); + // elements accessor and options accessor are not called on the server expect(el).not.toBeCalled(); - expect(cb).not.toBeCalled(); + expect(options).not.toBeCalled(); + }); + + test("createIntersectionObserver() - SSR returns tuple with throwing isVisible", () => { + const [entries, isVisible] = createIntersectionObserver(() => []); + expect(entries).toEqual([]); + expect(() => isVisible({} as Element)).toThrow(); }); test("createViewportObserver() - SSR", () => { expect(() => createViewportObserver()).not.toThrow(); }); - test("createVisibilityObserver() - SSR", () => { - expect(() => createVisibilityObserver()).not.toThrow(); + test("createVisibilityObserver() - SSR throws before first observation without initialValue", () => { + const div = {} as Element; + const isVisible = createVisibilityObserver(div); + expect(() => isVisible()).toThrow(); + }); + + test("createVisibilityObserver() - SSR returns initialValue when provided", () => { + const div = {} as Element; + const isVisible = createVisibilityObserver(div, { initialValue: false }); + expect(isVisible()).toBe(false); + + const isVisibleTrue = createVisibilityObserver(div, { initialValue: true }); + expect(isVisibleTrue()).toBe(true); }); }); diff --git a/packages/intersection-observer/vitest.config.ts b/packages/intersection-observer/vitest.config.ts index c49c1a2c4..b1802d6eb 100644 --- a/packages/intersection-observer/vitest.config.ts +++ b/packages/intersection-observer/vitest.config.ts @@ -19,8 +19,12 @@ export default defineConfig(({ mode }) => { ? ["@solid-primitives/source", "node"] : ["@solid-primitives/source", "browser", "development"], alias: { - "solid-js/web": new URL("./node_modules/@solidjs/web/dist/web.js", import.meta.url) - .pathname, + "solid-js/web": new URL( + testSSR + ? "./node_modules/@solidjs/web/dist/server.js" + : "./node_modules/@solidjs/web/dist/web.js", + import.meta.url, + ).pathname, }, }, test: { From 96976054566bca44d9f74a1c55be8f0332f62b4e Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:42:05 -0400 Subject: [PATCH 5/6] Run formatter --- packages/intersection-observer/README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/intersection-observer/README.md b/packages/intersection-observer/README.md index 4347c1292..cb70a970e 100644 --- a/packages/intersection-observer/README.md +++ b/packages/intersection-observer/README.md @@ -65,6 +65,7 @@ function makeIntersectionObserver( ## `createIntersectionObserver` Returns a tuple of: + - A **store array** of `IntersectionObserverEntry` objects — one slot per element, updated in place when that element's intersection state changes. Reading `entries[i].isIntersecting` only re-runs the computation that reads slot `i`. - **`isVisible(el)`** — a pending-aware helper that throws `NotReadyError` until the first observation fires for that element, then returns `entry.isIntersecting` reactively. Integrates with `` for a natural loading fallback. @@ -96,10 +97,7 @@ createEffect(() => { function createIntersectionObserver( elements: Accessor, options?: MaybeAccessor, -): readonly [ - entries: readonly IntersectionObserverEntry[], - isVisible: (el: Element) => boolean, -]; +): readonly [entries: readonly IntersectionObserverEntry[], isVisible: (el: Element) => boolean]; ``` ## `createViewportObserver` @@ -107,6 +105,7 @@ function createIntersectionObserver( This primitive comes with a number of flexible options. You can specify a callback at the root with an array of elements or individual callbacks for individual elements. The `add` function has two forms: + - `add(el, callback)` — imperative: register an element with its callback directly. - `add(callback)` — curried ref form: returns a `(el) => void` ref callback for use as `ref={add(e => ...)}` in JSX. @@ -159,7 +158,7 @@ const visible = createVisibilityObserver(() => el, { threshold: 0.8 }); Hidden

}>

Visible!

-
+
; ``` Provide `initialValue` to opt out of the pending state and start with a known value: @@ -167,7 +166,7 @@ Provide `initialValue` to opt out of the pending state and start with a known va ```tsx const visible = createVisibilityObserver(() => el, { initialValue: false }); // visible() === false immediately, no pending state -
{visible() ? "Visible" : "Hidden"}
+
{visible() ? "Visible" : "Hidden"}
; ``` Options accepted in addition to `IntersectionObserverInit`: From 426028fb36d229cf0b7c14460180947d00ac0041 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:06:45 -0400 Subject: [PATCH 6/6] Update to latest solid branch --- packages/intersection-observer/package.json | 8 ++-- packages/intersection-observer/src/index.ts | 1 + pnpm-lock.yaml | 46 +++++++++++++++------ 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/intersection-observer/package.json b/packages/intersection-observer/package.json index c4f618e7c..3041a6167 100644 --- a/packages/intersection-observer/package.json +++ b/packages/intersection-observer/package.json @@ -54,14 +54,14 @@ ], "devDependencies": { "@solid-primitives/range": "workspace:^", - "@solidjs/web": "2.0.0-experimental.16", - "solid-js": "2.0.0-experimental.16" + "@solidjs/web": "2.0.0-beta.7", + "solid-js": "2.0.0-beta.7" }, "dependencies": { "@solid-primitives/utils": "workspace:^" }, "peerDependencies": { - "@solidjs/web": "2.0.0-experimental.16", - "solid-js": "2.0.0-experimental.16" + "@solidjs/web": "2.0.0-beta.7", + "solid-js": "2.0.0-beta.7" } } diff --git a/packages/intersection-observer/src/index.ts b/packages/intersection-observer/src/index.ts index 2b09e211b..14fb967ce 100644 --- a/packages/intersection-observer/src/index.ts +++ b/packages/intersection-observer/src/index.ts @@ -307,6 +307,7 @@ export function createVisibilityObserver( // rawVisible tracks the actual observed value; NOT_SET means "first IO hasn't fired yet". const [rawVisible, setRawVisible] = createSignal( options?.initialValue !== undefined ? options.initialValue : NOT_SET, + { ownedWrite: true }, ); // Plain accessor — reading rawVisible() inside a reactive scope is tracked normally. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38c8edd45..8fe0f78d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -465,11 +465,11 @@ importers: specifier: workspace:^ version: link:../range '@solidjs/web': - specifier: 2.0.0-experimental.16 - version: 2.0.0-experimental.16(solid-js@2.0.0-experimental.16) + specifier: 2.0.0-beta.7 + version: 2.0.0-beta.7(@solidjs/signals@2.0.0-beta.7)(solid-js@2.0.0-beta.7) solid-js: - specifier: 2.0.0-experimental.16 - version: 2.0.0-experimental.16 + specifier: 2.0.0-beta.7 + version: 2.0.0-beta.7 packages/jsx-tokenizer: dependencies: @@ -2045,6 +2045,7 @@ packages: '@graphql-tools/prisma-loader@8.0.4': resolution: {integrity: sha512-hqKPlw8bOu/GRqtYr0+dINAI13HinTVYBDqhwGAPIFmLr5s+qKskzgCiwbsckdrb5LWVFmVZc+UXn80OGiyBzg==} engines: {node: '>=16.0.0'} + deprecated: 'This package was intended to be used with an older versions of Prisma.\nThe newer versions of Prisma has a different approach to GraphQL integration.\nTherefore, this package is no longer needed and has been deprecated and removed.\nLearn more: https://www.prisma.io/graphql' peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 @@ -2593,15 +2594,19 @@ packages: '@solidjs/signals@0.11.3': resolution: {integrity: sha512-udMfutYPOlcxKUmc5+n1QtarsxOiAlC6LJY2TqFyaMwdXgo+reiYUcYGDlOiAPXfCLE0lavZHQ/6GT5pJbXKBA==} + '@solidjs/signals@2.0.0-beta.7': + resolution: {integrity: sha512-SgK6oQlQZofz82LiEJ2RzT3sbs1lWTqFEtLoWjLsUo/dk1v9EoIFpJJlmvgkXvNugASWG+l1yOHa1a8lPamxug==} + '@solidjs/start@1.1.4': resolution: {integrity: sha512-ma1TBYqoTju87tkqrHExMReM5Z/+DTXSmi30CCTavtwuR73Bsn4rVGqm528p4sL2koRMfAuBMkrhuttjzhL68g==} peerDependencies: vinxi: ^0.5.3 - '@solidjs/web@2.0.0-experimental.16': - resolution: {integrity: sha512-TcgvLKOYN2cIExX307SRxvd1aXj5eLaQUlGDLdQoQBqDHhuDdeZUfKLYrn/JOzWs1u5DYNOc+gR0SaG17vYsng==} + '@solidjs/web@2.0.0-beta.7': + resolution: {integrity: sha512-m5VjmDBufrOX0ZKGbhvwkT0CPK0TbMxDbxVPDB1PH2evGbWXQZcUlrpFM1N8RBO5md3aR/T1PgMfnOjleJbrRg==} peerDependencies: - solid-js: ^2.0.0-experimental.16 + '@solidjs/signals': ^2.0.0-beta.7 + solid-js: ^2.0.0-beta.7 '@speed-highlight/core@1.2.7': resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==} @@ -3524,6 +3529,7 @@ packages: dax-sh@0.43.2: resolution: {integrity: sha512-uULa1sSIHgXKGCqJ/pA0zsnzbHlVnuq7g8O2fkHokWFNwEGIhh5lAJlxZa1POG5En5ba7AU4KcBAvGQWMMf8rg==} + deprecated: This package has moved to simply be 'dax' instead of 'dax-sh' db0@0.3.2: resolution: {integrity: sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw==} @@ -4175,11 +4181,12 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -6022,6 +6029,9 @@ packages: solid-js@1.9.7: resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} + solid-js@2.0.0-beta.7: + resolution: {integrity: sha512-7JHs+BhLeZXoU+u9dG+eKnyxxfZyGpOuJEBbN/1XbHKO/WhxecdplOAurlg/YDllNWPhsbXqmLR1H2paqSu62g==} + solid-js@2.0.0-experimental.16: resolution: {integrity: sha512-zZ1dU7cR0EnvLnrYiRLQbCFiDw5blLdlqmofgLzKUYE1TCMWDcisBlSwz0Ez8l4yXB4adbdhtaYCuynH4xSq9A==} @@ -6219,6 +6229,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -6734,6 +6745,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -8614,6 +8626,8 @@ snapshots: '@solidjs/signals@0.11.3': {} + '@solidjs/signals@2.0.0-beta.7': {} + '@solidjs/start@1.1.4(solid-js@1.9.7)(vinxi@0.5.7(@types/node@22.15.31)(db0@0.3.2)(ioredis@5.6.1)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0))': dependencies: '@tanstack/server-functions-plugin': 1.121.0(vite@6.3.5(@types/node@22.15.31)(jiti@2.4.2)(sass@1.77.8)(terser@5.42.0)(tsx@4.20.2)(yaml@2.5.0)) @@ -8637,11 +8651,12 @@ snapshots: - supports-color - vite - '@solidjs/web@2.0.0-experimental.16(solid-js@2.0.0-experimental.16)': + '@solidjs/web@2.0.0-beta.7(@solidjs/signals@2.0.0-beta.7)(solid-js@2.0.0-beta.7)': dependencies: - seroval: 1.3.2 - seroval-plugins: 1.3.2(seroval@1.3.2) - solid-js: 2.0.0-experimental.16 + '@solidjs/signals': 2.0.0-beta.7 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + solid-js: 2.0.0-beta.7 '@speed-highlight/core@1.2.7': {} @@ -12609,6 +12624,13 @@ snapshots: seroval: 1.3.2 seroval-plugins: 1.3.2(seroval@1.3.2) + solid-js@2.0.0-beta.7: + dependencies: + '@solidjs/signals': 2.0.0-beta.7 + csstype: 3.1.3 + seroval: 1.5.2 + seroval-plugins: 1.5.2(seroval@1.5.2) + solid-js@2.0.0-experimental.16: dependencies: '@solidjs/signals': 0.11.3