From ebf7ee209e764e95398bece6419df068a7324237 Mon Sep 17 00:00:00 2001 From: Dean Radcliffe <24406+deanrad@users.noreply.github.com> Date: Tue, 15 Nov 2022 18:07:05 -0600 Subject: [PATCH] rxfx/react 1.0.1 useClosureListener, useClosureFilter --- react/package.json | 10 +-- react/src/index.ts | 1 + react/src/useClosureHandlers.ts | 58 +++++++++++++++++ react/test/useClosureHandler.spec.ts | 83 ++++++++++++++++++++++++ yarn.lock | 97 +++++++++++++++++++++++++--- 5 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 react/src/useClosureHandlers.ts create mode 100644 react/test/useClosureHandler.spec.ts diff --git a/react/package.json b/react/package.json index 047bc40..0173942 100644 --- a/react/package.json +++ b/react/package.json @@ -1,6 +1,6 @@ { "name": "@rxfx/react", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "author": "Dean Radcliffe", "repository": "https://github.com/deanrad/rxfx", @@ -43,6 +43,7 @@ "@testing-library/dom": "^8.18.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.4.3", "@types/jest": ">= 27.0.1", "@types/react": "^18.0.21", @@ -59,8 +60,9 @@ "jest-silent-reporter": ">= 0.5.0", "lodash.merge": ">= 4.6.2", "prettier": ">= 2.6.2", - "react": ">= 18.2.0", - "react-dom": ">= 18.2.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-test-renderer": "^17.0.2", "rimraf": ">= 3.0.2", "rollup": ">= 2.56.2", "size-limit": ">= 5.0.3", @@ -72,7 +74,7 @@ "react": ">=16.6.0" }, "prettier": { - "printWidth": 80, + "printWidth": 100, "tabWidth": 2, "semi": true, "singleQuote": true, diff --git a/react/src/index.ts b/react/src/index.ts index 819bcbc..1eb5eeb 100644 --- a/react/src/index.ts +++ b/react/src/index.ts @@ -1,3 +1,4 @@ export * from './useWhileMounted'; export * from './serviceHooks'; export * from './Unmounter'; +export * from './useClosureHandlers'; diff --git a/react/src/useClosureHandlers.ts b/react/src/useClosureHandlers.ts new file mode 100644 index 0000000..2acfd50 --- /dev/null +++ b/react/src/useClosureHandlers.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; +import { last, Subscription } from 'rxjs'; + +import { defaultBus as bus, EffectObserver, EventHandler } from '@rxfx/bus'; + +export interface HandlerArgs { + matches: { + match: ((i: TBusItem) => i is TMatchType) | ((i: TBusItem) => boolean); + }; +} + +export type TBusItem = any; // because defaultBus is type Bus +export interface ListenerArgs + extends HandlerArgs { + handler: EventHandler; + observeWith?: EffectObserver; +} +export interface FilterArgs + extends HandlerArgs { + filter: (item: TMatchType) => TBusItem | null | undefined; +} + +/** + * Registers a listener, but un-registers and re-registers it on any change of deps. + * Existing handlings will be terminated. This may run more often that you expect, + * given the nature of deps and unstable fucntions. + * @see https://reactjs.org/docs/hooks-faq.html#what-can-i-do-if-my-effect-dependencies-change-too-often + */ +export function useClosureListener( + args: ListenerArgs, + deps: any[] = [] +): Subscription { + const lastSub = useRef(new Subscription()); /* just to make .unsubscribe() safe */ + useEffect(() => { + lastSub.current = bus.listen(args.matches.match, args.handler, args.observeWith); + + return () => lastSub.current.unsubscribe(); + }, deps); + return lastSub.current; +} + +/** + * Registers a filters, but un-registers and re-registers it on any change of deps. + * This may run more often that you expect, given the nature of deps and unstable fucntions. + * @see https://reactjs.org/docs/hooks-faq.html#what-can-i-do-if-my-effect-dependencies-change-too-often + */ +export function useClosureFilter( + args: FilterArgs, + deps: any[] = [] +): Subscription { + const lastSub = useRef(new Subscription()); /* just to make .unsubscribe() safe */ + useEffect(() => { + lastSub.current = bus.filter(args.matches.match, args.filter); + + return () => lastSub.current.unsubscribe(); + }, deps); + return lastSub.current; +} diff --git a/react/test/useClosureHandler.spec.ts b/react/test/useClosureHandler.spec.ts new file mode 100644 index 0000000..5070fb0 --- /dev/null +++ b/react/test/useClosureHandler.spec.ts @@ -0,0 +1,83 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { createEvent } from '@rxfx/service'; +import { defaultBus } from '@rxfx/bus'; +import { Subscription } from 'rxjs'; +import { useClosureFilter, useClosureListener } from '../src/useClosureHandlers'; + +const UPLOAD_COMPLETE = createEvent('upload/complete'); + +describe('useClosureListener', () => { + let errorSub: Subscription; + beforeAll(() => (errorSub = defaultBus.errors.subscribe(console.error))); + afterAll(() => errorSub?.unsubscribe()); + beforeEach(() => jest.clearAllMocks()); + + const mockResultValue = 2; + const handlerSpy = jest.fn().mockResolvedValue(mockResultValue); + const nextSpy = jest.fn(); + const handlerArgs = { + matches: UPLOAD_COMPLETE, + handler: handlerSpy, + observeWith: { next: nextSpy }, + }; + + describe('First invocation', () => { + it('subscribes its listener the first time', async () => { + const { result } = renderHook( + (props = []) => { + useClosureListener(...props); + }, + { initialProps: [handlerArgs, []] } + ); + defaultBus.trigger(UPLOAD_COMPLETE()); + + expect(handlerSpy).toBeCalledWith(UPLOAD_COMPLETE()); + await Promise.resolve(); + expect(nextSpy).toBeCalledWith(mockResultValue); + }); + }); + + describe('When deps change', () => { + it('subscribes the new listener instead', async () => { + const firstDeps = [1]; + const secondDeps = [2]; + + const { result, rerender } = renderHook( + (props = []) => useClosureListener(...props), + { initialProps: [handlerArgs, firstDeps] } + ); + + // The first render will call through with the first handler value + defaultBus.trigger(UPLOAD_COMPLETE()); + await Promise.resolve(); + expect(nextSpy).toBeCalledWith(mockResultValue); + + // The 2nd render will call through with the 2nd handler value - but not both + // since the subscription was unsubscribed! + rerender([ + { + ...handlerArgs, + handler: jest.fn().mockResolvedValue(3.14), + }, + secondDeps, + ]); + defaultBus.trigger(UPLOAD_COMPLETE()); + await Promise.resolve(); + expect(nextSpy.mock.calls).toEqual([[2], [3.14]]); + }); + }); +}); + +describe('useClosureFilter', () => { + it('works just like useClosureListener but for filters', () => { + const filterSpy = jest.fn(); + const { result } = renderHook( + (props = []) => { + useClosureFilter(...props); + }, + { initialProps: [{ matches: UPLOAD_COMPLETE, filter: filterSpy }, []] } + ); + defaultBus.trigger(UPLOAD_COMPLETE()); + expect(filterSpy).toHaveBeenCalledWith(UPLOAD_COMPLETE()); + }); +}); diff --git a/yarn.lock b/yarn.lock index 3106c9b..fd924ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2522,6 +2522,7 @@ __metadata: "@testing-library/dom": ^8.18.1 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^13.4.0 + "@testing-library/react-hooks": ^8.0.1 "@testing-library/user-event": ^14.4.3 "@types/jest": ">= 27.0.1" "@types/react": ^18.0.21 @@ -2538,8 +2539,9 @@ __metadata: jest-silent-reporter: ">= 0.5.0" lodash.merge: ">= 4.6.2" prettier: ">= 2.6.2" - react: ">= 18.2.0" - react-dom: ">= 18.2.0" + react: ^17.0.2 + react-dom: ">= 17.0.2" + react-test-renderer: 17.0.2 rimraf: ">= 3.0.2" rollup: ">= 2.56.2" rxjs: ^7.3.0 @@ -2759,6 +2761,28 @@ __metadata: languageName: node linkType: hard +"@testing-library/react-hooks@npm:^8.0.1": + version: 8.0.1 + resolution: "@testing-library/react-hooks@npm:8.0.1" + dependencies: + "@babel/runtime": ^7.12.5 + react-error-boundary: ^3.1.0 + peerDependencies: + "@types/react": ^16.9.0 || ^17.0.0 + react: ^16.9.0 || ^17.0.0 + react-dom: ^16.9.0 || ^17.0.0 + react-test-renderer: ^16.9.0 || ^17.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + react-dom: + optional: true + react-test-renderer: + optional: true + checksum: 7fe44352e920deb5cb1876f80d64e48615232072c9d5382f1e0284b3aab46bb1c659a040b774c45cdf084a5257b8fe463f7e08695ad8480d8a15635d4d3d1f6d + languageName: node + linkType: hard + "@testing-library/react@npm:^13.4.0": version: 13.4.0 resolution: "@testing-library/react@npm:13.4.0" @@ -11280,7 +11304,7 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:>= 18.2.0": +"react-dom@npm:>= 17.0.2, react-dom@npm:>= 18.2.0": version: 18.2.0 resolution: "react-dom@npm:18.2.0" dependencies: @@ -11292,20 +11316,57 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^17.0.1": - version: 17.0.2 - resolution: "react-is@npm:17.0.2" - checksum: 9d6d111d8990dc98bc5402c1266a808b0459b5d54830bbea24c12d908b536df7883f268a7868cfaedde3dd9d4e0d574db456f84d2e6df9c4526f99bb4b5344d8 +"react-error-boundary@npm:^3.1.0": + version: 3.1.4 + resolution: "react-error-boundary@npm:3.1.4" + dependencies: + "@babel/runtime": ^7.12.5 + peerDependencies: + react: ">=16.13.1" + checksum: f36270a5d775a25c8920f854c0d91649ceea417b15b5bc51e270a959b0476647bb79abb4da3be7dd9a4597b029214e8fe43ea914a7f16fa7543c91f784977f1b languageName: node linkType: hard -"react-is@npm:^18.0.0": +"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0": version: 18.2.0 resolution: "react-is@npm:18.2.0" checksum: e72d0ba81b5922759e4aff17e0252bd29988f9642ed817f56b25a3e217e13eea8a7f2322af99a06edb779da12d5d636e9fda473d620df9a3da0df2a74141d53e languageName: node linkType: hard +"react-is@npm:^17.0.1, react-is@npm:^17.0.2": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 9d6d111d8990dc98bc5402c1266a808b0459b5d54830bbea24c12d908b536df7883f268a7868cfaedde3dd9d4e0d574db456f84d2e6df9c4526f99bb4b5344d8 + languageName: node + linkType: hard + +"react-shallow-renderer@npm:^16.13.1": + version: 16.15.0 + resolution: "react-shallow-renderer@npm:16.15.0" + dependencies: + object-assign: ^4.1.1 + react-is: ^16.12.0 || ^17.0.0 || ^18.0.0 + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 6052c7e3e9627485120ebd8257f128aad8f56386fe8d42374b7743eac1be457c33506d153c7886b4e32923c0c352d402ab805ef9ca02dbcd8393b2bdeb6e5af8 + languageName: node + linkType: hard + +"react-test-renderer@npm:17.0.2": + version: 17.0.2 + resolution: "react-test-renderer@npm:17.0.2" + dependencies: + object-assign: ^4.1.1 + react-is: ^17.0.2 + react-shallow-renderer: ^16.13.1 + scheduler: ^0.20.2 + peerDependencies: + react: 17.0.2 + checksum: e6b5c6ed2a0bde2c34f1ab9523ff9bc4c141a271daf730d6b852374e83acc0155d58ab71a318251e953ebfa65b8bebb9c5dce3eba1ccfcbef7cc4e1e8261c401 + languageName: node + linkType: hard + "react@npm:>= 18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0" @@ -11315,6 +11376,16 @@ __metadata: languageName: node linkType: hard +"react@npm:^17.0.2": + version: 17.0.2 + resolution: "react@npm:17.0.2" + dependencies: + loose-envify: ^1.1.0 + object-assign: ^4.1.1 + checksum: b254cc17ce3011788330f7bbf383ab653c6848902d7936a87b09d835d091e3f295f7e9dd1597c6daac5dc80f90e778c8230218ba8ad599f74adcc11e33b9d61b + languageName: node + linkType: hard + "readable-stream@npm:1 || 2, readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.1.5, readable-stream@npm:^2.2.2, readable-stream@npm:^2.3.3, readable-stream@npm:^2.3.6, readable-stream@npm:~2.3.6": version: 2.3.7 resolution: "readable-stream@npm:2.3.7" @@ -11856,6 +11927,16 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.20.2": + version: 0.20.2 + resolution: "scheduler@npm:0.20.2" + dependencies: + loose-envify: ^1.1.0 + object-assign: ^4.1.1 + checksum: c4b35cf967c8f0d3e65753252d0f260271f81a81e427241295c5a7b783abf4ea9e905f22f815ab66676f5313be0a25f47be582254db8f9241b259213e999b8fc + languageName: node + linkType: hard + "scheduler@npm:^0.23.0": version: 0.23.0 resolution: "scheduler@npm:0.23.0"