diff --git a/README.md b/README.md
index 1121f5e..a260762 100644
--- a/README.md
+++ b/README.md
@@ -295,7 +295,7 @@ pnpm storybook
```
The latest Storybook build for `react-atom-trigger` is also available at
-[storybook.atomtrigger.dev](https://storybook.atomtrigger.dev/).
+[sb.atomtrigger.visiofutura.com](https://sb.atomtrigger.visiofutura.com/).
### CodeSandbox
diff --git a/package.json b/package.json
index 65e57b3..abfa8a7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "react-atom-trigger",
- "version": "2.1.1",
+ "version": "2.1.2",
"description": "Geometry-based scroll trigger for React with predictable enter/leave behavior. A modern alternative to react-waypoint and React Intersection Observer solutions.",
"keywords": [
"element visibility",
@@ -18,7 +18,7 @@
"visibility",
"waypoint"
],
- "homepage": "https://atomtrigger.dev",
+ "homepage": "https://atomtrigger.visiofutura.com",
"bugs": {
"url": "https://github.com/innrvoice/react-atom-trigger/issues"
},
diff --git a/src/AtomTrigger.childMode.test.tsx b/src/AtomTrigger.childMode.test.tsx
index 6f07132..7a64ea2 100644
--- a/src/AtomTrigger.childMode.test.tsx
+++ b/src/AtomTrigger.childMode.test.tsx
@@ -220,6 +220,16 @@ describe('AtomTrigger child mode', () => {
expect(warn.mock.calls.filter(([warning]) => warning === message)).toHaveLength(1);
});
+ it('treats boolean children as empty sentinel mode instead of invalid child mode', () => {
+ setNodeEnv('development');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const view = render({false});
+
+ expect(view.container.querySelector('.atom-trigger-sentinel')).toBeInstanceOf(HTMLDivElement);
+ expect(warn).not.toHaveBeenCalled();
+ });
+
it('cleans up child observation when a previously observed child stops exposing a DOM node', async () => {
const onEnter = vi.fn();
const rootRef = React.createRef();
diff --git a/src/AtomTrigger.observation.ts b/src/AtomTrigger.observation.ts
index 4a7204d..f386635 100644
--- a/src/AtomTrigger.observation.ts
+++ b/src/AtomTrigger.observation.ts
@@ -22,6 +22,12 @@ export type SubscriptionSnapshot = ObservationConfig & {
target: SchedulerTarget;
};
+export type ObservationSubscriptionInput = Omit & {
+ disabled: boolean;
+ node: Element | null;
+ target: SchedulerTarget | null;
+};
+
export type ObservationState = {
registration: SentinelRegistration;
subscription: SubscriptionSnapshot | null;
@@ -50,6 +56,29 @@ function clearObservationSubscription(observation: ObservationState): void {
observation.subscription = null;
}
+function applyObservationConfig(
+ registration: SentinelRegistration,
+ config: ObservationConfig,
+): void {
+ Object.assign(registration, config);
+}
+
+function isSameSubscription(
+ current: SubscriptionSnapshot | null,
+ next: SubscriptionSnapshot,
+): boolean {
+ return (
+ current !== null &&
+ current.node === next.node &&
+ current.target === next.target &&
+ current.rootMargin === next.rootMargin &&
+ current.threshold === next.threshold &&
+ current.once === next.once &&
+ current.oncePerDirection === next.oncePerDirection &&
+ current.fireOnInitialVisible === next.fireOnInitialVisible
+ );
+}
+
export function createObservationState(
config: ObservationConfig,
callbacks: ObservationCallbacks,
@@ -72,16 +101,7 @@ export function updateObservationCallbacks(
export function syncObservationSubscription(
observation: ObservationState,
- input: {
- disabled: boolean;
- node: Element | null;
- target: SchedulerTarget | null;
- rootMargin: string;
- threshold: number;
- once: boolean;
- oncePerDirection: boolean;
- fireOnInitialVisible: boolean;
- },
+ input: ObservationSubscriptionInput,
): void {
const registration = observation.registration;
@@ -102,7 +122,7 @@ export function syncObservationSubscription(
if (input.disabled || !input.target) {
clearObservationSubscription(observation);
- Object.assign(registration, nextConfig);
+ applyObservationConfig(registration, nextConfig);
resetObservationState(registration);
return;
}
@@ -112,24 +132,14 @@ export function syncObservationSubscription(
target: input.target,
};
- const subscriptionUnchanged =
- observation.subscription !== null &&
- observation.subscription.node === nextSubscription.node &&
- observation.subscription.target === nextSubscription.target &&
- observation.subscription.rootMargin === nextSubscription.rootMargin &&
- observation.subscription.threshold === nextSubscription.threshold &&
- observation.subscription.once === nextSubscription.once &&
- observation.subscription.oncePerDirection === nextSubscription.oncePerDirection &&
- observation.subscription.fireOnInitialVisible === nextSubscription.fireOnInitialVisible;
-
- if (subscriptionUnchanged) {
- Object.assign(registration, nextConfig);
+ if (isSameSubscription(observation.subscription, nextSubscription)) {
+ applyObservationConfig(registration, nextConfig);
return;
}
resetObservationState(registration);
clearObservationSubscription(observation);
- Object.assign(registration, nextConfig);
+ applyObservationConfig(registration, nextConfig);
observation.unsubscribe = registerSentinel(input.target, registration);
observation.subscription = nextSubscription;
}
diff --git a/src/AtomTrigger.scheduler.test.ts b/src/AtomTrigger.scheduler.test.ts
index e3c822e..2e25d6d 100644
--- a/src/AtomTrigger.scheduler.test.ts
+++ b/src/AtomTrigger.scheduler.test.ts
@@ -1,6 +1,10 @@
import { fireEvent } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { registerSentinel, type SentinelRegistration } from './AtomTrigger.scheduler';
+import {
+ getNextSampleCause,
+ registerSentinel,
+ type SentinelRegistration,
+} from './AtomTrigger.scheduler';
import { resolveSchedulerTarget } from './AtomTrigger.root';
import { resetObservationState } from './AtomTrigger.sampling';
import { finishDomTestRun, prepareDomTestRun, setNodeEnv, setRect } from './AtomTrigger.testUtils';
@@ -314,6 +318,12 @@ describe('AtomTrigger scheduler helpers', () => {
dispose();
});
+ it('keeps the strongest queued sample cause for the next animation frame', () => {
+ expect(getNextSampleCause('geometry-change', 'root-change')).toBe('root-change');
+ expect(getNextSampleCause('root-change', 'scroll')).toBe('scroll');
+ expect(getNextSampleCause('scroll', 'geometry-change')).toBe('scroll');
+ });
+
it('ignores a late animation frame after the last sentinel is disposed', () => {
const { getFrameIds, runFrame } = installQueuedAnimationFrames();
const onEnter = vi.fn();
diff --git a/src/AtomTrigger.scheduler.ts b/src/AtomTrigger.scheduler.ts
index 8ea62ff..7404cb2 100644
--- a/src/AtomTrigger.scheduler.ts
+++ b/src/AtomTrigger.scheduler.ts
@@ -32,6 +32,8 @@ type RootScheduler = {
cleanup: () => void;
};
+type RootSchedulerState = Omit;
+
const rootSchedulers = new WeakMap();
function createViewportRootBounds(): DOMRectReadOnly {
@@ -42,16 +44,33 @@ function isWindowTarget(target: SchedulerTarget): target is Window {
return target === window || isWindowLike(target);
}
+export function getNextSampleCause(
+ current: SampleCause | null,
+ incoming: SampleCause,
+): SampleCause {
+ if (current === null) {
+ return incoming;
+ }
+
+ if (current === 'geometry-change' && incoming !== 'geometry-change') {
+ return incoming;
+ }
+
+ if (current === 'root-change' && incoming === 'scroll') {
+ return incoming;
+ }
+
+ return current;
+}
+
function createRootScheduler(target: SchedulerTarget): RootScheduler {
- const scheduler: RootScheduler = {
+ const scheduler: RootSchedulerState = {
registrations: new Set(),
rafId: 0,
pendingSampleCause: null,
previousBaseRootBounds: null,
resizeObserver: null,
intersectionObserver: null,
- queueSample: () => {},
- cleanup: () => {},
};
const flushSamples = () => {
@@ -87,13 +106,7 @@ function createRootScheduler(target: SchedulerTarget): RootScheduler {
};
const queueSample = (cause: SampleCause = 'geometry-change') => {
- if (
- scheduler.pendingSampleCause === null ||
- (scheduler.pendingSampleCause === 'geometry-change' && cause !== 'geometry-change') ||
- (scheduler.pendingSampleCause === 'root-change' && cause === 'scroll')
- ) {
- scheduler.pendingSampleCause = cause;
- }
+ scheduler.pendingSampleCause = getNextSampleCause(scheduler.pendingSampleCause, cause);
if (scheduler.rafId !== 0) {
return;
@@ -139,8 +152,7 @@ function createRootScheduler(target: SchedulerTarget): RootScheduler {
);
}
- scheduler.queueSample = queueSample;
- scheduler.cleanup = () => {
+ const cleanup = () => {
if (scheduler.rafId !== 0) {
cancelAnimationFrame(scheduler.rafId);
scheduler.rafId = 0;
@@ -154,7 +166,7 @@ function createRootScheduler(target: SchedulerTarget): RootScheduler {
scheduler.previousBaseRootBounds = null;
};
- return scheduler;
+ return Object.assign(scheduler, { queueSample, cleanup });
}
function getOrCreateRootScheduler(target: SchedulerTarget): RootScheduler {
diff --git a/src/AtomTrigger.tsx b/src/AtomTrigger.tsx
index 1acd5ee..cc1c80b 100644
--- a/src/AtomTrigger.tsx
+++ b/src/AtomTrigger.tsx
@@ -45,12 +45,13 @@ const AtomTrigger: React.FC = ({
const normalizedRootMargin = normalizeRootMargin(rootMargin);
const normalizedThreshold = normalizeThreshold(threshold);
- const hasObservedChild = children !== null && children !== undefined;
+ const usesChildObservation =
+ children !== null && children !== undefined && typeof children !== 'boolean';
const childCount = React.Children.count(children);
const singleChildElement = childCount === 1 && React.isValidElement(children) ? children : null;
const invalidChildWarning = getInvalidChildWarning(
- hasObservedChild,
+ usesChildObservation,
childCount,
singleChildElement,
);
@@ -62,16 +63,16 @@ const AtomTrigger: React.FC = ({
const originalChildRef = getElementRef(childElementWithRef);
const { childNode, attachObservedChildRef } = useObservedChildNode({
originalChildRef,
- hasObservedChild,
+ hasObservedChild: usesChildObservation,
invalidChildWarning,
shouldWarnAboutMissingDomRef: childElementWithRef !== null,
});
React.useEffect(() => {
- if (process.env.NODE_ENV === 'development' && hasObservedChild && className) {
+ if (process.env.NODE_ENV === 'development' && usesChildObservation && className) {
warnOnce(getWarningMessage('childModeClassName'));
}
- }, [className, hasObservedChild]);
+ }, [className, usesChildObservation]);
React.useEffect(() => {
if (process.env.NODE_ENV === 'development' && invalidChildWarning) {
@@ -99,7 +100,7 @@ const AtomTrigger: React.FC = ({
return;
}
- const node = hasObservedChild ? childNode : sentinelRef.current;
+ const node = usesChildObservation ? childNode : sentinelRef.current;
const targetSource: SchedulerTargetSource =
rootRef !== undefined
? { kind: 'rootRef', target: trackedRootRefTarget }
@@ -159,7 +160,7 @@ const AtomTrigger: React.FC = ({
root,
rootRef,
trackedRootRefTarget,
- hasObservedChild,
+ usesChildObservation,
]);
React.useEffect(
@@ -174,7 +175,7 @@ const AtomTrigger: React.FC = ({
[],
);
- if (!hasObservedChild) {
+ if (!usesChildObservation) {
return ;
}