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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
},
Expand Down
10 changes: 10 additions & 0 deletions src/AtomTrigger.childMode.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AtomTrigger className="atom-trigger-sentinel">{false}</AtomTrigger>);

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<HTMLDivElement>();
Expand Down
58 changes: 34 additions & 24 deletions src/AtomTrigger.observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export type SubscriptionSnapshot = ObservationConfig & {
target: SchedulerTarget;
};

export type ObservationSubscriptionInput = Omit<ObservationConfig, 'node'> & {
disabled: boolean;
node: Element | null;
target: SchedulerTarget | null;
};

export type ObservationState = {
registration: SentinelRegistration;
subscription: SubscriptionSnapshot | null;
Expand Down Expand Up @@ -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,
Expand All @@ -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;

Expand All @@ -102,7 +122,7 @@ export function syncObservationSubscription(

if (input.disabled || !input.target) {
clearObservationSubscription(observation);
Object.assign(registration, nextConfig);
applyObservationConfig(registration, nextConfig);
resetObservationState(registration);
return;
}
Expand All @@ -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;
}
Expand Down
12 changes: 11 additions & 1 deletion src/AtomTrigger.scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down
38 changes: 25 additions & 13 deletions src/AtomTrigger.scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ type RootScheduler = {
cleanup: () => void;
};

type RootSchedulerState = Omit<RootScheduler, 'queueSample' | 'cleanup'>;

const rootSchedulers = new WeakMap<SchedulerTarget, RootScheduler>();

function createViewportRootBounds(): DOMRectReadOnly {
Expand All @@ -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<SentinelRegistration>(),
rafId: 0,
pendingSampleCause: null,
previousBaseRootBounds: null,
resizeObserver: null,
intersectionObserver: null,
queueSample: () => {},
cleanup: () => {},
};

const flushSamples = () => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
17 changes: 9 additions & 8 deletions src/AtomTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ const AtomTrigger: React.FC<AtomTriggerProps> = ({
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,
);
Expand All @@ -62,16 +63,16 @@ const AtomTrigger: React.FC<AtomTriggerProps> = ({
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) {
Expand Down Expand Up @@ -99,7 +100,7 @@ const AtomTrigger: React.FC<AtomTriggerProps> = ({
return;
}

const node = hasObservedChild ? childNode : sentinelRef.current;
const node = usesChildObservation ? childNode : sentinelRef.current;
const targetSource: SchedulerTargetSource =
rootRef !== undefined
? { kind: 'rootRef', target: trackedRootRefTarget }
Expand Down Expand Up @@ -159,7 +160,7 @@ const AtomTrigger: React.FC<AtomTriggerProps> = ({
root,
rootRef,
trackedRootRefTarget,
hasObservedChild,
usesChildObservation,
]);

React.useEffect(
Expand All @@ -174,7 +175,7 @@ const AtomTrigger: React.FC<AtomTriggerProps> = ({
[],
);

if (!hasObservedChild) {
if (!usesChildObservation) {
return <div ref={sentinelRef} style={defaultSentinelStyle} className={className} />;
}

Expand Down
Loading