diff --git a/README.md b/README.md index 1ab798b5..854aad8b 100644 --- a/README.md +++ b/README.md @@ -396,3 +396,6 @@ const App = () => { ); }; ``` + +## Known issues +If you are using React 18 + `StrictMode`, `rxjs-hooks` will not work properly. Because in React 18, `StrictMode` will force unmount hooks to trigger twice, which will result in unexpected behaviours. diff --git a/package.json b/package.json index a4f3cefd..6a72d3e4 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,12 @@ "devDependencies": { "@types/jest": "^27.0.1", "@types/lodash": "^4.14.149", + "@types/react": "^18.0.12", "@types/react-dom": "^18.0.0", "@types/react-test-renderer": "^18.0.0", "@types/sinon": "^10.0.0", "@types/sinon-chai": "^3.2.3", + "@types/use-sync-external-store": "^0.0.3", "@typescript-eslint/eslint-plugin": "^4.19.0", "@typescript-eslint/parser": "^4.19.0", "@vitejs/plugin-react": "^1.3.2", @@ -43,9 +45,9 @@ "jest": "^27.0.4", "lint-staged": "^13.0.0", "prettier": "^2.0.1", - "react": "17.0.2", - "react-dom": "17.0.2", - "react-test-renderer": "17.0.2", + "react": "18.1.0", + "react-dom": "18.1.0", + "react-test-renderer": "18.1.0", "rxjs": "^7.0.0", "sinon": "^14.0.0", "standard": "^17.0.0", @@ -55,11 +57,12 @@ }, "dependencies": { "tslib": "^2.1.0", - "use-constant": "^1.0.0" + "use-constant": "^1.0.0", + "use-sync-external-store": "^1.1.0" }, "peerDependencies": { - "react": "17.0.2", - "rxjs": "^7.0.0" + "react": ">=16.8.0", + "rxjs": ">=7.0.0" }, "lint-staged": { "*.js": [ diff --git a/playground/index.tsx b/playground/index.tsx index c9701979..6f967ee2 100644 --- a/playground/index.tsx +++ b/playground/index.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import ReactDOM from 'react-dom' +import ReactDOM from 'react-dom/client' import { interval, Observable, timer } from 'rxjs' import { exhaustMap, map, scan, switchMap } from 'rxjs/operators' @@ -43,4 +43,4 @@ function App() { ) } -ReactDOM.render(, document.querySelector('#app')) +ReactDOM.createRoot(document.querySelector('#app')!).render() diff --git a/src/__test__/use-event-callback.spec.tsx b/src/__test__/use-event-callback.spec.tsx index e8d1ef0b..340e8fb4 100644 --- a/src/__test__/use-event-callback.spec.tsx +++ b/src/__test__/use-event-callback.spec.tsx @@ -8,7 +8,7 @@ import { find } from './find' import { useEventCallback } from '../use-event-callback' describe('useEventCallback specs', () => { - function createFixture( + function createFixture( factory: (event$: Observable>) => Observable, initialValue?: T, ) { diff --git a/src/use-event-callback.ts b/src/use-event-callback.ts index 4959eb7d..59653a3a 100644 --- a/src/use-event-callback.ts +++ b/src/use-event-callback.ts @@ -1,6 +1,8 @@ -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useCallback, useMemo } from 'react' import useConstant from 'use-constant' import { Observable, BehaviorSubject, Subject } from 'rxjs' +import { tap } from 'rxjs/operators' +import { useSyncExternalStore } from 'use-sync-external-store/shim' import { RestrictArray, VoidAsNull, Not } from './type' @@ -44,42 +46,46 @@ export function useEventCallback( inputs?: RestrictArray, ): ReturnedState { const initialValue = (typeof initialState !== 'undefined' ? initialState : null) as VoidAsNull - const [state, setState] = useState(initialValue) const event$ = useConstant(() => new Subject()) const state$ = useConstant(() => new BehaviorSubject(initialValue)) const inputs$ = useConstant( () => new BehaviorSubject | null>(typeof inputs === 'undefined' ? null : inputs), ) - function eventCallback(e: EventValue) { - return event$.next(e) - } - const returnedCallback = useCallback(eventCallback, []) + useEffect(() => { + return () => { + state$.complete() + inputs$.complete() + event$.complete() + } + }, []) + + const returnedCallback = useCallback(function eventCallback(e: EventValue) { + event$.next(e) + }, []) useEffect(() => { inputs$.next(inputs!) }, inputs || []) - useEffect(() => { - setState(initialValue) + const subscribe = useMemo(() => { let value$: Observable - if (!inputs) { value$ = (callback as EventCallback)(event$, state$ as Observable) } else { value$ = (callback as any)(event$, state$ as Observable, inputs$ as Observable) } - const subscription = value$.subscribe((value) => { - state$.next(value) - setState(value as VoidAsNull) - }) - return () => { - subscription.unsubscribe() - state$.complete() - inputs$.complete() - event$.complete() + return (onStorageChange: () => void) => { + const subscription = value$.pipe(tap((s) => state$.next(s))).subscribe(onStorageChange) + return () => subscription.unsubscribe() } - }, []) // immutable forever + }, []) + + const getSnapShot = useMemo(() => { + return () => state$.getValue() as VoidAsNull + }, []) + + const state = useSyncExternalStore(subscribe, getSnapShot, getSnapShot) return [returnedCallback as VoidableEventCallback, state] } diff --git a/src/use-observable.ts b/src/use-observable.ts index 76826f50..f3dd872c 100644 --- a/src/use-observable.ts +++ b/src/use-observable.ts @@ -1,6 +1,8 @@ import { Observable, BehaviorSubject } from 'rxjs' -import { useState, useEffect } from 'react' +import { tap } from 'rxjs/operators' +import { useEffect, useMemo } from 'react' import useConstant from 'use-constant' +import { useSyncExternalStore } from 'use-sync-external-store/shim' import { RestrictArray } from './type' @@ -22,39 +24,41 @@ export function useObservable>( initialState?: State, inputs?: RestrictArray, ): State | null { - const [state, setState] = useState(typeof initialState !== 'undefined' ? initialState : null) - const state$ = useConstant(() => new BehaviorSubject(initialState)) const inputs$ = useConstant(() => new BehaviorSubject | undefined>(inputs)) + useEffect(() => { + return () => { + state$.complete() + inputs$.complete() + } + }, []) + useEffect(() => { inputs$.next(inputs) }, inputs || []) - useEffect(() => { - let output$: BehaviorSubject + const subscribe = useMemo(() => { + let output$: Observable if (inputs) { output$ = ( inputFactory as ( state$: Observable, inputs$: Observable | undefined>, ) => Observable - )(state$, inputs$) as BehaviorSubject + )(state$, inputs$) } else { - output$ = (inputFactory as unknown as (state$: Observable) => Observable)( - state$, - ) as BehaviorSubject + output$ = (inputFactory as unknown as (state$: Observable) => Observable)(state$) } - const subscription = output$.subscribe((value) => { - state$.next(value) - setState(value) - }) - return () => { - subscription.unsubscribe() - inputs$.complete() - state$.complete() + return (onStorageChange: () => void) => { + const subscription = output$.pipe(tap((s) => state$.next(s))).subscribe(onStorageChange) + return () => subscription.unsubscribe() } - }, []) // immutable forever + }, []) + + const getSnapShot = useMemo(() => { + return () => state$.getValue() ?? null + }, []) - return state + return useSyncExternalStore(subscribe, getSnapShot, getSnapShot) } diff --git a/yarn.lock b/yarn.lock index 183f1c16..f02ee2d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -800,10 +800,10 @@ dependencies: "@types/react" "*" -"@types/react@*": - version "17.0.3" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.3.tgz#ba6e215368501ac3826951eef2904574c262cc79" - integrity sha512-wYOUxIgs2HZZ0ACNiIayItyluADNbONl7kt8lkLjVK8IitMH5QMyAh75Fwhmo37r1m7L2JaFj03sIfxBVDvRAg== +"@types/react@*", "@types/react@^18.0.12": + version "18.0.12" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.12.tgz#cdaa209d0a542b3fcf69cf31a03976ec4cdd8840" + integrity sha512-duF1OTASSBQtcigUvhuiTB1Ya3OvSy+xORCiEf20H0P0lzx+/KeVsA99U5UjLXSbyo1DRJDlLKqTeM1ngosqtg== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -839,6 +839,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw== +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + "@types/yargs-parser@*": version "20.2.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9" @@ -3911,55 +3916,57 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -react-dom@17.0.2: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" - integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== +react-dom@18.1.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.1.0.tgz#7f6dd84b706408adde05e1df575b3a024d7e8a2f" + integrity sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" - scheduler "^0.20.2" + scheduler "^0.22.0" -"react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1, react-is@^17.0.2: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" - integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +"react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.1.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67" + integrity sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg== react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-refresh@^0.13.0: version "0.13.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.13.0.tgz#cbd01a4482a177a5da8d44c9755ebb1f26d5a1c1" integrity sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg== -react-shallow-renderer@^16.13.1: - version "16.14.1" - resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.14.1.tgz#bf0d02df8a519a558fd9b8215442efa5c840e124" - integrity sha512-rkIMcQi01/+kxiTE9D3fdS959U1g7gs+/rborw++42m1O9FAQiNI/UNRZExVUoAOprn4umcXf+pFRou8i4zuBg== +react-shallow-renderer@^16.15.0: + version "16.15.0" + resolved "https://registry.yarnpkg.com/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz#48fb2cf9b23d23cde96708fe5273a7d3446f4457" + integrity sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA== dependencies: object-assign "^4.1.1" - react-is "^16.12.0 || ^17.0.0" + react-is "^16.12.0 || ^17.0.0 || ^18.0.0" -react-test-renderer@17.0.2: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-17.0.2.tgz#4cd4ae5ef1ad5670fc0ef776e8cc7e1231d9866c" - integrity sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ== +react-test-renderer@18.1.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-18.1.0.tgz#35b75754834cf9ab517b6813db94aee0a6b545c3" + integrity sha512-OfuueprJFW7h69GN+kr4Ywin7stcuqaYAt1g7airM5cUgP0BoF5G5CXsPGmXeDeEkncb2fqYNECO4y18sSqphg== dependencies: - object-assign "^4.1.1" - react-is "^17.0.2" - react-shallow-renderer "^16.13.1" - scheduler "^0.20.2" + react-is "^18.1.0" + react-shallow-renderer "^16.15.0" + scheduler "^0.22.0" -react@17.0.2: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" - integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== +react@18.1.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.1.0.tgz#6f8620382decb17fdc5cc223a115e2adbf104890" + integrity sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" regexp.prototype.flags@^1.4.1, regexp.prototype.flags@^1.4.3: version "1.4.3" @@ -4087,13 +4094,12 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" -scheduler@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" - integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== +scheduler@^0.22.0: + version "0.22.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.22.0.tgz#83a5d63594edf074add9a7198b1bae76c3db01b8" + integrity sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" semver@7.x, semver@^7.0.0, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5: version "7.3.7" @@ -4612,6 +4618,11 @@ use-constant@^1.0.0: resolved "https://registry.yarnpkg.com/use-constant/-/use-constant-1.1.0.tgz#76d36a0edf16d4cc8565361f522b55da5f8f3f22" integrity sha512-yrflEfv7Xv/W8WlYV6nwRH01K+2BpR4cWxuzY03yPRjYZuHixhGlvnJN5O2bRYrXGpJ4zy8QjFABGIQ2QXeBOA== +use-sync-external-store@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz#3343c3fe7f7e404db70f8c687adf5c1652d34e82" + integrity sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ== + uuid@^8.0.0: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"