diff --git a/MIGRATION.md b/MIGRATION.md index 76b9b28..62e4b61 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 @@ -222,30 +224,6 @@ 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(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: @@ -253,5 +231,5 @@ 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`. diff --git a/README.md b/README.md index d06a1bb..10966ad 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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; - 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. @@ -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). diff --git a/package.json b/package.json index 80f6f9f..ff3f434 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 2cd4182..93c222f 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -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}`); + } +} diff --git a/scripts/react-compat-smoke.mjs b/scripts/react-compat-smoke.mjs index 8a14922..bd27323 100644 --- a/scripts/react-compat-smoke.mjs +++ b/scripts/react-compat-smoke.mjs @@ -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') { @@ -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(); diff --git a/src/AtomTrigger.childMode.helpers.test.ts b/src/AtomTrigger.childMode.helpers.test.ts index 4158759..b99451b 100644 --- a/src/AtomTrigger.childMode.helpers.test.ts +++ b/src/AtomTrigger.childMode.helpers.test.ts @@ -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', ); }); diff --git a/src/AtomTrigger.childMode.ts b/src/AtomTrigger.childMode.ts index a73fb1d..1f9ddd7 100644 --- a/src/AtomTrigger.childMode.ts +++ b/src/AtomTrigger.childMode.ts @@ -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; @@ -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; @@ -89,7 +89,7 @@ export function useObservedChildNode({ }: { originalChildRef: React.Ref | undefined; hasObservedChild: boolean; - invalidChildWarning: string | null; + invalidChildWarning: AtomTriggerWarning | null; shouldWarnAboutMissingDomRef: boolean; }): ObservedChildBinding { const [childNode, setChildNode] = React.useState(null); @@ -117,7 +117,7 @@ export function useObservedChildNode({ clearObservedChildNode(); if (process.env.NODE_ENV === 'development') { - warnOnce(warningMessages.nonDomChildRef); + warnOnce(getWarningMessage('nonDomChildRef')); } }, [clearObservedChildNode, originalChildRef], @@ -137,7 +137,7 @@ export function useObservedChildNode({ } if (process.env.NODE_ENV === 'development') { - warnOnce(warningMessages.unsupportedChildRef); + warnOnce(getWarningMessage('unsupportedChildRef')); } }, missingDomRefWarningDelayMs); diff --git a/src/AtomTrigger.root.ts b/src/AtomTrigger.root.ts index c39528d..c531bf3 100644 --- a/src/AtomTrigger.root.ts +++ b/src/AtomTrigger.root.ts @@ -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; @@ -12,8 +12,7 @@ export type SchedulerTargetSource = function resolveExplicitRootTarget( source: Extract, ): 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) { @@ -25,7 +24,7 @@ function resolveExplicitRootTarget( } if (process.env.NODE_ENV === 'development') { - warnOnce(warningMessage); + warnOnce(getWarningMessage(warning)); } return null; } diff --git a/src/AtomTrigger.testUtils.tsx b/src/AtomTrigger.testUtils.tsx index 94d8d8c..0596fb6 100644 --- a/src/AtomTrigger.testUtils.tsx +++ b/src/AtomTrigger.testUtils.tsx @@ -1,3 +1,2 @@ export * from './testUtils/atomTriggerHarnesses'; export * from './testUtils/domEnvironment'; -export * from './testUtils/hookHarnesses'; diff --git a/src/AtomTrigger.tsx b/src/AtomTrigger.tsx index dfb7dcc..1acd5ee 100644 --- a/src/AtomTrigger.tsx +++ b/src/AtomTrigger.tsx @@ -19,7 +19,7 @@ import { useTrackedRootRefTarget, type SchedulerTargetSource, } from './AtomTrigger.root'; -import { warningMessages, warnOnce } from './AtomTrigger.warnings'; +import { getWarningMessage, warnOnce } from './AtomTrigger.warnings'; const defaultSentinelStyle = { display: 'table' } satisfies React.CSSProperties; @@ -69,19 +69,19 @@ const AtomTrigger: React.FC = ({ React.useEffect(() => { if (process.env.NODE_ENV === 'development' && hasObservedChild && className) { - warnOnce(warningMessages.childModeClassName); + warnOnce(getWarningMessage('childModeClassName')); } }, [className, hasObservedChild]); React.useEffect(() => { if (process.env.NODE_ENV === 'development' && invalidChildWarning) { - warnOnce(invalidChildWarning); + warnOnce(getWarningMessage(invalidChildWarning)); } }, [invalidChildWarning]); React.useEffect(() => { if (process.env.NODE_ENV === 'development' && once && oncePerDirection) { - warnOnce(warningMessages.conflictingOnceModes); + warnOnce(getWarningMessage('conflictingOnceModes')); } }, [once, oncePerDirection]); diff --git a/src/AtomTrigger.warnings.ts b/src/AtomTrigger.warnings.ts index 8bbb9e9..b206a52 100644 --- a/src/AtomTrigger.warnings.ts +++ b/src/AtomTrigger.warnings.ts @@ -21,6 +21,12 @@ export const warningMessages = { '[react-atom-trigger] `rootRef.current` must resolve to a real DOM element. Observation is paused until it does.', } as const; +export type AtomTriggerWarning = keyof typeof warningMessages; + +export function getWarningMessage(warning: AtomTriggerWarning): string { + return warningMessages[warning]; +} + function getKnownNodeEnv(): 'development' | 'production' | null { if (typeof process === 'undefined' || !process.env) { return null; diff --git a/src/index.ts b/src/index.ts index 80b50ca..a9a5524 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,10 +9,3 @@ export type { TriggerPosition, TriggerType, } from './AtomTrigger.types'; -export type { - ListenerOptions, - ScrollPosition, - UseScrollPositionOptions, - ViewportSize, -} from './utils'; -export { useScrollPosition, useViewportSize } from './utils'; diff --git a/src/testUtils/hookHarnesses.tsx b/src/testUtils/hookHarnesses.tsx deleted file mode 100644 index 0b79431..0000000 --- a/src/testUtils/hookHarnesses.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { useScrollPosition, useViewportSize } from '../index'; - -export function ScrollPositionHarness() { - const [useSecondTarget, setUseSecondTarget] = React.useState(false); - const targetRef = React.useRef(null); - const position = useScrollPosition({ target: targetRef, throttleMs: 0 }); - - return ( -
- - {useSecondTarget ? ( -
- ) : ( -
- )} - - {position.x},{position.y} - -
- ); -} - -export function RemovableScrollPositionHarness() { - const [showTarget, setShowTarget] = React.useState(true); - const targetRef = React.useRef(null); - const position = useScrollPosition({ target: targetRef, throttleMs: 0 }); - - return ( -
- - {showTarget ?
: null} - - {position.x},{position.y} - -
- ); -} - -export function ToggleableScrollPositionHarness() { - const [enabled, setEnabled] = React.useState(true); - const targetRef = React.useRef(null); - const position = useScrollPosition({ target: targetRef, throttleMs: 0, enabled }); - - return ( -
- -
- - {position.x},{position.y} - -
- ); -} - -export function WindowScrollPositionHarness({ throttleMs }: { throttleMs: number }) { - const position = useScrollPosition({ throttleMs }); - - return ( - - {position.x},{position.y} - - ); -} - -export function ViewportSizeHarness({ throttleMs }: { throttleMs: number }) { - const size = useViewportSize({ throttleMs }); - - return ( - - {size.width},{size.height} - - ); -} diff --git a/src/utils.syncExternalStore.test.tsx b/src/utils.syncExternalStore.test.tsx deleted file mode 100644 index 1d7e2f1..0000000 --- a/src/utils.syncExternalStore.test.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import React from 'react'; -import { act, render } from '@testing-library/react'; -import { hydrateRoot } from 'react-dom/client'; -import { renderToString } from 'react-dom/server'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -async function mockReactWithoutUseSyncExternalStore() { - vi.resetModules(); - vi.doMock('react', async () => { - const actual = await vi.importActual('react'); - - return { - ...actual, - default: { - ...actual, - useSyncExternalStore: undefined, - }, - useSyncExternalStore: undefined, - }; - }); -} - -async function importFallbackModule() { - await mockReactWithoutUseSyncExternalStore(); - return import('./utils.syncExternalStore'); -} - -async function importFallbackUtilsModule() { - await mockReactWithoutUseSyncExternalStore(); - return import('./utils'); -} - -async function importUtilsModuleWithMockedCompatHook() { - vi.resetModules(); - vi.doMock('./utils.syncExternalStore', async () => { - const actual = await vi.importActual( - './utils.syncExternalStore', - ); - - return { - ...actual, - useCompatSyncExternalStore: function ( - _subscribe: () => () => void, - getSnapshot: () => T, - _getServerSnapshot: () => T, - ): T { - return getSnapshot(); - }, - }; - }); - return import('./utils'); -} - -async function renderToStringWithoutWindow(element: React.ReactElement): Promise { - const previousWindow = globalThis.window; - const previousDocument = globalThis.document; - const previousNavigator = globalThis.navigator; - const previousHTMLElement = globalThis.HTMLElement; - const previousElement = globalThis.Element; - const previousNode = globalThis.Node; - const previousDOMRect = globalThis.DOMRect; - - vi.stubGlobal('window', undefined); - vi.stubGlobal('document', undefined); - vi.stubGlobal('navigator', undefined); - vi.stubGlobal('HTMLElement', undefined); - vi.stubGlobal('Element', undefined); - vi.stubGlobal('Node', undefined); - vi.stubGlobal('DOMRect', undefined); - - try { - return renderToString(element); - } finally { - vi.stubGlobal('window', previousWindow); - vi.stubGlobal('document', previousDocument); - vi.stubGlobal('navigator', previousNavigator); - vi.stubGlobal('HTMLElement', previousHTMLElement); - vi.stubGlobal('Element', previousElement); - vi.stubGlobal('Node', previousNode); - vi.stubGlobal('DOMRect', previousDOMRect); - } -} - -function getHydrationMismatchCalls(errorSpy: ReturnType) { - return errorSpy.mock.calls.filter((call: unknown[]) => { - const [message] = call; - const text = String(message); - return text.includes('Hydration failed because') || text.includes("didn't match"); - }); -} - -function setWindowScroll(left: number, top: number): void { - Object.defineProperty(window, 'scrollX', { - configurable: true, - value: left, - writable: true, - }); - Object.defineProperty(window, 'scrollY', { - configurable: true, - value: top, - writable: true, - }); -} - -function setWindowSize(width: number, height: number): void { - Object.defineProperty(window, 'innerWidth', { - configurable: true, - value: width, - writable: true, - }); - Object.defineProperty(window, 'innerHeight', { - configurable: true, - value: height, - writable: true, - }); -} - -afterEach(() => { - vi.doUnmock('react'); - vi.doUnmock('./utils.syncExternalStore'); - vi.resetModules(); - vi.unstubAllGlobals(); -}); - -describe('sync external store compat helpers', () => { - it('falls back to an effect-driven subscription when React.useSyncExternalStore is unavailable', async () => { - const { useCompatSyncExternalStore } = await importFallbackModule(); - const unsubscribe = vi.fn(); - let currentValue = 1; - let notifyStoreChange: (() => void) | undefined; - - function Harness() { - const subscribe = React.useCallback((onStoreChange: () => void) => { - notifyStoreChange = onStoreChange; - return unsubscribe; - }, []); - const getSnapshot = React.useCallback(() => currentValue, []); - const getServerSnapshot = React.useCallback(() => 0, []); - const value = useCompatSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - - return {value}; - } - - const view = render(); - - expect(view.getByTestId('compat-snapshot').textContent).toBe('1'); - - act(() => { - currentValue = 2; - notifyStoreChange?.(); - }); - - expect(view.getByTestId('compat-snapshot').textContent).toBe('2'); - - view.unmount(); - - expect(unsubscribe).toHaveBeenCalledTimes(1); - }); - - it('uses the server snapshot in the fallback path when window is unavailable', async () => { - const { useCompatSyncExternalStore } = await importFallbackModule(); - - vi.stubGlobal('window', undefined); - vi.stubGlobal('document', undefined); - vi.stubGlobal('navigator', undefined); - vi.stubGlobal('HTMLElement', undefined); - vi.stubGlobal('Element', undefined); - vi.stubGlobal('Node', undefined); - vi.stubGlobal('DOMRect', undefined); - - function Harness() { - const subscribe = React.useCallback(() => () => {}, []); - const getSnapshot = React.useCallback(() => 1, []); - const getServerSnapshot = React.useCallback(() => 0, []); - const value = useCompatSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - - return {value}; - } - - expect(renderToString()).toContain('0'); - }); - - it('uses the server snapshot for the first hydrated client render before refreshing live data', async () => { - const { useCompatSyncExternalStore } = await importFallbackModule(); - const unsubscribe = vi.fn(); - let currentValue = 7; - - function Harness() { - const subscribe = React.useCallback(() => unsubscribe, []); - const getSnapshot = React.useCallback(() => currentValue, []); - const getServerSnapshot = React.useCallback(() => 0, []); - const value = useCompatSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - - return {value}; - } - - const serverMarkup = await renderToStringWithoutWindow(); - const container = document.createElement('div'); - document.body.appendChild(container); - container.innerHTML = serverMarkup; - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - const root = hydrateRoot(container, ); - - expect(container.innerHTML).toBe(serverMarkup); - - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).toBe('7'); - expect(error).not.toHaveBeenCalled(); - - root.unmount(); - container.remove(); - expect(unsubscribe).toHaveBeenCalledTimes(1); - }); - - it('skips fallback updates when the notified snapshot is unchanged', async () => { - const { useCompatSyncExternalStore } = await importFallbackModule(); - const sharedSnapshot = { value: 1 }; - let notifyStoreChange: (() => void) | undefined; - let renderCount = 0; - - function Harness() { - renderCount += 1; - const subscribe = React.useCallback((onStoreChange: () => void) => { - notifyStoreChange = onStoreChange; - return () => {}; - }, []); - const getSnapshot = React.useCallback(() => sharedSnapshot, []); - const getServerSnapshot = React.useCallback(() => sharedSnapshot, []); - const value = useCompatSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - - return {value.value}; - } - - const view = render(); - - expect(view.getByTestId('stable-fallback-snapshot').textContent).toBe('1'); - expect(renderCount).toBe(1); - - act(() => { - notifyStoreChange?.(); - }); - - expect(renderCount).toBe(1); - }); - - it('hydrates useScrollPosition in the fallback path without a server/client mismatch', async () => { - const { useScrollPosition } = await importFallbackUtilsModule(); - - function Harness() { - const position = useScrollPosition(); - return ( - - {position.x},{position.y} - - ); - } - - const serverMarkup = await renderToStringWithoutWindow(); - const container = document.createElement('div'); - document.body.appendChild(container); - container.innerHTML = serverMarkup; - setWindowScroll(12, 34); - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - const root = hydrateRoot(container, ); - - expect(container.innerHTML).toBe(serverMarkup); - - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).toBe('12,34'); - expect(getHydrationMismatchCalls(error)).toHaveLength(0); - - root.unmount(); - container.remove(); - }); - - it('returns the zero viewport snapshot when the live viewport snapshot runs without window', async () => { - const { useViewportSize } = await importUtilsModuleWithMockedCompatHook(); - - function Harness() { - const size = useViewportSize(); - return ( - - {size.width},{size.height} - - ); - } - - expect((await renderToStringWithoutWindow()).replaceAll('', '')).toContain( - '0,0', - ); - }); - - it('hydrates useViewportSize in the fallback path without a server/client mismatch', async () => { - const { useViewportSize } = await importFallbackUtilsModule(); - - function Harness() { - const size = useViewportSize(); - return ( - - {size.width},{size.height} - - ); - } - - const serverMarkup = await renderToStringWithoutWindow(); - const container = document.createElement('div'); - document.body.appendChild(container); - container.innerHTML = serverMarkup; - setWindowSize(1440, 900); - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - const root = hydrateRoot(container, ); - - expect(container.innerHTML).toBe(serverMarkup); - - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).toBe('1440,900'); - expect(getHydrationMismatchCalls(error)).toHaveLength(0); - - root.unmount(); - container.remove(); - }); - - it('reuses the previous snapshot object when the next value is equal', async () => { - const { getStableSnapshot } = await import('./utils.syncExternalStore'); - const firstValue = { width: 10, height: 20 }; - const ref = { current: firstValue }; - - const equalValue = getStableSnapshot( - ref, - { width: 10, height: 20 }, - (current, next) => current.width === next.width && current.height === next.height, - ); - - expect(equalValue).toBe(firstValue); - - const nextValue = getStableSnapshot( - ref, - { width: 20, height: 30 }, - (current, next) => current.width === next.width && current.height === next.height, - ); - - expect(nextValue).toEqual({ width: 20, height: 30 }); - expect(ref.current).toBe(nextValue); - }); -}); diff --git a/src/utils.syncExternalStore.ts b/src/utils.syncExternalStore.ts deleted file mode 100644 index 1d90fe3..0000000 --- a/src/utils.syncExternalStore.ts +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; - -type Subscribe = (onStoreChange: () => void) => () => void; -type GetSnapshot = () => T; -type UseSyncExternalStoreHook = ( - subscribe: Subscribe, - getSnapshot: GetSnapshot, - getServerSnapshot: GetSnapshot, -) => T; - -export function useCompatSyncExternalStore( - subscribe: Subscribe, - getSnapshot: GetSnapshot, - getServerSnapshot: GetSnapshot, -): T { - const reactUseSyncExternalStore = ( - React as typeof React & { useSyncExternalStore?: UseSyncExternalStoreHook } - ).useSyncExternalStore; - - if (reactUseSyncExternalStore) { - return reactUseSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - } - - const [snapshot, setSnapshot] = React.useState(() => getServerSnapshot()); - const snapshotRef = React.useRef(snapshot); - const useClientSubscriptionEffect = - typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; - - useClientSubscriptionEffect(() => { - const updateSnapshot = () => { - const nextSnapshot = getSnapshot(); - if (Object.is(snapshotRef.current, nextSnapshot)) { - return; - } - - snapshotRef.current = nextSnapshot; - setSnapshot(nextSnapshot); - }; - - updateSnapshot(); - return subscribe(updateSnapshot); - }, [subscribe, getSnapshot]); - - return snapshot; -} - -export function getStableSnapshot( - ref: React.MutableRefObject, - nextSnapshot: T, - isEqual: (current: T, next: T) => boolean, -): T { - if (isEqual(ref.current, nextSnapshot)) { - return ref.current; - } - - ref.current = nextSnapshot; - return nextSnapshot; -} diff --git a/src/utils.test.tsx b/src/utils.test.tsx deleted file mode 100644 index edc4c8e..0000000 --- a/src/utils.test.tsx +++ /dev/null @@ -1,480 +0,0 @@ -import React from 'react'; -import { act, fireEvent, render } from '@testing-library/react'; -import { hydrateRoot } from 'react-dom/client'; -import { renderToString } from 'react-dom/server'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useScrollPosition, useViewportSize } from './index'; -import { - finishDomTestRun, - prepareDomTestRun, - RemovableScrollPositionHarness, - ScrollPositionHarness, - setElementScroll, - setWindowScroll, - setWindowSize, - ToggleableScrollPositionHarness, - ViewportSizeHarness, - WindowScrollPositionHarness, -} from './AtomTrigger.testUtils'; - -function ElementScrollPositionHarness({ - target, - throttleMs, -}: { - target: HTMLElement; - throttleMs: number; -}) { - const position = useScrollPosition({ target, throttleMs }); - - return ( - - {position.x},{position.y} - - ); -} - -function DisabledViewportSizeHarness({ - enabled, - throttleMs, -}: { - enabled: boolean; - throttleMs: number; -}) { - const size = useViewportSize({ enabled, throttleMs }); - - return ( - - {size.width},{size.height} - - ); -} - -function ToggleableViewportSizeHarness() { - const [enabled, setEnabled] = React.useState(true); - const size = useViewportSize({ enabled, throttleMs: 0 }); - - return ( -
- - - {size.width},{size.height} - -
- ); -} - -beforeEach(() => { - prepareDomTestRun(); -}); - -afterEach(() => { - finishDomTestRun(); -}); - -async function renderToStringWithoutWindow(element: React.ReactElement): Promise { - const previousWindow = globalThis.window; - const previousDocument = globalThis.document; - const previousNavigator = globalThis.navigator; - const previousHTMLElement = globalThis.HTMLElement; - const previousElement = globalThis.Element; - const previousNode = globalThis.Node; - const previousDOMRect = globalThis.DOMRect; - - vi.stubGlobal('window', undefined); - vi.stubGlobal('document', undefined); - vi.stubGlobal('navigator', undefined); - vi.stubGlobal('HTMLElement', undefined); - vi.stubGlobal('Element', undefined); - vi.stubGlobal('Node', undefined); - vi.stubGlobal('DOMRect', undefined); - - try { - return renderToString(element); - } finally { - vi.stubGlobal('window', previousWindow); - vi.stubGlobal('document', previousDocument); - vi.stubGlobal('navigator', previousNavigator); - vi.stubGlobal('HTMLElement', previousHTMLElement); - vi.stubGlobal('Element', previousElement); - vi.stubGlobal('Node', previousNode); - vi.stubGlobal('DOMRect', previousDOMRect); - } -} - -describe('utility hooks', () => { - it('hydrates useScrollPosition without a server/client text mismatch', async () => { - function HydratedScrollHarness() { - const position = useScrollPosition(); - return ( - - {position.x},{position.y} - - ); - } - - const serverMarkup = await renderToStringWithoutWindow(); - const container = document.createElement('div'); - document.body.appendChild(container); - container.innerHTML = serverMarkup; - setWindowScroll(12, 34); - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - const root = hydrateRoot(container, ); - - expect(container.innerHTML).toBe(serverMarkup); - - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).toBe('12,34'); - expect(error).not.toHaveBeenCalled(); - - root.unmount(); - container.remove(); - }); - - it('hydrates useViewportSize without a server/client text mismatch', async () => { - function HydratedViewportHarness() { - const size = useViewportSize(); - return ( - - {size.width},{size.height} - - ); - } - - const serverMarkup = await renderToStringWithoutWindow(); - const container = document.createElement('div'); - document.body.appendChild(container); - container.innerHTML = serverMarkup; - setWindowSize(1440, 900); - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - const root = hydrateRoot(container, ); - - expect(container.innerHTML).toBe(serverMarkup); - - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).toBe('1440,900'); - expect(error).not.toHaveBeenCalled(); - - root.unmount(); - container.remove(); - }); - - it('rebinds useScrollPosition when a ref target swaps to a different element', () => { - vi.useFakeTimers(); - - const view = render(); - const position = view.getByTestId('scroll-position'); - const firstTarget = view.getByTestId('scroll-target-1'); - - setElementScroll(firstTarget, 3, 12); - fireEvent.scroll(firstTarget); - - act(() => { - vi.runAllTimers(); - }); - - expect(position.textContent).toBe('3,12'); - - fireEvent.click(view.getByRole('button', { name: 'swap target' })); - - act(() => { - vi.runAllTimers(); - }); - - const secondTarget = view.getByTestId('scroll-target-2'); - const positionAfterSwap = position.textContent; - setElementScroll(firstTarget, 9, 30); - fireEvent.scroll(firstTarget); - - act(() => { - vi.runAllTimers(); - }); - - expect(position.textContent).toBe(positionAfterSwap); - - setElementScroll(secondTarget, 7, 44); - fireEvent.scroll(secondTarget); - - act(() => { - vi.runAllTimers(); - }); - - expect(position.textContent).toBe('7,44'); - }); - - it('tracks scroll position for an explicit HTMLElement target', () => { - const target = document.createElement('div'); - setElementScroll(target, 2, 8); - - const view = render(); - const position = view.getByTestId('element-scroll-position'); - - expect(position.textContent).toBe('2,8'); - - setElementScroll(target, 6, 18); - fireEvent.scroll(target); - - expect(position.textContent).toBe('6,18'); - }); - - it('resets useScrollPosition to 0,0 when a ref target disappears', () => { - vi.useFakeTimers(); - - const view = render(); - const position = view.getByTestId('removable-scroll-position'); - const target = view.getByTestId('removable-scroll-target'); - - setElementScroll(target, 4, 21); - fireEvent.scroll(target); - - act(() => { - vi.runAllTimers(); - }); - - expect(position.textContent).toBe('4,21'); - - fireEvent.click(view.getByRole('button', { name: 'remove target' })); - - act(() => { - vi.runAllTimers(); - }); - - expect(position.textContent).toBe('0,0'); - }); - - it('keeps the latest scroll snapshot when disabled and refreshes it after re-enabling', () => { - vi.useFakeTimers(); - - const view = render(); - const position = view.getByTestId('toggleable-scroll-position'); - const target = view.getByTestId('toggleable-scroll-target'); - - setElementScroll(target, 6, 24); - fireEvent.scroll(target); - - act(() => { - vi.runAllTimers(); - }); - - expect(position.textContent).toBe('6,24'); - - fireEvent.click(view.getByRole('button', { name: 'disable scroll position' })); - - act(() => { - vi.runAllTimers(); - }); - - expect(position.textContent).toBe('6,24'); - - setElementScroll(target, 9, 40); - fireEvent.scroll(target); - - act(() => { - vi.runAllTimers(); - }); - - expect(position.textContent).toBe('6,24'); - - fireEvent.click(view.getByRole('button', { name: 'enable scroll position' })); - - act(() => { - vi.runAllTimers(); - }); - - expect(position.textContent).toBe('9,40'); - }); - - it('throttles window scroll updates with leading and trailing behavior', () => { - vi.useFakeTimers(); - - const view = render(); - const position = view.getByTestId('window-scroll-position'); - - expect(position.textContent).toBe('0,0'); - - act(() => { - setWindowScroll(5, 10); - fireEvent.scroll(window); - }); - - expect(position.textContent).toBe('5,10'); - - act(() => { - vi.advanceTimersByTime(20); - setWindowScroll(9, 30); - fireEvent.scroll(window); - }); - - expect(position.textContent).toBe('5,10'); - - act(() => { - vi.advanceTimersByTime(20); - setWindowScroll(11, 40); - fireEvent.scroll(window); - }); - - expect(position.textContent).toBe('5,10'); - - act(() => { - vi.advanceTimersByTime(60); - }); - - expect(position.textContent).toBe('11,40'); - }); - - it('ignores window scroll notifications when the position did not change', () => { - const view = render(); - const position = view.getByTestId('window-scroll-position'); - - act(() => { - setWindowScroll(5, 10); - fireEvent.scroll(window); - }); - - expect(position.textContent).toBe('5,10'); - - act(() => { - fireEvent.scroll(window); - }); - - expect(position.textContent).toBe('5,10'); - }); - - it('throttles viewport resize updates with leading and trailing behavior', () => { - vi.useFakeTimers(); - - const view = render(); - const size = view.getByTestId('viewport-size'); - - expect(size.textContent).toBe('1024,768'); - - act(() => { - setWindowSize(1100, 768); - fireEvent.resize(window); - }); - - expect(size.textContent).toBe('1100,768'); - - act(() => { - vi.advanceTimersByTime(20); - setWindowSize(1200, 768); - fireEvent.resize(window); - }); - - expect(size.textContent).toBe('1100,768'); - - act(() => { - vi.advanceTimersByTime(20); - setWindowSize(1300, 768); - fireEvent.resize(window); - }); - - expect(size.textContent).toBe('1100,768'); - - act(() => { - vi.advanceTimersByTime(60); - }); - - expect(size.textContent).toBe('1300,768'); - }); - - it('ignores viewport resize notifications when the size did not change', () => { - const view = render(); - const size = view.getByTestId('viewport-size'); - - expect(size.textContent).toBe('1024,768'); - - act(() => { - fireEvent.resize(window); - }); - - expect(size.textContent).toBe('1024,768'); - }); - - it('cancels a pending throttled scroll update when the listener unmounts', () => { - vi.useFakeTimers(); - const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); - const target = document.createElement('div'); - setElementScroll(target, 0, 0); - - const view = render(); - - act(() => { - setElementScroll(target, 5, 10); - fireEvent.scroll(target); - }); - - act(() => { - vi.advanceTimersByTime(20); - setElementScroll(target, 8, 20); - fireEvent.scroll(target); - }); - - view.unmount(); - - expect(clearTimeoutSpy).toHaveBeenCalled(); - }); - - it('keeps viewport size fixed when the hook starts disabled', () => { - vi.useFakeTimers(); - - const view = render(); - const size = view.getByTestId('disabled-viewport-size'); - - expect(size.textContent).toBe('1024,768'); - - act(() => { - setWindowSize(1400, 900); - fireEvent.resize(window); - vi.runAllTimers(); - }); - - expect(size.textContent).toBe('1024,768'); - }); - - it('keeps the latest viewport snapshot when disabled and refreshes it after re-enabling', () => { - vi.useFakeTimers(); - - const view = render(); - const size = view.getByTestId('toggleable-viewport-size'); - - expect(size.textContent).toBe('1024,768'); - - act(() => { - setWindowSize(1200, 800); - fireEvent.resize(window); - vi.runAllTimers(); - }); - - expect(size.textContent).toBe('1200,800'); - - fireEvent.click(view.getByRole('button', { name: 'disable viewport size' })); - - act(() => { - vi.runAllTimers(); - }); - - act(() => { - setWindowSize(1400, 900); - fireEvent.resize(window); - vi.runAllTimers(); - }); - - expect(size.textContent).toBe('1200,800'); - - fireEvent.click(view.getByRole('button', { name: 'enable viewport size' })); - - act(() => { - vi.runAllTimers(); - }); - - expect(size.textContent).toBe('1400,900'); - }); -}); diff --git a/src/utils.tsx b/src/utils.tsx deleted file mode 100644 index 73b0313..0000000 --- a/src/utils.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import React from 'react'; -import { isWindowLike } from './AtomTrigger.runtime'; -import { getStableSnapshot, useCompatSyncExternalStore } from './utils.syncExternalStore'; - -export type ScrollPosition = { - x: number; - y: number; -}; - -export type ViewportSize = { - width: number; - height: number; -}; - -export type ListenerOptions = { - passive?: boolean; - throttleMs?: number; - enabled?: boolean; -}; - -export type UseScrollPositionOptions = ListenerOptions & { - target?: Window | HTMLElement | React.RefObject; -}; - -const zeroScrollPosition: ScrollPosition = { x: 0, y: 0 }; -const zeroViewportSize: ViewportSize = { width: 0, height: 0 }; - -type ThrottleController = { - schedule: () => void; - cancel: () => void; -}; - -function createThrottle(callback: () => void, wait: number): ThrottleController { - let timeoutId: ReturnType | null = null; - let lastInvocationTime: number | null = null; - - const invoke = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - - lastInvocationTime = Date.now(); - callback(); - }; - - const schedule = () => { - if (wait <= 0) { - invoke(); - return; - } - - const now = Date.now(); - if (lastInvocationTime === null || now - lastInvocationTime >= wait) { - invoke(); - return; - } - - if (!timeoutId) { - timeoutId = setTimeout( - () => { - invoke(); - }, - wait - (now - lastInvocationTime), - ); - } - }; - - const cancel = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - }; - - return { schedule, cancel }; -} - -function getViewportSize(): ViewportSize { - if (typeof window === 'undefined') { - return zeroViewportSize; - } - - return { - width: window.innerWidth, - height: window.innerHeight, - }; -} - -function isRefBasedTarget( - target: UseScrollPositionOptions['target'], -): target is React.RefObject { - return Boolean( - target && typeof target === 'object' && !isWindowLike(target) && 'current' in target, - ); -} - -function getScrollTarget(target: UseScrollPositionOptions['target']): Window | HTMLElement | null { - if (isRefBasedTarget(target)) { - return target.current; - } - - if (target) { - return target; - } - - if (typeof window === 'undefined') { - return null; - } - - return window; -} - -function isWindowTarget(target: Window | HTMLElement): target is Window { - return target === window || isWindowLike(target); -} - -function getTargetScrollPosition(target: Window | HTMLElement): ScrollPosition { - if (isWindowTarget(target)) { - return { - x: target.scrollX, - y: target.scrollY, - }; - } - - const elementTarget = target as HTMLElement; - return { - x: elementTarget.scrollLeft, - y: elementTarget.scrollTop, - }; -} - -function getInitialScrollPosition(target: UseScrollPositionOptions['target']): ScrollPosition { - const initialTarget = getScrollTarget(target); - - if (!initialTarget) { - return zeroScrollPosition; - } - - return getTargetScrollPosition(initialTarget); -} - -function areScrollPositionsEqual(current: ScrollPosition, next: ScrollPosition): boolean { - return current.x === next.x && current.y === next.y; -} - -function areViewportSizesEqual(current: ViewportSize, next: ViewportSize): boolean { - return current.width === next.width && current.height === next.height; -} - -export function useViewportSize(options?: ListenerOptions): ViewportSize { - const enabled = options?.enabled !== false; - const passive = options?.passive; - const throttleMs = options?.throttleMs ?? 16; - const lastSnapshotRef = React.useRef(getViewportSize()); - - const getSnapshot = React.useCallback(() => { - if (typeof window === 'undefined') { - return zeroViewportSize; - } - - if (!enabled) { - return lastSnapshotRef.current; - } - - return getStableSnapshot(lastSnapshotRef, getViewportSize(), areViewportSizesEqual); - }, [enabled]); - - const subscribe = React.useCallback( - (onStoreChange: () => void) => { - if (typeof window === 'undefined' || !enabled) { - return () => {}; - } - - const throttledResize = createThrottle(() => { - const nextSize = getViewportSize(); - if (areViewportSizesEqual(lastSnapshotRef.current, nextSize)) { - return; - } - - lastSnapshotRef.current = nextSize; - onStoreChange(); - }, throttleMs); - - window.addEventListener('resize', throttledResize.schedule, { - passive, - }); - - return () => { - throttledResize.cancel(); - window.removeEventListener('resize', throttledResize.schedule); - }; - }, - [enabled, passive, throttleMs], - ); - - return useCompatSyncExternalStore(subscribe, getSnapshot, () => zeroViewportSize); -} - -export function useScrollPosition(options?: UseScrollPositionOptions): ScrollPosition { - const target = options?.target; - const targetUsesRef = isRefBasedTarget(target); - const enabled = options?.enabled !== false; - const passive = options?.passive; - const throttleMs = options?.throttleMs ?? 16; - const lastSnapshotRef = React.useRef(getInitialScrollPosition(target)); - const [trackedRefTarget, setTrackedRefTarget] = React.useState(() => - targetUsesRef ? getScrollTarget(target) : null, - ); - const useRefTargetEffect = - typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect; - - useRefTargetEffect(() => { - if (!targetUsesRef) { - return; - } - - // target.current can swap during commit without changing the ref object itself. - // Mirror it after each commit so subscriptions rebind to the live scroll container. - const nextTarget = getScrollTarget(target); - setTrackedRefTarget(currentTarget => - currentTarget === nextTarget ? currentTarget : nextTarget, - ); - }); - - const scrollTarget = targetUsesRef ? trackedRefTarget : getScrollTarget(target); - - const getSnapshot = React.useCallback(() => { - if (!enabled) { - return lastSnapshotRef.current; - } - - if (!scrollTarget) { - return getStableSnapshot(lastSnapshotRef, zeroScrollPosition, areScrollPositionsEqual); - } - - return getStableSnapshot( - lastSnapshotRef, - getTargetScrollPosition(scrollTarget), - areScrollPositionsEqual, - ); - }, [enabled, scrollTarget]); - - const subscribe = React.useCallback( - (onStoreChange: () => void) => { - if (!enabled || !scrollTarget) { - return () => {}; - } - - const throttledScroll = createThrottle(() => { - const nextPosition = getTargetScrollPosition(scrollTarget); - if (areScrollPositionsEqual(lastSnapshotRef.current, nextPosition)) { - return; - } - - lastSnapshotRef.current = nextPosition; - onStoreChange(); - }, throttleMs); - - scrollTarget.addEventListener('scroll', throttledScroll.schedule, { - passive, - }); - - return () => { - throttledScroll.cancel(); - scrollTarget.removeEventListener('scroll', throttledScroll.schedule); - }; - }, - [enabled, passive, scrollTarget, throttleMs], - ); - - return useCompatSyncExternalStore(subscribe, getSnapshot, () => zeroScrollPosition); -}