refactor(frontend): remove react-use dependency

pull/6000/head
boojack 1 month ago
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

@ -62,7 +62,6 @@
"react-leaflet-cluster": "^4.1.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.15.0",
"react-use": "^17.6.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",

@ -143,9 +143,6 @@ importers:
react-router-dom:
specifier: ^7.15.0
version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-use:
specifier: ^17.6.0
version: 17.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
@ -1463,9 +1460,6 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/js-cookie@2.2.7':
resolution: {integrity: sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==}
'@types/katex@0.16.8':
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
@ -1561,9 +1555,6 @@ packages:
'@vitest/utils@4.1.5':
resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
'@xobotyi/scrollbar-width@1.9.5':
resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
@ -1707,9 +1698,6 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
copy-to-clipboard@3.3.3:
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
copy-to-clipboard@4.0.2:
resolution: {integrity: sha512-gklSft7IuhriZKHKpuoA1fpJSLPNgvUMWMo5BlnzAJm0zNKnznoSv23IjtNqclx8eKi6ZcdvFFzYEER/+U1LoQ==}
@ -1726,13 +1714,6 @@ packages:
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
engines: {node: '>=10'}
css-in-js-utils@3.1.0:
resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==}
css-tree@1.1.3:
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
engines: {node: '>=8.0.0'}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@ -1965,9 +1946,6 @@ packages:
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
error-stack-parser@2.1.4:
resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
@ -2000,15 +1978,6 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-shallow-equal@1.0.0:
resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==}
fastest-stable-stringify@2.0.2:
resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -2118,9 +2087,6 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
hyphenate-style-name@1.1.0:
resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==}
i18next@26.0.10:
resolution: {integrity: sha512-k3yGPAlWR2RdMYoVXJoDZDT87qeHIWKH7gVksdZMpRty7QX/D9QZeYGvN08KGbKHke9wn01eYT+EEsrqX/YTlw==}
peerDependencies:
@ -2144,9 +2110,6 @@ packages:
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
inline-style-prefixer@7.0.1:
resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==}
internmap@1.0.1:
resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
@ -2184,9 +2147,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
js-cookie@2.2.1:
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -2404,9 +2364,6 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
@ -2515,12 +2472,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nano-css@5.6.2:
resolution: {integrity: sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==}
peerDependencies:
react: '*'
react-dom: '*'
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -2699,18 +2650,6 @@ packages:
'@types/react':
optional: true
react-universal-interface@0.6.2:
resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==}
peerDependencies:
react: '*'
tslib: '*'
react-use@17.6.0:
resolution: {integrity: sha512-OmedEScUMKFfzn1Ir8dBxiLLSOzhKe/dPZwVxcujweSj45aNM7BEGPb9BEVIgVEqEXx6f3/TsXzwIktNgUR02g==}
peerDependencies:
react: '*'
react-dom: '*'
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
@ -2750,9 +2689,6 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -2773,9 +2709,6 @@ packages:
roughjs@4.6.6:
resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
rtl-css-js@1.16.1:
resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==}
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
@ -2789,10 +2722,6 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
screenfull@5.2.0:
resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==}
engines: {node: '>=0.10.0'}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@ -2800,10 +2729,6 @@ packages:
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-harmonic-interval@1.0.1:
resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==}
engines: {node: '>=6.9'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@ -2814,10 +2739,6 @@ packages:
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.5.6:
resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==}
engines: {node: '>=0.10.0'}
source-map@0.5.7:
resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==}
engines: {node: '>=0.10.0'}
@ -2829,21 +2750,9 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
stack-generator@2.0.10:
resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
stackframe@1.3.4:
resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==}
stacktrace-gps@3.1.2:
resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==}
stacktrace-js@2.0.2:
resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
@ -2891,10 +2800,6 @@ packages:
textarea-caret@3.1.0:
resolution: {integrity: sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==}
throttle-debounce@3.0.1:
resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==}
engines: {node: '>=10'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -2917,9 +2822,6 @@ packages:
resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==}
hasBin: true
toggle-selection@1.0.6:
resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==}
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
@ -2938,9 +2840,6 @@ packages:
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
engines: {node: '>=6.10'}
ts-easing@0.2.0:
resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -4381,8 +4280,6 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/js-cookie@2.2.7': {}
'@types/katex@0.16.8': {}
'@types/leaflet@1.9.21':
@ -4482,8 +4379,6 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@xobotyi/scrollbar-width@1.9.5': {}
acorn@8.16.0: {}
ansi-regex@5.0.1: {}
@ -4607,10 +4502,6 @@ snapshots:
cookie@1.1.1: {}
copy-to-clipboard@3.3.3:
dependencies:
toggle-selection: 1.0.6
copy-to-clipboard@4.0.2: {}
core-js-compat@3.49.0:
@ -4633,15 +4524,6 @@ snapshots:
path-type: 4.0.0
yaml: 1.10.3
css-in-js-utils@3.1.0:
dependencies:
hyphenate-style-name: 1.1.0
css-tree@1.1.3:
dependencies:
mdn-data: 2.0.14
source-map: 0.6.1
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
@ -4891,10 +4773,6 @@ snapshots:
dependencies:
is-arrayish: 0.2.1
error-stack-parser@2.1.4:
dependencies:
stackframe: 1.3.4
es-errors@1.3.0: {}
es-module-lexer@2.1.0: {}
@ -4915,12 +4793,6 @@ snapshots:
extend@3.0.2: {}
fast-deep-equal@3.1.3: {}
fast-shallow-equal@1.0.0: {}
fastest-stable-stringify@2.0.2: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@ -5084,8 +4956,6 @@ snapshots:
html-void-elements@3.0.0: {}
hyphenate-style-name@1.1.0: {}
i18next@26.0.10(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
@ -5103,10 +4973,6 @@ snapshots:
inline-style-parser@0.2.7: {}
inline-style-prefixer@7.0.1:
dependencies:
css-in-js-utils: 3.1.0
internmap@1.0.1: {}
internmap@2.0.3: {}
@ -5134,8 +5000,6 @@ snapshots:
jiti@2.6.1: {}
js-cookie@2.2.1: {}
js-tokens@4.0.0: {}
jsdom@29.1.1:
@ -5444,8 +5308,6 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdn-data@2.0.14: {}
mdn-data@2.27.1: {}
mermaid@11.14.0:
@ -5686,19 +5548,6 @@ snapshots:
ms@2.1.3: {}
nano-css@5.6.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
css-tree: 1.1.3
csstype: 3.2.3
fastest-stable-stringify: 2.0.2
inline-style-prefixer: 7.0.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
rtl-css-js: 1.16.1
stacktrace-js: 2.0.2
stylis: 4.4.0
nanoid@3.3.11: {}
node-releases@2.0.38: {}
@ -5879,30 +5728,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
react-universal-interface@0.6.2(react@19.2.6)(tslib@2.8.1):
dependencies:
react: 19.2.6
tslib: 2.8.1
react-use@17.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@types/js-cookie': 2.2.7
'@xobotyi/scrollbar-width': 1.9.5
copy-to-clipboard: 3.3.3
fast-deep-equal: 3.1.3
fast-shallow-equal: 1.0.0
js-cookie: 2.2.1
nano-css: 5.6.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-universal-interface: 0.6.2(react@19.2.6)(tslib@2.8.1)
resize-observer-polyfill: 1.5.1
screenfull: 5.2.0
set-harmonic-interval: 1.0.1
throttle-debounce: 3.0.1
ts-easing: 0.2.0
tslib: 2.8.1
react@19.2.6: {}
redent@3.0.0:
@ -5982,8 +5807,6 @@ snapshots:
require-from-string@2.0.2: {}
resize-observer-polyfill@1.5.1: {}
resolve-from@4.0.0: {}
resolve@1.22.12:
@ -6023,10 +5846,6 @@ snapshots:
points-on-curve: 0.2.0
points-on-path: 0.2.1
rtl-css-js@1.16.1:
dependencies:
'@babel/runtime': 7.29.2
rw@1.3.3: {}
safer-buffer@2.1.2: {}
@ -6037,14 +5856,10 @@ snapshots:
scheduler@0.27.0: {}
screenfull@5.2.0: {}
semver@6.3.1: {}
set-cookie-parser@2.7.2: {}
set-harmonic-interval@1.0.1: {}
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
@ -6054,33 +5869,14 @@ snapshots:
buffer-from: 1.1.2
source-map: 0.6.1
source-map@0.5.6: {}
source-map@0.5.7: {}
source-map@0.6.1: {}
space-separated-tokens@2.0.2: {}
stack-generator@2.0.10:
dependencies:
stackframe: 1.3.4
stackback@0.0.2: {}
stackframe@1.3.4: {}
stacktrace-gps@3.1.2:
dependencies:
source-map: 0.5.6
stackframe: 1.3.4
stacktrace-js@2.0.2:
dependencies:
error-stack-parser: 2.1.4
stack-generator: 2.0.10
stacktrace-gps: 3.1.2
std-env@4.1.0: {}
stringify-entities@4.0.4:
@ -6123,8 +5919,6 @@ snapshots:
textarea-caret@3.1.0: {}
throttle-debounce@3.0.1: {}
tinybench@2.9.0: {}
tinyexec@1.1.1: {}
@ -6142,8 +5936,6 @@ snapshots:
dependencies:
tldts-core: 7.0.28
toggle-selection@1.0.6: {}
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.28
@ -6158,8 +5950,6 @@ snapshots:
ts-dedent@2.2.0: {}
ts-easing@0.2.0: {}
tslib@2.8.1: {}
tw-animate-css@1.4.0: {}

@ -12,7 +12,6 @@ import {
PlusIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDebounce } from "react-use";
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
import type { MapPoint } from "@/components/map/types";
import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
@ -28,6 +27,7 @@ import {
DropdownMenuTrigger,
useDropdownMenuSubHoverDelay,
} from "@/components/ui/dropdown-menu";
import { useDebouncedEffect } from "@/hooks";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
@ -76,7 +76,7 @@ const InsertMenu = (props: InsertMenuProps) => {
const [debouncedPosition, setDebouncedPosition] = useState<MapPoint | undefined>(undefined);
useDebounce(
useDebouncedEffect(
() => {
setDebouncedPosition(locationState.position);
},

@ -1,9 +1,9 @@
import { create } from "@bufbuild/protobuf";
import { useEffect, useMemo, useState } from "react";
import useDebounce from "react-use/lib/useDebounce";
import { memoServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { buildMemoCreatorFilter } from "@/helpers/resource-names";
import { useDebouncedEffect } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import {
type Memo,
@ -38,7 +38,7 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
}
}, [isOpen]);
useDebounce(
useDebouncedEffect(
async () => {
if (!isOpen) return;

@ -1,7 +1,7 @@
import { HashIcon, MoreVerticalIcon, TagsIcon } from "lucide-react";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { Switch } from "@/components/ui/switch";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useLocalStorage } from "@/hooks";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import TagTree from "../TagTree";

@ -1,4 +1,4 @@
import useWindowScroll from "react-use/lib/useWindowScroll";
import { useEffect, useState } from "react";
import useMediaQuery from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
import NavigationDrawer from "./NavigationDrawer";
@ -10,10 +10,23 @@ interface Props {
const MobileHeader = (props: Props) => {
const { className, children } = props;
const { y: offsetTop } = useWindowScroll();
const [offsetTop, setOffsetTop] = useState(() => (typeof window === "undefined" ? 0 : window.scrollY));
const md = useMediaQuery("md");
const sm = useMediaQuery("sm");
useEffect(() => {
const handleScroll = () => {
setOffsetTop(window.scrollY);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
if (md) return null;
return (

@ -1,6 +1,5 @@
import { ChevronRightIcon, HashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import useToggle from "react-use/lib/useToggle";
import { useCallback, useEffect, useState } from "react";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
interface Tag {
@ -91,10 +90,10 @@ const TagItemContainer = (props: TagItemContainerProps) => {
const tagFilters = getFiltersByFactor("tagSearch");
const isActive = tagFilters.some((f: MemoFilter) => f.value === tag.text);
const hasSubTags = tag.subTags.length > 0;
const [showSubTags, toggleSubTags] = useToggle(false);
const [showSubTags, setShowSubTags] = useState(false);
useEffect(() => {
toggleSubTags(expandSubTags);
setShowSubTags(expandSubTags);
}, [expandSubTags]);
const handleTagClick = () => {
@ -110,10 +109,10 @@ const TagItemContainer = (props: TagItemContainerProps) => {
}
};
const handleToggleBtnClick = (event: React.MouseEvent) => {
const handleToggleBtnClick = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
toggleSubTags();
};
setShowSubTags((current) => !current);
}, []);
return (
<>

@ -1,8 +1,10 @@
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";

@ -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];
};

@ -1,6 +1,5 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { Outlet, useLocation, useSearchParams } from "react-router-dom";
import usePrevious from "react-use/lib/usePrevious";
import Navigation from "@/components/Navigation";
import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
@ -33,14 +32,18 @@ const RootLayout = () => {
const { profile } = useInstance();
const { removeFilter } = useMemoFilterContext();
const { pathname } = location;
const prevPathname = usePrevious(pathname);
const prevPathnameRef = useRef<string | undefined>(undefined);
useEffect(() => {
const prevPathname = prevPathnameRef.current;
// When the route changes and there is no filter in the search params, remove all filters.
if (prevPathname !== pathname && !searchParams.has("filter")) {
if (prevPathname !== undefined && prevPathname !== pathname && !searchParams.has("filter")) {
removeFilter(() => true);
}
}, [prevPathname, pathname, searchParams, removeFilter]);
prevPathnameRef.current = pathname;
}, [pathname, searchParams, removeFilter]);
return (
<div className="w-full min-h-full flex flex-row justify-center items-start sm:pl-16">

@ -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…
Cancel
Save