From d9378366055f4922ca92fbf44e337a7ab04fd607 Mon Sep 17 00:00:00 2001 From: innrVoice Date: Wed, 13 May 2026 14:11:29 +0300 Subject: [PATCH] 2.1.2 --- README.md | 2 +- package.json | 4 +-- src/AtomTrigger.childMode.test.tsx | 10 ++++++ src/AtomTrigger.observation.ts | 58 +++++++++++++++++------------- src/AtomTrigger.scheduler.test.ts | 12 ++++++- src/AtomTrigger.scheduler.ts | 38 +++++++++++++------- src/AtomTrigger.tsx | 17 ++++----- 7 files changed, 92 insertions(+), 49 deletions(-) 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
; }