In a Playwright component test, what fails when mount's props carry a renderCell function and a JSX empty state?
answer
- Symptom looks like a component bug
- Three props fail, one boundary
- Reduce props to plain data first
- Renderer lookup keyed by a string
- Compositions become story exports
basics
~20 sNeither prop can cross into the browser: a function and a framework element are not serialisable, so the grid renders as if they were missing. Move both into the story file and send a plain variant flag instead.
solid answer
~50 sBoth props fail for one reason: `mount` serialises its props object to reach the page, and a function and an element are behaviour rather than data. The grid ends up rendering as if `renderCell` and the empty state were never supplied, so the failure looks like a component bug and gets debugged in the wrong place. The fix is structural. Define the cell renderers in the story file as a lookup keyed by a plain string, take `renderer: 'plain' | 'currency'` as a prop, and let the story pass the right function to the grid. Compose the empty state inside the story too, or give it a dedicated export if it is a different tree rather than a different value. Afterwards the test sends only data, and anything the grid calls back is recorded into a hidden input read with `toHaveValue`.
code
typescript · 22 lines// data-grid.story.tsx
import { DataGrid } from '../src/data-grid';
import { EmptyState } from '../src/empty-state';
const cellRenderers = {
plain: (value: number) => String(value),
currency: (value: number) =>
new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(value),
};
export function GridWithRenderer(props: {
rows: Array<{ id: string; amount: number }>;
renderer: 'plain' | 'currency';
}) {
return (
<DataGrid
rows={props.rows}
renderCell={cellRenderers[props.renderer]}
empty={<EmptyState title="No rows" />}
/>
);
}go deeper
Recognise the pattern: props that are functions or elements never arrive, so the fix is to move them into the story and send a plain flag.
Explain why all such props fail together, and show the restructure with a renderer lookup keyed by a string prop.
Walk through the diagnosis, reducing props to plain data, comparing against a direct render, and reading the recorder to prove the handler never ran.
Make the boundary a standard: story prop types restricted to data, review rules that catch function props, and a package-wide place where renderers live.
## What the symptom looks like The suite does not usually announce the real cause. A grid story mounted with `renderCell` and an `empty` element renders with default cells and no empty state, so the first instinct is to blame the component: "the grid ignores renderCell". Engineers then add logging inside the component, discover the prop is absent, and start looking for a bug in prop spreading that does not exist. Either the mount call rejects the unserialisable value outright or the component simply never receives it - in both cases the behaviour under test was never in the page. ## Both props fail for the same reason The test runs in Node, the grid renders in a browser, and every prop crosses that gap by serialisation. Data survives; behaviour and identity do not. | Prop passed from the test | Why it cannot cross | |---|---| | `renderCell: (v) => format(v)` | A function is a closure over test-process scope | | `empty: <EmptyState title="No rows" />` | An element is a graph of component references | | `formatter: new Intl.NumberFormat('en-GB')` | A class instance depends on its prototype | | `rows: [{ id: 'r1', amount: 12 }]` | It can cross - it is plain data | Noticing that the first three fail together is the diagnostic insight. It is not three bugs; it is one boundary. ## Diagnosing it in a real suite 1. Reduce the props object to plain values only, and confirm the story still renders. If it does, the boundary is the cause. 2. Round-trip the props through `JSON.stringify` in the test and look at what disappears; functions and elements vanish, which mirrors what the page receives. 3. Render the story directly in the application or a dev harness with the same props. Working there and failing under `mount` points at transport, not at the component. 4. Assert on what the story recorded rather than on what the test thinks it passed - a recorder input holding `''` proves the handler never ran. 5. Grep the suite for other props whose value is a function or an element; the same mistake is usually copied across several stories. ## The restructure Move behaviour to the browser side and leave a plain switch in its place: - keep a renderer lookup in the story file, keyed by a string the test can send; - accept `renderer: 'plain' | 'currency'` as a prop and index the lookup with it; - compose the empty state inside the story, or promote it to its own story export when the tree, not the value, is what differs; - keep `rows` in props, because rows are data and belong to the test case; - record any callback payload into a hidden input so the test can still assert on what the grid invoked. ```ts const cellRenderers = { plain: (value: number) => String(value), currency: (value: number) => new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(value), }; ``` The test now says which renderer it wants with a string, and the function it names has always lived in the page. ## Why this is worth enforcing rather than patching Teams sometimes try to smuggle the function across by stringifying its source and rebuilding it in the page. That drops the closure it captured, turns reviewable code into an evaluated string, and hides the intent from anyone reading the story later. Others reach for a page-init script to plant a global before mounting, which still leaves the story reaching for something invisible in its own file. The structural fix is cheaper and it survives refactors: the story owns behaviour, the props argument carries data, and the boundary is visible in the story's prop type. ## What to add to review - A prop whose value is a function or an element is a defect, not a style preference. - A story prop type made only of primitives, arrays and plain objects documents the boundary for the next author. - One renderer lookup per component beats a variant flag per call site. - When a test wants a composition rather than a value, the answer is another story export.
- A colleague proposes stringifying the renderer and rebuilding it in the page. What do you say?That it trades a clear structural fix for an obscure one. The rebuilt function loses the closure it captured, the source becomes an evaluated string nobody type-checks, and the story no longer shows what it renders. A named renderer in the story file costs less and reads better.
- How do you stop the same mistake reappearing across a design-system package?Make story prop types speak the rule: primitives, arrays and plain objects only. A reviewer can then spot a violation without knowing the transport details, and new stories inherit the constraint by copying a neighbour that already respects it.
- The grid also calls onSort during the test. How is that verified after the restructure?The story wires `onSort` itself and appends what it received to state rendered into a hidden input with a `data-testid`. The test clicks the column header and asserts the input with `toHaveValue`, so evidence of the call travels back as DOM state.
saying these in an interview costs you the question
- Blaming the component when the prop never crossed the boundary
- Rebuilding a function from its source string inside the page
- Assuming only functions fail and elements are fine
- Adding a global through an init script instead of restructuring
- Keeping renderer selection in the test rather than the story