@react-spa-scaffold/mcp 2.2.0 → 2.4.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/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +4 -0
- package/dist/constants.js.map +1 -1
- package/dist/features/definitions/api.d.ts.map +1 -1
- package/dist/features/definitions/api.js +2 -1
- package/dist/features/definitions/api.js.map +1 -1
- package/dist/features/definitions/database.d.ts +3 -0
- package/dist/features/definitions/database.d.ts.map +1 -0
- package/dist/features/definitions/database.js +45 -0
- package/dist/features/definitions/database.js.map +1 -0
- package/dist/features/definitions/deployment.d.ts +3 -0
- package/dist/features/definitions/deployment.d.ts.map +1 -0
- package/dist/features/definitions/deployment.js +14 -0
- package/dist/features/definitions/deployment.js.map +1 -0
- package/dist/features/definitions/electron.d.ts +3 -0
- package/dist/features/definitions/electron.d.ts.map +1 -0
- package/dist/features/definitions/electron.js +23 -0
- package/dist/features/definitions/electron.js.map +1 -0
- package/dist/features/definitions/index.d.ts +3 -0
- package/dist/features/definitions/index.d.ts.map +1 -1
- package/dist/features/definitions/index.js +3 -0
- package/dist/features/definitions/index.js.map +1 -1
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +4 -1
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.d.ts +1 -0
- package/dist/features/types.d.ts.map +1 -1
- package/dist/features/types.test.js +5 -2
- package/dist/features/types.test.js.map +1 -1
- package/dist/resources/docs.d.ts.map +1 -1
- package/dist/resources/docs.js +5 -0
- package/dist/resources/docs.js.map +1 -1
- package/dist/tools/add-features.js +1 -1
- package/dist/tools/add-features.js.map +1 -1
- package/dist/tools/get-features.test.js +7 -0
- package/dist/tools/get-features.test.js.map +1 -1
- package/dist/tools/get-scaffold.d.ts +1 -0
- package/dist/tools/get-scaffold.d.ts.map +1 -1
- package/dist/tools/get-scaffold.js +4 -1
- package/dist/tools/get-scaffold.js.map +1 -1
- package/dist/tools/get-scaffold.test.js +50 -0
- package/dist/tools/get-scaffold.test.js.map +1 -1
- package/dist/utils/docs.d.ts.map +1 -1
- package/dist/utils/docs.js +2 -0
- package/dist/utils/docs.js.map +1 -1
- package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/index.js +4 -1
- package/dist/utils/scaffold/claude-md/index.js.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.d.ts +3 -0
- package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.js +174 -2
- package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
- package/dist/utils/scaffold/compute.d.ts.map +1 -1
- package/dist/utils/scaffold/compute.js +4 -2
- package/dist/utils/scaffold/compute.js.map +1 -1
- package/dist/utils/scaffold/generators.d.ts +7 -2
- package/dist/utils/scaffold/generators.d.ts.map +1 -1
- package/dist/utils/scaffold/generators.js +100 -22
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +40 -12
- package/templates/.github/workflows/ci.yml +49 -2
- package/templates/.github/workflows/deploy.yml +46 -0
- package/templates/CLAUDE.md +180 -1
- package/templates/docs/AUTHENTICATION.md +325 -0
- package/templates/docs/DEPLOYMENT.md +296 -0
- package/templates/docs/E2E_TESTING.md +81 -4
- package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
- package/templates/docs/TESTING.md +195 -77
- package/templates/e2e/auth/auth.setup.ts +60 -0
- package/templates/e2e/fixtures/index.ts +11 -0
- package/templates/e2e/tests/profile.auth.spec.ts +103 -0
- package/templates/e2e/tests/profile.spec.ts +64 -0
- package/templates/e2e/tests/register-form.spec.ts +38 -0
- package/templates/forge.config.js +53 -0
- package/templates/gitignore +5 -0
- package/templates/package.json +13 -1
- package/templates/playwright.config.ts +33 -3
- package/templates/src/App.tsx +32 -19
- package/templates/src/components/layout/Header.test.tsx +17 -1
- package/templates/src/components/layout/Header.tsx +11 -0
- package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +3 -3
- package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
- package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
- package/templates/src/components/shared/ProfileSync/index.ts +1 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +3 -3
- package/templates/src/components/shared/index.ts +1 -0
- package/templates/src/contexts/performanceContext.tsx +3 -3
- package/templates/src/contexts/queryContext.tsx +9 -8
- package/templates/src/contexts/supabaseContext.test.tsx +59 -0
- package/templates/src/contexts/supabaseContext.tsx +87 -0
- package/templates/src/hooks/index.ts +17 -0
- package/templates/src/hooks/supabase/index.ts +12 -0
- package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
- package/templates/src/hooks/supabase/useProfiles.ts +213 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
- package/templates/src/lib/api.test.ts +30 -38
- package/templates/src/lib/api.ts +1 -7
- package/templates/src/lib/config.ts +54 -4
- package/templates/src/lib/env.ts +36 -14
- package/templates/src/lib/index.ts +4 -2
- package/templates/src/lib/routes.ts +1 -0
- package/templates/src/lib/sentry.ts +13 -10
- package/templates/src/lib/supabase/client.ts +58 -0
- package/templates/src/lib/supabase/index.ts +5 -0
- package/templates/src/main.ts +227 -0
- package/templates/src/main.tsx +32 -42
- package/templates/src/mocks/constants.ts +31 -0
- package/templates/src/mocks/fixtures/index.ts +3 -1
- package/templates/src/mocks/fixtures/profiles.ts +55 -0
- package/templates/src/mocks/fixtures/users.ts +91 -0
- package/templates/src/mocks/handlers/index.ts +2 -1
- package/templates/src/mocks/handlers/supabase.ts +64 -0
- package/templates/src/mocks/handlers/todos.ts +1 -1
- package/templates/src/mocks/index.ts +6 -0
- package/templates/src/pages/Profile.test.tsx +263 -0
- package/templates/src/pages/Profile.tsx +171 -0
- package/templates/src/pages/index.ts +1 -0
- package/templates/src/preload.ts +26 -0
- package/templates/src/stores/preferencesStore.ts +2 -1
- package/templates/src/test/clerkMock.tsx +49 -9
- package/templates/src/test/fetchMock.ts +58 -0
- package/templates/src/test/index.ts +49 -3
- package/templates/src/test/mocks.ts +128 -1
- package/templates/src/test/providers.tsx +7 -4
- package/templates/src/test/supabaseMock.ts +112 -0
- package/templates/src/test-setup.ts +26 -0
- package/templates/src/types/database.ts +46 -0
- package/templates/src/types/global.d.ts +28 -0
- package/templates/src/types/index.ts +1 -0
- package/templates/src/types/supabase.ts +167 -0
- package/templates/src/vite-env.d.ts +6 -0
- package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
- package/templates/vite.main.config.mjs +20 -0
- package/templates/vite.preload.config.mjs +17 -0
- package/templates/vite.renderer.config.mjs +52 -0
package/templates/package.json
CHANGED
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"e2e:perf": "PERF_TEST=true playwright test --project=performance",
|
|
47
47
|
"e2e:perf:ui": "PERF_TEST=true playwright test --project=performance --ui",
|
|
48
48
|
"i18n:extract": "lingui extract",
|
|
49
|
+
"db:types": "dotenv -- supabase gen types typescript --project-id $SUPABASE_PROJECT_ID > src/types/supabase.ts",
|
|
50
|
+
"db:push": "supabase db push",
|
|
51
|
+
"db:reset": "supabase db reset",
|
|
52
|
+
"db:studio": "supabase studio",
|
|
49
53
|
"prepare": "husky",
|
|
50
54
|
"mcp:build": "npm run build -w @react-spa-scaffold/mcp",
|
|
51
55
|
"mcp:dev": "npm run dev -w @react-spa-scaffold/mcp",
|
|
@@ -53,7 +57,9 @@
|
|
|
53
57
|
"mcp:inspect": "npm run inspect -w @react-spa-scaffold/mcp",
|
|
54
58
|
"changeset": "changeset",
|
|
55
59
|
"version": "changeset version",
|
|
56
|
-
"release": "changeset publish"
|
|
60
|
+
"release": "changeset publish",
|
|
61
|
+
"sync:check": "syncpack lint",
|
|
62
|
+
"sync:fix": "syncpack fix-mismatches"
|
|
57
63
|
},
|
|
58
64
|
"dependencies": {
|
|
59
65
|
"@clerk/react-router": "^2.3.7",
|
|
@@ -64,6 +70,7 @@
|
|
|
64
70
|
"@lingui/react": "^5.7.0",
|
|
65
71
|
"@radix-ui/react-slot": "^1.2.3",
|
|
66
72
|
"@sentry/react": "^10.32.1",
|
|
73
|
+
"@supabase/supabase-js": "^2.89.0",
|
|
67
74
|
"@tanstack/react-query": "^5.90.14",
|
|
68
75
|
"class-variance-authority": "^0.7.1",
|
|
69
76
|
"clsx": "^2.1.1",
|
|
@@ -84,6 +91,7 @@
|
|
|
84
91
|
"devDependencies": {
|
|
85
92
|
"@changesets/changelog-github": "^0.5.2",
|
|
86
93
|
"@changesets/cli": "^2.29.8",
|
|
94
|
+
"@clerk/testing": "^1.13.26",
|
|
87
95
|
"@commitlint/config-conventional": "^20.2.0",
|
|
88
96
|
"@eslint/js": "^9.28.0",
|
|
89
97
|
"@lingui/babel-plugin-lingui-macro": "^5.7.0",
|
|
@@ -95,6 +103,7 @@
|
|
|
95
103
|
"@react-spa-scaffold/tsconfig": "*",
|
|
96
104
|
"@sentry/vite-plugin": "^4.6.1",
|
|
97
105
|
"@tailwindcss/vite": "^4.1.17",
|
|
106
|
+
"@tanstack/react-query-devtools": "^5.91.2",
|
|
98
107
|
"@testing-library/jest-dom": "^6.6.3",
|
|
99
108
|
"@testing-library/react": "^16.3.0",
|
|
100
109
|
"@testing-library/user-event": "^14.6.1",
|
|
@@ -106,6 +115,7 @@
|
|
|
106
115
|
"babel-plugin-macros": "^3.1.0",
|
|
107
116
|
"chrome-launcher": "^1.2.1",
|
|
108
117
|
"commitlint": "^20.2.0",
|
|
118
|
+
"dotenv-cli": "^11.0.0",
|
|
109
119
|
"eslint": "^9.28.0",
|
|
110
120
|
"eslint-config-prettier": "^10.1.0",
|
|
111
121
|
"eslint-plugin-lingui": "^0.11.0",
|
|
@@ -119,6 +129,8 @@
|
|
|
119
129
|
"prettier": "^3.5.3",
|
|
120
130
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
121
131
|
"shadcn": "^3.6.2",
|
|
132
|
+
"supabase": "^2.70.5",
|
|
133
|
+
"syncpack": "^13.0.4",
|
|
122
134
|
"tailwindcss": "^4.1.17",
|
|
123
135
|
"typescript": "~5.9.0",
|
|
124
136
|
"typescript-eslint": "^8.33.0",
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { defineConfig, devices } from '@playwright/test';
|
|
2
2
|
|
|
3
|
+
import { AUTH_STATE_FILE } from './e2e/fixtures';
|
|
4
|
+
|
|
5
|
+
// CI performance tests use preview server (serves pre-built dist) for faster startup
|
|
6
|
+
// Local dev uses dev server with VITE_PERF_TEST for hot reload
|
|
7
|
+
const isPerfCI = process.env.PERF_CI === 'true';
|
|
8
|
+
const baseURL = isPerfCI ? 'http://localhost:4173' : 'http://localhost:5173';
|
|
9
|
+
|
|
10
|
+
function getWebServerCommand(): string {
|
|
11
|
+
if (isPerfCI) return 'npm run preview';
|
|
12
|
+
if (process.env.PERF_TEST) return 'VITE_PERF_TEST=true npm run dev';
|
|
13
|
+
return 'npm run dev';
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
export default defineConfig({
|
|
4
17
|
testDir: './e2e',
|
|
5
18
|
fullyParallel: true,
|
|
@@ -8,7 +21,7 @@ export default defineConfig({
|
|
|
8
21
|
workers: process.env.CI ? 1 : undefined,
|
|
9
22
|
reporter: process.env.CI ? 'github' : [['list'], ['html']],
|
|
10
23
|
use: {
|
|
11
|
-
baseURL
|
|
24
|
+
baseURL,
|
|
12
25
|
trace: 'on-first-retry',
|
|
13
26
|
screenshot: 'only-on-failure',
|
|
14
27
|
},
|
|
@@ -16,6 +29,12 @@ export default defineConfig({
|
|
|
16
29
|
timeout: 10000,
|
|
17
30
|
},
|
|
18
31
|
projects: [
|
|
32
|
+
// Auth setup - runs first to create authenticated state
|
|
33
|
+
{
|
|
34
|
+
name: 'setup',
|
|
35
|
+
testDir: './e2e/auth',
|
|
36
|
+
testMatch: /.*\.setup\.ts/,
|
|
37
|
+
},
|
|
19
38
|
{
|
|
20
39
|
name: 'desktop',
|
|
21
40
|
testDir: './e2e/tests',
|
|
@@ -26,6 +45,17 @@ export default defineConfig({
|
|
|
26
45
|
testDir: './e2e/tests',
|
|
27
46
|
use: { ...devices['Pixel 5'] },
|
|
28
47
|
},
|
|
48
|
+
// Authenticated tests - depend on setup project
|
|
49
|
+
{
|
|
50
|
+
name: 'authenticated',
|
|
51
|
+
testDir: './e2e/tests',
|
|
52
|
+
testMatch: /.*\.auth\.spec\.ts/,
|
|
53
|
+
dependencies: ['setup'],
|
|
54
|
+
use: {
|
|
55
|
+
...devices['Desktop Chrome'],
|
|
56
|
+
storageState: AUTH_STATE_FILE,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
29
59
|
{
|
|
30
60
|
name: 'performance',
|
|
31
61
|
testDir: './e2e/performance',
|
|
@@ -39,8 +69,8 @@ export default defineConfig({
|
|
|
39
69
|
},
|
|
40
70
|
],
|
|
41
71
|
webServer: {
|
|
42
|
-
command:
|
|
43
|
-
url:
|
|
72
|
+
command: getWebServerCommand(),
|
|
73
|
+
url: baseURL,
|
|
44
74
|
reuseExistingServer: !process.env.CI,
|
|
45
75
|
timeout: 120000,
|
|
46
76
|
},
|
package/templates/src/App.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { lazy, Suspense } from 'react';
|
|
|
3
3
|
import { Route, Routes } from 'react-router';
|
|
4
4
|
|
|
5
5
|
import { Header } from '@/components/layout';
|
|
6
|
-
import { SEO } from '@/components/shared';
|
|
6
|
+
import { ProfileSync, ProtectedRoute, SEO } from '@/components/shared';
|
|
7
7
|
import { PageLoading } from '@/components/ui/loading';
|
|
8
8
|
import { SkipLink } from '@/components/ui/visually-hidden';
|
|
9
9
|
import { useThemeEffect } from '@/hooks';
|
|
@@ -14,29 +14,42 @@ import { ROUTES } from '@/lib/routes';
|
|
|
14
14
|
const HomePage = lazy(() => import('@/pages/Home').then((m) => ({ default: m.HomePage })));
|
|
15
15
|
// eslint-disable-next-line lingui/no-unlocalized-strings
|
|
16
16
|
const NotFoundPage = lazy(() => import('@/pages/NotFound').then((m) => ({ default: m.NotFoundPage })));
|
|
17
|
+
// eslint-disable-next-line lingui/no-unlocalized-strings
|
|
18
|
+
const ProfilePage = lazy(() => import('@/pages/Profile').then((m) => ({ default: m.ProfilePage })));
|
|
17
19
|
|
|
18
20
|
export default function App() {
|
|
19
21
|
const { t } = useLingui();
|
|
20
22
|
useThemeEffect();
|
|
21
23
|
|
|
22
24
|
return (
|
|
23
|
-
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
25
|
+
<>
|
|
26
|
+
<ProfileSync />
|
|
27
|
+
<div className="bg-background text-foreground min-h-screen">
|
|
28
|
+
<SEO
|
|
29
|
+
description={t({
|
|
30
|
+
message: 'A modern React 19 application with TypeScript and Vite',
|
|
31
|
+
comment: 'Default site-wide meta description for SEO',
|
|
32
|
+
})}
|
|
33
|
+
/>
|
|
34
|
+
<SkipLink />
|
|
35
|
+
<Header />
|
|
36
|
+
<main id="main">
|
|
37
|
+
<Suspense fallback={<PageLoading />}>
|
|
38
|
+
<Routes>
|
|
39
|
+
<Route path={ROUTES.HOME} element={<HomePage />} />
|
|
40
|
+
<Route
|
|
41
|
+
path={ROUTES.PROFILE}
|
|
42
|
+
element={
|
|
43
|
+
<ProtectedRoute>
|
|
44
|
+
<ProfilePage />
|
|
45
|
+
</ProtectedRoute>
|
|
46
|
+
}
|
|
47
|
+
/>
|
|
48
|
+
<Route path={ROUTES.NOT_FOUND} element={<NotFoundPage />} />
|
|
49
|
+
</Routes>
|
|
50
|
+
</Suspense>
|
|
51
|
+
</main>
|
|
52
|
+
</div>
|
|
53
|
+
</>
|
|
41
54
|
);
|
|
42
55
|
}
|
|
@@ -2,7 +2,7 @@ import { screen } from '@testing-library/react';
|
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { Header } from '@/components/layout/Header';
|
|
5
|
-
import { render } from '@/test';
|
|
5
|
+
import { render, setMockClerkSignedIn } from '@/test';
|
|
6
6
|
|
|
7
7
|
describe('Header', () => {
|
|
8
8
|
it('renders the app title', () => {
|
|
@@ -30,4 +30,20 @@ describe('Header', () => {
|
|
|
30
30
|
const header = screen.getByRole('banner');
|
|
31
31
|
expect(header).toBeInTheDocument();
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
it('renders sign-in button when user is signed out', () => {
|
|
35
|
+
setMockClerkSignedIn(false);
|
|
36
|
+
|
|
37
|
+
render(<Header />);
|
|
38
|
+
|
|
39
|
+
expect(screen.getByTestId('sign-in-button')).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('renders user button when user is signed in', () => {
|
|
43
|
+
setMockClerkSignedIn(true);
|
|
44
|
+
|
|
45
|
+
render(<Header />);
|
|
46
|
+
|
|
47
|
+
expect(screen.getByTestId('user-button')).toBeInTheDocument();
|
|
48
|
+
});
|
|
33
49
|
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { SignedIn } from '@clerk/react-router';
|
|
1
2
|
import { Trans } from '@lingui/react/macro';
|
|
3
|
+
import { Link } from 'react-router';
|
|
2
4
|
|
|
3
5
|
import { AccountButton, LanguageSwitcher, ThemeToggle } from '@/components/shared';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import { ROUTES } from '@/lib/routes';
|
|
4
8
|
|
|
5
9
|
export function Header() {
|
|
6
10
|
return (
|
|
@@ -10,6 +14,13 @@ export function Header() {
|
|
|
10
14
|
<Trans comment="Application name displayed in the header navigation">My App</Trans>
|
|
11
15
|
</h1>
|
|
12
16
|
<div className="flex items-center gap-2">
|
|
17
|
+
<SignedIn>
|
|
18
|
+
<Button variant="ghost" size="sm" asChild>
|
|
19
|
+
<Link to={ROUTES.PROFILE}>
|
|
20
|
+
<Trans comment="Profile navigation link in header">Profile</Trans>
|
|
21
|
+
</Link>
|
|
22
|
+
</Button>
|
|
23
|
+
</SignedIn>
|
|
13
24
|
<LanguageSwitcher />
|
|
14
25
|
<ThemeToggle />
|
|
15
26
|
<AccountButton />
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { screen } from '@testing-library/react';
|
|
2
2
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
3
|
|
|
4
|
-
import { render,
|
|
4
|
+
import { render, setMockClerkSignedIn, setMockClerkLoaded, resetClerkMocks } from '@/test';
|
|
5
5
|
|
|
6
6
|
import { AccountButton } from './AccountButton';
|
|
7
7
|
|
|
@@ -11,7 +11,7 @@ describe('AccountButton', () => {
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('shows skeleton when not loaded', () => {
|
|
14
|
-
|
|
14
|
+
setMockClerkLoaded(false);
|
|
15
15
|
const { container } = render(<AccountButton />);
|
|
16
16
|
expect(container.querySelector('.rounded-full')).toBeInTheDocument();
|
|
17
17
|
});
|
|
@@ -22,7 +22,7 @@ describe('AccountButton', () => {
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
it('shows sign in button when signed out', () => {
|
|
25
|
-
|
|
25
|
+
setMockClerkSignedIn(false);
|
|
26
26
|
render(<AccountButton />);
|
|
27
27
|
expect(screen.getByTestId('sign-in-button')).toBeInTheDocument();
|
|
28
28
|
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { render, setMockClerkSignedIn, resetClerkMocks } from '@/test';
|
|
5
|
+
|
|
6
|
+
import { ProfileSync } from './ProfileSync';
|
|
7
|
+
|
|
8
|
+
// Mock the hooks
|
|
9
|
+
const mockMutate = vi.fn();
|
|
10
|
+
vi.mock('@/hooks', async () => {
|
|
11
|
+
const actual = await vi.importActual('@/hooks');
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
useCurrentProfile: vi.fn(() => ({ data: [], isLoading: false })),
|
|
15
|
+
useUpsertProfile: vi.fn(() => ({ mutate: mockMutate, isPending: false })),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('ProfileSync', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
resetClerkMocks();
|
|
22
|
+
mockMutate.mockClear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders nothing (invisible)', () => {
|
|
26
|
+
setMockClerkSignedIn(true);
|
|
27
|
+
const { container } = render(<ProfileSync />);
|
|
28
|
+
expect(container).toBeEmptyDOMElement();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does not sync when signed out', async () => {
|
|
32
|
+
setMockClerkSignedIn(false);
|
|
33
|
+
render(<ProfileSync />);
|
|
34
|
+
await waitFor(() => expect(mockMutate).not.toHaveBeenCalled());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('syncs profile when signed in', async () => {
|
|
38
|
+
setMockClerkSignedIn(true);
|
|
39
|
+
render(<ProfileSync />);
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(mockMutate).toHaveBeenCalledWith(expect.objectContaining({ id: 'user_123' }), expect.any(Object));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfileSync - Invisible component that syncs Clerk user data to Supabase.
|
|
3
|
+
*
|
|
4
|
+
* This component ensures the authenticated user's profile exists in Supabase
|
|
5
|
+
* and stays in sync with their Clerk account data. It runs once per session
|
|
6
|
+
* and handles first-time profile creation automatically.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* // Add to your app layout or protected routes
|
|
11
|
+
* function App() {
|
|
12
|
+
* return (
|
|
13
|
+
* <>
|
|
14
|
+
* <ProfileSync />
|
|
15
|
+
* <Routes>...</Routes>
|
|
16
|
+
* </>
|
|
17
|
+
* );
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useEffect, useRef } from 'react';
|
|
23
|
+
import { useUser } from '@clerk/react-router';
|
|
24
|
+
|
|
25
|
+
import { useCurrentProfile, useUpsertProfile } from '@/hooks';
|
|
26
|
+
|
|
27
|
+
interface ProfileSyncProps {
|
|
28
|
+
/**
|
|
29
|
+
* Callback fired when profile sync completes successfully.
|
|
30
|
+
*/
|
|
31
|
+
onSyncComplete?: () => void;
|
|
32
|
+
/**
|
|
33
|
+
* Callback fired when profile sync fails.
|
|
34
|
+
*/
|
|
35
|
+
onSyncError?: (error: Error) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Syncs the current Clerk user's data to Supabase profiles table.
|
|
40
|
+
*
|
|
41
|
+
* This component:
|
|
42
|
+
* 1. Waits for Clerk user to be loaded
|
|
43
|
+
* 2. Checks if a profile already exists
|
|
44
|
+
* 3. Creates or updates the profile if needed
|
|
45
|
+
* 4. Only syncs once per component mount to prevent loops
|
|
46
|
+
*
|
|
47
|
+
* The sync uses upsert, so it's safe to call multiple times - it will
|
|
48
|
+
* create the profile if it doesn't exist, or update if data changed.
|
|
49
|
+
*/
|
|
50
|
+
export function ProfileSync({ onSyncComplete, onSyncError }: ProfileSyncProps) {
|
|
51
|
+
const { user, isLoaded: isUserLoaded } = useUser();
|
|
52
|
+
const { data: profiles, isLoading: isProfileLoading } = useCurrentProfile();
|
|
53
|
+
const { mutate: upsertProfile } = useUpsertProfile();
|
|
54
|
+
|
|
55
|
+
// Track if we've already synced this session to prevent infinite loops
|
|
56
|
+
const hasSynced = useRef(false);
|
|
57
|
+
const isSyncing = useRef(false);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
// Skip if already synced, currently syncing, or data not ready
|
|
61
|
+
if (hasSynced.current || isSyncing.current) return;
|
|
62
|
+
if (!isUserLoaded || !user) return;
|
|
63
|
+
if (isProfileLoading) return;
|
|
64
|
+
|
|
65
|
+
const existingProfile = profiles?.[0];
|
|
66
|
+
|
|
67
|
+
// Check if sync is needed:
|
|
68
|
+
// - No profile exists, OR
|
|
69
|
+
// - Email has changed (user updated in Clerk)
|
|
70
|
+
const needsSync = !existingProfile || existingProfile.email !== user.primaryEmailAddress?.emailAddress;
|
|
71
|
+
|
|
72
|
+
if (!needsSync) {
|
|
73
|
+
hasSynced.current = true;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Perform the sync
|
|
78
|
+
isSyncing.current = true;
|
|
79
|
+
|
|
80
|
+
upsertProfile(
|
|
81
|
+
{
|
|
82
|
+
id: user.id,
|
|
83
|
+
email: user.primaryEmailAddress?.emailAddress ?? '',
|
|
84
|
+
full_name: user.fullName ?? null,
|
|
85
|
+
avatar_url: user.imageUrl ?? null,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
onSuccess: () => {
|
|
89
|
+
hasSynced.current = true;
|
|
90
|
+
isSyncing.current = false;
|
|
91
|
+
onSyncComplete?.();
|
|
92
|
+
},
|
|
93
|
+
onError: (error) => {
|
|
94
|
+
isSyncing.current = false;
|
|
95
|
+
// Don't set hasSynced so it can retry on next render
|
|
96
|
+
onSyncError?.(new Error(error.message));
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
}, [isUserLoaded, user, profiles, isProfileLoading, upsertProfile, onSyncComplete, onSyncError]);
|
|
101
|
+
|
|
102
|
+
// This component renders nothing - it's purely for side effects
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ProfileSync } from './ProfileSync';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { screen } from '@testing-library/react';
|
|
2
2
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
3
|
|
|
4
|
-
import { render,
|
|
4
|
+
import { render, setMockClerkSignedIn, setMockClerkLoaded, resetClerkMocks } from '@/test';
|
|
5
5
|
|
|
6
6
|
import { ProtectedRoute } from './ProtectedRoute';
|
|
7
7
|
|
|
@@ -20,7 +20,7 @@ describe('ProtectedRoute', () => {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
it('shows loading when auth is not loaded', () => {
|
|
23
|
-
|
|
23
|
+
setMockClerkLoaded(false);
|
|
24
24
|
const { container } = render(
|
|
25
25
|
<ProtectedRoute>
|
|
26
26
|
<div>Protected Content</div>
|
|
@@ -31,7 +31,7 @@ describe('ProtectedRoute', () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
it('redirects when not signed in', () => {
|
|
34
|
-
|
|
34
|
+
setMockClerkSignedIn(false);
|
|
35
35
|
render(
|
|
36
36
|
<ProtectedRoute>
|
|
37
37
|
<div>Protected Content</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { AccountButton } from './AccountButton';
|
|
2
2
|
export { ErrorBoundary } from './ErrorBoundary';
|
|
3
3
|
export { LanguageSwitcher } from './LanguageSwitcher';
|
|
4
|
+
export { ProfileSync } from './ProfileSync';
|
|
4
5
|
export { ProtectedRoute } from './ProtectedRoute';
|
|
5
6
|
export { RegisterForm } from './RegisterForm';
|
|
6
7
|
export { SEO } from './SEO';
|
|
@@ -2,6 +2,8 @@ import { lazy, Suspense, type ReactNode } from 'react';
|
|
|
2
2
|
|
|
3
3
|
import { usePerformance as useLibPerformance } from 'react-performance-tracking/react';
|
|
4
4
|
|
|
5
|
+
import { PERFORMANCE_CONFIG } from '@/lib/config';
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Lazy-load the PerformanceProvider to avoid bundling it in production.
|
|
7
9
|
* This provider is only used during performance testing.
|
|
@@ -29,9 +31,7 @@ interface PerformanceProviderWrapperProps {
|
|
|
29
31
|
* @see https://github.com/mkaczkowski/react-performance-tracking
|
|
30
32
|
*/
|
|
31
33
|
export function PerformanceProviderWrapper({ children }: PerformanceProviderWrapperProps) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (!isPerformanceEnabled) {
|
|
34
|
+
if (!PERFORMANCE_CONFIG.enabled) {
|
|
35
35
|
return <>{children}</>;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
|
2
3
|
import { type ReactNode, useState } from 'react';
|
|
3
4
|
|
|
4
|
-
/**
|
|
5
|
-
* Create QueryClient with optimized defaults.
|
|
6
|
-
* Using a function ensures each provider instance gets its own client
|
|
7
|
-
*/
|
|
5
|
+
/** Create QueryClient with optimized defaults. */
|
|
8
6
|
function createQueryClient() {
|
|
9
7
|
return new QueryClient({
|
|
10
8
|
defaultOptions: {
|
|
11
9
|
queries: {
|
|
12
10
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
13
|
-
gcTime: 1000 * 60 * 30, // 30 minutes
|
|
11
|
+
gcTime: 1000 * 60 * 30, // 30 minutes
|
|
14
12
|
retry: 1,
|
|
15
13
|
refetchOnWindowFocus: false,
|
|
16
14
|
},
|
|
@@ -19,9 +17,12 @@ function createQueryClient() {
|
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
export function QueryProvider({ children }: { children: ReactNode }) {
|
|
22
|
-
// Use useState to ensure queryClient is created once per component instance
|
|
23
|
-
// This is the recommended pattern from TanStack Query docs
|
|
24
20
|
const [queryClient] = useState(createQueryClient);
|
|
25
21
|
|
|
26
|
-
return
|
|
22
|
+
return (
|
|
23
|
+
<QueryClientProvider client={queryClient}>
|
|
24
|
+
{children}
|
|
25
|
+
<ReactQueryDevtools initialIsOpen={false} />
|
|
26
|
+
</QueryClientProvider>
|
|
27
|
+
);
|
|
27
28
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { SupabaseProvider, useSupabase } from './supabaseContext';
|
|
6
|
+
|
|
7
|
+
// Mock Clerk's useSession hook
|
|
8
|
+
vi.mock('@clerk/react-router', () => ({
|
|
9
|
+
useSession: () => ({
|
|
10
|
+
session: {
|
|
11
|
+
getToken: vi.fn().mockResolvedValue('mock-clerk-token'),
|
|
12
|
+
},
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Mock the Supabase client factory
|
|
17
|
+
vi.mock('@/lib/supabase', () => ({
|
|
18
|
+
createSupabaseClient: vi.fn().mockReturnValue({
|
|
19
|
+
from: vi.fn().mockReturnValue({
|
|
20
|
+
select: vi.fn().mockResolvedValue({ data: [], error: null }),
|
|
21
|
+
}),
|
|
22
|
+
}),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe('SupabaseContext', () => {
|
|
26
|
+
const wrapper = ({ children }: { children: ReactNode }) => <SupabaseProvider>{children}</SupabaseProvider>;
|
|
27
|
+
|
|
28
|
+
describe('SupabaseProvider', () => {
|
|
29
|
+
it('provides supabase client to children', () => {
|
|
30
|
+
const { result } = renderHook(() => useSupabase(), { wrapper });
|
|
31
|
+
|
|
32
|
+
expect(result.current).toBeDefined();
|
|
33
|
+
expect(result.current.from).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('provides a client with from method', () => {
|
|
37
|
+
const { result } = renderHook(() => useSupabase(), { wrapper });
|
|
38
|
+
|
|
39
|
+
expect(typeof result.current.from).toBe('function');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('useSupabase', () => {
|
|
44
|
+
// Note: The "throws outside provider" behavior is tested implicitly
|
|
45
|
+
// The global mock in test-setup.ts provides a mock client for all tests,
|
|
46
|
+
// so we verify the actual throw behavior through the real implementation.
|
|
47
|
+
// This test uses the mocked version which always returns a client.
|
|
48
|
+
|
|
49
|
+
it('returns the same client instance on re-renders', () => {
|
|
50
|
+
const { result, rerender } = renderHook(() => useSupabase(), { wrapper });
|
|
51
|
+
|
|
52
|
+
const firstClient = result.current;
|
|
53
|
+
rerender();
|
|
54
|
+
const secondClient = result.current;
|
|
55
|
+
|
|
56
|
+
expect(firstClient).toBe(secondClient);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supabase context provider with Clerk authentication integration.
|
|
3
|
+
*
|
|
4
|
+
* Provides a typed Supabase client that automatically uses Clerk session tokens
|
|
5
|
+
* for authentication. Must be placed inside ClerkProvider in the component tree.
|
|
6
|
+
*
|
|
7
|
+
* @see https://supabase.com/docs/guides/auth/third-party/clerk
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useSession } from '@clerk/react-router';
|
|
11
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
12
|
+
|
|
13
|
+
import { createSupabaseClient, type TypedSupabaseClient } from '@/lib/supabase';
|
|
14
|
+
|
|
15
|
+
const SupabaseContext = createContext<TypedSupabaseClient | null>(null);
|
|
16
|
+
|
|
17
|
+
interface SupabaseProviderProps {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Provides a Supabase client with Clerk authentication to the app.
|
|
23
|
+
*
|
|
24
|
+
* The client automatically injects Clerk session tokens into Supabase requests,
|
|
25
|
+
* enabling Row Level Security (RLS) policies based on `auth.uid()`.
|
|
26
|
+
*
|
|
27
|
+
* Must be placed INSIDE ClerkProvider in the provider hierarchy since it
|
|
28
|
+
* requires access to the Clerk session via useSession().
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* // In main.tsx
|
|
33
|
+
* <ClerkThemeProvider>
|
|
34
|
+
* <SupabaseProvider>
|
|
35
|
+
* <App />
|
|
36
|
+
* </SupabaseProvider>
|
|
37
|
+
* </ClerkThemeProvider>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function SupabaseProvider({ children }: SupabaseProviderProps) {
|
|
41
|
+
const { session } = useSession();
|
|
42
|
+
|
|
43
|
+
// Create client with stable getToken reference
|
|
44
|
+
// Only recreate when session ID changes (sign in/out), not on every render
|
|
45
|
+
const supabase = useMemo(
|
|
46
|
+
() =>
|
|
47
|
+
createSupabaseClient(async () => {
|
|
48
|
+
if (!session) return null;
|
|
49
|
+
return session.getToken();
|
|
50
|
+
}),
|
|
51
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- session.id is stable, session object is not
|
|
52
|
+
[session?.id],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return <SupabaseContext.Provider value={supabase}>{children}</SupabaseContext.Provider>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hook to access the Supabase client.
|
|
60
|
+
*
|
|
61
|
+
* Returns a typed Supabase client that automatically handles Clerk authentication.
|
|
62
|
+
* All database operations will use RLS policies based on the current user.
|
|
63
|
+
*
|
|
64
|
+
* @throws Error if used outside SupabaseProvider
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```tsx
|
|
68
|
+
* function MyComponent() {
|
|
69
|
+
* const supabase = useSupabase();
|
|
70
|
+
*
|
|
71
|
+
* // Fetch user's own profile (RLS enforced)
|
|
72
|
+
* const { data } = await supabase
|
|
73
|
+
* .from('profiles')
|
|
74
|
+
* .select('*')
|
|
75
|
+
* .single();
|
|
76
|
+
* }
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function useSupabase(): TypedSupabaseClient {
|
|
80
|
+
const context = useContext(SupabaseContext);
|
|
81
|
+
|
|
82
|
+
if (!context) {
|
|
83
|
+
throw new Error('useSupabase must be used within a SupabaseProvider');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return context;
|
|
87
|
+
}
|