@react-spa-scaffold/mcp 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +1 -0
- package/dist/constants.js.map +1 -1
- package/dist/features/definitions/auth.d.ts +3 -0
- package/dist/features/definitions/auth.d.ts.map +1 -0
- package/dist/features/definitions/auth.js +17 -0
- package/dist/features/definitions/auth.js.map +1 -0
- package/dist/features/definitions/core.d.ts.map +1 -1
- package/dist/features/definitions/core.js +16 -1
- package/dist/features/definitions/core.js.map +1 -1
- package/dist/features/definitions/forms.d.ts.map +1 -1
- package/dist/features/definitions/forms.js +4 -0
- package/dist/features/definitions/forms.js.map +1 -1
- package/dist/features/definitions/index.d.ts +1 -0
- package/dist/features/definitions/index.d.ts.map +1 -1
- package/dist/features/definitions/index.js +1 -0
- package/dist/features/definitions/index.js.map +1 -1
- package/dist/features/definitions/mobile.d.ts.map +1 -1
- package/dist/features/definitions/mobile.js +11 -2
- package/dist/features/definitions/mobile.js.map +1 -1
- package/dist/features/definitions/observability.js +1 -1
- package/dist/features/definitions/observability.js.map +1 -1
- package/dist/features/definitions/routing.d.ts.map +1 -1
- package/dist/features/definitions/routing.js +2 -1
- package/dist/features/definitions/routing.js.map +1 -1
- package/dist/features/definitions/state.d.ts.map +1 -1
- package/dist/features/definitions/state.js +9 -2
- package/dist/features/definitions/state.js.map +1 -1
- package/dist/features/definitions/testing.d.ts.map +1 -1
- package/dist/features/definitions/testing.js +4 -2
- package/dist/features/definitions/testing.js.map +1 -1
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +2 -1
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.test.js +4 -2
- package/dist/features/types.test.js.map +1 -1
- package/dist/tools/get-scaffold.d.ts.map +1 -1
- package/dist/tools/get-scaffold.js +6 -3
- package/dist/tools/get-scaffold.js.map +1 -1
- package/dist/tools/get-scaffold.test.js +5 -2
- package/dist/tools/get-scaffold.test.js.map +1 -1
- package/dist/utils/scaffold/compute.d.ts.map +1 -1
- package/dist/utils/scaffold/compute.js +3 -1
- package/dist/utils/scaffold/compute.js.map +1 -1
- package/dist/utils/scaffold/generators.d.ts.map +1 -1
- package/dist/utils/scaffold/generators.js +7 -0
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +6 -0
- package/templates/.github/workflows/ci.yml +8 -3
- package/templates/CLAUDE.md +74 -1
- package/templates/docs/ARCHITECTURE.md +13 -12
- package/templates/docs/CODING_STANDARDS.md +65 -0
- package/templates/docs/E2E_TESTING.md +52 -7
- package/templates/e2e/fixtures/index.ts +13 -2
- package/templates/package.json +7 -3
- package/templates/playwright.config.ts +6 -1
- package/templates/src/components/layout/Header.tsx +2 -1
- package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
- package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
- package/templates/src/components/shared/AccountButton/index.ts +1 -0
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +43 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
- package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
- package/templates/src/components/shared/index.ts +4 -2
- package/templates/src/contexts/clerkContext.tsx +45 -0
- package/templates/src/hooks/index.ts +23 -2
- package/templates/src/hooks/useCopyFeedback.test.ts +129 -0
- package/templates/src/hooks/useCopyFeedback.ts +41 -0
- package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
- package/templates/src/hooks/useDebouncedCallback.ts +47 -0
- package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
- package/templates/src/hooks/useDocumentTitle.ts +31 -0
- package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
- package/templates/src/hooks/useIOSViewportReset.ts +18 -0
- package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
- package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
- package/templates/src/hooks/useLocalStorage.test.ts +111 -0
- package/templates/src/hooks/useLocalStorage.ts +77 -0
- package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
- package/templates/src/hooks/useSyncedFormData.ts +21 -0
- package/templates/src/hooks/useSyncedState.test.ts +119 -0
- package/templates/src/hooks/useSyncedState.ts +30 -0
- package/templates/src/index.css +1 -0
- package/templates/src/lib/constants.ts +10 -0
- package/templates/src/lib/createSelectors.test.ts +136 -0
- package/templates/src/lib/createSelectors.ts +31 -0
- package/templates/src/lib/index.ts +1 -0
- package/templates/src/lib/sentry.ts +55 -0
- package/templates/src/lib/storage.ts +6 -2
- package/templates/src/main.tsx +18 -8
- package/templates/src/stores/preferencesStore.ts +34 -9
- package/templates/src/test/clerkMock.tsx +97 -0
- package/templates/src/test/index.ts +3 -0
- package/templates/src/test/providers.tsx +7 -4
- package/templates/src/test-setup.ts +16 -2
- package/templates/vitest.config.ts +9 -1
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
vi.mock('@/lib/config', () => ({
|
|
5
|
+
APP_CONFIG: { name: 'TestApp' },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import { useDocumentTitle } from './useDocumentTitle';
|
|
9
|
+
|
|
10
|
+
describe('useDocumentTitle', () => {
|
|
11
|
+
const originalTitle = 'Original Title';
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
document.title = originalTitle;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.title = originalTitle;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('sets document title with app name suffix', () => {
|
|
22
|
+
renderHook(() => useDocumentTitle('My Page'));
|
|
23
|
+
expect(document.title).toBe('My Page | TestApp');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('sets just app name when title is undefined', () => {
|
|
27
|
+
renderHook(() => useDocumentTitle(undefined));
|
|
28
|
+
expect(document.title).toBe('TestApp');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('sets just app name when title is empty string', () => {
|
|
32
|
+
renderHook(() => useDocumentTitle(''));
|
|
33
|
+
expect(document.title).toBe('TestApp');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('restores previous title on unmount', () => {
|
|
37
|
+
const { unmount } = renderHook(() => useDocumentTitle('My Page'));
|
|
38
|
+
expect(document.title).toBe('My Page | TestApp');
|
|
39
|
+
|
|
40
|
+
unmount();
|
|
41
|
+
expect(document.title).toBe(originalTitle);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('updates title when title prop changes', () => {
|
|
45
|
+
const { rerender } = renderHook(({ title }) => useDocumentTitle(title), {
|
|
46
|
+
initialProps: { title: 'Page 1' },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(document.title).toBe('Page 1 | TestApp');
|
|
50
|
+
|
|
51
|
+
rerender({ title: 'Page 2' });
|
|
52
|
+
expect(document.title).toBe('Page 2 | TestApp');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles title with special characters', () => {
|
|
56
|
+
renderHook(() => useDocumentTitle('Page & "Title" | More'));
|
|
57
|
+
expect(document.title).toBe('Page & "Title" | More | TestApp');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { APP_CONFIG } from '@/lib/config';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sets the document title dynamically, with cleanup on unmount.
|
|
7
|
+
*
|
|
8
|
+
* @param title - The page title (will be formatted as "{title} | {APP_NAME}")
|
|
9
|
+
*/
|
|
10
|
+
export function useDocumentTitle(title?: string): void {
|
|
11
|
+
const previousTitleRef = useRef<string | undefined>(undefined);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (typeof document === 'undefined') return;
|
|
15
|
+
|
|
16
|
+
// Store previous title on first run
|
|
17
|
+
if (previousTitleRef.current === undefined) {
|
|
18
|
+
previousTitleRef.current = document.title;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Format title as "{title} | {APP_NAME}" or just "{APP_NAME}" if empty
|
|
22
|
+
document.title = title ? `${title} | ${APP_CONFIG.name}` : APP_CONFIG.name;
|
|
23
|
+
|
|
24
|
+
return () => {
|
|
25
|
+
// Restore previous title on unmount
|
|
26
|
+
if (previousTitleRef.current !== undefined) {
|
|
27
|
+
document.title = previousTitleRef.current;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}, [title]);
|
|
31
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useIOSViewportReset } from './useIOSViewportReset';
|
|
5
|
+
|
|
6
|
+
describe('useIOSViewportReset', () => {
|
|
7
|
+
it('returns a stable blur handler function', () => {
|
|
8
|
+
const { result, rerender } = renderHook(() => useIOSViewportReset());
|
|
9
|
+
|
|
10
|
+
const firstRef = result.current;
|
|
11
|
+
rerender();
|
|
12
|
+
const secondRef = result.current;
|
|
13
|
+
|
|
14
|
+
expect(typeof firstRef).toBe('function');
|
|
15
|
+
expect(firstRef).toBe(secondRef);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('scrolls to top when blur handler is called', () => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
|
21
|
+
|
|
22
|
+
const { result } = renderHook(() => useIOSViewportReset());
|
|
23
|
+
|
|
24
|
+
// Call the blur handler
|
|
25
|
+
result.current();
|
|
26
|
+
|
|
27
|
+
// Should not scroll immediately
|
|
28
|
+
expect(scrollToSpy).not.toHaveBeenCalled();
|
|
29
|
+
|
|
30
|
+
// Advance past the 50ms delay
|
|
31
|
+
vi.advanceTimersByTime(50);
|
|
32
|
+
|
|
33
|
+
expect(scrollToSpy).toHaveBeenCalledWith(0, 0);
|
|
34
|
+
|
|
35
|
+
scrollToSpy.mockRestore();
|
|
36
|
+
vi.useRealTimers();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is safe to call handler multiple times', () => {
|
|
40
|
+
vi.useFakeTimers();
|
|
41
|
+
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
|
42
|
+
|
|
43
|
+
const { result } = renderHook(() => useIOSViewportReset());
|
|
44
|
+
|
|
45
|
+
// Call multiple times
|
|
46
|
+
result.current();
|
|
47
|
+
result.current();
|
|
48
|
+
result.current();
|
|
49
|
+
|
|
50
|
+
vi.advanceTimersByTime(50);
|
|
51
|
+
|
|
52
|
+
// Each call triggers a scroll
|
|
53
|
+
expect(scrollToSpy).toHaveBeenCalledTimes(3);
|
|
54
|
+
|
|
55
|
+
scrollToSpy.mockRestore();
|
|
56
|
+
vi.useRealTimers();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a blur handler that fixes iOS Safari's viewport scroll bug
|
|
5
|
+
* when the keyboard is dismissed.
|
|
6
|
+
*
|
|
7
|
+
* Uses a 50ms delay to allow the viewport to settle before resetting.
|
|
8
|
+
*/
|
|
9
|
+
export function useIOSViewportReset(): () => void {
|
|
10
|
+
const handleBlur = useCallback(() => {
|
|
11
|
+
// Use setTimeout to allow viewport to settle after keyboard dismissal
|
|
12
|
+
setTimeout(() => {
|
|
13
|
+
window.scrollTo(0, 0);
|
|
14
|
+
}, 50);
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
return handleBlur;
|
|
18
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useKeyboardShortcut } from './useKeyboardShortcuts';
|
|
5
|
+
|
|
6
|
+
// Mock react-hotkeys-hook
|
|
7
|
+
vi.mock('react-hotkeys-hook', () => ({
|
|
8
|
+
useHotkeys: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { useHotkeys } from 'react-hotkeys-hook';
|
|
12
|
+
|
|
13
|
+
const mockedUseHotkeys = vi.mocked(useHotkeys);
|
|
14
|
+
|
|
15
|
+
describe('useKeyboardShortcut', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockedUseHotkeys.mockClear();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('calls useHotkeys with the correct shortcut and handler', () => {
|
|
21
|
+
const handler = vi.fn();
|
|
22
|
+
|
|
23
|
+
renderHook(() => useKeyboardShortcut('mod+s', handler));
|
|
24
|
+
|
|
25
|
+
expect(mockedUseHotkeys).toHaveBeenCalledWith(
|
|
26
|
+
'mod+s',
|
|
27
|
+
handler,
|
|
28
|
+
expect.objectContaining({
|
|
29
|
+
enableOnFormTags: false,
|
|
30
|
+
preventDefault: true,
|
|
31
|
+
}),
|
|
32
|
+
[handler],
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('respects custom options', () => {
|
|
37
|
+
const handler = vi.fn();
|
|
38
|
+
|
|
39
|
+
renderHook(() =>
|
|
40
|
+
useKeyboardShortcut('mod+enter', handler, {
|
|
41
|
+
enableOnFormTags: true,
|
|
42
|
+
preventDefault: false,
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(mockedUseHotkeys).toHaveBeenCalledWith(
|
|
47
|
+
'mod+enter',
|
|
48
|
+
handler,
|
|
49
|
+
expect.objectContaining({
|
|
50
|
+
enableOnFormTags: true,
|
|
51
|
+
preventDefault: false,
|
|
52
|
+
}),
|
|
53
|
+
[handler],
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('uses default options when not provided', () => {
|
|
58
|
+
const handler = vi.fn();
|
|
59
|
+
|
|
60
|
+
renderHook(() => useKeyboardShortcut('escape', handler));
|
|
61
|
+
|
|
62
|
+
expect(mockedUseHotkeys).toHaveBeenCalledWith(
|
|
63
|
+
'escape',
|
|
64
|
+
handler,
|
|
65
|
+
expect.objectContaining({
|
|
66
|
+
enableOnFormTags: false,
|
|
67
|
+
preventDefault: true,
|
|
68
|
+
}),
|
|
69
|
+
[handler],
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('can be used for multiple shortcuts by calling hook multiple times', () => {
|
|
74
|
+
const saveHandler = vi.fn();
|
|
75
|
+
const undoHandler = vi.fn();
|
|
76
|
+
|
|
77
|
+
renderHook(() => {
|
|
78
|
+
useKeyboardShortcut('mod+s', saveHandler);
|
|
79
|
+
useKeyboardShortcut('mod+z', undoHandler);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(mockedUseHotkeys).toHaveBeenCalledTimes(2);
|
|
83
|
+
expect(mockedUseHotkeys).toHaveBeenCalledWith('mod+s', saveHandler, expect.any(Object), [saveHandler]);
|
|
84
|
+
expect(mockedUseHotkeys).toHaveBeenCalledWith('mod+z', undoHandler, expect.any(Object), [undoHandler]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useHotkeys, type Options } from 'react-hotkeys-hook';
|
|
2
|
+
|
|
3
|
+
interface ShortcutOptions {
|
|
4
|
+
/** Enable shortcuts in form tags (input, textarea, select). Default: false */
|
|
5
|
+
enableOnFormTags?: boolean;
|
|
6
|
+
/** Prevent default browser behavior. Default: true */
|
|
7
|
+
preventDefault?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_OPTIONS: Required<ShortcutOptions> = {
|
|
11
|
+
enableOnFormTags: false,
|
|
12
|
+
preventDefault: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Registers a single keyboard shortcut with platform-aware modifiers.
|
|
17
|
+
* Uses react-hotkeys-hook under the hood.
|
|
18
|
+
*
|
|
19
|
+
* For multiple shortcuts, call this hook once for each shortcut.
|
|
20
|
+
*
|
|
21
|
+
* @param shortcut - The keyboard shortcut (e.g., 'mod+s', 'mod+enter')
|
|
22
|
+
* @param handler - Function to call when shortcut is triggered
|
|
23
|
+
* @param options - Configuration options for shortcut behavior
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* // Single shortcut
|
|
28
|
+
* useKeyboardShortcut('mod+s', () => handleSave());
|
|
29
|
+
*
|
|
30
|
+
* // Multiple shortcuts (call the hook multiple times)
|
|
31
|
+
* useKeyboardShortcut('mod+s', () => handleSave());
|
|
32
|
+
* useKeyboardShortcut('mod+z', () => handleUndo());
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function useKeyboardShortcut(shortcut: string, handler: () => void, options: ShortcutOptions = {}): void {
|
|
36
|
+
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
37
|
+
|
|
38
|
+
const hotkeyOptions: Options = {
|
|
39
|
+
enableOnFormTags: mergedOptions.enableOnFormTags,
|
|
40
|
+
preventDefault: mergedOptions.preventDefault,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
useHotkeys(shortcut, handler, hotkeyOptions, [handler]);
|
|
44
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useLocalStorage } from './useLocalStorage';
|
|
5
|
+
|
|
6
|
+
describe('useLocalStorage', () => {
|
|
7
|
+
const key = 'test-key';
|
|
8
|
+
const initialValue = 'initial';
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
localStorage.clear();
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns initial value when localStorage is empty', () => {
|
|
16
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
17
|
+
expect(result.current[0]).toBe(initialValue);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns stored value from localStorage', () => {
|
|
21
|
+
localStorage.setItem(key, JSON.stringify('stored'));
|
|
22
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
23
|
+
expect(result.current[0]).toBe('stored');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('updates value with direct setter', () => {
|
|
27
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
28
|
+
|
|
29
|
+
act(() => {
|
|
30
|
+
result.current[1]('new-value');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result.current[0]).toBe('new-value');
|
|
34
|
+
expect(JSON.parse(localStorage.getItem(key)!)).toBe('new-value');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('updates value with updater function', () => {
|
|
38
|
+
const { result } = renderHook(() => useLocalStorage(key, 0));
|
|
39
|
+
|
|
40
|
+
act(() => {
|
|
41
|
+
result.current[1]((prev) => prev + 1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result.current[0]).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('handles complex objects', () => {
|
|
48
|
+
const initial = { name: 'test', count: 0 };
|
|
49
|
+
const { result } = renderHook(() => useLocalStorage(key, initial));
|
|
50
|
+
|
|
51
|
+
act(() => {
|
|
52
|
+
result.current[1]({ name: 'updated', count: 1 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.current[0]).toEqual({ name: 'updated', count: 1 });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('re-reads from localStorage when key changes', () => {
|
|
59
|
+
localStorage.setItem('key-a', JSON.stringify('value-a'));
|
|
60
|
+
localStorage.setItem('key-b', JSON.stringify('value-b'));
|
|
61
|
+
|
|
62
|
+
const { result, rerender } = renderHook(({ k }) => useLocalStorage(k, 'default'), {
|
|
63
|
+
initialProps: { k: 'key-a' },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result.current[0]).toBe('value-a');
|
|
67
|
+
|
|
68
|
+
rerender({ k: 'key-b' });
|
|
69
|
+
expect(result.current[0]).toBe('value-b');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('handles JSON parse errors gracefully', () => {
|
|
73
|
+
localStorage.setItem(key, 'not-valid-json');
|
|
74
|
+
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
75
|
+
|
|
76
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
77
|
+
|
|
78
|
+
expect(result.current[0]).toBe(initialValue);
|
|
79
|
+
consoleWarn.mockRestore();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('syncs across tabs via storage event', () => {
|
|
83
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
84
|
+
|
|
85
|
+
act(() => {
|
|
86
|
+
window.dispatchEvent(
|
|
87
|
+
new StorageEvent('storage', {
|
|
88
|
+
key,
|
|
89
|
+
newValue: JSON.stringify('from-other-tab'),
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result.current[0]).toBe('from-other-tab');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('ignores storage events for different keys', () => {
|
|
98
|
+
const { result } = renderHook(() => useLocalStorage(key, initialValue));
|
|
99
|
+
|
|
100
|
+
act(() => {
|
|
101
|
+
window.dispatchEvent(
|
|
102
|
+
new StorageEvent('storage', {
|
|
103
|
+
key: 'other-key',
|
|
104
|
+
newValue: JSON.stringify('other-value'),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(result.current[0]).toBe(initialValue);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useState-like hook with localStorage persistence.
|
|
5
|
+
* Syncs state across tabs via storage events.
|
|
6
|
+
*
|
|
7
|
+
* @param key - localStorage key
|
|
8
|
+
* @param initialValue - Default value when key doesn't exist
|
|
9
|
+
*/
|
|
10
|
+
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
|
11
|
+
// Lazy initialization reads from localStorage on first render
|
|
12
|
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
13
|
+
if (typeof window === 'undefined') {
|
|
14
|
+
return initialValue;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const item = localStorage.getItem(key);
|
|
18
|
+
return item ? (JSON.parse(item) as T) : initialValue;
|
|
19
|
+
} catch {
|
|
20
|
+
console.warn(`Failed to parse localStorage key "${key}"`);
|
|
21
|
+
return initialValue;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Track the previous key to detect changes
|
|
26
|
+
const [prevKey, setPrevKey] = useState(key);
|
|
27
|
+
|
|
28
|
+
// Re-read from localStorage when key changes
|
|
29
|
+
if (key !== prevKey) {
|
|
30
|
+
setPrevKey(key);
|
|
31
|
+
try {
|
|
32
|
+
const item = localStorage.getItem(key);
|
|
33
|
+
const newValue = item ? (JSON.parse(item) as T) : initialValue;
|
|
34
|
+
setStoredValue(newValue);
|
|
35
|
+
} catch {
|
|
36
|
+
setStoredValue(initialValue);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Sync to localStorage whenever value changes
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (typeof window === 'undefined') return;
|
|
43
|
+
try {
|
|
44
|
+
localStorage.setItem(key, JSON.stringify(storedValue));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.warn(`Failed to write localStorage key "${key}":`, error);
|
|
47
|
+
}
|
|
48
|
+
}, [key, storedValue]);
|
|
49
|
+
|
|
50
|
+
// Setter that supports both direct values and updater functions
|
|
51
|
+
const setValue = useCallback((value: T | ((prev: T) => T)) => {
|
|
52
|
+
setStoredValue((prev) => {
|
|
53
|
+
const nextValue = value instanceof Function ? value(prev) : value;
|
|
54
|
+
return nextValue;
|
|
55
|
+
});
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
// Sync across tabs via storage events
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (typeof window === 'undefined') return;
|
|
61
|
+
|
|
62
|
+
const handleStorage = (e: StorageEvent) => {
|
|
63
|
+
if (e.key === key && e.newValue !== null) {
|
|
64
|
+
try {
|
|
65
|
+
setStoredValue(JSON.parse(e.newValue) as T);
|
|
66
|
+
} catch {
|
|
67
|
+
// Ignore parse errors from other tabs
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
window.addEventListener('storage', handleStorage);
|
|
73
|
+
return () => window.removeEventListener('storage', handleStorage);
|
|
74
|
+
}, [key]);
|
|
75
|
+
|
|
76
|
+
return [storedValue, setValue];
|
|
77
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useSyncedFormData } from './useSyncedFormData';
|
|
5
|
+
|
|
6
|
+
describe('useSyncedFormData', () => {
|
|
7
|
+
it('returns source data initially', () => {
|
|
8
|
+
const { result } = renderHook(() => useSyncedFormData({ name: 'test' }, 'trigger-1'));
|
|
9
|
+
expect(result.current[0]).toEqual({ name: 'test' });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('provides a setter function', () => {
|
|
13
|
+
const { result } = renderHook(() => useSyncedFormData({ name: 'initial' }, 'trigger-1'));
|
|
14
|
+
expect(typeof result.current[1]).toBe('function');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('syncs when trigger value changes', () => {
|
|
18
|
+
const { result, rerender } = renderHook(
|
|
19
|
+
({ sourceData, syncTrigger }) => useSyncedFormData(sourceData, syncTrigger),
|
|
20
|
+
{ initialProps: { sourceData: { name: 'v1' }, syncTrigger: 'trigger-1' } },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
expect(result.current[0]).toEqual({ name: 'v1' });
|
|
24
|
+
|
|
25
|
+
// Trigger changes - should sync to new source data
|
|
26
|
+
rerender({ sourceData: { name: 'v2' }, syncTrigger: 'trigger-2' });
|
|
27
|
+
expect(result.current[0]).toEqual({ name: 'v2' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('syncs when source data changes with same trigger', () => {
|
|
31
|
+
const { result, rerender } = renderHook(
|
|
32
|
+
({ sourceData, syncTrigger }) => useSyncedFormData(sourceData, syncTrigger),
|
|
33
|
+
{ initialProps: { sourceData: { name: 'v1' }, syncTrigger: 'trigger-1' } },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Source data changes (trigger also changes per useEffect deps)
|
|
37
|
+
rerender({ sourceData: { name: 'v2' }, syncTrigger: 'trigger-1' });
|
|
38
|
+
// Per the implementation, it syncs when either trigger or sourceData changes
|
|
39
|
+
expect(result.current[0]).toEqual({ name: 'v2' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('handles dialog open/close pattern (boolean trigger)', () => {
|
|
43
|
+
const { result, rerender } = renderHook(
|
|
44
|
+
({ sourceData, syncTrigger }) => useSyncedFormData(sourceData, syncTrigger),
|
|
45
|
+
{ initialProps: { sourceData: { name: 'original' }, syncTrigger: false } },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Dialog opens
|
|
49
|
+
rerender({ sourceData: { name: 'original' }, syncTrigger: true });
|
|
50
|
+
expect(result.current[0]).toEqual({ name: 'original' });
|
|
51
|
+
|
|
52
|
+
// Dialog closes
|
|
53
|
+
rerender({ sourceData: { name: 'original' }, syncTrigger: false });
|
|
54
|
+
expect(result.current[0]).toEqual({ name: 'original' });
|
|
55
|
+
|
|
56
|
+
// Dialog reopens with new data
|
|
57
|
+
rerender({ sourceData: { name: 'new-data' }, syncTrigger: true });
|
|
58
|
+
expect(result.current[0]).toEqual({ name: 'new-data' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('handles ID-based trigger pattern', () => {
|
|
62
|
+
const { result, rerender } = renderHook(
|
|
63
|
+
({ sourceData, syncTrigger }) => useSyncedFormData(sourceData, syncTrigger),
|
|
64
|
+
{ initialProps: { sourceData: { name: 'item-1' }, syncTrigger: 'id-1' } },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Switch to item 2
|
|
68
|
+
rerender({ sourceData: { name: 'item-2' }, syncTrigger: 'id-2' });
|
|
69
|
+
expect(result.current[0]).toEqual({ name: 'item-2' });
|
|
70
|
+
|
|
71
|
+
// Switch back to item 1
|
|
72
|
+
rerender({ sourceData: { name: 'item-1' }, syncTrigger: 'id-1' });
|
|
73
|
+
expect(result.current[0]).toEqual({ name: 'item-1' });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Syncs form data when a trigger condition changes (e.g., dialog opens, ID changes).
|
|
5
|
+
* Similar to useSyncedState but trigger-based rather than active-state-based.
|
|
6
|
+
*
|
|
7
|
+
* @param sourceData - The source data to sync from
|
|
8
|
+
* @param syncTrigger - Value that triggers a re-sync when changed (e.g., item ID, dialog open state)
|
|
9
|
+
*/
|
|
10
|
+
export function useSyncedFormData<T>(sourceData: T, syncTrigger: unknown): [T, Dispatch<SetStateAction<T>>] {
|
|
11
|
+
const [formData, setFormData] = useState<T>(sourceData);
|
|
12
|
+
|
|
13
|
+
// Sync form data when trigger changes
|
|
14
|
+
// This is an intentional pattern for synchronizing external props to local state
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional sync pattern
|
|
17
|
+
setFormData(sourceData);
|
|
18
|
+
}, [syncTrigger, sourceData]);
|
|
19
|
+
|
|
20
|
+
return [formData, setFormData];
|
|
21
|
+
}
|