From a739fc5ea84c52ca686f49fe67252c2c9d1071b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=90=E8=97=A4=E6=98=AD=E6=96=87?= Date: Fri, 8 Sep 2023 21:56:19 +0900 Subject: [PATCH] refactor zodRefine, add hooks lint, refactor hooks. --- apps/example-next/src/components/Counter.tsx | 19 ++++---- package.json | 1 + packages/eslint-config-custom/index.js | 7 +++ packages/location-state-core/src/hooks.ts | 48 ++++++++----------- .../src/location-sync.test.tsx | 44 ++++++++++++++++- pnpm-lock.yaml | 15 ++++++ 6 files changed, 93 insertions(+), 41 deletions(-) diff --git a/apps/example-next/src/components/Counter.tsx b/apps/example-next/src/components/Counter.tsx index 505ad706..3451efb0 100644 --- a/apps/example-next/src/components/Counter.tsx +++ b/apps/example-next/src/components/Counter.tsx @@ -1,22 +1,21 @@ "use client"; -import { useLocationState, StoreName } from "@location-state/core"; -import { z } from "zod"; +import { useLocationState, StoreName, Refine } from "@location-state/core"; +import { z, ZodType } from "zod"; -const schema = z.number(); +const zodRefine = + (schema: ZodType): Refine => + (value) => { + const result = schema.safeParse(value); + return result.success ? result.data : undefined; + }; export function Counter({ storeName }: { storeName: StoreName }) { const [counter, setCounter] = useLocationState({ name: "counter", defaultValue: 0, storeName, - refine: (value) => { - const result = schema.safeParse(value); - if (result.success) { - return result.data; - } - return undefined; - }, + refine: zodRefine(z.number()), }); console.debug("rendered Counter", { storeName, counter }); diff --git a/package.json b/package.json index 2d8aa5d2..400eac0e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "eslint": "8.47.0", "eslint-config-prettier": "9.0.0", "eslint-plugin-react": "7.33.2", + "eslint-plugin-react-hooks": "4.6.0", "husky": "8.0.3", "jest": "29.6.1", "navigation-api-types": "0.3.1", diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config-custom/index.js index 2ca236f7..8a7560a9 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config-custom/index.js @@ -7,6 +7,7 @@ module.exports = { "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", + "plugin:react-hooks/recommended", "prettier", ], parser: "@typescript-eslint/parser", @@ -18,5 +19,11 @@ module.exports = { rules: { "react/react-in-jsx-scope": "off", "@typescript-eslint/no-non-null-asserted-optional-chain": "off", + "react-hooks/exhaustive-deps": "error", + }, + settings: { + react: { + version: "detect", + }, }, }; diff --git a/packages/location-state-core/src/hooks.ts b/packages/location-state-core/src/hooks.ts index e953356d..ad641d32 100644 --- a/packages/location-state-core/src/hooks.ts +++ b/packages/location-state-core/src/hooks.ts @@ -1,12 +1,12 @@ import { LocationStateContext } from "./context"; import { StoreName } from "./types"; -import { useCallback, useContext, useSyncExternalStore } from "react"; +import { useCallback, useContext, useState, useSyncExternalStore } from "react"; const useStore = (storeName: StoreName | string) => { const { stores } = useContext(LocationStateContext); const store = stores[storeName]; if (!store) { - throw new Error("`LocationStateProvider` is required."); + throw new Error(`Store not found: ${storeName}`); } return store; @@ -21,11 +21,13 @@ export type LocationStateDefinition = { refine?: Refine; }; -type SetStateArg = T | ((prev: T) => T); +type Updater = (prev: T) => T; +type UpdaterOrArg = T | Updater; +type SetState = (updaterOrValue: UpdaterOrArg) => void; export const useLocationState = ( definition: LocationStateDefinition, -): [T, (setterOrValue: SetStateArg) => void] => { +): [T, SetState] => { const storeState = useLocationStateValue(definition); const setStoreState = useLocationSetState(definition); return [storeState, setStoreState]; @@ -56,36 +58,24 @@ export const useLocationStateValue = ({ return storeState; }; -export const useLocationSetState = ({ - name, - defaultValue, - storeName, - refine, -}: LocationStateDefinition): ((setterOrValue: SetStateArg) => void) => { +export const useLocationSetState = ( + props: LocationStateDefinition, +): SetState => { + const { name, defaultValue, storeName, refine } = useState(props)[0]; const store = useStore(storeName); const setStoreState = useCallback( - (setterOrValue: SetStateArg) => { - if (typeof setterOrValue === "function") { - const setter = setterOrValue as (prev: T) => T; - const storeValue = store.get(name) as T | undefined; - const refinedValue = refine ? refine(storeValue) : storeValue; - const prev = refinedValue ?? defaultValue; - store.set(name, setter(prev)); + (updaterOrValue: UpdaterOrArg) => { + if (typeof updaterOrValue !== "function") { + store.set(name, updaterOrValue); return; } - store.set(name, setterOrValue); + const updater = updaterOrValue as Updater; + const storeValue = store.get(name) as T | undefined; + const refinedValue = refine ? refine(storeValue) : storeValue; + const prev = refinedValue ?? defaultValue; + store.set(name, updater(prev)); }, - [name, store], + [name, store, defaultValue, refine], ); return setStoreState; }; - -export const useLocationStateSnapshot = ({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - storeName, -}: { - name: string; - storeName: StoreName | string; -}): T => { - throw new Error("Not implemented yet"); -}; diff --git a/packages/location-state-core/src/location-sync.test.tsx b/packages/location-state-core/src/location-sync.test.tsx index f35a5be1..fc485220 100644 --- a/packages/location-state-core/src/location-sync.test.tsx +++ b/packages/location-state-core/src/location-sync.test.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import { LocationStateDefinition, useLocationSetState, @@ -20,7 +21,7 @@ beforeEach(() => { sessionStorage.clear(); }); -describe("`useLocationState` used.", () => { +describe("using `useLocationState`.", () => { function LocationSyncCounter() { const [count, setCount] = useLocationState({ name: "count", @@ -91,7 +92,7 @@ describe("`useLocationState` used.", () => { }); }); -describe("`useLocationStateValue`/`useLocationSetState` used.", () => { +describe("using `useLocationStateValue`/`useLocationSetState`.", () => { function LocationSyncCounter() { const counter: LocationStateDefinition = { name: "counter", @@ -137,3 +138,42 @@ describe("`useLocationStateValue`/`useLocationSetState` used.", () => { ); }); }); + +describe("using `useLocationSetState`.", () => { + function LocationSyncCounter() { + const counter: LocationStateDefinition = { + name: "counter", + defaultValue: 0, + storeName: "session", + }; + const rendered = useRef(1); + const setCount = useLocationSetState(counter); + useEffect(() => { + rendered.current++; + }, []); + + return ( +
+

rendered: {rendered.current}

+ +
+ ); + } + + function LocationSyncCounterPage() { + return ( + + + + ); + } + + test("setCount does not re-render.", async () => { + // Arrange + const { user } = renderWithUser(); + // Act + await user.click(await screen.findByRole("button", { name: "increment" })); + // Assert + expect(screen.getByRole("heading")).toHaveTextContent("rendered: 1"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ccb40eb..e387f031 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: eslint-plugin-react: specifier: 7.33.2 version: 7.33.2(eslint@8.47.0) + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.0(eslint@8.47.0) husky: specifier: 8.0.3 version: 8.0.3 @@ -3396,6 +3399,18 @@ packages: semver: 6.3.1 dev: false + /eslint-plugin-react-hooks@4.6.0(eslint@8.47.0): + resolution: + { + integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==, + } + engines: { node: ">=10" } + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + dependencies: + eslint: 8.47.0 + dev: true + /eslint-plugin-react-hooks@4.6.0(eslint@8.48.0): resolution: {