Skip to content

Commit 7dbac4d

Browse files
committedSep 20, 2024
feat: Add vanilla React adapter sandbox
Showcasing two adapters: plain client-side React (no framework) and unit testing with Vitest.
1 parent edb9a13 commit 7dbac4d

18 files changed

+1278
-38
lines changed
 

‎packages/adapters/react/.gitignore

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
dist
12+
dist-ssr
13+
*.local
14+
15+
# Editor directories and files
16+
.vscode/*
17+
!.vscode/extensions.json
18+
.idea
19+
.DS_Store
20+
*.suo
21+
*.ntvs*
22+
*.njsproj
23+
*.sln
24+
*.sw?

‎packages/adapters/react/README.md

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# React + TypeScript + Vite
2+
3+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4+
5+
Currently, two official plugins are available:
6+
7+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9+
10+
## Expanding the ESLint configuration
11+
12+
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13+
14+
- Configure the top-level `parserOptions` property like this:
15+
16+
```js
17+
export default tseslint.config({
18+
languageOptions: {
19+
// other options...
20+
parserOptions: {
21+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
22+
tsconfigRootDir: import.meta.dirname,
23+
},
24+
},
25+
})
26+
```
27+
28+
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
29+
- Optionally add `...tseslint.configs.stylisticTypeChecked`
30+
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
31+
32+
```js
33+
// eslint.config.js
34+
import react from 'eslint-plugin-react'
35+
36+
export default tseslint.config({
37+
// Set the react version
38+
settings: { react: { version: '18.3' } },
39+
plugins: {
40+
// Add the react plugin
41+
react,
42+
},
43+
rules: {
44+
// other rules...
45+
// Enable its recommended rules
46+
...react.configs.recommended.rules,
47+
...react.configs['jsx-runtime'].rules,
48+
},
49+
})
50+
```

‎packages/adapters/react/index.html

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Vite + React + TS</title>
8+
</head>
9+
<body>
10+
<div id="root"></div>
11+
<script type="module" src="/src/main.tsx"></script>
12+
</body>
13+
</html>

‎packages/adapters/react/package.json

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "adapters-react",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite --port 4000",
8+
"build": "tsc -b && vite build",
9+
"preview": "vite preview",
10+
"test": "vitest"
11+
},
12+
"dependencies": {
13+
"nuqs": "workspace:*",
14+
"react": "rc",
15+
"react-dom": "rc"
16+
},
17+
"devDependencies": {
18+
"@testing-library/dom": "^10.1.0",
19+
"@testing-library/jest-dom": "^6.4.5",
20+
"@testing-library/react": "^15.0.7",
21+
"@testing-library/user-event": "^14.5.2",
22+
"@types/node": "^20.16.3",
23+
"@types/react": "^18.3.7",
24+
"@types/react-dom": "^18.3.0",
25+
"@vitejs/plugin-react": "^4.3.1",
26+
"globals": "^15.9.0",
27+
"jsdom": "^24.1.0",
28+
"typescript": "^5.5.3",
29+
"vite": "^5.4.1",
30+
"vitest": "^1.6.0"
31+
}
32+
}

‎packages/adapters/react/src/App.tsx

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { CounterButton } from './components/counter-button'
2+
import { SearchInput } from './components/search-input'
3+
4+
export default function App() {
5+
return (
6+
<>
7+
<h1>Vite + React + nuqs</h1>
8+
<div>
9+
<CounterButton />
10+
<SearchInput />
11+
</div>
12+
</>
13+
)
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { render, screen } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { CounterButton } from './counter-button'
6+
7+
describe('CounterButton', () => {
8+
it('should render the button with state loaded from the URL', () => {
9+
render(<CounterButton />, {
10+
wrapper: ({ children }) => (
11+
<NuqsTestingAdapter searchParams="?count=42">
12+
{children}
13+
</NuqsTestingAdapter>
14+
)
15+
})
16+
expect(screen.getByRole('button')).toHaveTextContent('count is 42')
17+
})
18+
it('should increment the count when clicked', async () => {
19+
const user = userEvent.setup()
20+
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
21+
render(<CounterButton />, {
22+
wrapper: ({ children }) => (
23+
<NuqsTestingAdapter searchParams="?count=42" onUrlUpdate={onUrlUpdate}>
24+
{children}
25+
</NuqsTestingAdapter>
26+
)
27+
})
28+
const button = screen.getByRole('button')
29+
await user.click(button)
30+
expect(button).toHaveTextContent('count is 43')
31+
expect(onUrlUpdate).toHaveBeenCalledOnce()
32+
expect(onUrlUpdate.mock.calls[0][0].queryString).toBe('?count=43')
33+
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('count')).toBe('43')
34+
expect(onUrlUpdate.mock.calls[0][0].options.history).toBe('push')
35+
})
36+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { parseAsInteger, useQueryState } from 'nuqs'
2+
3+
export function CounterButton() {
4+
const [count, setCount] = useQueryState(
5+
'count',
6+
parseAsInteger.withDefault(0).withOptions({ history: 'push' })
7+
)
8+
return <button onClick={() => setCount(c => c + 1)}>count is {count}</button>
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { render, screen } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { SearchInput } from './search-input'
6+
7+
describe('SearchInput', () => {
8+
it('should render the input with state loaded from the URL', () => {
9+
render(<SearchInput />, {
10+
wrapper: ({ children }) => (
11+
<NuqsTestingAdapter
12+
searchParams={{
13+
search: 'nuqs'
14+
}}
15+
>
16+
{children}
17+
</NuqsTestingAdapter>
18+
)
19+
})
20+
const input = screen.getByRole('search')
21+
expect(input).toHaveValue('nuqs')
22+
})
23+
it('should follow the user typing text', async () => {
24+
const user = userEvent.setup()
25+
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
26+
render(<SearchInput />, {
27+
wrapper: ({ children }) => (
28+
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate} rateLimitFactor={0}>
29+
{children}
30+
</NuqsTestingAdapter>
31+
)
32+
})
33+
const expectedState = 'Hello, world!'
34+
const expectedParam = 'Hello,+world!'
35+
const searchInput = screen.getByRole('search')
36+
await user.type(searchInput, expectedState)
37+
expect(searchInput).toHaveValue(expectedState)
38+
expect(onUrlUpdate).toHaveBeenCalledTimes(expectedParam.length)
39+
for (let i = 0; i < expectedParam.length; i++) {
40+
expect(onUrlUpdate.mock.calls[i][0].queryString).toBe(
41+
`?search=${expectedParam.slice(0, i + 1)}`
42+
)
43+
}
44+
})
45+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { parseAsString, useQueryStates } from 'nuqs'
2+
3+
export function SearchInput() {
4+
const [{ search }, setSearch] = useQueryStates({
5+
search: parseAsString.withDefault('').withOptions({ clearOnDefault: true })
6+
})
7+
return (
8+
<input
9+
role="search"
10+
type="search"
11+
value={search}
12+
onChange={e => setSearch({ search: e.target.value })}
13+
/>
14+
)
15+
}

‎packages/adapters/react/src/main.tsx

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { NuqsAdapter } from 'nuqs/adapters/react'
2+
import { StrictMode } from 'react'
3+
import { createRoot } from 'react-dom/client'
4+
import App from './App.tsx'
5+
6+
createRoot(document.getElementById('root')!).render(
7+
<StrictMode>
8+
<NuqsAdapter>
9+
<App />
10+
</NuqsAdapter>
11+
</StrictMode>
12+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="vite/client" />
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"useDefineForClassFields": true,
5+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
6+
"types": ["node", "@testing-library/jest-dom"],
7+
"module": "ESNext",
8+
"skipLibCheck": true,
9+
10+
/* Bundler mode */
11+
"moduleResolution": "bundler",
12+
"allowImportingTsExtensions": true,
13+
"isolatedModules": true,
14+
"moduleDetection": "force",
15+
"noEmit": true,
16+
"jsx": "react-jsx",
17+
18+
/* Linting */
19+
"strict": true,
20+
"noUnusedLocals": true,
21+
"noUnusedParameters": true,
22+
"noFallthroughCasesInSwitch": true
23+
},
24+
"include": ["src"]
25+
}

‎packages/adapters/react/tsconfig.json

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"files": [],
3+
"references": [
4+
{ "path": "./tsconfig.app.json" },
5+
{ "path": "./tsconfig.node.json" }
6+
]
7+
}
+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"lib": ["ES2023"],
5+
"types": ["node", "@testing-library/jest-dom"],
6+
"module": "ESNext",
7+
"skipLibCheck": true,
8+
9+
/* Bundler mode */
10+
"moduleResolution": "bundler",
11+
"allowImportingTsExtensions": true,
12+
"isolatedModules": true,
13+
"moduleDetection": "force",
14+
"noEmit": true,
15+
16+
/* Linting */
17+
"strict": true,
18+
"noUnusedLocals": true,
19+
"noUnusedParameters": true,
20+
"noFallthroughCasesInSwitch": true
21+
},
22+
"include": ["vite.config.ts"]
23+
}
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import react from '@vitejs/plugin-react'
2+
import { defineConfig } from 'vite'
3+
4+
// https://vitejs.dev/config/
5+
export default defineConfig(() => ({
6+
plugins: [react()],
7+
// Vitest configuration
8+
test: {
9+
globals: true,
10+
environment: 'jsdom',
11+
setupFiles: ['vitest.setup.ts'],
12+
include: ['**/*.test.?(c|m)[jt]s?(x)'],
13+
env: {
14+
IS_REACT_ACT_ENVIRONMENT: 'true'
15+
}
16+
}
17+
}))
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as matchers from '@testing-library/jest-dom/matchers'
2+
import { cleanup } from '@testing-library/react'
3+
import { afterEach, expect } from 'vitest'
4+
5+
expect.extend(matchers)
6+
7+
// https://testing-library.com/docs/react-testing-library/api/#cleanup
8+
afterEach(cleanup)

0 commit comments

Comments
 (0)