In a Playwright component test, why can't you pass an onSelect callback in mount's props?
answer
- Test in Node, component in browser
- The props argument is serialised
- Data crosses, closures do not
- Callback wired inside the story export
- Effect written to DOM for readback
basics
~20 sPlaywright sends mount's props across a process boundary into the browser, so only plain serialisable data survives. A function carries behaviour that cannot be transferred, so callbacks belong in the story file, not in the props object.
solid answer
~50 sPlaywright renders the component in a real browser while the test file runs in Node, so the props argument of `mount` is serialised across that boundary. Plain data crosses: strings, numbers, booleans, `null`, arrays and plain objects. A function does not, because its closure lives in the test process and there is nothing meaningful to serialise. For a design-system date picker you therefore pass `value` and `locale` as props, and wire `onSelect` inside the story export that `mount` renders. To let the test see that the callback fired, the story records what the handler received into a hidden input carrying a `data-testid`, and the test asserts on it with `toHaveValue`. The rule is worth stating plainly in review: props are data, callbacks are part of the scenario, and whatever a callback learns must be written into the DOM before a test can read it.
code
typescript · 17 lines// date-picker.story.tsx - the scenario that renders inside the browser
import { useState } from 'react';
import { DatePicker } from '../src/date-picker';
export function PickerWithRecorder(props: { value: string; locale: string }) {
const [picked, setPicked] = useState('');
return (
<>
<DatePicker
value={props.value}
locale={props.locale}
onSelect={(iso: string) => setPicked(iso)}
/>
<input type="hidden" data-testid="selected" readOnly value={picked} />
</>
);
}go deeper
Remember the shape of the rule: the props argument takes plain data only, and any callback the component needs is wired inside the story file that gets rendered.
Explain the why: the test process and the browser page are separate, props are serialised to cross, and a closure has nothing meaningful to serialise into.
Show how you stop this biting a team: story files that own their handlers, one recorder convention for callback payloads, and a review rule that rejects function props on sight.
Own the boundary as a design constraint. The props surface is the contract between the suite and the component, and keeping it plain data is what makes stories reviewable, portable and cheap to maintain.
## Two processes, one serialised hop A Playwright component test is not a simulated render inside the test process. The test file runs in Node; the component is rendered by a real browser engine in a page. `mount` therefore has to ship everything it is given across that gap, and in Playwright 1.63 the only values that survive the trip are ones Playwright can serialise: strings, numbers, booleans, `null`, arrays and plain objects made of those. A function is not such a value. It is behaviour plus a closure over variables that only exist in the Node process. There is no representation of "this function, still bound to that scope" that can be handed to a browser page, so a callback in the props argument is not a subtle mistake that degrades quietly. The thing you meant to pass simply does not exist on the other side. ## What crosses and what does not | Value in the props object | Crosses into the page? | Do this instead | |---|---|---| | `value: '2026-03-14'`, `locale: 'en-GB'` | Yes, plain data | Pass it | | `rows: [{ id: 'r1', label: 'Jan' }]` | Yes, array of plain objects | Pass it | | `onSelect: (iso) => picked.push(iso)` | No, a function | Wire it inside the story | | `emptyState: <EmptyGrid />` | No, a framework element | Add a story export that composes it | | `formatter: new Intl.NumberFormat('de')` | No, a class instance | Pass a locale string, build it in the story | The pattern behind the table: **data crosses, identity and behaviour do not**. Anything whose usefulness depends on its prototype, its closure, or a live handle it holds - a class instance, a DOM node, a framework element, a stream - belongs on the browser side of the boundary. ## Where the callback actually lives The story export that `mount` renders is ordinary browser-side code. It imports the real component, so it can hand it a real handler: - the story declares the state it wants to observe; - the story passes its own function as the component's callback prop; - the handler writes what it received into the DOM, typically a hidden `<input>` with a stable `data-testid`; - the test acts on the component and reads that input back with `toHaveValue`. Nothing about the callback crosses the boundary. What crosses back is the string the story chose to write, and it crosses the way everything else does in Playwright: as DOM state the test can query. ## The recorder pattern, step by step 1. Add a piece of state to the story for each callback you care about. 2. Wire the component's callback prop to a handler that updates that state. 3. Render a hidden input whose `value` is the serialised state and whose `data-testid` names the callback. 4. In the test, drive the UI, then assert `expect(component.getByTestId('selected')).toHaveValue('2026-03-15')`. Because `toHaveValue` retries until its timeout, step four needs no manual wait after the click. The assertion settles as soon as the story re-renders the recorder. ```ts // The first argument identifies the story export; only the second carries data. const component = await mount<typeof PickerWithRecorder>(pickerStoryId, { value: '2026-03-14', locale: 'en-GB', }); await component.getByRole('button', { name: '15' }).click(); await expect(component.getByTestId('selected')).toHaveValue('2026-03-15'); ``` ## Typing does not rescue you `mount<typeof PickerWithRecorder>(...)` types the props object against the story's own signature, which is genuinely useful: a misspelled prop or a missing required one becomes a compile error. But TypeScript has no idea what is serialisable. If a story declares `onSelect: (iso: string) => void`, the compiler will happily accept a function that can never arrive. The defence is the story's prop type itself - design it so the unsayable thing cannot be said. A story whose props are `{ value: string; locale: string; mode: 'default' | 'rejects' }` cannot be misused, because every member of that type is data. ## Common mistakes - Passing a test-side spy as a prop and then wondering why it never records anything. - Assuming the component renders in the Node process and shares memory with the test. - Treating a framework element prop as just another object; it is not plain data. - Expecting a callback's argument to reach the test without ever touching the DOM. - Putting the branching in the test when it belongs in the story: send a plain flag such as `mode: 'rejects'` and let the story choose the handler. Once the boundary is explicit, most component-test confusion in a design-system package disappears: the props argument is a data contract, and everything with behaviour is written in the story file that renders in the browser.
- If props must be serialisable, how does a test choose which callback behaviour the story uses?Send a plain discriminator and let the story map it to a handler. A prop such as `mode: 'rejects'` makes the story wire an `onSelect` that refuses the date, while the test still transmits nothing but a string. All branching stays in browser-side story code.
- Does a framework element passed as a prop behave any differently from a function?No. An element is an object holding component references and closures, so it is no more serialisable than a function. The composed tree has to be written in the story file and exported as its own story, leaving only the plain parts in the props argument.
- Can the story reach back into the test process at all?Not directly. Everything the test learns has to be observable in the page: DOM text, an attribute, or an input value. That is why the recorder input exists, and why teams keep one naming convention for those test ids across a package.
Props are a postcard: you can write data on it, but you cannot mail a live phone line. The phone has to be installed at the other end already.
saying these in an interview costs you the question
- Claiming mount props accept any JavaScript value
- Passing a test-side spy function as a prop
- Believing the component renders in the Node process
- Expecting a callback payload to reach the test without touching the DOM
- Treating a JSX element prop as plain serialisable data