diff --git a/package.json b/package.json index 2e4c085..180bc22 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ }, "homepage": "https://github.com/WealthArc/key-value-state-container#readme", "dependencies": { - "key-value-state-container": "^1.0.0", + "key-value-state-container": "~1.0.0", "lodash": "^4.17.4", "react": "^18.2.0" }, diff --git a/src/tests/GameManaComponent.tsx b/src/tests/GameManaComponent.tsx new file mode 100644 index 0000000..17c943b --- /dev/null +++ b/src/tests/GameManaComponent.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { RendersWithContainerId } from "../types/contracts"; +import { State } from "./state-container/game-logic"; +import { useEnhancedSelector } from "../use-enhanced-selector"; + +interface Props extends RendersWithContainerId {} + +export const GameManaComponent = ({ containerId }: Props) => { + const renderedRef = React.useRef(0); + const mana = useEnhancedSelector({ + containerId, + selector: ({ header }) => header.mana, + })(); + renderedRef.current = renderedRef.current + 1; + return ( +
+
Mana: {mana}
+
Rendered: {renderedRef.current}
+
+ ); +}; diff --git a/src/tests/SumComponent.tsx b/src/tests/SumComponent.tsx index a9b391b..4af0fff 100644 --- a/src/tests/SumComponent.tsx +++ b/src/tests/SumComponent.tsx @@ -1,7 +1,10 @@ import React from "react"; import { useSelector } from "../use-selector"; import { RendersWithContainerId } from "../types/contracts"; -import { Action, State } from "./state-container/enhanced-logic"; +import { + Action, + State, +} from "./state-container/increment-decrement-container-logic"; export interface RendersWithListeningToPath { path: keyof State | "*"; @@ -16,5 +19,10 @@ export const SumComponent = ({ containerId, path }: Props) => { statePath: [path], }); renderedRef.current = renderedRef.current + 1; - return
The sum is: {sum}
; + return ( +
+
The sum is: {sum}
+
Rendered: {renderedRef.current}
+
+ ); }; diff --git a/src/tests/state-container/game-logic.ts b/src/tests/state-container/game-logic.ts new file mode 100644 index 0000000..00ab8c7 --- /dev/null +++ b/src/tests/state-container/game-logic.ts @@ -0,0 +1,63 @@ +/** + * The enhanced logic adds a special action `zero` that bypasses the reducer. + */ +import { dispatchAction, Reducer } from "key-value-state-container"; + +export type Action = { + name: "use-mana"; + payload: number; +}; + +/** + * Header as displayed on the screen + */ +type Header = { + /** + * Mana points. + */ + mana: number; + + /** + * The score of the user. + */ + score: number; +}; + +export type State = { + header: Header; +}; + +export const reducer: Reducer = async ({ state, action }) => { + switch (action.name) { + case "use-mana": { + return { + ...state, + header: { + ...state.header, + mana: state.header.mana - action.payload, + }, + }; + } + default: { + return state; + } + } +}; + +export const dispatchActions = ({ + containerId, + randomArray, +}: { + containerId: string; + randomArray: number[]; +}) => { + for (let i = 0; i < randomArray.length; i++) { + dispatchAction({ + action: { + name: "use-mana", + payload: randomArray[i], + }, + containerId, + }); + } +}; diff --git a/src/tests/state-container/enhanced-logic.ts b/src/tests/state-container/increment-decrement-container-logic.ts similarity index 95% rename from src/tests/state-container/enhanced-logic.ts rename to src/tests/state-container/increment-decrement-container-logic.ts index 6bc9e61..e040849 100644 --- a/src/tests/state-container/enhanced-logic.ts +++ b/src/tests/state-container/increment-decrement-container-logic.ts @@ -1,7 +1,4 @@ -/** - * The enhanced logic adds a special action `zero` that bypasses the reducer. - */ import { dispatchAction, Reducer } from "key-value-state-container"; export type Action = @@ -26,6 +23,7 @@ export type Action = export type State = { sum: number; + /** * How many times the `increment` action has been dispatched. */ diff --git a/src/tests/use-game-mana-selector.test.tsx b/src/tests/use-game-mana-selector.test.tsx new file mode 100644 index 0000000..d9dea6e --- /dev/null +++ b/src/tests/use-game-mana-selector.test.tsx @@ -0,0 +1,46 @@ +import _ from "lodash"; +import React from "react"; + +import { render, act } from "@testing-library/react"; +import { finishedProcessingQueue } from "key-value-state-container"; + +import { reducer, dispatchActions } from "./state-container/game-logic"; +import { ContainerRoot } from "../ContainerRoot"; +import { GameManaComponent } from "./GameManaComponent"; + +const containerId = "use-enhanced-selector-state-container"; +const initialMana = 1000; + +test("useGameManaSelector test", async () => { + /** + * Mana spent for fighting monsters 👾. + */ + const manaSpentRandomArray = Array.from({ length: 25 }, () => _.random(1, 8)); + const expectedManaLeft = manaSpentRandomArray.reduce( + (acc, el) => acc - el, + initialMana + ); + const { getByTestId, unmount } = render( + + + + ); + await act(async () => { + dispatchActions({ containerId, randomArray: manaSpentRandomArray }); + await finishedProcessingQueue({ containerId }); + }); + expect(getByTestId("mana").innerHTML).toEqual(`Mana: ${expectedManaLeft}`); + expect(getByTestId("rendered").innerHTML).toEqual("Rendered: 2"); + act(() => { + unmount(); + }); +}); diff --git a/src/tests/use-selector.test.tsx b/src/tests/use-increment-decrement-selector.test.tsx similarity index 89% rename from src/tests/use-selector.test.tsx rename to src/tests/use-increment-decrement-selector.test.tsx index 5530615..1e9c0fb 100644 --- a/src/tests/use-selector.test.tsx +++ b/src/tests/use-increment-decrement-selector.test.tsx @@ -4,7 +4,10 @@ import React from "react"; import { render, act } from "@testing-library/react"; import { finishedProcessingQueue } from "key-value-state-container"; -import { reducer, dispatchActions } from "./state-container/enhanced-logic"; +import { + reducer, + dispatchActions, +} from "./state-container/increment-decrement-container-logic"; import { RendersWithListeningToPath, SumComponent } from "./SumComponent"; import { ContainerRoot } from "../ContainerRoot"; @@ -27,6 +30,7 @@ const useSelectorTest = async ({ path }: RendersWithListeningToPath) => { await finishedProcessingQueue({ containerId }); }); expect(getByTestId("sum").innerHTML).toEqual(`The sum is: ${expectedSum}`); + expect(getByTestId("rendered").innerHTML).toEqual("Rendered: 2"); act(() => { unmount(); }); diff --git a/src/types/contracts/index.ts b/src/types/contracts/index.ts index 90c0599..28dedf1 100644 --- a/src/types/contracts/index.ts +++ b/src/types/contracts/index.ts @@ -1,4 +1,7 @@ + + +export * from "./renders-with-container-id"; +export * from "./selector"; export * from "./use-dispatch-action"; export * from "./use-get-container-id"; -export * from "./use-selector"; -export * from "./renders-with-container-id"; \ No newline at end of file +export * from "./use-selector"; \ No newline at end of file diff --git a/src/types/contracts/selector.ts b/src/types/contracts/selector.ts new file mode 100644 index 0000000..725e58d --- /dev/null +++ b/src/types/contracts/selector.ts @@ -0,0 +1,31 @@ +/** + The MIT License (MIT) + + Copyright Tomasz Szatkowski, Patryk Parcheta + WealthArc https://www.wealtharc.com (c) 2023, 2024 + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +*/ + +/** + * Unfortunately, the returned type has to be manually declared. + */ +export type Selector = ( + state: TState +) => TResult; diff --git a/src/use-enhanced-selector.ts b/src/use-enhanced-selector.ts new file mode 100644 index 0000000..7743501 --- /dev/null +++ b/src/use-enhanced-selector.ts @@ -0,0 +1,93 @@ +/** + The MIT License (MIT) + + Copyright Tomasz Szatkowski, Patryk Parcheta + WealthArc https://www.wealtharc.com (c) 2023, 2024 + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +*/ + +import { + getContainer, + getUniqueId, + registerStateChangedCallback, + unregisterStateChangedCallback, +} from "key-value-state-container"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Selector } from "types/contracts"; + +type Args = { + containerId: string; + selector: Selector; + lateInvoke?: boolean; +}; + +export const useEnhancedSelector = + ({ + selector, + containerId, + lateInvoke, + }: Args) => + (): TResult => { + const listenerIdRef = useRef(getUniqueId()); + + const memoizedSelector = useCallback(selector, []); + + const [selectedState, setSelectedState] = useState>(() => { + return memoizedSelector( + getContainer({ + containerId, + ignoreUnregistered: true, + }) + ); + }); + + useEffect(() => { + const listenerId = listenerIdRef.current; + const lateInvokeValue = + typeof lateInvoke === "undefined" ? true : lateInvoke; + + registerStateChangedCallback({ + callback: ({ newState }) => { + const newSelectValue = memoizedSelector(newState); + + if ( + JSON.stringify(selectedState) !== JSON.stringify(newSelectValue) + ) { + setSelectedState(newSelectValue); + } + }, + listenerId, + containerId, + lateInvoke: lateInvokeValue, + statePath: "*", + }); + + return () => { + unregisterStateChangedCallback({ + containerId, + lateInvoke: lateInvokeValue, + listenerId, + statePath: "*", + }); + }; + }, [memoizedSelector, selectedState]); + + return selectedState; + };