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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 13 additions & 35 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,18 @@ The new version samples geometry internally, so some edge timing can feel a bit

## Hook and type changes

| v1.x | v2.x |
| -------------------------------------- | --------------------------------------------- |
| `useWindowScroll` | `useScrollPosition()` |
| `useContainerScroll({ containerRef })` | `useScrollPosition({ target: containerRef })` |
| `useWindowDimensions` | `useViewportSize()` |
| `Options` | `ListenerOptions` |
| `ScrollEvent` | removed |
| `Dimensions` | removed |
| `DebugInfo` | removed |
| `log` | removed |
`v2.1` no longer exports scroll or viewport helper hooks. `AtomTrigger` does its own observation internally, so these hooks are not needed for normal trigger usage.

| v1.x | v2.1.x |
| -------------------------------------- | ----------------------------------------- |
| `useWindowScroll` | removed, use your app's own scroll hook |
| `useContainerScroll({ containerRef })` | removed, use your app's own scroll hook |
| `useWindowDimensions` | removed, use your app's own viewport hook |
| `Options` | removed |
| `ScrollEvent` | removed |
| `Dimensions` | removed |
| `DebugInfo` | removed |
| `log` | removed |

## Common upgrades

Expand Down Expand Up @@ -222,36 +224,12 @@ After migrating, please check it in the actual UI. `rootMargin` is the place whe
/>
```

## Small hook examples

### Replace `useWindowScroll`

```tsx
const position = useScrollPosition();
console.log(position.y);
```

### Replace `useContainerScroll`

```tsx
const containerRef = React.useRef<HTMLDivElement>(null);
const position = useScrollPosition({ target: containerRef });
console.log(position.y);
```

### Replace `useWindowDimensions`

```tsx
const viewport = useViewportSize();
console.log(viewport.height);
```

## Final check

Your migration is probably done when all of these are true:

1. No `AtomTrigger` still passes `scrollEvent`, `dimensions`, `behavior`, `callback`, `getDebugInfo`, `triggerOnce` or `offset`.
2. Trigger handlers now use `onEnter`, `onLeave` and/or `onEvent`.
3. Custom containers use `root` or `rootRef`.
4. Hook imports were moved to `useScrollPosition` and `useViewportSize`.
4. Old helper hook imports were removed or replaced with hooks from your own codebase.
5. You checked the real UI, not only TypeScript errors, especially around `threshold` and `rootMargin`.
37 changes: 6 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
`react-atom-trigger` helps with the usual "run some code when this thing enters or leaves view" problem.
It is a lightweight React alternative to `react-waypoint`, written in TypeScript.

## v2 is a breaking release
## Breaking changes

If you are coming from `v1.x`, please check [MIGRATION.md](./MIGRATION.md).
`v2` is a breaking release. If you are coming from `v1.x`, please check
[MIGRATION.md](./MIGRATION.md).

`v2.1` removes the helper hooks `useScrollPosition` and `useViewportSize`. `AtomTrigger` does its own observation and does not depend on those hooks.

If you want to stay on the old API:

Expand Down Expand Up @@ -208,33 +211,6 @@ The payload is library-owned geometry data. It is not a native `IntersectionObse
`isInitial` is `true` only for the synthetic first `enter` created by
`fireOnInitialVisible`.

## Hooks

For someone who wants everything out-of-the-box, `useScrollPosition` and `useViewportSize` are also available.

```ts
useScrollPosition(options?: {
target?: Window | HTMLElement | React.RefObject<HTMLElement | null>;
passive?: boolean;
throttleMs?: number;
enabled?: boolean;
}): { x: number; y: number }
```

```ts
useViewportSize(options?: {
passive?: boolean;
throttleMs?: number;
enabled?: boolean;
}): { width: number; height: number }
```

Both hooks are SSR-safe and hydration-safe across the supported React range. During hydration, the first client render matches the server snapshot and then refreshes from the live source, including the compat path used when React does not expose `useSyncExternalStore`. Default throttling is `16ms`.

If you pass `enabled={false}`, the hook pauses its listeners but keeps the latest value it already knows.
It does not fake a reset back to zero.
When you enable it again, it reads from the source immediately and then continues updating as usual.

## Notes

- In sentinel mode, `threshold` is usually only interesting if your sentinel has real width or height. The default sentinel is almost point-like.
Expand All @@ -252,8 +228,7 @@ The short version:
2. `behavior` is gone.
3. `triggerOnce` became `once` or `oncePerDirection`.
4. `scrollEvent`, `dimensions` and `offset` are gone.
5. `useWindowScroll` / `useContainerScroll` became `useScrollPosition`.
6. `useWindowDimensions` became `useViewportSize`.
5. Legacy helper hooks are no longer exported in `v2.1`. Use your app's own scroll or viewport hooks when needed.

For the real upgrade notes and examples, see [MIGRATION.md](./MIGRATION.md).

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-atom-trigger",
"version": "2.0.11",
"version": "2.1.0",
"description": "Geometry-based scroll trigger for React with precise enter/leave control. A modern alternative to react-waypoint.",
"keywords": [
"intersection",
Expand Down
8 changes: 7 additions & 1 deletion scripts/package-smoke.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
const mod = await import('../lib/index.js');

for (const exportName of ['AtomTrigger', 'useScrollPosition', 'useViewportSize']) {
for (const exportName of ['AtomTrigger']) {
if (!(exportName in mod)) {
throw new Error(`Missing expected export: ${exportName}`);
}
}

for (const exportName of ['useScrollPosition', 'useViewportSize']) {
if (exportName in mod) {
throw new Error(`Unexpected removed export: ${exportName}`);
}
}
98 changes: 1 addition & 97 deletions scripts/react-compat-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ globalThis.IntersectionObserver = class {
};

const packageImport = process.env.REACT_ATOM_TRIGGER_IMPORT ?? '../lib/index.js';
const { AtomTrigger, useScrollPosition, useViewportSize } = await import(packageImport);
const { AtomTrigger } = await import(packageImport);

async function createRenderer(container) {
if (ReactDOMClient && typeof ReactDOMClient.createRoot === 'function') {
Expand Down Expand Up @@ -163,101 +163,5 @@ async function runChildModeSmoke() {
container.remove();
}

async function runHooksSmoke() {
const container = document.createElement('div');
document.body.appendChild(container);

function HooksHarness({ enabled }) {
const position = useScrollPosition({ throttleMs: 0, enabled });
const viewport = useViewportSize({ throttleMs: 0, enabled });

return React.createElement(
'output',
{ id: 'hooks-output' },
`${position.x},${position.y}|${viewport.width},${viewport.height}`,
);
}

const renderer = await render(React.createElement(HooksHarness, { enabled: true }), container);
await waitForTick();

Object.defineProperty(window, 'scrollX', {
configurable: true,
value: 14,
writable: true,
});
Object.defineProperty(window, 'scrollY', {
configurable: true,
value: 28,
writable: true,
});
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1440,
writable: true,
});
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 900,
writable: true,
});

window.dispatchEvent(new window.Event('scroll'));
window.dispatchEvent(new window.Event('resize'));
await waitForTick();

const output = container.querySelector('#hooks-output');
if (!(output instanceof HTMLElement)) {
throw new Error('Hooks smoke did not render output.');
}

if (output.textContent !== '14,28|1440,900') {
throw new Error(`Hooks smoke failed, got "${output.textContent}".`);
}

renderer.render(React.createElement(HooksHarness, { enabled: false }));
await waitForTick();

Object.defineProperty(window, 'scrollX', {
configurable: true,
value: 18,
writable: true,
});
Object.defineProperty(window, 'scrollY', {
configurable: true,
value: 32,
writable: true,
});
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1600,
writable: true,
});
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 960,
writable: true,
});

window.dispatchEvent(new window.Event('scroll'));
window.dispatchEvent(new window.Event('resize'));
await waitForTick();

if (output.textContent !== '14,28|1440,900') {
throw new Error(`Hooks disabled smoke failed, got "${output.textContent}".`);
}

renderer.render(React.createElement(HooksHarness, { enabled: true }));
await waitForTick();

if (output.textContent !== '18,32|1600,960') {
throw new Error(`Hooks re-enable smoke failed, got "${output.textContent}".`);
}

await renderer.unmount();
container.remove();
}

await runAtomTriggerSmoke();
await runChildModeSmoke();
await runHooksSmoke();
6 changes: 3 additions & 3 deletions src/AtomTrigger.childMode.helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,16 @@ describe('AtomTrigger child mode helpers', () => {
});

it('warns when more than one top-level child is passed', () => {
expect(getInvalidChildWarning(true, 2, childElement)).toBe(warningMessages.invalidChildCount);
expect(getInvalidChildWarning(true, 2, childElement)).toBe('invalidChildCount');
});

it('warns when the child is not a React element', () => {
expect(getInvalidChildWarning(true, 1, null)).toBe(warningMessages.invalidChildElement);
expect(getInvalidChildWarning(true, 1, null)).toBe('invalidChildElement');
});

it('warns when the child is a fragment', () => {
expect(getInvalidChildWarning(true, 1, React.createElement(React.Fragment))).toBe(
warningMessages.fragmentChild,
'fragmentChild',
);
});

Expand Down
16 changes: 8 additions & 8 deletions src/AtomTrigger.childMode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { warningMessages, warnOnce } from './AtomTrigger.warnings';
import { getWarningMessage, type AtomTriggerWarning, warnOnce } from './AtomTrigger.warnings';
import { isDomElementLike } from './AtomTrigger.runtime';

const missingDomRefWarningDelayMs = 16;
Expand Down Expand Up @@ -61,21 +61,21 @@ export function getInvalidChildWarning(
usesChildObservation: boolean,
childCount: number,
singleChildElement: React.ReactElement | null,
): string | null {
): AtomTriggerWarning | null {
if (!usesChildObservation) {
return null;
}

if (childCount !== 1) {
return warningMessages.invalidChildCount;
return 'invalidChildCount';
}

if (!singleChildElement) {
return warningMessages.invalidChildElement;
return 'invalidChildElement';
}

if (singleChildElement.type === React.Fragment) {
return warningMessages.fragmentChild;
return 'fragmentChild';
}

return null;
Expand All @@ -89,7 +89,7 @@ export function useObservedChildNode({
}: {
originalChildRef: React.Ref<unknown> | undefined;
hasObservedChild: boolean;
invalidChildWarning: string | null;
invalidChildWarning: AtomTriggerWarning | null;
shouldWarnAboutMissingDomRef: boolean;
}): ObservedChildBinding {
const [childNode, setChildNode] = React.useState<Element | null>(null);
Expand Down Expand Up @@ -117,7 +117,7 @@ export function useObservedChildNode({

clearObservedChildNode();
if (process.env.NODE_ENV === 'development') {
warnOnce(warningMessages.nonDomChildRef);
warnOnce(getWarningMessage('nonDomChildRef'));
}
},
[clearObservedChildNode, originalChildRef],
Expand All @@ -137,7 +137,7 @@ export function useObservedChildNode({
}

if (process.env.NODE_ENV === 'development') {
warnOnce(warningMessages.unsupportedChildRef);
warnOnce(getWarningMessage('unsupportedChildRef'));
}
}, missingDomRefWarningDelayMs);

Expand Down
7 changes: 3 additions & 4 deletions src/AtomTrigger.root.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { isDomElementLike } from './AtomTrigger.runtime';
import { warningMessages, warnOnce } from './AtomTrigger.warnings';
import { getWarningMessage, type AtomTriggerWarning, warnOnce } from './AtomTrigger.warnings';

export type SchedulerTarget = Window | Element;

Expand All @@ -12,8 +12,7 @@ export type SchedulerTargetSource =
function resolveExplicitRootTarget(
source: Extract<SchedulerTargetSource, { kind: 'rootRef' | 'root' }>,
): Element | null {
const warningMessage =
source.kind === 'rootRef' ? warningMessages.invalidRootRef : warningMessages.invalidRoot;
const warning: AtomTriggerWarning = source.kind === 'rootRef' ? 'invalidRootRef' : 'invalidRoot';
const { target } = source;

if (target === null || target === undefined) {
Expand All @@ -25,7 +24,7 @@ function resolveExplicitRootTarget(
}

if (process.env.NODE_ENV === 'development') {
warnOnce(warningMessage);
warnOnce(getWarningMessage(warning));
}
return null;
}
Expand Down
1 change: 0 additions & 1 deletion src/AtomTrigger.testUtils.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export * from './testUtils/atomTriggerHarnesses';
export * from './testUtils/domEnvironment';
export * from './testUtils/hookHarnesses';
Loading
Loading