# React web apps

Squiggle drives a React web app in a Chrome window of its own: it reads the page, taps and types with real input, and records what the app does, with nothing added to your project. A flow written for your iOS app runs on the web version unchanged when the two share testIDs.

## Before you start

- A React app that renders with `react-dom` (React 18.3 or later), served by its dev server: Vite, Next.js, or anything else that serves the page on a local port.
- macOS on Apple Silicon, or Linux on x64 or arm64.
- Google Chrome, Chromium or Microsoft Edge installed. Squiggle drives Chromium browsers only, and picks the first of the three it finds. To choose one, add `--browser` with its path or, on Linux, its command name: `--browser chromium`.
- A development build. A production build served on your machine works too, with `--production`; see [Measure a production build](https://squiggle.sh/docs/web#measure-a-production-build).

## Open your app in Squiggle

Start your dev server as you always do, then in another terminal:

```shell
squiggle up --url 5173
```

`--url` takes a port, a port and path (`5173/settings`), a host and port (`localhost:5173`) or a full URL. Squiggle opens the page in a Chrome window of its own, with its own profile, so your browser and its extensions aren’t touched. When the page connects, the command prints `ready — web:http://localhost:5173`. `squiggle up 5173` does the same: an address in place of a bundle id (a port, `:5173`, a host and port, or a URL starting `http://` or `https://`) opens that web app.

Leave it running. The window is drivable for as long as the command runs, the way a dev server serves only while it runs; Ctrl+C closes the window. If your dev server is still starting, `squiggle up` waits up to 20 seconds for it to answer.

In a project whose only app is a web app (`apps.web` in `.squiggle/config.jsonc`), or one that sets `web.url`, `squiggle up` with no `--url` opens it. With neither, it looks for the dev server your bundler runs (Vite on 5173, Next.js on 3000, or the port your `dev` script names) and opens it when exactly one answers. In a project that also has an iOS or macOS app, `squiggle up` brings that app up, as before; pass `--url` for the web app.

## Run a flow

A web app is named by its origin, prefixed `web:`, wherever Squiggle asks for a device:

```shell
squiggle flow login --udid web:http://localhost:5173
```

Your agent finds the window too: `status` lists it beside any app in the simulator, with its tabs and whether the project owns it, and `target` switches to it. An agent can open the app itself with `launch_app { url: "5173" }`, which uses the window `squiggle up` holds or opens one of its own; on a web target nothing holds yet, `launch_app` with no `url` opens it at its origin.

### Start from a clean slate

`squiggle flow --fresh`, a flow’s `reset: relaunch` and `launch_app { relaunch: "always" }` reopen the app in a new tab with no cookies, storage or cache, the way a first visit starts, at the address `squiggle up` opened. `clearState` clears the app’s cookies and storage in the window it’s in before reopening it, and `stopApp` closes its tabs.

### Start signed in, or as a returning visitor

A `seed` on `launchApp` reopens the app with cookies, `localStorage` and `sessionStorage` already set, so the first page load reads them:

```yaml
- launchApp:
    seed:
      cookies: [{ name: session, value: 4f9c2e81 }]
      localStorage: { theme: dark, onboarded: true }
      sessionStorage: { checkoutStep: shipping }
```

A cookie with no `domain` belongs to the app’s host, and one with no `path` to `/`; a cookie also takes `expires`, `httpOnly`, `secure` and `sameSite`, as Playwright’s `addCookies` does. A storage value that isn’t a string is stored as JSON. The storage is written once, before the page’s own scripts run, so a reload keeps whatever the app changed. A seed always reopens the app in a new tab, which starts with none of a run’s `emulate` settings: put `emulate` after `launchApp`.

On iOS a seed reports `unsupported` and the app isn’t launched. To start an iOS app from saved data, use a save slot: `squiggle save`, then `squiggle load`.

### Share flows with your iOS app

Declare the web app in `.squiggle/config.jsonc` under `apps`, beside your iOS app:

```ts
{
  "apps": {
    "ios": "com.example.shop",
    "web": "http://localhost:5173"
  }
}
```

With that in place:

- A flow whose `appId` is your iOS app runs on the web app as it is, on whatever port you point it at.
- Runs, recordings and performance data from the web app are saved into the project, as they are for your iOS app. A web app on an origin the project doesn’t declare is driven, and nothing from it is saved; the run says so.

`apps.web` can include a path (`http://localhost:5173/admin`): `squiggle up` opens that page, and the origin is what the project owns.

A `testID` matches a `data-testid` attribute, and `label` and `role` match `aria-label` and the element’s role, and `checked`, `selected` and `expanded` match its state (see [Selectors](https://squiggle.sh/docs/flows/selectors)). Routes match by path: an Expo Router group never appears in a URL, so `route: (tabs)/index` names the page at `/`.

## Use your own browser

`squiggle up --url` drives only the window it opens. To run flows in a browser you opened yourself, or in Playwright or Storybook, add Squiggle to the app.

### Set it up with squiggle init

In a Vite or Next.js app, run `squiggle init` in the app directory. It lists each change and asks before writing anything: it adds `@squiggle.sh/react`, adds the plugin to your Vite or Next.js config, declares your dev server’s origin under `apps.web`, and writes a first flow, `smoke`. `squiggle init --dry-run` prints the list and writes nothing. The origin uses the port your `dev` script or Vite config names, or 5173 for Vite and 3000 for Next.js. Then start your dev server, open the app, and run:

```shell
squiggle flow smoke --udid web:http://localhost:5173
```

For any other bundler, `init` prints what to do instead.

### Set it up by hand

```shell
bun add @squiggle.sh/react
```

With Vite, add the plugin:

```ts
import react from '@vitejs/plugin-react';
import { squiggle } from '@squiggle.sh/react/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [react(), squiggle()],
});
```

With Next.js, App Router or Pages Router, wrap your config, and import Squiggle first in `instrumentation-client.ts` (in `src/` if your app has one):

```ts
import { withSquiggle } from '@squiggle.sh/react/next';

export default withSquiggle({
  // your Next.js config
});
```

```ts
import '@squiggle.sh/react';
```

With webpack or Rspack, add the plugin. It runs Squiggle in a build with `mode: 'development'` only:

```ts
const { squiggle } = require('@squiggle.sh/react/webpack');

module.exports = {
  mode: 'development',
  entry: './src/main.tsx',
  plugins: [squiggle()],
};
```

With esbuild or `Bun.build`, add the plugin from `@squiggle.sh/react/esbuild`; it works with both. It runs Squiggle in a development build only: one that defines `process.env.NODE_ENV` as `"development"`, or, with that left undefined, one that isn’t minified. For a production `Bun.build`, define `process.env.NODE_ENV` as `"production"`, as React needs too. `Bun.build` renames the second of two components with one name (two `Row`s become `Row` and `Row2`), and Squiggle shows them by those names.

```ts
import { squiggle } from '@squiggle.sh/react/esbuild';
import { build } from 'esbuild';

await build({
  entryPoints: ['src/main.tsx'],
  bundle: true,
  outdir: 'dist',
  plugins: [squiggle()],
});
```

Declare the app’s origin under `apps.web`, as in [Share flows with your iOS app](https://squiggle.sh/docs/web#share-flows-with-your-ios-app): Squiggle accepts a connection only from an origin your project declares. Then start your dev server and run a flow with `--udid web:<origin>`, with no `squiggle up`.

With the plugin, the page also says which directory it was built in, and its data is saved only into the project that contains that directory. Two projects can both run their dev server on `localhost:3000`; each one’s runs stay in its own project. Run Squiggle from another project and the run says nothing was saved, and why.

Your agent finds the page too. When nothing your project owns is up, it waits a couple of seconds for a page to connect, and drives your app rather than a window another project’s `squiggle up` holds. `target` lists the page as `web:<origin>` with `dialledIn: true`, and `target { to: "web:http://localhost:3000" }` switches to it.

The page connects to Squiggle on port 7777 of the machine it runs on. If your dev server uses HTTPS, or you open the app from another machine, connect through the dev server instead: pass `transport: 'proxy'` to the Vite or Next.js plugin, as in `squiggle({ transport: 'proxy' })` or `withSquiggle(config, { transport: 'proxy' })`. The page then connects to `/__squiggle` on your dev server, which passes the connection on to Squiggle. Declare the origin the browser uses, such as `https://localhost:5173` or `http://192.168.1.20:5173`. While no Squiggle command is running, `next dev` prints `Failed to proxy` each time the page tries again; that stops once one starts. The webpack and esbuild plugins connect directly only.

In a browser Squiggle didn’t open, steps run through the page itself. Taps, checkboxes and typing work; long presses, swipes and drags need the window `squiggle up --url` opens.

The plugin also lets Squiggle see your zustand stores (zustand 5), in your own browser and in the window `squiggle up --url` opens. Each store is named after the variable it’s assigned to, so `export const useCart = create(…)` is `useCart` in `assert: { store }`, as in your iOS app. Without the plugin, stores aren’t seen. Stores made with `createStore` from `zustand/vanilla` aren’t seen either; import it from `zustand` instead.

Legend State (version 3) works the same way: with the plugin, stores made with `observable` and `useObservable` show up, under ids such as `store:legend-1` in the order they are made. Without the plugin they aren’t seen.

Squiggle runs only under the dev server. A production build includes the package but runs none of it, so it never connects from your users’ browsers. It also runs only in the browser: code that imports it and renders on the server, such as a Next.js server component calling `identify`, does nothing there.

The SDK’s functions, such as `identify`, `logEvent` and `defineAction`, come from `@squiggle.sh/react`. See the [SDK reference](https://squiggle.sh/docs/reference/sdk).

Render `<SquiggleOverlay />` anywhere in your app to get a button that opens a live list of your components and stores, as on React Native. It shows only under the dev server, and never in the window `squiggle up --url` opens.

### Record errors React keeps to itself

With React 19, an error no error boundary catches, and a hydration mismatch React recovers from, are handed only to the root. Pass `squiggleRootOptions()` to `createRoot` or `hydrateRoot` and Squiggle records them with their component stack:

```ts
import { squiggleRootOptions } from '@squiggle.sh/react';
import { createRoot } from 'react-dom/client';

createRoot(document.getElementById('root')!, squiggleRootOptions()).render(
  <App />
);
```

Pass your own `onUncaughtError` or `onRecoverableError` to `squiggleRootOptions` and it still runs; without one, React reports the error as it does by default.

## What works

- **Reading the page**: `describe`, `find` and `query`, with the page’s buttons, links and fields as well as your components. Elements hidden with `display: none` are left out. Controls inside a shadow root or a same-origin iframe are read and tapped like any other.
- **Your code**: `describe { fields: 'source' }` gives each element the line that writes it, and `find { callSite }` shows what a line renders. `inspect` shows a component’s props, state and hooks, each hook named after the variable it’s assigned to. Both read your dev server’s source maps, so they need no setup, and work in a development build only.
- **Input**: Taps, typing, keys, scrolling and drags, as real input in Squiggle’s window. A swipe on something that doesn’t scroll, such as a slider or a sortable row, drags it. A tap on a disabled control is refused rather than reported as done, and a tap on an element the page doesn’t draw doesn’t reach it. Scrolling the page or a list, by wheel, touch, keys or a step, is saved with the run for the heatmap and product analytics.
- **Alerts and prompts**: A page’s `alert`, `confirm` and `prompt` appear in `describe` like a system alert in your iOS app: its message, a text field for a prompt, and its `OK` and `Cancel` buttons. `tap { text: OK }` answers it, and text typed into a prompt’s field goes with its OK. One that appears while nothing is driving the window is closed, so the window doesn’t stay blocked.
- **Tabs and popups**: Every tab of the app in Squiggle’s window connects, a popup the page opens included. A run drives the newest tab; the others wait. A link or form that loads a whole new page keeps the run going on the new page.
- **Waits and assertions**: Waits finish when animations, scrolling and loading boundaries have finished, so a page that is still loading isn’t asserted on. A route wait also waits for the new page to appear, with or without `--headless`. Requests from the page’s workers count toward network idle.
- **Navigation**: `navigate`, `route` and `waitFor: { route }` follow the page’s URL, with React Router, TanStack Router, and the Next.js App and Pages Routers. With React Router, TanStack Router and the Pages Router, a screen tour can list every route the app declares; the App Router keeps its routes on the server, so it can’t.
- **State**: TanStack Query’s queries, and store changes in Redux (through react-redux’s `Provider`) and Jotai, show up in the graph, so `assert: { query }` and `assert: { store }` work as on a device. zustand and Legend State stores show up too when the app loads Squiggle through a plugin (see [Use your own browser](https://squiggle.sh/docs/web#use-your-own-browser)).
- **Save and load**: `squiggle save` and `squiggle load` keep the page’s path, its stores, `localStorage`, `sessionStorage`, the cookies the page can read, and its IndexedDB databases. See [Save and restore your app’s state](https://squiggle.sh/docs/reference/sdk#save-and-restore-your-apprsquos-state).
- **Device settings**: Flow steps that set the location (`setLocation`, `travel`), turn the viewport (`setOrientation`), take the tab offline (`setAirplaneMode`), grant or deny a permission (`setPermissions`), hand files to a file picker (`addMedia`), run JavaScript in the page (`evalScript`, `runScript`), and set the viewport size, colour scheme, locale and user agent (`emulate`). Each lasts until the run ends. A phone viewport keeps the browser’s own user agent; set `userAgent` as well to look like a phone to code that reads it. See [the flow reference](https://squiggle.sh/docs/reference/flow#browser-tab).
- **Screenshots, video and motion**: `screenshot`, `flow --record`, filmstrips, the visual checks (`noBlankFrame`, `legibleContrast`, `noOverlap`), and the checks that read every frame: `assert: { smooth }`, a scroll’s blank band, and `squiggle motion`. `record_start` and `record_stop` record a session, and `squiggle replay` checks a flow’s assertions against it.
- **Network**: Stubs and the chaos profiles (`--chaos slow`, `flaky`, `truncated`, `offline-midstep`) apply to the app’s `fetch` and XHR requests. The page’s own scripts and styles are left alone. While a stub or a chaos profile is set, the page bypasses its service worker, so a request the worker would answer from its cache meets the stub too.
- **Errors and logs**: Uncaught errors, rejected promises and Content Security Policy refusals are saved with the run, and so are the page’s console lines, which `assert: { log }` checks.
- **Exploring and suites**: `squiggle crawl` and the `crawl` tool explore the app; a link that loads a whole new page is followed, not reported as a crash. `squiggle flows --udid web:<origin>` runs a suite; with `--shards 3` it runs three flows at a time, each in a browser context of its own. `squiggle bisect` and `squiggle bakeoff` with `--platform web` serve each commit from a dev server of their own. `squiggle flow --tune` measures a tunable’s values on the tab and proposes one (see [Tune a constant against measured frames](https://squiggle.sh/docs/performance#tune-a-constant-against-measured-frames)). `squiggle watch` re-runs a flow on every save, and `squiggle card` (the agent’s `report_card`) tours and grades the screens a change touches.
- **Motion and legibility**: `squiggle motion` records an element’s position and opacity at every frame and fits the spring that reproduces it. `squiggle legibility` names the line to fix for each control an agent can’t address.
- **Agents**: Every MCP tool answers for the window, and the agent kit’s skills cover it. `squiggle benchmark run --reference --suite web` scores the web version of the agent benchmark.
- **Performance**: Web Vitals (LCP, INP, CLS, TTFB, FCP), frame rate and dropped frames, main-thread CPU and the JavaScript heap, renders, CPU profiles (`perf profile`) and heap snapshots (`perf heap`). The page load is timed in phases (navigation, the document, the page scripts, the first React commit, and hydration for a server-rendered page). Reads and writes to `localStorage`, `sessionStorage`, IndexedDB (including through idb-keyval, Dexie and localforage) and cookies show up as storage activity. The dev server’s own hot-reload connection isn’t counted as your app’s WebSocket traffic. Each `fetch` and XHR request is timed with its DNS, connect, TLS and first-byte phases, a stall arrives with a profile naming the function that ran, and the findings work as on a simulator: long tasks, jank, memory that grows per visit, request waterfalls, motion driven from JS rather than CSS, and slower cold starts. Findings and render counts name the file and line in your source, read through the dev server’s source maps when the run ends, so `propose_fix` can quote them. Crashes, symbolication through source maps, `squiggle probe` and Web Vitals budgets work too; see [Profiling, memory and crashes](https://squiggle.sh/docs/performance/profiling).

## Watch and drive it in Studio

While `squiggle up --url` runs, Studio lists the window beside your simulators, by its host (`localhost:5173`). Choose it in the device menu and the device panel shows the page in a browser frame:

- Clicks, typing and scrolling on the page in the panel reach the real page.
- The **Viewport** menu lays the page out at a phone’s or a tablet’s size, or the window’s own.
- **Inspect component** picks the element under the pointer, including one drawn over another.
- **Capture** › **Screenshot** saves a PNG of the page to the Gallery.
- Recording on the **Flows** screen turns your clicks into steps that name the control, and **Replay** runs the flow on the page.
- **Performance** shows a Web Vitals lane on the timeline, and Performance, Product and Analytics can show web sessions alone with their **Web** filter.

A browser tab has no hardware buttons, system settings, location or permissions, so those menus aren’t shown for it. **Record screen** and the **AX tree** overlay read **Not available for a browser tab**, and a web session has no crash reports: its uncaught errors are under **Errors** on the Analytics screen.

If the window is listed but the panel stays empty, stop `squiggle up --url` and run it again.

## Measure a production build

A development build is slower than what your users get, so measure performance on a production build. Build the app, serve it with `vite preview` or `next start`, and open it with `--production` (or `--prod`):

```shell
squiggle up --url 4173 --production
```

Flows, Web Vitals and the other measurements work as on a development build. `inspect` shows props and hook values but can’t edit them, and has no owners or source lines; it lists those under `production.unsupported`.

A production build minifies component names. To see your components by name, and to match them with `component` selectors, build with source maps; Squiggle reads each component’s name back from them, on Vite, webpack, esbuild and Next.js (Turbopack and webpack):

- Vite: `build.sourcemap: true`, or `vite build --sourcemap`.
- webpack: `devtool: 'source-map'`.
- esbuild: `sourcemap: true`.
- Next.js: `productionBrowserSourceMaps: true` in `next.config`.

If the server serves the `.map` files beside the scripts, nothing else is needed. Served maps let anyone who loads the page read your source, so a server can keep them back: then point `--maps` at the build’s output folder, and Squiggle reads them from there:

```shell
vite build --sourcemap
squiggle up --url 4173 --production --maps dist
```

Without maps, selectors that name a component may not match; `testID`, `label` and `text` do. A bundler’s keep-names option (`build.rolldownOptions.output.keepNames` on Vite, `keepNames` on esbuild, Terser’s `keep_fnames` on webpack) keeps the names too, at the cost of a bigger bundle (about a fifth on Vite).

## Choose the browser and the screen

Set these under `web` in `.squiggle/config.jsonc`:

```ts
{
  "apps": { "web": "http://localhost:5173" },
  "web": {
    "browser": "chrome",
    "channel": "beta",
    "viewport": { "width": 390, "height": 844, "mobile": true },
    "permissions": { "geolocation": "granted", "notifications": "denied" }
  }
}
```

- **`browser` and `channel`**: Which browser `squiggle up` opens: `chrome`, `chromium`, `edge` or a path to one, on the `stable`, `beta`, `dev` or `canary` channel. `SQUIGGLE_CHROME` wins over both.
- **`viewport`**: The page’s size in CSS pixels. With `mobile: true` the page sees a phone: taps, presses and swipes arrive as touch input.
- **`permissions`**: Browser permissions for the app, such as `geolocation` or `notifications`, as `granted`, `denied` or `prompt`. They hold in every fresh tab a relaunch opens.
- **`url`**: The address `squiggle up` opens with no `--url`, in any form `--url` takes.

## Run without a window

On a machine with no screen, or when you’d rather not see the window, start it headless:

```shell
SQUIGGLE_HEADLESS=1 squiggle up --url 5173
```

It starts headless by itself when the `CI` environment variable is set.

## Limits

- Chromium browsers only. Squiggle doesn’t drive Firefox or Safari.
- One `squiggle up` per origin. A second one for the same origin says the window is already open and exits.
- Two runs on one origin at once need two ports: the second runs in a tab of its own, with its own cookies and storage, on the port it listens on (`squiggle flow login 7778 --udid …`, or `flows --shards`). On one port the second is refused, as on a simulator.
- The window Squiggle opened is the one it drives. A tab of the same app in your own browser isn’t connected, and doesn’t take the window’s place even when it loads the SDK through the plugin.
- `deterministic_session` pins the network, the locale and animations (the page is told you prefer reduced motion, and CSS animations and transitions finish at once; motion your code drives frame by frame still moves). The clock isn’t pinned, and the reply says so.
- Hardware buttons and native views don’t exist on the web, so steps and checks that need them don’t apply.

## If something goes wrong

Run `squiggle doctor` in the app’s directory. For a web app it checks that the plugin is in your config, that the origin is declared under `apps.web`, that the dev server answers, and that it serves a development build, and names the fix for each.

- **`nothing answers at http://localhost:5173`**: Your dev server isn’t running on that port. Start it, then run `squiggle up --url` again.
- **`http://localhost:4173/ serves a production build`**: The page runs a production build of React. Add `--production` to measure it, or start your dev server instead.
- **`no Chrome, Chromium or Edge found`**: Install one, or point Squiggle at one installed somewhere unusual with `--browser /path/to/chrome`. To set it for every command in a shell, use `SQUIGGLE_CHROME=/path/to/chrome`.
- **`/usr/bin/chromium did not start`**: The browser exited or hung before Squiggle could connect to it; the message ends with what the browser printed. In a Docker container, where a browser can’t start its sandbox, Squiggle starts it without one. If the error persists, try another browser with `--browser`.
- **`no browser holds web:http://localhost:5173`, or `No browser Squiggle opened holds it`**: `squiggle up --url` isn’t running for that origin, and no page with Squiggle added connected within 10 seconds. Start it and leave it running.
- **`could not recognise vite.config.ts`**: `squiggle init` didn’t find a shape it can edit safely, so it left the file alone. Make the change it names yourself, as in [Set it up by hand](https://squiggle.sh/docs/web#set-it-up-by-hand).
- **`the dev server reloaded the page (an HMR full reload, after a file changed)`**: In a failed run’s app log: a file changed while the flow ran, and the dev server reloaded the page under it. Run the flow again once you’ve stopped editing.
- **`the browser closed`**: The window `squiggle up` opened was closed, so the app is no longer drivable. Run `squiggle up --url` again.

For anything else, see [Troubleshooting](https://squiggle.sh/docs/troubleshooting).
