@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
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
1
|
import { Trans } from '@lingui/react/macro';
|
|
2
|
+
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
|
3
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
3
4
|
|
|
4
|
-
import {
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
7
|
+
import { captureException } from '@/lib/sentry';
|
|
5
8
|
|
|
6
9
|
interface Props {
|
|
7
10
|
children: ReactNode;
|
|
@@ -15,6 +18,48 @@ interface State {
|
|
|
15
18
|
error: Error | null;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
interface ErrorFallbackUIProps {
|
|
22
|
+
error: Error | null;
|
|
23
|
+
onRetry: () => void;
|
|
24
|
+
onReload: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ErrorFallbackUI({ error, onRetry, onReload }: ErrorFallbackUIProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex min-h-screen items-center justify-center p-4">
|
|
30
|
+
<Card className="max-w-md">
|
|
31
|
+
<CardHeader>
|
|
32
|
+
<div className="flex items-center gap-2">
|
|
33
|
+
<AlertTriangle className="text-destructive size-5" />
|
|
34
|
+
<CardTitle>
|
|
35
|
+
<Trans>Something went wrong</Trans>
|
|
36
|
+
</CardTitle>
|
|
37
|
+
</div>
|
|
38
|
+
<CardDescription>
|
|
39
|
+
<Trans>An unexpected error occurred. You can try again or reload the page.</Trans>
|
|
40
|
+
</CardDescription>
|
|
41
|
+
</CardHeader>
|
|
42
|
+
<CardContent className="space-y-4">
|
|
43
|
+
{error && (
|
|
44
|
+
<div className="bg-muted rounded-md p-3">
|
|
45
|
+
<p className="text-muted-foreground font-mono text-xs break-all">{error.message}</p>
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
<div className="flex gap-2">
|
|
49
|
+
<Button onClick={onRetry} variant="default">
|
|
50
|
+
<Trans>Try Again</Trans>
|
|
51
|
+
</Button>
|
|
52
|
+
<Button onClick={onReload} variant="outline">
|
|
53
|
+
<RefreshCw className="mr-2 size-4" />
|
|
54
|
+
<Trans>Reload Page</Trans>
|
|
55
|
+
</Button>
|
|
56
|
+
</div>
|
|
57
|
+
</CardContent>
|
|
58
|
+
</Card>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
18
63
|
export class ErrorBoundary extends Component<Props, State> {
|
|
19
64
|
constructor(props: Props) {
|
|
20
65
|
super(props);
|
|
@@ -28,22 +73,11 @@ export class ErrorBoundary extends Component<Props, State> {
|
|
|
28
73
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
29
74
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
|
30
75
|
|
|
76
|
+
// Report error to Sentry with component stack
|
|
77
|
+
captureException(error, { componentStack: errorInfo.componentStack ?? undefined });
|
|
78
|
+
|
|
31
79
|
// Call custom error handler if provided
|
|
32
80
|
this.props.onError?.(error, errorInfo);
|
|
33
|
-
|
|
34
|
-
// Report to Sentry in production (if enabled and configured)
|
|
35
|
-
if (import.meta.env.PROD && SENTRY_CONFIG.enabled && SENTRY_CONFIG.dsn) {
|
|
36
|
-
// eslint-disable-next-line lingui/no-unlocalized-strings
|
|
37
|
-
import('@sentry/react')
|
|
38
|
-
.then((Sentry) => {
|
|
39
|
-
Sentry.captureException(error, {
|
|
40
|
-
extra: { componentStack: errorInfo.componentStack },
|
|
41
|
-
});
|
|
42
|
-
})
|
|
43
|
-
.catch(() => {
|
|
44
|
-
// Sentry failed to load, error already logged above
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
81
|
}
|
|
48
82
|
|
|
49
83
|
/**
|
|
@@ -54,49 +88,17 @@ export class ErrorBoundary extends Component<Props, State> {
|
|
|
54
88
|
this.setState({ hasError: false, error: null });
|
|
55
89
|
};
|
|
56
90
|
|
|
91
|
+
handleReload = () => {
|
|
92
|
+
window.location.reload();
|
|
93
|
+
};
|
|
94
|
+
|
|
57
95
|
render() {
|
|
58
96
|
if (this.state.hasError) {
|
|
59
97
|
if (this.props.fallback) {
|
|
60
98
|
return this.props.fallback;
|
|
61
99
|
}
|
|
62
100
|
|
|
63
|
-
return
|
|
64
|
-
<div className="flex min-h-screen items-center justify-center p-4">
|
|
65
|
-
<div className="text-center">
|
|
66
|
-
<h1 className="text-destructive text-2xl font-bold">
|
|
67
|
-
<Trans comment="Error boundary - main error heading">Something went wrong</Trans>
|
|
68
|
-
</h1>
|
|
69
|
-
<p className="text-muted-foreground mt-2">
|
|
70
|
-
<Trans comment="Error boundary - error explanation">
|
|
71
|
-
We're sorry, but something unexpected happened.
|
|
72
|
-
</Trans>
|
|
73
|
-
</p>
|
|
74
|
-
{import.meta.env.DEV && this.state.error && (
|
|
75
|
-
<details className="bg-muted mt-4 rounded-md p-4 text-left">
|
|
76
|
-
<summary className="cursor-pointer font-medium">
|
|
77
|
-
<Trans comment="Error boundary - debug section heading">Error details</Trans>
|
|
78
|
-
</summary>
|
|
79
|
-
<pre className="mt-2 overflow-auto text-sm">{this.state.error.message}</pre>
|
|
80
|
-
<pre className="mt-1 overflow-auto text-xs opacity-75">{this.state.error.stack}</pre>
|
|
81
|
-
</details>
|
|
82
|
-
)}
|
|
83
|
-
<div className="mt-6 flex justify-center gap-3">
|
|
84
|
-
<button
|
|
85
|
-
onClick={this.reset}
|
|
86
|
-
className="bg-secondary text-secondary-foreground rounded px-4 py-2 transition-colors hover:opacity-90"
|
|
87
|
-
>
|
|
88
|
-
<Trans comment="Error boundary - try again button">Try Again</Trans>
|
|
89
|
-
</button>
|
|
90
|
-
<button
|
|
91
|
-
onClick={() => window.location.reload()}
|
|
92
|
-
className="bg-primary text-primary-foreground rounded px-4 py-2 transition-colors hover:opacity-90"
|
|
93
|
-
>
|
|
94
|
-
<Trans comment="Error boundary - refresh button">Refresh Page</Trans>
|
|
95
|
-
</button>
|
|
96
|
-
</div>
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
);
|
|
101
|
+
return <ErrorFallbackUI error={this.state.error} onRetry={this.reset} onReload={this.handleReload} />;
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
return this.props.children;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { render, setMockSignedIn, setMockLoaded, resetClerkMocks } from '@/test';
|
|
5
|
+
|
|
6
|
+
import { ProtectedRoute } from './ProtectedRoute';
|
|
7
|
+
|
|
8
|
+
describe('ProtectedRoute', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
resetClerkMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders children when signed in', () => {
|
|
14
|
+
render(
|
|
15
|
+
<ProtectedRoute>
|
|
16
|
+
<div>Protected Content</div>
|
|
17
|
+
</ProtectedRoute>,
|
|
18
|
+
);
|
|
19
|
+
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('shows loading when auth is not loaded', () => {
|
|
23
|
+
setMockLoaded(false);
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<ProtectedRoute>
|
|
26
|
+
<div>Protected Content</div>
|
|
27
|
+
</ProtectedRoute>,
|
|
28
|
+
);
|
|
29
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
30
|
+
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('redirects when not signed in', () => {
|
|
34
|
+
setMockSignedIn(false);
|
|
35
|
+
render(
|
|
36
|
+
<ProtectedRoute>
|
|
37
|
+
<div>Protected Content</div>
|
|
38
|
+
</ProtectedRoute>,
|
|
39
|
+
);
|
|
40
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
41
|
+
expect(screen.getByTestId('redirect-to-sign-in')).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { RedirectToSignIn, useAuth } from '@clerk/react-router';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { PageLoading } from '@/components/ui/loading';
|
|
5
|
+
|
|
6
|
+
interface ProtectedRouteProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wraps routes that require authentication.
|
|
12
|
+
* Shows loading state while auth loads, redirects to sign-in if not authenticated.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <Route path="/dashboard" element={
|
|
17
|
+
* <ProtectedRoute>
|
|
18
|
+
* <DashboardPage />
|
|
19
|
+
* </ProtectedRoute>
|
|
20
|
+
* } />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
|
24
|
+
const { isLoaded, isSignedIn } = useAuth();
|
|
25
|
+
|
|
26
|
+
if (!isLoaded) {
|
|
27
|
+
return <PageLoading />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!isSignedIn) {
|
|
31
|
+
return <RedirectToSignIn />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return <>{children}</>;
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ProtectedRoute } from './ProtectedRoute';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { AccountButton } from './AccountButton';
|
|
2
2
|
export { ErrorBoundary } from './ErrorBoundary';
|
|
3
|
-
export { SEO } from './SEO';
|
|
4
3
|
export { LanguageSwitcher } from './LanguageSwitcher';
|
|
4
|
+
export { ProtectedRoute } from './ProtectedRoute';
|
|
5
5
|
export { RegisterForm } from './RegisterForm';
|
|
6
|
+
export { SEO } from './SEO';
|
|
7
|
+
export { ThemeToggle } from './ThemeToggle';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ClerkProvider } from '@clerk/react-router';
|
|
2
|
+
import { shadcn } from '@clerk/themes';
|
|
3
|
+
import type { Appearance } from '@clerk/types';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
interface ClerkThemeProviderProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
publishableKey: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Clerk appearance configuration.
|
|
13
|
+
* Uses shadcn theme which auto-adapts to light/dark mode.
|
|
14
|
+
* Requires @clerk/themes/shadcn.css to be imported in index.css.
|
|
15
|
+
*/
|
|
16
|
+
const appearance: Appearance = {
|
|
17
|
+
baseTheme: shadcn,
|
|
18
|
+
variables: {
|
|
19
|
+
// Typography
|
|
20
|
+
fontFamily: '"Inter Variable", sans-serif',
|
|
21
|
+
// Match app's border radius (--radius: 0.45rem in index.css)
|
|
22
|
+
borderRadius: '0.45rem',
|
|
23
|
+
},
|
|
24
|
+
elements: {
|
|
25
|
+
// Make modal fullscreen on mobile devices
|
|
26
|
+
modalBackdrop: 'backdrop-blur-sm',
|
|
27
|
+
modalContent: 'sm:max-w-md max-sm:min-h-svh max-sm:min-w-full max-sm:rounded-none',
|
|
28
|
+
card: 'max-sm:rounded-none max-sm:shadow-none',
|
|
29
|
+
// Ensure consistent border radius on form elements
|
|
30
|
+
formFieldInput: 'rounded-md',
|
|
31
|
+
formButtonPrimary: 'rounded-md',
|
|
32
|
+
socialButtonsBlockButton: 'rounded-md',
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ClerkProvider wrapper with consistent theming.
|
|
38
|
+
*/
|
|
39
|
+
export function ClerkThemeProvider({ children, publishableKey }: ClerkThemeProviderProps) {
|
|
40
|
+
return (
|
|
41
|
+
<ClerkProvider publishableKey={publishableKey} afterSignOutUrl="/" appearance={appearance}>
|
|
42
|
+
{children}
|
|
43
|
+
</ClerkProvider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -1,7 +1,28 @@
|
|
|
1
|
+
// Media query and responsive hooks
|
|
1
2
|
export { useMediaQuery, BREAKPOINTS } from './useMediaQuery';
|
|
2
|
-
export {
|
|
3
|
+
export { useMobileContext } from '@/contexts/mobileContext';
|
|
3
4
|
export { useTouchSizes } from './useTouchSizes';
|
|
5
|
+
export { useIOSViewportReset } from './useIOSViewportReset';
|
|
6
|
+
|
|
7
|
+
// Theme and UI hooks
|
|
8
|
+
export { useThemeEffect } from './useThemeEffect';
|
|
9
|
+
export { useDocumentTitle } from './useDocumentTitle';
|
|
10
|
+
|
|
11
|
+
// State and storage hooks
|
|
12
|
+
export { useLocalStorage } from './useLocalStorage';
|
|
13
|
+
export { useSyncedState } from './useSyncedState';
|
|
14
|
+
export { useSyncedFormData } from './useSyncedFormData';
|
|
15
|
+
|
|
16
|
+
// Utility hooks
|
|
17
|
+
export { useCopyFeedback } from './useCopyFeedback';
|
|
18
|
+
export { useDebouncedCallback } from './useDebouncedCallback';
|
|
19
|
+
export { useKeyboardShortcut } from './useKeyboardShortcuts';
|
|
20
|
+
|
|
21
|
+
// i18n hooks
|
|
4
22
|
export { useLanguage } from './useLanguage';
|
|
23
|
+
|
|
24
|
+
// API hooks
|
|
5
25
|
export { useExampleQuery } from './useExampleQuery';
|
|
26
|
+
|
|
27
|
+
// Form hooks
|
|
6
28
|
export { useRegisterForm } from './useRegisterForm';
|
|
7
|
-
export { useMobileContext } from '@/contexts/mobileContext';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { TIMING } from '@/lib/constants';
|
|
5
|
+
|
|
6
|
+
import { useCopyFeedback } from './useCopyFeedback';
|
|
7
|
+
|
|
8
|
+
describe('useCopyFeedback', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.useFakeTimers();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns isCopied: false initially', () => {
|
|
18
|
+
const { result } = renderHook(() => useCopyFeedback());
|
|
19
|
+
expect(result.current.isCopied).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('sets isCopied to true when triggerCopied is called', () => {
|
|
23
|
+
const { result } = renderHook(() => useCopyFeedback());
|
|
24
|
+
|
|
25
|
+
act(() => {
|
|
26
|
+
result.current.triggerCopied();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(result.current.isCopied).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('resets isCopied to false after default duration', () => {
|
|
33
|
+
const { result } = renderHook(() => useCopyFeedback());
|
|
34
|
+
|
|
35
|
+
act(() => {
|
|
36
|
+
result.current.triggerCopied();
|
|
37
|
+
});
|
|
38
|
+
expect(result.current.isCopied).toBe(true);
|
|
39
|
+
|
|
40
|
+
// Just before duration ends
|
|
41
|
+
act(() => {
|
|
42
|
+
vi.advanceTimersByTime(TIMING.COPY_FEEDBACK_DURATION - 1);
|
|
43
|
+
});
|
|
44
|
+
expect(result.current.isCopied).toBe(true);
|
|
45
|
+
|
|
46
|
+
// At duration
|
|
47
|
+
act(() => {
|
|
48
|
+
vi.advanceTimersByTime(1);
|
|
49
|
+
});
|
|
50
|
+
expect(result.current.isCopied).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('respects custom duration', () => {
|
|
54
|
+
const customDuration = 1000;
|
|
55
|
+
const { result } = renderHook(() => useCopyFeedback(customDuration));
|
|
56
|
+
|
|
57
|
+
act(() => {
|
|
58
|
+
result.current.triggerCopied();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
act(() => {
|
|
62
|
+
vi.advanceTimersByTime(customDuration - 1);
|
|
63
|
+
});
|
|
64
|
+
expect(result.current.isCopied).toBe(true);
|
|
65
|
+
|
|
66
|
+
act(() => {
|
|
67
|
+
vi.advanceTimersByTime(1);
|
|
68
|
+
});
|
|
69
|
+
expect(result.current.isCopied).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('resets timer if triggered again quickly', () => {
|
|
73
|
+
const duration = 1000;
|
|
74
|
+
const { result } = renderHook(() => useCopyFeedback(duration));
|
|
75
|
+
|
|
76
|
+
act(() => {
|
|
77
|
+
result.current.triggerCopied();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Wait 500ms
|
|
81
|
+
act(() => {
|
|
82
|
+
vi.advanceTimersByTime(500);
|
|
83
|
+
});
|
|
84
|
+
expect(result.current.isCopied).toBe(true);
|
|
85
|
+
|
|
86
|
+
// Trigger again - resets timer
|
|
87
|
+
act(() => {
|
|
88
|
+
result.current.triggerCopied();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Wait another 500ms (1000ms total from first trigger)
|
|
92
|
+
act(() => {
|
|
93
|
+
vi.advanceTimersByTime(500);
|
|
94
|
+
});
|
|
95
|
+
expect(result.current.isCopied).toBe(true);
|
|
96
|
+
|
|
97
|
+
// Wait remaining 500ms from second trigger
|
|
98
|
+
act(() => {
|
|
99
|
+
vi.advanceTimersByTime(500);
|
|
100
|
+
});
|
|
101
|
+
expect(result.current.isCopied).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('cleans up timeout on unmount', () => {
|
|
105
|
+
const { result, unmount } = renderHook(() => useCopyFeedback(1000));
|
|
106
|
+
|
|
107
|
+
act(() => {
|
|
108
|
+
result.current.triggerCopied();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Unmount before timeout completes - should not cause errors
|
|
112
|
+
unmount();
|
|
113
|
+
|
|
114
|
+
// This should not throw
|
|
115
|
+
act(() => {
|
|
116
|
+
vi.advanceTimersByTime(1000);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('maintains stable triggerCopied reference', () => {
|
|
121
|
+
const { result, rerender } = renderHook(() => useCopyFeedback());
|
|
122
|
+
|
|
123
|
+
const firstRef = result.current.triggerCopied;
|
|
124
|
+
rerender();
|
|
125
|
+
const secondRef = result.current.triggerCopied;
|
|
126
|
+
|
|
127
|
+
expect(firstRef).toBe(secondRef);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { TIMING } from '@/lib/constants';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages temporary "Copied!" feedback state with automatic reset after a duration.
|
|
7
|
+
*
|
|
8
|
+
* @param duration - How long to show feedback (defaults to TIMING.COPY_FEEDBACK_DURATION)
|
|
9
|
+
*/
|
|
10
|
+
export function useCopyFeedback(duration: number = TIMING.COPY_FEEDBACK_DURATION): {
|
|
11
|
+
isCopied: boolean;
|
|
12
|
+
triggerCopied: () => void;
|
|
13
|
+
} {
|
|
14
|
+
const [isCopied, setIsCopied] = useState(false);
|
|
15
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
16
|
+
|
|
17
|
+
// Cleanup on unmount
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
return () => {
|
|
20
|
+
if (timeoutRef.current) {
|
|
21
|
+
clearTimeout(timeoutRef.current);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const triggerCopied = useCallback(() => {
|
|
27
|
+
// Clear any existing timeout
|
|
28
|
+
if (timeoutRef.current) {
|
|
29
|
+
clearTimeout(timeoutRef.current);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setIsCopied(true);
|
|
33
|
+
|
|
34
|
+
// Reset after duration
|
|
35
|
+
timeoutRef.current = setTimeout(() => {
|
|
36
|
+
setIsCopied(false);
|
|
37
|
+
}, duration);
|
|
38
|
+
}, [duration]);
|
|
39
|
+
|
|
40
|
+
return { isCopied, triggerCopied };
|
|
41
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { TIMING } from '@/lib/constants';
|
|
5
|
+
|
|
6
|
+
import { useDebouncedCallback } from './useDebouncedCallback';
|
|
7
|
+
|
|
8
|
+
describe('useDebouncedCallback', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.useFakeTimers();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('debounces callback execution', () => {
|
|
18
|
+
const callback = vi.fn();
|
|
19
|
+
const { result } = renderHook(() => useDebouncedCallback(callback));
|
|
20
|
+
|
|
21
|
+
act(() => {
|
|
22
|
+
result.current('arg1');
|
|
23
|
+
result.current('arg2');
|
|
24
|
+
result.current('arg3');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Not called yet
|
|
28
|
+
expect(callback).not.toHaveBeenCalled();
|
|
29
|
+
|
|
30
|
+
// Advance time past debounce delay
|
|
31
|
+
act(() => {
|
|
32
|
+
vi.advanceTimersByTime(TIMING.DEBOUNCE_DELAY);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Called once with last arguments
|
|
36
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(callback).toHaveBeenCalledWith('arg3');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('uses default delay from TIMING.DEBOUNCE_DELAY', () => {
|
|
41
|
+
const callback = vi.fn();
|
|
42
|
+
const { result } = renderHook(() => useDebouncedCallback(callback));
|
|
43
|
+
|
|
44
|
+
act(() => {
|
|
45
|
+
result.current();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Just before default delay
|
|
49
|
+
act(() => {
|
|
50
|
+
vi.advanceTimersByTime(TIMING.DEBOUNCE_DELAY - 1);
|
|
51
|
+
});
|
|
52
|
+
expect(callback).not.toHaveBeenCalled();
|
|
53
|
+
|
|
54
|
+
// At default delay
|
|
55
|
+
act(() => {
|
|
56
|
+
vi.advanceTimersByTime(1);
|
|
57
|
+
});
|
|
58
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('respects custom delay', () => {
|
|
62
|
+
const callback = vi.fn();
|
|
63
|
+
const customDelay = 500;
|
|
64
|
+
const { result } = renderHook(() => useDebouncedCallback(callback, customDelay));
|
|
65
|
+
|
|
66
|
+
act(() => {
|
|
67
|
+
result.current();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
act(() => {
|
|
71
|
+
vi.advanceTimersByTime(customDelay - 1);
|
|
72
|
+
});
|
|
73
|
+
expect(callback).not.toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
act(() => {
|
|
76
|
+
vi.advanceTimersByTime(1);
|
|
77
|
+
});
|
|
78
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('resets timer on each call', () => {
|
|
82
|
+
const callback = vi.fn();
|
|
83
|
+
const delay = 100;
|
|
84
|
+
const { result } = renderHook(() => useDebouncedCallback(callback, delay));
|
|
85
|
+
|
|
86
|
+
act(() => {
|
|
87
|
+
result.current();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Advance halfway
|
|
91
|
+
act(() => {
|
|
92
|
+
vi.advanceTimersByTime(50);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Call again - resets timer
|
|
96
|
+
act(() => {
|
|
97
|
+
result.current();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Advance another 50ms (100ms total from first call)
|
|
101
|
+
act(() => {
|
|
102
|
+
vi.advanceTimersByTime(50);
|
|
103
|
+
});
|
|
104
|
+
expect(callback).not.toHaveBeenCalled();
|
|
105
|
+
|
|
106
|
+
// Advance remaining 50ms
|
|
107
|
+
act(() => {
|
|
108
|
+
vi.advanceTimersByTime(50);
|
|
109
|
+
});
|
|
110
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('maintains stable callback reference', () => {
|
|
114
|
+
const callback = vi.fn();
|
|
115
|
+
const { result, rerender } = renderHook(() => useDebouncedCallback(callback, 100));
|
|
116
|
+
|
|
117
|
+
const firstRef = result.current;
|
|
118
|
+
rerender();
|
|
119
|
+
const secondRef = result.current;
|
|
120
|
+
|
|
121
|
+
expect(firstRef).toBe(secondRef);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('uses latest callback version', () => {
|
|
125
|
+
let counter = 0;
|
|
126
|
+
const { result, rerender } = renderHook(({ cb }) => useDebouncedCallback(cb, 100), {
|
|
127
|
+
initialProps: { cb: () => counter++ },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
act(() => {
|
|
131
|
+
result.current();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Update callback before debounce fires
|
|
135
|
+
const newCallback = vi.fn(() => (counter = 100));
|
|
136
|
+
rerender({ cb: newCallback });
|
|
137
|
+
|
|
138
|
+
act(() => {
|
|
139
|
+
vi.advanceTimersByTime(100);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Should have used the new callback
|
|
143
|
+
expect(counter).toBe(100);
|
|
144
|
+
expect(newCallback).toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('cleans up timeout on unmount', () => {
|
|
148
|
+
const callback = vi.fn();
|
|
149
|
+
const { result, unmount } = renderHook(() => useDebouncedCallback(callback, 100));
|
|
150
|
+
|
|
151
|
+
act(() => {
|
|
152
|
+
result.current();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
unmount();
|
|
156
|
+
|
|
157
|
+
act(() => {
|
|
158
|
+
vi.advanceTimersByTime(100);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Callback should not be called after unmount
|
|
162
|
+
expect(callback).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { TIMING } from '@/lib/constants';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns a debounced version of a callback that only fires after a delay
|
|
7
|
+
* since the last call.
|
|
8
|
+
*
|
|
9
|
+
* @param callback - Function to debounce
|
|
10
|
+
* @param delay - Delay in ms (defaults to TIMING.DEBOUNCE_DELAY)
|
|
11
|
+
*/
|
|
12
|
+
export function useDebouncedCallback<T extends (...args: never[]) => void>(
|
|
13
|
+
callback: T,
|
|
14
|
+
delay: number = TIMING.DEBOUNCE_DELAY,
|
|
15
|
+
): T {
|
|
16
|
+
const callbackRef = useRef(callback);
|
|
17
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
18
|
+
|
|
19
|
+
// Keep callback ref up to date without restarting debounce
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
callbackRef.current = callback;
|
|
22
|
+
}, [callback]);
|
|
23
|
+
|
|
24
|
+
// Cleanup on unmount
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
return () => {
|
|
27
|
+
if (timeoutRef.current) {
|
|
28
|
+
clearTimeout(timeoutRef.current);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
// Stable reference via useCallback (delay is the only dependency)
|
|
34
|
+
const debouncedCallback = useCallback(
|
|
35
|
+
(...args: Parameters<T>) => {
|
|
36
|
+
if (timeoutRef.current) {
|
|
37
|
+
clearTimeout(timeoutRef.current);
|
|
38
|
+
}
|
|
39
|
+
timeoutRef.current = setTimeout(() => {
|
|
40
|
+
callbackRef.current(...args);
|
|
41
|
+
}, delay);
|
|
42
|
+
},
|
|
43
|
+
[delay],
|
|
44
|
+
) as T;
|
|
45
|
+
|
|
46
|
+
return debouncedCallback;
|
|
47
|
+
}
|