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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions .changeset/redesign-intersection-observer-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
"@solid-primitives/intersection-observer": major
---

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 `<Loading>` for a natural loading fallback:

```tsx
const [entries, isVisible] = createIntersectionObserver(targets);

<Loading fallback={<p>Checking…</p>}>
<Show when={isVisible(el)}><p>Visible!</p></Show>
</Loading>
```

**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.

**Reactive options (new):**
`options` may now be a reactive accessor (`MaybeAccessor<IntersectionObserverInit>`). When the accessor's value changes, the observer is disconnected and recreated with the new options, and all currently tracked elements are re-observed.

## `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()
<div use:intersectionObserver={(e) => console.log(e.isIntersecting)}></div>

// After
const [add] = createViewportObserver()
<div ref={add(e => console.log(e.isIntersecting))}></div>
```

`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 `<Loading>` 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 <Loading>)

// 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
// Before
const useVisibilityObserver = createVisibilityObserver({ threshold: 0.8 });
const visible = useVisibilityObserver(() => el);

// After
const visible = createVisibilityObserver(() => el, { threshold: 0.8 });
```

**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.

```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
```
5 changes: 5 additions & 0 deletions .changeset/restore-make-intersection-observer.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/audio/dev/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const App: Component = () => {
<div class="flex flex-col items-center">
<div class="flex items-center justify-center space-x-4 rounded-full bg-white p-1 shadow">
<button
class="scale-200 flex cursor-pointer border-none bg-transparent"
class="flex scale-200 cursor-pointer border-none bg-transparent"
disabled={audio.state == AudioState.ERROR}
onClick={() => setPlaying(audio.state == AudioState.PLAYING ? false : true)}
>
Expand All @@ -97,7 +97,7 @@ const App: Component = () => {
step="0.1"
max={audio.duration}
value={audio.currentTime}
class="form-range w-40 cursor-pointer appearance-none rounded-3xl bg-gray-200 transition hover:bg-gray-300 focus:outline-none focus:ring-0"
class="form-range w-40 cursor-pointer appearance-none rounded-3xl bg-gray-200 transition hover:bg-gray-300 focus:ring-0 focus:outline-none"
/>
<div class="flex px-2">
<Icon class="w-6 text-blue-600" path={speakerWave} />
Expand Down
2 changes: 1 addition & 1 deletion packages/geolocation/dev/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const Client: Component = () => {
</div>
</div>
</Show>
<div class="w-100 h-100" ref={el => (ref = el)} />
<div class="h-100 w-100" ref={el => (ref = el)} />
<div class="p-4">
{location()?.latitude}, {location()?.longitude}
</div>
Expand Down
152 changes: 109 additions & 43 deletions packages/intersection-observer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,44 +25,100 @@ 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`

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 `<Loading>` for a natural loading fallback.

```tsx
import { createIntersectionObserver } from "@solid-primitives/intersection-observer";

const [targets, setTargets] = createSignal<Element[]>([some_element]);
const [targets, setTargets] = createSignal<Element[]>([]);

const [entries, isVisible] = createIntersectionObserver(targets, { threshold: 0.5 });

createIntersectionObserver(els, entries => {
// entries — reactive store, fine-grained per-element tracking:
createEffect(() => {
entries.forEach(e => console.log(e.isIntersecting));
});

// isVisible — integrates with <Loading> for pending state:
<Loading fallback={<p>Checking…</p>}>
<Show when={isVisible(el)}><p>Visible!</p></Show>
</Loading>

<div ref={el => 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<Element[]>,
onChange: IntersectionObserverCallback,
options?: IntersectionObserverInit,
): void;
options?: MaybeAccessor<IntersectionObserverInit>,
): 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()
<div use:intersectionObserver={(e) => console.log(e.isIntersecting)}></div>
// Ref usage (replaces old use: directive):
const [add] = createViewportObserver();
<div ref={add(e => console.log(e.isIntersecting))}></div>
```

### Definition
Expand All @@ -83,63 +140,69 @@ function createViewportObserver(

## `createVisibilityObserver`

Creates reactive signal that changes when a single element's visibility changes.

### How to use it

`createVisibilityObserver` takes a `IntersectionObserverInit` object as the first argument. Use it to set thresholds, margins, and other options.
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.

- `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.
When `initialValue` is omitted, `visible()` throws `NotReadyError` until the first `IntersectionObserver` callback fires — integrating naturally with `<Loading>` for a loading fallback:

```tsx
import { createVisibilityObserver } from "@solid-primitives/intersection-observer";

let el: HTMLDivElement | undefined;

const useVisibilityObserver = createVisibilityObserver({ threshold: 0.8 });
const visible = createVisibilityObserver(() => el, { threshold: 0.8 });

// make sure that you pass the element reference in a thunk if it is undefined initially
const visible = useVisibilityObserver(() => el);

<div ref={el}>{visible() ? "Visible" : "Hidden"}</div>;
// Pending until first IO fires — shows fallback in the meantime:
<Loading fallback={<p>Checking…</p>}>
<Show when={visible()} fallback={<p>Hidden</p>}>
<p>Visible!</p>
</Show>
</Loading>;
```

You can use this shorthand when creating a visibility signal for a single element:
Provide `initialValue` to opt out of the pending state and start with a known value:

```tsx
let el: HTMLDivElement | undefined;
const visible = createVisibilityObserver(() => el, { initialValue: false });
// visible() === false immediately, no pending state
<div>{visible() ? "Visible" : "Hidden"}</div>;
```

const visible = createVisibilityObserver({ threshold: 0.8 })(() => el);
Options accepted in addition to `IntersectionObserverInit`:

<div ref={el}>{visible() ? "Visible" : "Hidden"}</div>;
```
- `initialValue` — Opt-in initial value; when omitted, `visible()` throws `NotReadyError` until the first observation.

### 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**

#### `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";

const useVisibilityObserver = createVisibilityObserver(
let el: HTMLDivElement | undefined;

const visible = createVisibilityObserver(
() => el,
{ threshold: 0.8 },
withOccurrence((entry, { occurrence }) => {
console.log(occurrence); // => "Entering" | "Leaving" | "Inside" | "Outside"
Expand All @@ -150,12 +213,15 @@ const useVisibilityObserver = 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";

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) {
Expand All @@ -169,11 +235,11 @@ const useVisibilityObserver = createVisibilityObserver(
### Definition

```ts
function createViewportObserver(
elements: MaybeAccessor<Element[]>,
callback: EntryCallback,
options?: IntersectionObserverInit,
): CreateViewportObserverReturnValue;
function createVisibilityObserver(
element: Accessor<Element | FalsyValue> | Element,
options?: IntersectionObserverInit & { initialValue?: boolean },
setter?: MaybeAccessor<VisibilitySetter>,
): Accessor<boolean>;
```

## Demo
Expand Down
Loading
Loading