mirror of https://github.com/usememos/memos
refactor(frontend): remove react-use dependency
parent
7c3bff4e98
commit
53abb8020e
@ -0,0 +1,673 @@
|
||||
# Remove react-use Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove `react-use` from the frontend while preserving current hook behavior and eliminating the transitive `js-cookie@2.2.1` dependency.
|
||||
|
||||
**Architecture:** Replace simple one-off `react-use` helpers with native React hooks inside the consuming components. Add two focused local hooks in `web/src/hooks/` for reused debounce behavior and typed localStorage state. Regenerate the pnpm lockfile from `web/package.json` instead of manually editing lockfile entries.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript 6, Vite 8, Vitest 4, Testing Library, pnpm 11.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `web/src/hooks/useDebouncedEffect.ts`
|
||||
- Shared debounce hook for effect-style callbacks.
|
||||
- Create `web/src/hooks/useLocalStorage.ts`
|
||||
- Typed localStorage state hook for persisted UI preferences.
|
||||
- Modify `web/src/hooks/index.ts`
|
||||
- Export the two new hooks for existing `@/hooks` barrel import style.
|
||||
- Create `web/tests/hooks.test.tsx`
|
||||
- Unit tests for the two new local hooks.
|
||||
- Modify `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
|
||||
- Replace `react-use` debounce import with local `useDebouncedEffect`.
|
||||
- Modify `web/src/components/MemoEditor/hooks/useLinkMemo.ts`
|
||||
- Replace deep `react-use` debounce import with local `useDebouncedEffect`.
|
||||
- Modify `web/src/components/MemoExplorer/TagsSection.tsx`
|
||||
- Replace deep `react-use` localStorage import with local `useLocalStorage`.
|
||||
- Modify `web/src/components/TagTree.tsx`
|
||||
- Replace `useToggle` with native `useState`.
|
||||
- Modify `web/src/components/MobileHeader.tsx`
|
||||
- Replace `useWindowScroll` with native `useState` and `useEffect`.
|
||||
- Modify `web/src/layouts/RootLayout.tsx`
|
||||
- Replace `usePrevious` with native `useRef` and `useEffect`.
|
||||
- Modify `web/package.json`
|
||||
- Remove the direct `react-use` dependency.
|
||||
- Modify `web/pnpm-lock.yaml`
|
||||
- Regenerate through pnpm after `react-use` is removed.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Failing Hook Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `web/tests/hooks.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write tests for local hook behavior**
|
||||
|
||||
Create `web/tests/hooks.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDebouncedEffect, useLocalStorage } from "@/hooks";
|
||||
|
||||
describe("useLocalStorage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("uses the default value when storage is empty", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-empty", false));
|
||||
|
||||
expect(result.current[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("reads and writes JSON values", () => {
|
||||
window.localStorage.setItem("hook-test-existing", JSON.stringify(true));
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-existing", false));
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current[1](false);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(false);
|
||||
expect(window.localStorage.getItem("hook-test-existing")).toBe("false");
|
||||
});
|
||||
|
||||
it("supports updater functions", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-updater", false));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((current) => !current);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
expect(window.localStorage.getItem("hook-test-updater")).toBe("true");
|
||||
});
|
||||
|
||||
it("falls back to the default value for malformed storage", () => {
|
||||
window.localStorage.setItem("hook-test-malformed", "{bad json");
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-malformed", true));
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDebouncedEffect", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("runs the latest callback after the delay", () => {
|
||||
const calls: string[] = [];
|
||||
const { rerender } = renderHook(
|
||||
({ value }) => {
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
calls.push(value);
|
||||
},
|
||||
100,
|
||||
[value],
|
||||
);
|
||||
},
|
||||
{ initialProps: { value: "first" } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(99);
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
rerender({ value: "second" });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(calls).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("clears the pending timeout on unmount", () => {
|
||||
const calls: string[] = [];
|
||||
const { unmount } = renderHook(() => {
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
calls.push("called");
|
||||
},
|
||||
100,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests and verify they fail because hooks do not exist**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm test tests/hooks.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL with missing exports for `useDebouncedEffect` and `useLocalStorage` from `@/hooks`.
|
||||
|
||||
- [ ] **Step 3: Commit failing tests**
|
||||
|
||||
```bash
|
||||
git add web/tests/hooks.test.tsx
|
||||
git commit -m "test: cover local frontend hooks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement Local Shared Hooks
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/useDebouncedEffect.ts`
|
||||
- Create: `web/src/hooks/useLocalStorage.ts`
|
||||
- Modify: `web/src/hooks/index.ts`
|
||||
- Test: `web/tests/hooks.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `useDebouncedEffect`**
|
||||
|
||||
Create `web/src/hooks/useDebouncedEffect.ts`:
|
||||
|
||||
```ts
|
||||
import { type DependencyList, useEffect } from "react";
|
||||
|
||||
export const useDebouncedEffect = (effect: () => void | Promise<void>, delay: number, deps: DependencyList): void => {
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
void effect();
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [delay, ...deps]);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `useLocalStorage`**
|
||||
|
||||
Create `web/src/hooks/useLocalStorage.ts`:
|
||||
|
||||
```ts
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type SetLocalStorageValue<T> = T | ((currentValue: T) => T);
|
||||
|
||||
const readLocalStorageValue = <T>(key: string, defaultValue: T): T => {
|
||||
if (typeof window === "undefined") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const storedValue = window.localStorage.getItem(key);
|
||||
return storedValue === null ? defaultValue : (JSON.parse(storedValue) as T);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
export const useLocalStorage = <T>(key: string, defaultValue: T): [T, (value: SetLocalStorageValue<T>) => void] => {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => readLocalStorageValue(key, defaultValue));
|
||||
|
||||
useEffect(() => {
|
||||
setStoredValue(readLocalStorageValue(key, defaultValue));
|
||||
}, [key, defaultValue]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(value: SetLocalStorageValue<T>) => {
|
||||
setStoredValue((currentValue) => {
|
||||
const nextValue = typeof value === "function" ? (value as (currentValue: T) => T)(currentValue) : value;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(nextValue));
|
||||
} catch {
|
||||
// Keep React state updated even if persistence is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
return nextValue;
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return [storedValue, setValue];
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Export hooks from the barrel**
|
||||
|
||||
Modify `web/src/hooks/index.ts` to include:
|
||||
|
||||
```ts
|
||||
export * from "./useAsyncEffect";
|
||||
export * from "./useCurrentUser";
|
||||
export * from "./useDateFilterNavigation";
|
||||
export * from "./useDebouncedEffect";
|
||||
export * from "./useFilteredMemoStats";
|
||||
export * from "./useLoading";
|
||||
export * from "./useLocalStorage";
|
||||
export * from "./useMediaQuery";
|
||||
export * from "./useMemoFilters";
|
||||
export * from "./useMemoSorting";
|
||||
export * from "./useNavigateTo";
|
||||
export * from "./useUserLocale";
|
||||
export * from "./useUserTheme";
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run hook tests and verify they pass**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm test tests/hooks.test.tsx
|
||||
```
|
||||
|
||||
Expected: PASS for all `useLocalStorage` and `useDebouncedEffect` tests.
|
||||
|
||||
- [ ] **Step 5: Commit shared hooks**
|
||||
|
||||
```bash
|
||||
git add web/src/hooks/useDebouncedEffect.ts web/src/hooks/useLocalStorage.ts web/src/hooks/index.ts web/tests/hooks.test.tsx
|
||||
git commit -m "feat: add local frontend hooks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Replace Simple react-use Helpers With React Hooks
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/TagTree.tsx`
|
||||
- Modify: `web/src/components/MobileHeader.tsx`
|
||||
- Modify: `web/src/layouts/RootLayout.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace `useToggle` in `TagTree`**
|
||||
|
||||
In `web/src/components/TagTree.tsx`, change the imports:
|
||||
|
||||
```ts
|
||||
import { ChevronRightIcon, HashIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
```
|
||||
|
||||
Replace the toggle state in `TagItemContainer` with:
|
||||
|
||||
```ts
|
||||
const [showSubTags, setShowSubTags] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowSubTags(expandSubTags);
|
||||
}, [expandSubTags]);
|
||||
```
|
||||
|
||||
Replace `handleToggleBtnClick` with:
|
||||
|
||||
```ts
|
||||
const handleToggleBtnClick = useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setShowSubTags((current) => !current);
|
||||
}, []);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `useWindowScroll` in `MobileHeader`**
|
||||
|
||||
In `web/src/components/MobileHeader.tsx`, replace the first import with React hooks:
|
||||
|
||||
```ts
|
||||
import { useEffect, useState } from "react";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
import NavigationDrawer from "./NavigationDrawer";
|
||||
```
|
||||
|
||||
Inside `MobileHeader`, replace `const { y: offsetTop } = useWindowScroll();` with:
|
||||
|
||||
```ts
|
||||
const [offsetTop, setOffsetTop] = useState(() => {
|
||||
if (typeof window === "undefined") return 0;
|
||||
return window.scrollY;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setOffsetTop(window.scrollY);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `usePrevious` in `RootLayout`**
|
||||
|
||||
In `web/src/layouts/RootLayout.tsx`, change the React import and remove the `react-use` import:
|
||||
|
||||
```ts
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Outlet, useLocation, useSearchParams } from "react-router-dom";
|
||||
```
|
||||
|
||||
Replace:
|
||||
|
||||
```ts
|
||||
const prevPathname = usePrevious(pathname);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
const prevPathnameRef = useRef<string | undefined>(undefined);
|
||||
```
|
||||
|
||||
Replace the route filter clearing effect with:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
const prevPathname = prevPathnameRef.current;
|
||||
|
||||
// When the route changes and there is no filter in the search params, remove all filters.
|
||||
if (prevPathname !== undefined && prevPathname !== pathname && !searchParams.has("filter")) {
|
||||
removeFilter(() => true);
|
||||
}
|
||||
|
||||
prevPathnameRef.current = pathname;
|
||||
}, [pathname, searchParams, removeFilter]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run TypeScript/lint check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit simple helper replacements**
|
||||
|
||||
```bash
|
||||
git add web/src/components/TagTree.tsx web/src/components/MobileHeader.tsx web/src/layouts/RootLayout.tsx
|
||||
git commit -m "refactor: replace simple react-use helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Replace Debounce And LocalStorage Call Sites
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
|
||||
- Modify: `web/src/components/MemoEditor/hooks/useLinkMemo.ts`
|
||||
- Modify: `web/src/components/MemoExplorer/TagsSection.tsx`
|
||||
- Test: `web/tests/hooks.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Update `InsertMenu` debounce import and call**
|
||||
|
||||
In `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`, remove:
|
||||
|
||||
```ts
|
||||
import { useDebounce } from "react-use";
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```ts
|
||||
import { useDebouncedEffect } from "@/hooks";
|
||||
```
|
||||
|
||||
Replace:
|
||||
|
||||
```ts
|
||||
useDebounce(
|
||||
() => {
|
||||
setDebouncedPosition(locationState.position);
|
||||
},
|
||||
1000,
|
||||
[locationState.position],
|
||||
);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
setDebouncedPosition(locationState.position);
|
||||
},
|
||||
1000,
|
||||
[locationState.position],
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `useLinkMemo` debounce import and call**
|
||||
|
||||
In `web/src/components/MemoEditor/hooks/useLinkMemo.ts`, remove:
|
||||
|
||||
```ts
|
||||
import useDebounce from "react-use/lib/useDebounce";
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```ts
|
||||
import { useDebouncedEffect } from "@/hooks";
|
||||
```
|
||||
|
||||
Replace:
|
||||
|
||||
```ts
|
||||
useDebounce(
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
useDebouncedEffect(
|
||||
```
|
||||
|
||||
Keep the existing async callback body, `300` delay, and `[isOpen, searchText]` dependency list unchanged.
|
||||
|
||||
- [ ] **Step 3: Update `TagsSection` localStorage import**
|
||||
|
||||
In `web/src/components/MemoExplorer/TagsSection.tsx`, replace:
|
||||
|
||||
```ts
|
||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
import { useLocalStorage } from "@/hooks";
|
||||
```
|
||||
|
||||
Keep both call sites unchanged:
|
||||
|
||||
```ts
|
||||
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
|
||||
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify no source imports from `react-use` remain**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n 'react-use' web/src web/package.json
|
||||
```
|
||||
|
||||
Expected: only `web/package.json` still reports `react-use` before the dependency removal task.
|
||||
|
||||
- [ ] **Step 5: Run targeted hook tests and lint**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm test tests/hooks.test.tsx
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
Expected: both commands PASS.
|
||||
|
||||
- [ ] **Step 6: Commit call-site replacements**
|
||||
|
||||
```bash
|
||||
git add web/src/components/MemoEditor/Toolbar/InsertMenu.tsx web/src/components/MemoEditor/hooks/useLinkMemo.ts web/src/components/MemoExplorer/TagsSection.tsx web/tests/hooks.test.tsx
|
||||
git commit -m "refactor: use local debounce and storage hooks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Remove react-use Dependency And Regenerate Lockfile
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/package.json`
|
||||
- Modify: `web/pnpm-lock.yaml`
|
||||
|
||||
- [ ] **Step 1: Remove `react-use` from `web/package.json`**
|
||||
|
||||
In `web/package.json`, remove this dependency line:
|
||||
|
||||
```json
|
||||
"react-use": "^17.6.0",
|
||||
```
|
||||
|
||||
Do not add `js-cookie` as a direct dependency.
|
||||
|
||||
- [ ] **Step 2: Regenerate the lockfile**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm install --lockfile-only
|
||||
```
|
||||
|
||||
Expected: command succeeds and updates `web/pnpm-lock.yaml`.
|
||||
|
||||
- [ ] **Step 3: Verify dependency graph removal**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm why react-use js-cookie
|
||||
```
|
||||
|
||||
Expected: output does not list installed versions for `react-use` or `js-cookie`.
|
||||
|
||||
- [ ] **Step 4: Verify repository text references**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n '"react-use"|react-use/lib|react-use@17\.6\.0|^\s+react-use:|js-cookie' web/package.json web/pnpm-lock.yaml web/src
|
||||
```
|
||||
|
||||
Expected: no matches.
|
||||
|
||||
- [ ] **Step 5: Commit dependency removal**
|
||||
|
||||
```bash
|
||||
git add web/package.json web/pnpm-lock.yaml
|
||||
git commit -m "chore: remove react-use dependency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final Verification
|
||||
|
||||
**Files:**
|
||||
- Verify: all files changed by Tasks 1-5
|
||||
|
||||
- [ ] **Step 1: Run frontend tests for the new hook coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm test tests/hooks.test.tsx
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run frontend lint**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run frontend build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Confirm no removed dependency remains**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm why react-use js-cookie
|
||||
rg -n '"react-use"|react-use/lib|react-use@17\.6\.0|^\s+react-use:|js-cookie' src package.json pnpm-lock.yaml
|
||||
```
|
||||
|
||||
Expected: no `react-use` or `js-cookie` dependency remains. The `rg` command returns no matches.
|
||||
|
||||
- [ ] **Step 5: Inspect final diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --stat HEAD~5..HEAD
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: diff contains only hook tests, local hooks, six call-site rewrites, and dependency files. Working tree is clean after all task commits.
|
||||
@ -0,0 +1,82 @@
|
||||
# Remove react-use Design
|
||||
|
||||
## Context
|
||||
|
||||
The frontend has a direct dependency on `react-use@17.6.0`. Current source usage is limited to six imports:
|
||||
|
||||
- `usePrevious` in `web/src/layouts/RootLayout.tsx`
|
||||
- `useLocalStorage` in `web/src/components/MemoExplorer/TagsSection.tsx`
|
||||
- `useWindowScroll` in `web/src/components/MobileHeader.tsx`
|
||||
- `useToggle` in `web/src/components/TagTree.tsx`
|
||||
- `useDebounce` in `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
|
||||
- `useDebounce` in `web/src/components/MemoEditor/hooks/useLinkMemo.ts`
|
||||
|
||||
`react-use` pulls in `js-cookie@2.2.1` transitively. A direct `js-cookie@3.0.7` dependency does not remove that vulnerable transitive copy, so the better remediation is to remove `react-use` rather than adding another copy of `js-cookie`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Remove `react-use` from `web/package.json` and `web/pnpm-lock.yaml`.
|
||||
- Remove all source imports from `react-use` and `react-use/lib/*`.
|
||||
- Prefer built-in React hooks directly where the replacement is simple.
|
||||
- Add local hooks only where reuse or browser-side-effect cleanup makes the code clearer and safer.
|
||||
- Confirm `react-use` and `js-cookie` are no longer present in the frontend dependency graph.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not introduce another general-purpose React hooks library.
|
||||
- Do not refactor unrelated frontend state management.
|
||||
- Do not change user-visible behavior of tag preferences, tag tree expansion, scroll shadow behavior, route filter clearing, memo link search, or reverse geocoding debounce.
|
||||
|
||||
## Approach
|
||||
|
||||
Use native React hooks for one-off, simple behavior:
|
||||
|
||||
- Replace `useToggle` in `TagTree` with `useState(false)` plus local callbacks.
|
||||
- Replace `usePrevious` in `RootLayout` with local `useRef` and `useEffect` while preserving the existing previous-path comparison.
|
||||
- Replace `useWindowScroll` in `MobileHeader` with local `useState` and `useEffect` for the scroll listener.
|
||||
|
||||
Create focused local hooks where repeated behavior or cleanup consistency matters:
|
||||
|
||||
- Add `useDebouncedEffect` under `web/src/hooks/` for the two debounce call sites. It schedules a callback after the configured delay and clears the timeout when dependencies change or the component unmounts.
|
||||
- Add a typed `useLocalStorage` under `web/src/hooks/` for tag display settings. It reads the stored value on initialization, falls back to the provided default, writes updates to `localStorage`, and tolerates unavailable or malformed storage by using the default.
|
||||
|
||||
Update imports to use `@/hooks/...` for local hooks. Keep hook APIs small and shaped around current usage rather than cloning the full `react-use` API.
|
||||
|
||||
## Data Flow And Behavior
|
||||
|
||||
Tag view settings continue to persist in `localStorage` under the same keys:
|
||||
|
||||
- `tag-view-as-tree`
|
||||
- `tag-tree-auto-expand`
|
||||
|
||||
Debounced effects continue to defer:
|
||||
|
||||
- reverse-geocoding position updates in the memo editor insert menu by 1000 ms
|
||||
- memo link search requests by 300 ms
|
||||
|
||||
The route filter clearing logic continues to run only when navigation changes route and the URL has no `filter` parameter.
|
||||
|
||||
The mobile header continues to show its shadow when the window has scrolled below the top.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The local storage hook catches read and write failures. On read failure or malformed JSON, it returns the default value. On write failure, it keeps React state updated so the current UI interaction still works, while avoiding a thrown render or event-handler error.
|
||||
|
||||
Debounced effects do not swallow errors inside the user callback. Existing call sites already handle their own asynchronous errors where needed.
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
Run frontend validation after implementation:
|
||||
|
||||
- `pnpm install --lockfile-only` from `web/` to regenerate `pnpm-lock.yaml`
|
||||
- `pnpm why react-use js-cookie` from `web/` and confirm neither dependency remains
|
||||
- `pnpm lint` from `web/`
|
||||
- `pnpm build` from `web/`
|
||||
|
||||
Manual checks should cover:
|
||||
|
||||
- toggling tag tree mode and auto-expand persists across reload
|
||||
- opening tag tree nodes still works
|
||||
- mobile header shadow appears after scrolling
|
||||
- memo link search still debounces and updates results
|
||||
- location reverse geocoding still waits for position changes before lookup
|
||||
@ -0,0 +1,16 @@
|
||||
import { type DependencyList, useEffect, useRef } from "react";
|
||||
|
||||
export const useDebouncedEffect = (effect: () => void | Promise<void>, delay: number, deps: DependencyList): void => {
|
||||
const effectRef = useRef(effect);
|
||||
effectRef.current = effect;
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
void effectRef.current();
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [delay, ...deps]);
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type SetLocalStorageValue<T> = T | ((currentValue: T) => T);
|
||||
|
||||
const readLocalStorageValue = <T>(key: string, defaultValue: T): T => {
|
||||
if (typeof window === "undefined") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const storedValue = window.localStorage.getItem(key);
|
||||
return storedValue === null ? defaultValue : (JSON.parse(storedValue) as T);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
export const useLocalStorage = <T>(key: string, defaultValue: T): [T, (value: SetLocalStorageValue<T>) => void] => {
|
||||
const defaultValueRef = useRef(defaultValue);
|
||||
defaultValueRef.current = defaultValue;
|
||||
|
||||
const [storedValue, setStoredValue] = useState<T>(() => readLocalStorageValue(key, defaultValue));
|
||||
|
||||
useEffect(() => {
|
||||
setStoredValue(readLocalStorageValue(key, defaultValueRef.current));
|
||||
}, [key]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(value: SetLocalStorageValue<T>) => {
|
||||
setStoredValue((currentValue) => {
|
||||
const nextValue = typeof value === "function" ? (value as (currentValue: T) => T)(currentValue) : value;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(nextValue));
|
||||
} catch {
|
||||
// Keep React state updated even if persistence is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
return nextValue;
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return [storedValue, setValue];
|
||||
};
|
||||
@ -0,0 +1,194 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDebouncedEffect, useLocalStorage } from "@/hooks";
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store = new Map<string, string>();
|
||||
let shouldThrowOnSet = false;
|
||||
|
||||
return {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
if (shouldThrowOnSet) {
|
||||
throw new Error("localStorage setItem unavailable");
|
||||
}
|
||||
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store = new Map<string, string>();
|
||||
},
|
||||
setShouldThrowOnSet: (value: boolean) => {
|
||||
shouldThrowOnSet = value;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
describe("useLocalStorage", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.setShouldThrowOnSet(false);
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorageMock.setShouldThrowOnSet(false);
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("uses the default value when storage is empty", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-empty", false));
|
||||
|
||||
expect(result.current[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("reads and writes JSON values", () => {
|
||||
window.localStorage.setItem("hook-test-existing", JSON.stringify(true));
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-existing", false));
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current[1](false);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(false);
|
||||
expect(window.localStorage.getItem("hook-test-existing")).toBe("false");
|
||||
});
|
||||
|
||||
it("supports updater functions", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-updater", false));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((current) => !current);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
expect(window.localStorage.getItem("hook-test-updater")).toBe("true");
|
||||
});
|
||||
|
||||
it("falls back to the default value for malformed storage", () => {
|
||||
window.localStorage.setItem("hook-test-malformed", "{bad json");
|
||||
|
||||
const { result } = renderHook(() => useLocalStorage("hook-test-malformed", true));
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps in-memory state when the default object reference changes and persistence is unavailable", () => {
|
||||
localStorageMock.setShouldThrowOnSet(true);
|
||||
|
||||
const initialDefaultValue = { enabled: false };
|
||||
const updatedValue = { enabled: true };
|
||||
const { result, rerender } = renderHook(({ defaultValue }) => useLocalStorage("hook-test-object", defaultValue), {
|
||||
initialProps: { defaultValue: initialDefaultValue },
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(initialDefaultValue);
|
||||
|
||||
act(() => {
|
||||
result.current[1](updatedValue);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(updatedValue);
|
||||
|
||||
rerender({ defaultValue: { enabled: false } });
|
||||
|
||||
expect(result.current[0]).toBe(updatedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDebouncedEffect", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("runs the latest callback after the delay", () => {
|
||||
const calls: string[] = [];
|
||||
const { rerender } = renderHook(
|
||||
({ value }) => {
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
calls.push(value);
|
||||
},
|
||||
100,
|
||||
[value],
|
||||
);
|
||||
},
|
||||
{ initialProps: { value: "first" } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(99);
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
rerender({ value: "second" });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(calls).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("uses the latest callback when unrelated props rerender before the delay", () => {
|
||||
const calls: string[] = [];
|
||||
const { rerender } = renderHook(
|
||||
({ dependency, value }) => {
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
calls.push(value);
|
||||
},
|
||||
100,
|
||||
[dependency],
|
||||
);
|
||||
},
|
||||
{ initialProps: { dependency: "same", value: "first" } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
rerender({ dependency: "same", value: "second" });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
expect(calls).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("clears the pending timeout on unmount", () => {
|
||||
const calls: string[] = [];
|
||||
const { unmount } = renderHook(() => {
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
calls.push("called");
|
||||
},
|
||||
100,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue