|
1 | | -Welcome to your new TanStack Start app! |
| 1 | +# twd-tanstack-example |
2 | 2 |
|
3 | | -# Getting Started |
| 3 | +A showcase of the [TanStack](https://tanstack.com) ecosystem — Router, Query, and Form — tested **in the browser, while you build**, with [TWD](https://github.com/BRIKEV/twd-js). |
4 | 4 |
|
5 | | -To run this application: |
| 5 | + |
6 | 6 |
|
7 | | -```bash |
8 | | -npm install |
9 | | -npm run dev |
10 | | -``` |
| 7 | +The sidebar on the left is TWD running real assertions against the app on the right. No extra renderer, no jsdom, no separate "test build" — just the dev server and a small panel. |
11 | 8 |
|
12 | | -# Building For Production |
| 9 | +--- |
13 | 10 |
|
14 | | -To build this application for production: |
| 11 | +## What this project demonstrates |
15 | 12 |
|
16 | | -```bash |
17 | | -npm run build |
18 | | -``` |
| 13 | +| TanStack | Where it lives | What it does | |
| 14 | +|---|---|---| |
| 15 | +| **Router** | `src/routes/*` | File-based routing, code-split routes, root context, navigation with `<Link>`, 404 `notFoundComponent` | |
| 16 | +| **Router loaders** | `src/routes/todos.tsx` | `loader` calls `queryClient.ensureQueryData(...)` so the page already has data when it mounts | |
| 17 | +| **Query** | `src/api/queries.ts`, `src/query-client.ts` | `queryOptions` shared between loader and component, `useSuspenseQuery` in the view, `useMutation` + `invalidateQueries` for create/delete | |
| 18 | +| **Form** | `src/routes/todos.tsx` | `useForm`, per-field validation, `<form.Subscribe>` for submit state | |
19 | 19 |
|
20 | | -## Testing |
| 20 | +The rest of the stack: |
21 | 21 |
|
22 | | -This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with: |
| 22 | +- **Vite** dev server on `:3000`, `/api` proxied to a json-server on `:3001`. |
| 23 | +- **Tailwind v4** for styling. |
| 24 | +- **TWD** (`twd-js` + `twd-relay`) for browser-side tests, **twd-cli** for headless CI runs. |
| 25 | +- **vite-plugin-istanbul** + **nyc** for coverage. |
| 26 | +- **openapi-mock-validator** (via twd-cli) validating mocks against `contracts/todos-3.0.json` on every CI run. |
23 | 27 |
|
24 | | -```bash |
25 | | -npm run test |
26 | | -``` |
| 28 | +--- |
27 | 29 |
|
28 | | -## Styling |
| 30 | +## Getting started |
29 | 31 |
|
30 | | -This project uses [Tailwind CSS](https://tailwindcss.com/) for styling. |
| 32 | +```bash |
| 33 | +npm install |
| 34 | +npm run serve:dev # json-server on :3001 + Vite dev on :3000 |
| 35 | +``` |
31 | 36 |
|
32 | | -### Removing Tailwind CSS |
| 37 | +Open <http://localhost:3000>. The TWD sidebar opens with the app — hit **Run All** to run the tests in `src/twd-tests/`. |
33 | 38 |
|
34 | | -If you prefer not to use Tailwind CSS: |
| 39 | +--- |
35 | 40 |
|
36 | | -1. Remove the demo pages in `src/routes/demo/` |
37 | | -2. Replace the Tailwind import in `src/styles.css` with your own styles |
38 | | -3. Remove `tailwindcss()` from the plugins array in `vite.config.ts` |
39 | | -4. Uninstall the packages: `npm install @tailwindcss/vite tailwindcss -D` |
| 41 | +## Writing a TWD test — the whole thing |
40 | 42 |
|
| 43 | +TWD tests are plain `.ts` files next to your code. They run **inside the same browser tab as your app**, so they can import anything the app uses. |
41 | 44 |
|
| 45 | +```ts |
| 46 | +// src/twd-tests/helloWorld.twd.test.ts |
| 47 | +import { twd, userEvent, screenDom } from 'twd-js' |
| 48 | +import { describe, it, beforeEach } from 'twd-js/runner' |
| 49 | +import { queryClient } from '#/query-client' |
42 | 50 |
|
43 | | -## Routing |
| 51 | +describe('Hello World Page', () => { |
| 52 | + beforeEach(() => { |
| 53 | + twd.clearRequestMockRules() |
| 54 | + queryClient.clear() |
| 55 | + }) |
44 | 56 |
|
45 | | -This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`. |
| 57 | + it('counts up when you click', async () => { |
| 58 | + await twd.visit('/') |
46 | 59 |
|
47 | | -### Adding A Route |
| 60 | + const button = await screenDom.findByText('Count is 0') |
| 61 | + await userEvent.click(button) |
| 62 | + twd.should(button, 'have.text', 'Count is 1') |
| 63 | + }) |
| 64 | +}) |
| 65 | +``` |
48 | 66 |
|
49 | | -To add a new route to your application just add a new file in the `./src/routes` directory. |
| 67 | +That's it. No render setup, no `MemoryRouter`, no `QueryClientProvider` wrapper in the test. The real router, the real query client, the real DOM. |
50 | 68 |
|
51 | | -TanStack will automatically generate the content of the route file for you. |
| 69 | +Look at `src/twd-tests/todoList.twd.test.ts` for the data-fetching version: it mocks `/api/todos` with `twd.mockRequest`, waits for the request with `twd.waitForRequest`, and asserts on the resulting DOM. |
52 | 70 |
|
53 | | -Now that you have two routes you can use a `Link` component to navigate between them. |
| 71 | +--- |
54 | 72 |
|
55 | | -### Adding Links |
| 73 | +## Testing consideration: SPA navigation keeps module-level state alive |
56 | 74 |
|
57 | | -To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`. |
| 75 | +This is something to keep in mind whenever you write TWD tests against an app that caches data in memory. It will come up in **any** project that uses TanStack Query, Zustand, Valtio, Apollo, or anything else that holds state at the module level — not just this one. If you're not aware of it, your tests will look like they have a network problem when they actually have a state problem. |
58 | 76 |
|
59 | | -```tsx |
60 | | -import { Link } from "@tanstack/react-router"; |
61 | | -``` |
| 77 | +**The setup.** `twd.visit('/somewhere')` is a router navigation, not a page reload — same browser tab, same JS runtime, same module instances. |
62 | 78 |
|
63 | | -Then anywhere in your JSX you can use it like so: |
| 79 | +**The trap.** TanStack Query caches results by key. A loader that calls `ensureQueryData(['todos'])` will fetch the first time, then on every subsequent `twd.visit('/todos')` it will return the cached array **without ever calling fetch**. The MSW mock you set up for that test never matches anything, and you get: |
64 | 80 |
|
65 | | -```tsx |
66 | | -<Link to="/about">About</Link> |
67 | 81 | ``` |
68 | | - |
69 | | -This will create a link that will navigate to the `/about` route. |
70 | | - |
71 | | -More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent). |
72 | | - |
73 | | -### Using A Layout |
74 | | - |
75 | | -In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`. |
76 | | - |
77 | | -Here is an example layout that includes a header: |
78 | | - |
79 | | -```tsx |
80 | | -import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' |
81 | | - |
82 | | -export const Route = createRootRoute({ |
83 | | - head: () => ({ |
84 | | - meta: [ |
85 | | - { charSet: 'utf-8' }, |
86 | | - { name: 'viewport', content: 'width=device-width, initial-scale=1' }, |
87 | | - { title: 'My App' }, |
88 | | - ], |
89 | | - }), |
90 | | - shellComponent: ({ children }) => ( |
91 | | - <html lang="en"> |
92 | | - <head> |
93 | | - <HeadContent /> |
94 | | - </head> |
95 | | - <body> |
96 | | - <header> |
97 | | - <nav> |
98 | | - <Link to="/">Home</Link> |
99 | | - <Link to="/about">About</Link> |
100 | | - </nav> |
101 | | - </header> |
102 | | - {children} |
103 | | - <Scripts /> |
104 | | - </body> |
105 | | - </html> |
106 | | - ), |
107 | | -}) |
| 82 | +Rule "getTodoList" was not executed within 1000ms. |
| 83 | + Executed rules: none |
108 | 84 | ``` |
109 | 85 |
|
110 | | -More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts). |
111 | | - |
112 | | -## Server Functions |
| 86 | +The test fails for what looks like a network reason but is really a *state* reason. |
113 | 87 |
|
114 | | -TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components. |
| 88 | +**The fix.** Export the `QueryClient` as a module-level singleton and call `clear()` between tests: |
115 | 89 |
|
116 | | -```tsx |
117 | | -import { createServerFn } from '@tanstack/react-start' |
| 90 | +```ts |
| 91 | +// src/query-client.ts |
| 92 | +import { QueryClient } from '@tanstack/react-query' |
118 | 93 |
|
119 | | -const getServerTime = createServerFn({ |
120 | | - method: 'GET', |
121 | | -}).handler(async () => { |
122 | | - return new Date().toISOString() |
| 94 | +export const queryClient = new QueryClient({ |
| 95 | + defaultOptions: { queries: { staleTime: 1000 * 30 } }, |
123 | 96 | }) |
124 | | - |
125 | | -// Use in a component |
126 | | -function MyComponent() { |
127 | | - const [time, setTime] = useState('') |
128 | | - |
129 | | - useEffect(() => { |
130 | | - getServerTime().then(setTime) |
131 | | - }, []) |
132 | | - |
133 | | - return <div>Server time: {time}</div> |
134 | | -} |
135 | 97 | ``` |
136 | 98 |
|
137 | | -## API Routes |
| 99 | +```ts |
| 100 | +// any *.twd.test.ts |
| 101 | +import { queryClient } from '#/query-client' |
138 | 102 |
|
139 | | -You can create API routes by using the `server` property in your route definitions: |
140 | | - |
141 | | -```tsx |
142 | | -import { createFileRoute } from '@tanstack/react-router' |
143 | | -import { json } from '@tanstack/react-start' |
144 | | - |
145 | | -export const Route = createFileRoute('/api/hello')({ |
146 | | - server: { |
147 | | - handlers: { |
148 | | - GET: () => json({ message: 'Hello, World!' }), |
149 | | - }, |
150 | | - }, |
| 103 | +beforeEach(() => { |
| 104 | + twd.clearRequestMockRules() |
| 105 | + queryClient.clear() |
151 | 106 | }) |
152 | 107 | ``` |
153 | 108 |
|
154 | | -## Data Fetching |
| 109 | +ESM modules are singletons, so the cache the test clears **is** the cache the app reads from. No `window` globals, no test-only branches. The same pattern works for Zustand (`store.getState().reset()`), Valtio, etc. — anything you can `import`, you can reset. |
155 | 110 |
|
156 | | -There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered. |
| 111 | +You can see the difference in two of this project's commits: the QueryClient extraction (`refactor: extract QueryClient to a singleton module`) and the test update that added `queryClient.clear()` to `beforeEach`. |
157 | 112 |
|
158 | | -For example: |
| 113 | +--- |
159 | 114 |
|
160 | | -```tsx |
161 | | -import { createFileRoute } from '@tanstack/react-router' |
| 115 | +## Project layout |
162 | 116 |
|
163 | | -export const Route = createFileRoute('/people')({ |
164 | | - loader: async () => { |
165 | | - const response = await fetch('https://swapi.dev/api/people') |
166 | | - return response.json() |
167 | | - }, |
168 | | - component: PeopleComponent, |
169 | | -}) |
170 | | - |
171 | | -function PeopleComponent() { |
172 | | - const data = Route.useLoaderData() |
173 | | - return ( |
174 | | - <ul> |
175 | | - {data.results.map((person) => ( |
176 | | - <li key={person.name}>{person.name}</li> |
177 | | - ))} |
178 | | - </ul> |
179 | | - ) |
180 | | -} |
| 117 | +``` |
| 118 | +src/ |
| 119 | + api/ |
| 120 | + todos.ts # fetch helpers, types derived from contracts/todos-3.0.json |
| 121 | + queries.ts # todosQueryOptions (shared by loader + component) |
| 122 | + routes/ |
| 123 | + __root.tsx # nav, Devtools panels, 404 component |
| 124 | + index.tsx # Home (counter) |
| 125 | + todos.tsx # loader + useSuspenseQuery + useMutation + useForm |
| 126 | + twd-tests/ # *.twd.test.ts run in-browser via TWD |
| 127 | + mocks/ |
| 128 | + query-client.ts # singleton QueryClient (importable from tests) |
| 129 | + router.tsx # createAppRouter() — wires routeTree + queryClient context |
| 130 | + main.tsx # <QueryClientProvider><RouterProvider/></QueryClientProvider> |
| 131 | +contracts/ |
| 132 | + todos-3.0.json # OpenAPI 3.0 spec, validated against mocks in CI |
| 133 | +data/ |
| 134 | + data.json # json-server seed |
| 135 | +twd.config.json # twd-cli headless config + contract validation |
181 | 136 | ``` |
182 | 137 |
|
183 | | -Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters). |
| 138 | +--- |
184 | 139 |
|
185 | | -# Demo files |
| 140 | +## Scripts |
186 | 141 |
|
187 | | -Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed. |
| 142 | +| Command | What it does | |
| 143 | +|---|---| |
| 144 | +| `npm run dev` | Vite dev server on `:3000` (TWD sidebar opens automatically) | |
| 145 | +| `npm run serve` | json-server on `:3001` | |
| 146 | +| `npm run serve:dev` | Both in parallel | |
| 147 | +| `npm run dev:ci` | Same as `dev` but with `CI=true` (turns on istanbul instrumentation) | |
| 148 | +| `npm run test:ci` | Headless run via `twd-cli` (used by GitHub Actions) | |
| 149 | +| `npm run collect:coverage:text` | Print coverage to stdout | |
188 | 150 |
|
189 | | -# Learn More |
| 151 | +--- |
190 | 152 |
|
191 | | -You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com). |
| 153 | +## CI |
192 | 154 |
|
193 | | -For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start). |
| 155 | +`.github/workflows/ci.yml` boots `dev:ci`, runs `twd-cli` headless via the official action, validates every mock response against the OpenAPI spec, posts a contract report as a PR comment, and prints coverage. See `twd.config.json` for the contract configuration. |
0 commit comments