@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.
Files changed (101) hide show
  1. package/README.md +2 -1
  2. package/dist/constants.d.ts +1 -0
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +1 -0
  5. package/dist/constants.js.map +1 -1
  6. package/dist/features/definitions/auth.d.ts +3 -0
  7. package/dist/features/definitions/auth.d.ts.map +1 -0
  8. package/dist/features/definitions/auth.js +17 -0
  9. package/dist/features/definitions/auth.js.map +1 -0
  10. package/dist/features/definitions/core.d.ts.map +1 -1
  11. package/dist/features/definitions/core.js +16 -1
  12. package/dist/features/definitions/core.js.map +1 -1
  13. package/dist/features/definitions/forms.d.ts.map +1 -1
  14. package/dist/features/definitions/forms.js +4 -0
  15. package/dist/features/definitions/forms.js.map +1 -1
  16. package/dist/features/definitions/index.d.ts +1 -0
  17. package/dist/features/definitions/index.d.ts.map +1 -1
  18. package/dist/features/definitions/index.js +1 -0
  19. package/dist/features/definitions/index.js.map +1 -1
  20. package/dist/features/definitions/mobile.d.ts.map +1 -1
  21. package/dist/features/definitions/mobile.js +11 -2
  22. package/dist/features/definitions/mobile.js.map +1 -1
  23. package/dist/features/definitions/observability.js +1 -1
  24. package/dist/features/definitions/observability.js.map +1 -1
  25. package/dist/features/definitions/routing.d.ts.map +1 -1
  26. package/dist/features/definitions/routing.js +2 -1
  27. package/dist/features/definitions/routing.js.map +1 -1
  28. package/dist/features/definitions/state.d.ts.map +1 -1
  29. package/dist/features/definitions/state.js +9 -2
  30. package/dist/features/definitions/state.js.map +1 -1
  31. package/dist/features/definitions/testing.d.ts.map +1 -1
  32. package/dist/features/definitions/testing.js +4 -2
  33. package/dist/features/definitions/testing.js.map +1 -1
  34. package/dist/features/registry.d.ts.map +1 -1
  35. package/dist/features/registry.js +2 -1
  36. package/dist/features/registry.js.map +1 -1
  37. package/dist/features/types.test.js +4 -2
  38. package/dist/features/types.test.js.map +1 -1
  39. package/dist/tools/get-scaffold.d.ts.map +1 -1
  40. package/dist/tools/get-scaffold.js +6 -3
  41. package/dist/tools/get-scaffold.js.map +1 -1
  42. package/dist/tools/get-scaffold.test.js +5 -2
  43. package/dist/tools/get-scaffold.test.js.map +1 -1
  44. package/dist/utils/scaffold/compute.d.ts.map +1 -1
  45. package/dist/utils/scaffold/compute.js +3 -1
  46. package/dist/utils/scaffold/compute.js.map +1 -1
  47. package/dist/utils/scaffold/generators.d.ts.map +1 -1
  48. package/dist/utils/scaffold/generators.js +7 -0
  49. package/dist/utils/scaffold/generators.js.map +1 -1
  50. package/package.json +1 -1
  51. package/templates/.env.example +6 -0
  52. package/templates/.github/workflows/ci.yml +8 -3
  53. package/templates/CLAUDE.md +74 -1
  54. package/templates/docs/ARCHITECTURE.md +13 -12
  55. package/templates/docs/CODING_STANDARDS.md +65 -0
  56. package/templates/docs/E2E_TESTING.md +52 -7
  57. package/templates/e2e/fixtures/index.ts +13 -2
  58. package/templates/package.json +7 -3
  59. package/templates/playwright.config.ts +6 -1
  60. package/templates/src/components/layout/Header.tsx +2 -1
  61. package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
  62. package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
  63. package/templates/src/components/shared/AccountButton/index.ts +1 -0
  64. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
  65. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
  66. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +43 -0
  67. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
  68. package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
  69. package/templates/src/components/shared/index.ts +4 -2
  70. package/templates/src/contexts/clerkContext.tsx +45 -0
  71. package/templates/src/hooks/index.ts +23 -2
  72. package/templates/src/hooks/useCopyFeedback.test.ts +129 -0
  73. package/templates/src/hooks/useCopyFeedback.ts +41 -0
  74. package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
  75. package/templates/src/hooks/useDebouncedCallback.ts +47 -0
  76. package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
  77. package/templates/src/hooks/useDocumentTitle.ts +31 -0
  78. package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
  79. package/templates/src/hooks/useIOSViewportReset.ts +18 -0
  80. package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
  81. package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
  82. package/templates/src/hooks/useLocalStorage.test.ts +111 -0
  83. package/templates/src/hooks/useLocalStorage.ts +77 -0
  84. package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
  85. package/templates/src/hooks/useSyncedFormData.ts +21 -0
  86. package/templates/src/hooks/useSyncedState.test.ts +119 -0
  87. package/templates/src/hooks/useSyncedState.ts +30 -0
  88. package/templates/src/index.css +1 -0
  89. package/templates/src/lib/constants.ts +10 -0
  90. package/templates/src/lib/createSelectors.test.ts +136 -0
  91. package/templates/src/lib/createSelectors.ts +31 -0
  92. package/templates/src/lib/index.ts +1 -0
  93. package/templates/src/lib/sentry.ts +55 -0
  94. package/templates/src/lib/storage.ts +6 -2
  95. package/templates/src/main.tsx +18 -8
  96. package/templates/src/stores/preferencesStore.ts +34 -9
  97. package/templates/src/test/clerkMock.tsx +97 -0
  98. package/templates/src/test/index.ts +3 -0
  99. package/templates/src/test/providers.tsx +7 -4
  100. package/templates/src/test-setup.ts +16 -2
  101. package/templates/vitest.config.ts +9 -1
@@ -0,0 +1,119 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { useSyncedState } from './useSyncedState';
5
+
6
+ describe('useSyncedState', () => {
7
+ it('returns external value initially', () => {
8
+ const { result } = renderHook(() => useSyncedState('initial', false));
9
+ expect(result.current[0]).toBe('initial');
10
+ });
11
+
12
+ it('updates local value via setter', () => {
13
+ const { result } = renderHook(() => useSyncedState('initial', false));
14
+
15
+ act(() => {
16
+ result.current[1]('local-change');
17
+ });
18
+
19
+ expect(result.current[0]).toBe('local-change');
20
+ });
21
+
22
+ it('syncs with external value when not active', () => {
23
+ const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
24
+ initialProps: { externalValue: 'v1', isActive: false },
25
+ });
26
+
27
+ expect(result.current[0]).toBe('v1');
28
+
29
+ // External value changes while not active - should sync
30
+ rerender({ externalValue: 'v2', isActive: false });
31
+ expect(result.current[0]).toBe('v2');
32
+ });
33
+
34
+ it('does not sync with external value while active', () => {
35
+ const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
36
+ initialProps: { externalValue: 'v1', isActive: true },
37
+ });
38
+
39
+ // Initial sync when becoming active
40
+ expect(result.current[0]).toBe('v1');
41
+
42
+ // User makes local edit while active
43
+ act(() => {
44
+ result.current[1]('local-edit');
45
+ });
46
+ expect(result.current[0]).toBe('local-edit');
47
+
48
+ // External value changes while still active - should NOT sync (preserve user edit)
49
+ rerender({ externalValue: 'v2', isActive: true });
50
+ expect(result.current[0]).toBe('local-edit');
51
+ });
52
+
53
+ it('syncs when switching from active to inactive', () => {
54
+ const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
55
+ initialProps: { externalValue: 'v1', isActive: true },
56
+ });
57
+
58
+ // Make local edit while active
59
+ act(() => {
60
+ result.current[1]('local-edit');
61
+ });
62
+ expect(result.current[0]).toBe('local-edit');
63
+
64
+ // External value changed while we were editing
65
+ rerender({ externalValue: 'v2', isActive: true });
66
+ expect(result.current[0]).toBe('local-edit');
67
+
68
+ // Switch to inactive - should sync with new external value
69
+ rerender({ externalValue: 'v2', isActive: false });
70
+ expect(result.current[0]).toBe('v2');
71
+ });
72
+
73
+ it('syncs when switching from inactive to active (e.g., opening drawer)', () => {
74
+ const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
75
+ initialProps: { externalValue: '', isActive: false },
76
+ });
77
+
78
+ expect(result.current[0]).toBe('');
79
+
80
+ // Simulate opening a drawer with new content - external value and isActive change together
81
+ rerender({ externalValue: 'section content', isActive: true });
82
+
83
+ // Should sync with the new external value when becoming active
84
+ expect(result.current[0]).toBe('section content');
85
+ });
86
+
87
+ it('preserves local edits while active after initial sync', () => {
88
+ const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
89
+ initialProps: { externalValue: '', isActive: false },
90
+ });
91
+
92
+ // Open drawer with content
93
+ rerender({ externalValue: 'initial content', isActive: true });
94
+ expect(result.current[0]).toBe('initial content');
95
+
96
+ // User edits locally
97
+ act(() => {
98
+ result.current[1]('user edited content');
99
+ });
100
+ expect(result.current[0]).toBe('user edited content');
101
+
102
+ // External value changes (e.g., from another source) - should NOT overwrite user edit
103
+ rerender({ externalValue: 'different content', isActive: true });
104
+ expect(result.current[0]).toBe('user edited content');
105
+ });
106
+
107
+ it('works with complex objects', () => {
108
+ const initial = { name: 'test', value: 1 };
109
+ const { result, rerender } = renderHook(({ externalValue, isActive }) => useSyncedState(externalValue, isActive), {
110
+ initialProps: { externalValue: initial, isActive: false },
111
+ });
112
+
113
+ expect(result.current[0]).toEqual(initial);
114
+
115
+ const updated = { name: 'updated', value: 2 };
116
+ rerender({ externalValue: updated, isActive: false });
117
+ expect(result.current[0]).toEqual(updated);
118
+ });
119
+ });
@@ -0,0 +1,30 @@
1
+ import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
2
+
3
+ /**
4
+ * Syncs local state with an external value, but only when not actively editing.
5
+ * Prevents external updates from overwriting user input mid-edit.
6
+ *
7
+ * @param externalValue - The external/server value to sync from
8
+ * @param isActive - Whether the user is currently editing (blocks sync)
9
+ */
10
+ export function useSyncedState<T>(externalValue: T, isActive: boolean): [T, Dispatch<SetStateAction<T>>] {
11
+ const [localValue, setLocalValue] = useState<T>(externalValue);
12
+ const prevIsActiveRef = useRef(isActive);
13
+
14
+ // Sync external value to local state when:
15
+ // 1. Not active (external updates flow through)
16
+ // 2. Activity state changed (sync on open/close transitions)
17
+ // This is an intentional pattern for synchronizing external props to local state
18
+ useEffect(() => {
19
+ const activityChanged = prevIsActiveRef.current !== isActive;
20
+
21
+ if (!isActive || activityChanged) {
22
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional sync pattern
23
+ setLocalValue(externalValue);
24
+ }
25
+
26
+ prevIsActiveRef.current = isActive;
27
+ }, [externalValue, isActive]);
28
+
29
+ return [localValue, setLocalValue];
30
+ }
@@ -1,6 +1,7 @@
1
1
  @import 'tailwindcss';
2
2
  @import 'tw-animate-css';
3
3
  @import 'shadcn/tailwind.css';
4
+ @import '@clerk/themes/shadcn.css';
4
5
  @import '@fontsource-variable/inter';
5
6
 
6
7
  @custom-variant dark (&:is(.dark *));
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Centralized timing constants for consistent UX across the application.
3
+ * Use these instead of magic numbers to ensure consistency.
4
+ */
5
+ export const TIMING = {
6
+ /** Debounce delay for user input in milliseconds */
7
+ DEBOUNCE_DELAY: 300,
8
+ /** Duration to show "Copied!" feedback in milliseconds */
9
+ COPY_FEEDBACK_DURATION: 2000,
10
+ } as const;
@@ -0,0 +1,136 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { create } from 'zustand';
4
+
5
+ import { createSelectors } from './createSelectors';
6
+
7
+ interface TestState {
8
+ count: number;
9
+ name: string;
10
+ increment: () => void;
11
+ setName: (name: string) => void;
12
+ }
13
+
14
+ describe('createSelectors', () => {
15
+ it('creates auto-generated selectors for all state properties', () => {
16
+ const useStoreBase = create<TestState>()((set) => ({
17
+ count: 0,
18
+ name: 'test',
19
+ increment: () => set((state) => ({ count: state.count + 1 })),
20
+ setName: (name) => set({ name }),
21
+ }));
22
+
23
+ const useStore = createSelectors(useStoreBase);
24
+
25
+ // Verify .use namespace exists
26
+ expect(useStore.use).toBeDefined();
27
+ expect(typeof useStore.use.count).toBe('function');
28
+ expect(typeof useStore.use.name).toBe('function');
29
+ expect(typeof useStore.use.increment).toBe('function');
30
+ expect(typeof useStore.use.setName).toBe('function');
31
+ });
32
+
33
+ it('selectors return correct state values', () => {
34
+ const useStoreBase = create<TestState>()((set) => ({
35
+ count: 42,
36
+ name: 'hello',
37
+ increment: () => set((state) => ({ count: state.count + 1 })),
38
+ setName: (name) => set({ name }),
39
+ }));
40
+
41
+ const useStore = createSelectors(useStoreBase);
42
+
43
+ const { result: countResult } = renderHook(() => useStore.use.count());
44
+ const { result: nameResult } = renderHook(() => useStore.use.name());
45
+
46
+ expect(countResult.current).toBe(42);
47
+ expect(nameResult.current).toBe('hello');
48
+ });
49
+
50
+ it('selectors return action functions', () => {
51
+ const useStoreBase = create<TestState>()((set) => ({
52
+ count: 0,
53
+ name: 'test',
54
+ increment: () => set((state) => ({ count: state.count + 1 })),
55
+ setName: (name) => set({ name }),
56
+ }));
57
+
58
+ const useStore = createSelectors(useStoreBase);
59
+
60
+ const { result: incrementResult } = renderHook(() => useStore.use.increment());
61
+ const { result: setNameResult } = renderHook(() => useStore.use.setName());
62
+
63
+ expect(typeof incrementResult.current).toBe('function');
64
+ expect(typeof setNameResult.current).toBe('function');
65
+ });
66
+
67
+ it('actions from selectors update state correctly', () => {
68
+ const useStoreBase = create<TestState>()((set) => ({
69
+ count: 0,
70
+ name: 'initial',
71
+ increment: () => set((state) => ({ count: state.count + 1 })),
72
+ setName: (name) => set({ name }),
73
+ }));
74
+
75
+ const useStore = createSelectors(useStoreBase);
76
+
77
+ // Get initial state
78
+ expect(useStore.getState().count).toBe(0);
79
+ expect(useStore.getState().name).toBe('initial');
80
+
81
+ // Use action from selector
82
+ const { result: incrementResult } = renderHook(() => useStore.use.increment());
83
+
84
+ act(() => {
85
+ incrementResult.current();
86
+ });
87
+
88
+ expect(useStore.getState().count).toBe(1);
89
+
90
+ // Use another action
91
+ const { result: setNameResult } = renderHook(() => useStore.use.setName());
92
+
93
+ act(() => {
94
+ setNameResult.current('updated');
95
+ });
96
+
97
+ expect(useStore.getState().name).toBe('updated');
98
+ });
99
+
100
+ it('preserves original store functionality', () => {
101
+ const useStoreBase = create<TestState>()((set) => ({
102
+ count: 0,
103
+ name: 'test',
104
+ increment: () => set((state) => ({ count: state.count + 1 })),
105
+ setName: (name) => set({ name }),
106
+ }));
107
+
108
+ const useStore = createSelectors(useStoreBase);
109
+
110
+ // Original hook still works
111
+ const { result } = renderHook(() => useStore((state) => state.count));
112
+ expect(result.current).toBe(0);
113
+
114
+ // getState still works
115
+ expect(useStore.getState().count).toBe(0);
116
+
117
+ // setState still works
118
+ act(() => {
119
+ useStore.setState({ count: 100 });
120
+ });
121
+ expect(useStore.getState().count).toBe(100);
122
+
123
+ // subscribe still works
124
+ let subscribedValue = 0;
125
+ const unsub = useStore.subscribe((state) => {
126
+ subscribedValue = state.count;
127
+ });
128
+
129
+ act(() => {
130
+ useStore.setState({ count: 200 });
131
+ });
132
+ expect(subscribedValue).toBe(200);
133
+
134
+ unsub();
135
+ });
136
+ });
@@ -0,0 +1,31 @@
1
+ import type { StoreApi, UseBoundStore } from 'zustand';
2
+
3
+ /**
4
+ * Type that extends a Zustand store with auto-generated selectors.
5
+ * Adds a `use` namespace with selector functions for each store property.
6
+ */
7
+ type WithSelectors<S> = S extends { getState: () => infer T } ? S & { use: { [K in keyof T]: () => T[K] } } : never;
8
+
9
+ /**
10
+ * Wraps a Zustand store and automatically generates selector hooks for all properties.
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * const useStoreBase = create<State>()((set) => ({ count: 0 }));
15
+ * export const useStore = createSelectors(useStoreBase);
16
+ *
17
+ * // Usage in components:
18
+ * const count = useStore.use.count();
19
+ * const increment = useStore.use.increment();
20
+ * ```
21
+ */
22
+ export function createSelectors<S extends UseBoundStore<StoreApi<object>>>(_store: S): WithSelectors<S> {
23
+ const store = _store as WithSelectors<typeof _store>;
24
+ store.use = {} as WithSelectors<S>['use'];
25
+
26
+ for (const key of Object.keys(store.getState())) {
27
+ (store.use as Record<string, () => unknown>)[key] = () => store((state) => state[key as keyof typeof state]);
28
+ }
29
+
30
+ return store;
31
+ }
@@ -12,3 +12,4 @@ export { env, validateEnv, type Env } from './env';
12
12
  export { api, ApiClientError } from './api';
13
13
  export { getStorageItem, setStorageItem, removeStorageItem, clearAppStorage } from './storage';
14
14
  export { registerFormSchema, type RegisterFormData } from './validations';
15
+ export { createSelectors } from './createSelectors';
@@ -0,0 +1,55 @@
1
+ import type * as SentryType from '@sentry/react';
2
+
3
+ let sentryInstance: typeof SentryType | null = null;
4
+
5
+ /**
6
+ * Initialize Sentry error tracking.
7
+ * Only runs in production when VITE_SENTRY_DSN is configured.
8
+ */
9
+ export async function initSentry(): Promise<void> {
10
+ // Skip in development or if already initialized
11
+ if (import.meta.env.DEV || sentryInstance) return;
12
+
13
+ const dsn = import.meta.env.VITE_SENTRY_DSN;
14
+ if (!dsn) return;
15
+
16
+ const sentry = await import('@sentry/react');
17
+
18
+ sentry.init({
19
+ dsn,
20
+ environment: import.meta.env.MODE,
21
+ sendDefaultPii: true,
22
+ integrations: [sentry.browserTracingIntegration()],
23
+ tracesSampleRate: 0.1,
24
+ });
25
+
26
+ sentryInstance = sentry;
27
+ }
28
+
29
+ /**
30
+ * Lazily initialize Sentry after the app is interactive.
31
+ * Uses requestIdleCallback with fallback for older browsers.
32
+ */
33
+ export function lazySentryInit(): void {
34
+ const init = () => void initSentry();
35
+
36
+ if ('requestIdleCallback' in window) {
37
+ window.requestIdleCallback(init, { timeout: 2000 });
38
+ } else {
39
+ setTimeout(init, 1000);
40
+ }
41
+ }
42
+
43
+ interface CaptureContext {
44
+ componentStack?: string;
45
+ }
46
+
47
+ /**
48
+ * Capture an exception to Sentry.
49
+ * No-op if Sentry is not initialized.
50
+ */
51
+ export function captureException(error: unknown, context?: CaptureContext): void {
52
+ sentryInstance?.captureException(error, {
53
+ extra: context?.componentStack ? { componentStack: context.componentStack } : undefined,
54
+ });
55
+ }
@@ -4,7 +4,8 @@
4
4
 
5
5
  import { STORAGE_KEYS } from './storageKeys';
6
6
 
7
- type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];
7
+ // Extract only string keys (exclude functions if any are added later)
8
+ type StorageKey = Extract<(typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS], string>;
8
9
 
9
10
  /**
10
11
  * Check if we're in a browser environment
@@ -81,7 +82,10 @@ export function clearAppStorage(): boolean {
81
82
 
82
83
  try {
83
84
  Object.values(STORAGE_KEYS).forEach((key) => {
84
- localStorage.removeItem(key);
85
+ // Skip function keys (like prompt()) if any exist
86
+ if (typeof key === 'string') {
87
+ localStorage.removeItem(key);
88
+ }
85
89
  });
86
90
  return true;
87
91
  } catch (error) {
@@ -6,6 +6,7 @@ import { BrowserRouter } from 'react-router';
6
6
  import './index.css';
7
7
  import { ErrorBoundary } from '@/components/shared';
8
8
  import { Toaster } from '@/components/ui/sonner';
9
+ import { ClerkThemeProvider } from '@/contexts/clerkContext';
9
10
  import { MobileProvider } from '@/contexts/mobileContext';
10
11
  import { PerformanceProviderWrapper } from '@/contexts/performanceContext';
11
12
  import { QueryProvider } from '@/contexts/queryContext';
@@ -15,6 +16,13 @@ import { initPreferencesSync } from '@/stores/preferencesStore';
15
16
 
16
17
  import App from './App';
17
18
 
19
+ // Import Clerk Publishable Key (per official docs)
20
+ const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
21
+
22
+ if (!PUBLISHABLE_KEY) {
23
+ throw new Error('Add your Clerk Publishable Key to the .env.local file');
24
+ }
25
+
18
26
  /**
19
27
  * Lazy load Sentry after initial render to avoid blocking web vitals.
20
28
  * Returns the Sentry module for use in global error handlers.
@@ -74,14 +82,16 @@ initI18n().then(() => {
74
82
  <QueryProvider>
75
83
  <I18nProvider i18n={i18n}>
76
84
  <BrowserRouter>
77
- <MobileProvider>
78
- <ErrorBoundary>
79
- <PerformanceProviderWrapper>
80
- <App />
81
- <Toaster />
82
- </PerformanceProviderWrapper>
83
- </ErrorBoundary>
84
- </MobileProvider>
85
+ <ClerkThemeProvider publishableKey={PUBLISHABLE_KEY}>
86
+ <MobileProvider>
87
+ <ErrorBoundary>
88
+ <PerformanceProviderWrapper>
89
+ <App />
90
+ <Toaster />
91
+ </PerformanceProviderWrapper>
92
+ </ErrorBoundary>
93
+ </MobileProvider>
94
+ </ClerkThemeProvider>
85
95
  </BrowserRouter>
86
96
  </I18nProvider>
87
97
  </QueryProvider>
@@ -1,6 +1,7 @@
1
1
  import { create } from 'zustand';
2
2
  import { devtools, persist } from 'zustand/middleware';
3
3
 
4
+ import { createSelectors } from '@/lib/createSelectors';
4
5
  import { STORAGE_KEYS } from '@/lib/storageKeys';
5
6
 
6
7
  export type Theme = 'light' | 'dark' | 'system';
@@ -31,18 +32,25 @@ function getSystemTheme(): 'light' | 'dark' {
31
32
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
32
33
  }
33
34
 
34
- export const usePreferencesStore = create<PreferencesState>()(
35
+ /** Current schema version for preferences store persistence */
36
+ const PREFERENCES_STORE_VERSION = 1;
37
+
38
+ const usePreferencesStoreBase = create<PreferencesState>()(
35
39
  devtools(
36
40
  persist(
37
41
  (set, get) => ({
38
42
  ...initialState,
39
- setTheme: (theme) => set({ theme }),
43
+ setTheme: (theme) => set({ theme }, undefined, 'preferences/setTheme'),
40
44
  toggleTheme: () =>
41
- set((state) => {
42
- const resolved = state.theme === 'system' ? getSystemTheme() : state.theme;
43
- return { theme: resolved === 'light' ? 'dark' : 'light' };
44
- }),
45
- reset: () => set(initialState),
45
+ set(
46
+ (state) => {
47
+ const resolved = state.theme === 'system' ? getSystemTheme() : state.theme;
48
+ return { theme: resolved === 'light' ? 'dark' : 'light' };
49
+ },
50
+ undefined,
51
+ 'preferences/toggleTheme',
52
+ ),
53
+ reset: () => set(initialState, undefined, 'preferences/reset'),
46
54
  getResolvedTheme: () => {
47
55
  const { theme } = get();
48
56
  return theme === 'system' ? getSystemTheme() : theme;
@@ -50,13 +58,30 @@ export const usePreferencesStore = create<PreferencesState>()(
50
58
  }),
51
59
  {
52
60
  name: STORAGE_KEYS.preferences,
53
- partialize: (state) => ({ theme: state.theme }),
61
+ version: PREFERENCES_STORE_VERSION,
62
+ partialize: (state): Preferences => ({ theme: state.theme }),
63
+ migrate: (persisted, version) => {
64
+ const state = persisted as Preferences;
65
+ if (version === 0) {
66
+ // v0 → v1: No changes needed, establishes baseline for future migrations
67
+ return state;
68
+ }
69
+ return state;
70
+ },
71
+ onRehydrateStorage: () => (_state, error) => {
72
+ if (error) {
73
+ console.error('Failed to hydrate preferences store:', error);
74
+ }
75
+ },
54
76
  },
55
77
  ),
56
- { name: 'preferences' },
78
+ { name: 'preferences', enabled: process.env.NODE_ENV === 'development' },
57
79
  ),
58
80
  );
59
81
 
82
+ /** Preferences store with auto-generated selectors */
83
+ export const usePreferencesStore = createSelectors(usePreferencesStoreBase);
84
+
60
85
  /**
61
86
  * Initialize multi-tab sync for preferences.
62
87
  * Call this once at app startup.
@@ -0,0 +1,97 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ // =============================================================================
4
+ // Mock State
5
+ // =============================================================================
6
+
7
+ interface ClerkMockState {
8
+ isSignedIn: boolean;
9
+ isLoaded: boolean;
10
+ }
11
+
12
+ const defaultState: ClerkMockState = {
13
+ isSignedIn: true,
14
+ isLoaded: true,
15
+ };
16
+
17
+ let mockState: ClerkMockState = { ...defaultState };
18
+
19
+ // =============================================================================
20
+ // Test Utilities
21
+ // =============================================================================
22
+
23
+ export function setMockSignedIn(value: boolean) {
24
+ mockState.isSignedIn = value;
25
+ }
26
+
27
+ export function setMockLoaded(value: boolean) {
28
+ mockState.isLoaded = value;
29
+ }
30
+
31
+ export function setMockClerkState(state: Partial<ClerkMockState>) {
32
+ mockState = { ...mockState, ...state };
33
+ }
34
+
35
+ export function resetClerkMocks() {
36
+ mockState = { ...defaultState };
37
+ }
38
+
39
+ // =============================================================================
40
+ // Mock Components
41
+ // =============================================================================
42
+
43
+ export function SignedIn({ children }: { children: ReactNode }) {
44
+ return mockState.isLoaded && mockState.isSignedIn ? <>{children}</> : null;
45
+ }
46
+
47
+ export function SignedOut({ children }: { children: ReactNode }) {
48
+ return mockState.isLoaded && !mockState.isSignedIn ? <>{children}</> : null;
49
+ }
50
+
51
+ export function SignInButton({ children }: { children?: ReactNode; mode?: string }) {
52
+ return <div data-testid="sign-in-button">{children}</div>;
53
+ }
54
+
55
+ export function SignUpButton({ children }: { children?: ReactNode; mode?: string }) {
56
+ return <div data-testid="sign-up-button">{children}</div>;
57
+ }
58
+
59
+ export function UserButton() {
60
+ return <button data-testid="user-button">User</button>;
61
+ }
62
+
63
+ export function RedirectToSignIn() {
64
+ return <div data-testid="redirect-to-sign-in" />;
65
+ }
66
+
67
+ export function ClerkProvider({
68
+ children,
69
+ }: {
70
+ children: ReactNode;
71
+ publishableKey?: string;
72
+ afterSignOutUrl?: string;
73
+ appearance?: unknown;
74
+ }) {
75
+ return <>{children}</>;
76
+ }
77
+
78
+ // =============================================================================
79
+ // Mock Hooks
80
+ // =============================================================================
81
+
82
+ export function useAuth() {
83
+ return {
84
+ isLoaded: mockState.isLoaded,
85
+ isSignedIn: mockState.isSignedIn,
86
+ userId: mockState.isSignedIn ? 'user_123' : null,
87
+ sessionId: mockState.isSignedIn ? 'sess_123' : null,
88
+ getToken: async () => (mockState.isSignedIn ? 'mock-token' : null),
89
+ };
90
+ }
91
+
92
+ export function useUser() {
93
+ return {
94
+ isLoaded: mockState.isLoaded,
95
+ user: mockState.isSignedIn ? { id: 'user_123', firstName: 'Test', lastName: 'User' } : null,
96
+ };
97
+ }
@@ -4,5 +4,8 @@ export { createTestQueryClient, render } from './providers';
4
4
  // Mock utilities
5
5
  export { mockMatchMedia } from './mocks';
6
6
 
7
+ // Clerk test utilities
8
+ export { resetClerkMocks, setMockClerkState, setMockLoaded, setMockSignedIn } from './clerkMock';
9
+
7
10
  // MSW server instance
8
11
  export { server } from '@/mocks/node';
@@ -5,6 +5,7 @@ import { render, type RenderOptions } from '@testing-library/react';
5
5
  import { type ReactElement, type ReactNode } from 'react';
6
6
  import { MemoryRouter } from 'react-router';
7
7
 
8
+ import { ClerkThemeProvider } from '@/contexts/clerkContext';
8
9
  import { MobileProvider } from '@/contexts/mobileContext';
9
10
  import { PerformanceProviderWrapper } from '@/contexts/performanceContext';
10
11
 
@@ -37,14 +38,16 @@ interface WrapperProps {
37
38
  function AllProviders({ children }: WrapperProps) {
38
39
  const queryClient = createTestQueryClient();
39
40
 
40
- // Provider order matches main.tsx: Query > I18n > Router > Mobile > Performance
41
+ // Provider order matches main.tsx: Query > I18n > Router > Clerk > Mobile > Performance
41
42
  return (
42
43
  <QueryClientProvider client={queryClient}>
43
44
  <I18nProvider i18n={i18n}>
44
45
  <MemoryRouter>
45
- <MobileProvider>
46
- <PerformanceProviderWrapper>{children}</PerformanceProviderWrapper>
47
- </MobileProvider>
46
+ <ClerkThemeProvider publishableKey="test_key">
47
+ <MobileProvider>
48
+ <PerformanceProviderWrapper>{children}</PerformanceProviderWrapper>
49
+ </MobileProvider>
50
+ </ClerkThemeProvider>
48
51
  </MemoryRouter>
49
52
  </I18nProvider>
50
53
  </QueryClientProvider>