React web apps
For AI agents: this page is Markdown at https://squiggle.sh/docs/web.md. Every docs page is listed at https://squiggle.sh/llms.txt, and all of them are one file at https://squiggle.sh/llms-full.txt.
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
--browserwith 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.
Open your app in Squiggle
Start your dev server as you always do, then in another terminal:
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:
squiggle flow login --udid web:http://localhost:5173Your 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:
- 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:
{ "apps": { "ios": "com.example.shop", "web": "http://localhost:5173" }}With that in place:
- A flow whose
appIdis 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). 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:
squiggle flow smoke --udid web:http://localhost:5173For any other bundler, init prints what to do instead.
Set it up by hand
bun add @squiggle.sh/reactWith Vite, add the plugin:
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):
import { withSquiggle } from '@squiggle.sh/react/next';export default withSquiggle({ // your Next.js config});import '@squiggle.sh/react';With webpack or Rspack, add the plugin. It runs Squiggle in a build with mode: 'development' only:
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 Rows become Row and Row2), and Squiggle shows them by those names.
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: 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.
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:
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,findandquery, with the page’s buttons, links and fields as well as your components. Elements hidden withdisplay: noneare 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, andfind { callSite }shows what a line renders.inspectshows 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,confirmandpromptappear indescribelike a system alert in your iOS app: its message, a text field for a prompt, and itsOKandCancelbuttons.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,routeandwaitFor: { 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, soassert: { query }andassert: { 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). - Save and load
squiggle saveandsquiggle loadkeep 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.- 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; setuserAgentas well to look like a phone to code that reads it. See the flow reference. - 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, andsquiggle motion.record_startandrecord_stoprecord a session, andsquiggle replaychecks a flow’s assertions against it.- Network
- Stubs and the chaos profiles (
--chaos slow,flaky,truncated,offline-midstep) apply to the app’sfetchand 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 crawland thecrawltool 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 3it runs three flows at a time, each in a browser context of its own.squiggle bisectandsquiggle bakeoffwith--platform webserve each commit from a dev server of their own.squiggle flow --tunemeasures a tunable’s values on the tab and proposes one (see Tune a constant against measured frames).squiggle watchre-runs a flow on every save, andsquiggle card(the agent’sreport_card) tours and grades the screens a change touches.- Motion and legibility
squiggle motionrecords an element’s position and opacity at every frame and fits the spring that reproduces it.squiggle legibilitynames 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 webscores 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 tolocalStorage,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. Eachfetchand 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, sopropose_fixcan quote them. Crashes, symbolication through source maps,squiggle probeand Web Vitals budgets work too; see Profiling, memory and crashes.
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):
squiggle up --url 4173 --productionFlows, 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, orvite build --sourcemap. - webpack:
devtool: 'source-map'. - esbuild:
sourcemap: true. - Next.js:
productionBrowserSourceMaps: trueinnext.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:
vite build --sourcemapsquiggle up --url 4173 --production --maps distWithout 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:
{ "apps": { "web": "http://localhost:5173" }, "web": { "browser": "chrome", "channel": "beta", "viewport": { "width": 390, "height": 844, "mobile": true }, "permissions": { "geolocation": "granted", "notifications": "denied" } }}browserandchannel- Which browser
squiggle upopens:chrome,chromium,edgeor a path to one, on thestable,beta,devorcanarychannel.SQUIGGLE_CHROMEwins over both. viewport- The page’s size in CSS pixels. With
mobile: truethe page sees a phone: taps, presses and swipes arrive as touch input. permissions- Browser permissions for the app, such as
geolocationornotifications, asgranted,deniedorprompt. They hold in every fresh tab a relaunch opens. url- The address
squiggle upopens with no--url, in any form--urltakes.
Run without a window
On a machine with no screen, or when you’d rather not see the window, start it headless:
SQUIGGLE_HEADLESS=1 squiggle up --url 5173It starts headless by itself when the CI environment variable is set.
Limits
- Chromium browsers only. Squiggle doesn’t drive Firefox or Safari.
- One
squiggle upper 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 …, orflows --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_sessionpins 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 --urlagain. http://localhost:4173/ serves a production build- The page runs a production build of React. Add
--productionto 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, useSQUIGGLE_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, orNo browser Squiggle opened holds itsquiggle up --urlisn’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.tssquiggle initdidn’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.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 upopened was closed, so the app is no longer drivable. Runsquiggle up --urlagain.
For anything else, see Troubleshooting.