diff --git a/packages/overlayscrollbars-react/CHANGELOG.md b/packages/overlayscrollbars-react/CHANGELOG.md index 388ac0a..033807d 100644 --- a/packages/overlayscrollbars-react/CHANGELOG.md +++ b/packages/overlayscrollbars-react/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.5.0 + +### Features + +- `OverlayScrollbarsComponent` accepts now the `defer` property +- `useOverlayScrollbars` params accept now the `defer` key +- `useOverlayScrollbars` will now always try to destroy the instance if the component unmounts. + +### Breaking Changes + +- Because initialization can be deferred now, the `initialize` function of the `useOverlayScrollbars` hook isn't returning the instance anymore. Use the `instance` function of the `useOverlayScrollbars` hook instead. + ## 0.4.0 Depends on `OverlayScrollbars` version `^2.0.0` and `React` version `>=16.8.0`. @@ -7,8 +19,8 @@ The component was rewritten using `hooks`. ([#218](https://github.com/KingSora/O ### Features -- `OverlayScrollbarsComponent` has now the `events` property -- `OverlayScrollbarsComponent` has now the `element` property +- `OverlayScrollbarsComponent` accepts now the `events` property +- `OverlayScrollbarsComponent` accepts now the `element` property - The `useOverlayScrollbars` hook was added for advanced usage ### Breaking Changes diff --git a/packages/overlayscrollbars-react/README.md b/packages/overlayscrollbars-react/README.md index 8e5e75a..d481f5e 100644 --- a/packages/overlayscrollbars-react/README.md +++ b/packages/overlayscrollbars-react/README.md @@ -57,7 +57,7 @@ import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; // ... - + example content ``` @@ -70,12 +70,16 @@ Additionally it has three optional properties: `element`, `options` and `events` - `element`: accepts a `string` which represents the tag of the root element. - `options`: accepts an `object` which represents the OverlayScrollbars options. - `events`: accepts an `object` which represents the OverlayScrollbars events. +- `defer`: accepts an `boolean` or `object`. Defers the initialization to a point in time when the browser is idle. > __Note__: None of the properties has to be memoized. +> __Note__: Its **highly recommended** to use the `defer` option whenever possible to defer the initialization of the component to a browser's idle period. + ```jsx // example usage { /* ... */ } }} @@ -100,11 +104,10 @@ import { useOverlayScrollbars } from "overlayscrollbars-react"; // example usage const Component = () => { const ref = useRef(); - const [initialize, instance] = useOverlayScrollbars({ options, events }); + const [initialize, instance] = useOverlayScrollbars({ options, events, defer }); useEffect(() => { - const osInstance = initialize(ref.current); - return () => osInstance.destroy(); + initialize(ref.current); }, [initialize]); return
@@ -113,6 +116,8 @@ const Component = () => { The hook is for advanced usage and lets you control the whole initialization process. This is useful if you want to integrate it with other plugins such as `react-window` or `react-virtualized`. +The hook will destroy the instance automatically if the component unmounts. + ### Parameters Parameters are optional and similar to the `OverlayScrollbarsComponent`. @@ -120,12 +125,13 @@ Its an `object` with two optional properties: - `options`: accepts an `object` which represents the OverlayScrollbars options. - `events`: accepts an `object` which represents the OverlayScrollbars events. +- `defer`: accepts an `boolean` or `object`. Defers the initialization to a point in time when the browser is idle. ### Return The `useOverlayScrollbars` hook returns a `tuple` with two values: -- The first value is the `initialization` function, it takes one argument which is the `InitializationTarget` and returns the OverlayScrollbars instance. +- The first value is the `initialization` function, it takes one argument which is the `InitializationTarget`. - The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized. > __Note__: The identity of both functions is stable and won't change, thus they can safely be used in any dependency array. diff --git a/packages/overlayscrollbars-react/package.json b/packages/overlayscrollbars-react/package.json index beb2101..d48d902 100644 --- a/packages/overlayscrollbars-react/package.json +++ b/packages/overlayscrollbars-react/package.json @@ -1,7 +1,7 @@ { "name": "overlayscrollbars-react", "private": true, - "version": "0.4.0", + "version": "0.5.0", "description": "OverlayScrollbars for React.", "author": "Rene Haas | KingSora", "license": "MIT", diff --git a/packages/overlayscrollbars-react/src/OverlayScrollbarsComponent.tsx b/packages/overlayscrollbars-react/src/OverlayScrollbarsComponent.tsx index 54f3484..0c6b554 100644 --- a/packages/overlayscrollbars-react/src/OverlayScrollbarsComponent.tsx +++ b/packages/overlayscrollbars-react/src/OverlayScrollbarsComponent.tsx @@ -12,6 +12,8 @@ export type OverlayScrollbarsComponentProps { @@ -25,26 +27,25 @@ const OverlayScrollbarsComponent = ( props: OverlayScrollbarsComponentProps, ref: ForwardedRef> ) => { - const { element = 'div', options, events, children, ...other } = props; + const { element = 'div', options, events, defer, children, ...other } = props; const Tag = element; const elementRef = useRef>(null); const childrenRef = useRef(null); - const [initialize, osInstance] = useOverlayScrollbars({ options, events }); + const [initialize, osInstance] = useOverlayScrollbars({ options, events, defer }); useEffect(() => { const { current: elm } = elementRef; const { current: childrenElm } = childrenRef; if (elm && childrenElm) { - const instance = initialize({ + initialize({ target: elm as any, elements: { viewport: childrenElm, content: childrenElm, }, }); - - return () => instance.destroy(); } + return () => osInstance()?.destroy(); }, [initialize, element]); useImperativeHandle( diff --git a/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts b/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts index d0f780e..6f92c41 100644 --- a/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts +++ b/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts @@ -6,36 +6,88 @@ import type { OverlayScrollbarsComponentRef, } from './OverlayScrollbarsComponent'; +type Defer = [ + requestDefer: (callback: () => any, options?: OverlayScrollbarsComponentProps['defer']) => void, + cancelDefer: () => void +]; + export interface UseOverlayScrollbarsParams { /** OverlayScrollbars options. */ options?: OverlayScrollbarsComponentProps['options']; /** OverlayScrollbars events. */ events?: OverlayScrollbarsComponentProps['events']; + /** Whether to defer the initialization to a point in time when the browser is idle. (or to the next frame if `window.requestIdleCallback` is unsupported) */ + defer?: OverlayScrollbarsComponentProps['defer']; } -export type UseOverlayScrollbarsInitialization = ( - target: InitializationTarget -) => OverlayScrollbars; +export type UseOverlayScrollbarsInitialization = (target: InitializationTarget) => void; export type UseOverlayScrollbarsInstance = () => ReturnType< OverlayScrollbarsComponentRef['osInstance'] >; +const createDefer = (): Defer => { + /* c8 ignore start */ + if (typeof window === 'undefined') { + // mock ssr calls with "noop" + // eslint-disable-next-line @typescript-eslint/no-empty-function + const noop = () => {}; + return [noop, noop]; + } + /* c8 ignore end */ + + let idleId: number; + let rafId: number; + const wnd = window; + const idleSupported = typeof wnd.requestIdleCallback === 'function'; + const rAF = wnd.requestAnimationFrame; + const cAF = wnd.cancelAnimationFrame; + const rIdle = idleSupported ? wnd.requestIdleCallback : rAF; + const cIdle = idleSupported ? wnd.cancelIdleCallback : cAF; + const clear = () => { + cIdle(idleId); + cAF(rafId); + }; + + return [ + (callback, options) => { + clear(); + idleId = rIdle( + idleSupported + ? () => { + clear(); + // inside idle its best practice to use rAF to change DOM for best performance + rafId = rAF(callback); + } + : callback, + typeof options === 'object' ? options : { timeout: 1500 } + ); + }, + clear, + ]; +}; + /** * Hook for advanced usage of OverlayScrollbars. (When the OverlayScrollbarsComponent is not enough) * @param params Parameters for customization. * @returns A tuple with two values: - * The first value is the initialization function, it takes one argument which is the `InitializationTarget` and returns the OverlayScrollbars instance. + * The first value is the initialization function, it takes one argument which is the `InitializationTarget`. * The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized. */ export const useOverlayScrollbars = ( params?: UseOverlayScrollbarsParams ): [UseOverlayScrollbarsInitialization, UseOverlayScrollbarsInstance] => { - const { options, events } = params || {}; + const { options, events, defer } = params || {}; + const [requestDefer, cancelDefer] = useMemo(createDefer, []); const instanceRef = useRef>(null); + const deferRef = useRef(defer); const optionsRef = useRef(options); const eventsRef = useRef(events); + useEffect(() => { + deferRef.current = defer; + }, [defer]); + useEffect(() => { const { current: instance } = instanceRef; @@ -56,24 +108,34 @@ export const useOverlayScrollbars = ( } }, [events]); + useEffect( + () => () => { + cancelDefer(); + instanceRef.current?.destroy(); + }, + [] + ); + return useMemo<[UseOverlayScrollbarsInitialization, UseOverlayScrollbarsInstance]>( () => [ - (target: InitializationTarget): OverlayScrollbars => { - // if already initialized return the current instance + (target) => { + // if already initialized do nothing const presentInstance = instanceRef.current; if (OverlayScrollbars.valid(presentInstance)) { - return presentInstance; + return; } + const currDefer = deferRef.current; const currOptions = optionsRef.current || {}; const currEvents = eventsRef.current || {}; - const osInstance = (instanceRef.current = OverlayScrollbars( - target, - currOptions, - currEvents - )); + const init = () => + (instanceRef.current = OverlayScrollbars(target, currOptions, currEvents)); - return osInstance; + if (currDefer) { + requestDefer(init, currDefer); + } else { + init(); + } }, () => instanceRef.current, ], diff --git a/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx b/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx index 37bc55c..828d739 100644 --- a/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx +++ b/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { describe, test, afterEach, expect, vitest } from 'vitest'; +import { describe, test, afterEach, expect, vitest, vi } from 'vitest'; import { render, screen, cleanup } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { OverlayScrollbars } from 'overlayscrollbars'; @@ -7,6 +7,15 @@ import { OverlayScrollbarsComponent } from '~/overlayscrollbars-react'; import type { RefObject } from 'react'; import type { OverlayScrollbarsComponentRef } from '~/overlayscrollbars-react'; +vi.useFakeTimers({ + toFake: [ + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'requestIdleCallback', + 'cancelIdleCallback', + ], +}); + describe('OverlayScrollbarsComponent', () => { afterEach(() => cleanup()); @@ -118,6 +127,44 @@ describe('OverlayScrollbarsComponent', () => { }); }); + describe('deferred initialization', () => { + test('basic defer', () => { + const { container } = render(); + + expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeUndefined(); + + vi.advanceTimersByTime(2000); + + expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeDefined(); + }); + + test('options defer', () => { + const { container } = render(); + + expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeUndefined(); + + vi.advanceTimersByTime(2000); + + expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeDefined(); + }); + + test('defer with unsupported Idle', () => { + const original = window.requestIdleCallback; + // @ts-ignore + window.requestIdleCallback = undefined; + + const { container } = render(); + + expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeUndefined(); + + vi.advanceTimersByTime(2000); + + expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeDefined(); + + window.requestIdleCallback = original; + }); + }); + test('ref', () => { const ref: RefObject = { current: null }; const { container } = render(); diff --git a/packages/overlayscrollbars-react/test/useOverlayScrollbars.test.tsx b/packages/overlayscrollbars-react/test/useOverlayScrollbars.test.tsx index 5ab1caa..919da4d 100644 --- a/packages/overlayscrollbars-react/test/useOverlayScrollbars.test.tsx +++ b/packages/overlayscrollbars-react/test/useOverlayScrollbars.test.tsx @@ -2,8 +2,8 @@ import { useRef } from 'react'; import { describe, test, afterEach, expect } from 'vitest'; import { render, screen, cleanup } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { OverlayScrollbars } from 'overlayscrollbars'; import { useOverlayScrollbars } from '~/overlayscrollbars-react'; -import type { OverlayScrollbars } from 'overlayscrollbars'; describe('useOverlayScrollbars', () => { afterEach(() => cleanup()); @@ -13,25 +13,22 @@ describe('useOverlayScrollbars', () => { const instanceRef = useRef(null); const [initialize, instance] = useOverlayScrollbars(); return ( - <> - - + } + instanceRef.current = instance(); + expect(instanceRef.current).toBe(instance()); + }} + > + initialize + ); }; - render(); + const { unmount } = render(); const initializeBtn = screen.getByRole('button'); userEvent.click(initializeBtn); @@ -41,5 +38,11 @@ describe('useOverlayScrollbars', () => { userEvent.click(initializeBtn); expect(snapshot).toBe(initializeBtn.innerHTML); + + expect(OverlayScrollbars(initializeBtn)).toBeDefined(); + + unmount(); + + expect(OverlayScrollbars(initializeBtn)).toBeUndefined(); }); }); diff --git a/website/src/hooks/useOverlayScrollbarsIdle.ts b/website/src/hooks/useOverlayScrollbarsIdle.ts index 75d34d8..46349bb 100644 --- a/website/src/hooks/useOverlayScrollbarsIdle.ts +++ b/website/src/hooks/useOverlayScrollbarsIdle.ts @@ -8,7 +8,7 @@ import type { import type { InitializationTarget } from 'overlayscrollbars'; type Defer = [ - request: (callback: () => any, options?: IdleRequestOptions) => void, + defer: (callback: () => any, options?: IdleRequestOptions) => void, clear: () => void ]; @@ -35,8 +35,8 @@ export const useOverlayScrollbarsIdle = ( (...args: Parameters) => void, UseOverlayScrollbarsInstance ] => { - const [requestIdle, clearIdle] = useMemo(() => createDefer(true), []); - const [requestRAF, clearRAF] = useMemo(() => createDefer(), []); + const [deferIdle, clearIdle] = useMemo(() => createDefer(true), []); + const [deferRAF, clearRAF] = useMemo(() => createDefer(), []); const [initialize, instance] = useOverlayScrollbars(params); useEffect(() => { @@ -50,9 +50,9 @@ export const useOverlayScrollbarsIdle = ( return useMemo( () => [ (target: InitializationTarget) => { - requestIdle( + deferIdle( () => { - requestRAF(() => { + deferRAF(() => { initialize(target); }); },