Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/strange-papayas-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
99 changes: 99 additions & 0 deletions .storybook/actionArgs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Keeps Storybook's auto-wired action spies from serializing DOM events.
*
* Storybook attaches an `action()` spy to every `on*` prop it finds in a
* component's argTypes (`addActionsFromArgTypes`). React Aria calls those spies
* with the raw React synthetic event, and the preview channel then serializes
* the whole payload with telejson at `maxDepth: 15` — once for the postMessage
* transport, and again for the dev-server websocket, which hardcodes that depth
* and ignores per-event options.
*
* A synthetic event reaches `nativeEvent`, `target` and `_targetInst` (the React
* fiber), so that walk drags in the entire component tree: ~174k
* `JSON.stringify` calls and ~435k regex tests, i.e. ~600ms of synchronous work
* on *every* focus change, in *every* story. Replacing event-like arguments
* with a compact plain summary before the spy sees them keeps the Actions panel
* readable and drops the cost to ~1ms.
*
* This is dev-server-only: a production build of the kit has no channel and no
* action spies, and pays none of this.
*/

const isEventLike = (value) =>
!!value &&
typeof value === 'object' &&
typeof value.preventDefault === 'function' &&
typeof value.stopPropagation === 'function';

const isDomNode = (value) =>
!!value && typeof value === 'object' && typeof value.tagName === 'string';

/** `input#«r0»[data-qa="Input"]` — enough to identify the node in the panel. */
const describeNode = (node) => {
if (!isDomNode(node)) return undefined;

const qa = node.getAttribute?.('data-qa');

return [
node.tagName.toLowerCase(),
node.id ? `#${node.id}` : '',
qa ? `[data-qa="${qa}"]` : '',
].join('');
};

const summarizeEvent = (event) => {
const summary = { __event: event.type ?? 'event' };
const target = describeNode(event.target);
const currentTarget = describeNode(event.currentTarget);

if (target) summary.target = target;
if (currentTarget && currentTarget !== target) {
summary.currentTarget = currentTarget;
}
if (typeof event.key === 'string') summary.key = event.key;
if (typeof event.target?.value === 'string') {
summary.value = event.target.value;
}

return summary;
};

const summarizeArg = (arg) => {
if (isEventLike(arg)) return summarizeEvent(arg);
if (isDomNode(arg)) return { __node: describeNode(arg) };

return arg;
};

/**
* Wraps every action arg so its arguments are summarized before the spy runs.
*
* Project annotations are composed last, so by the time this runs the actions
* addon has already attached its `action()` handlers and `storybook/test` has
* wrapped each one in an `fn()` spy. Wrapping from the outside is what makes
* this independent of both: neither the postMessage nor the websocket transport
* gets a live event to walk, whatever `maxDepth` each one happens to use.
*
* The spy's own properties are carried over, so it still reads as a mock and
* the Actions panel still recognises it. Its recorded calls hold the summarized
* arguments — no story in this repo asserts on them, and an assertion against a
* live `FocusEvent` is not something to preserve anyway.
*/
export const argsEnhancers = [
({ initialArgs }) => {
const patched = {};

for (const [name, value] of Object.entries(initialArgs)) {
if (typeof value !== 'function' || !value.isAction) continue;

const spy = value;
const wrapped = (...args) => spy(...args.map(summarizeArg));

Object.assign(wrapped, spy);
wrapped.isAction = true;
patched[name] = wrapped;
}

return patched;
},
];
5 changes: 5 additions & 0 deletions .storybook/preview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { Root } from '../src/components/Root';
import { getI18n, LOCALE_LABELS, SUPPORTED_LOCALES } from '../src/i18n';
import { setToolbarScheme } from '../src/stories/decorators/colorSchemeBridge';

// Summarizes DOM/React events before Storybook's action spies see them. Without
// it, serializing a focus event over the preview channel costs ~600ms per focus
// change in every story — see `.storybook/actionArgs.js`.
export { argsEnhancers } from './actionArgs';

// Brand both Storybook themes (manager chrome, sidebar selection, toolbar
// active tab, etc.) with the design system's primary purple — the same color
// the `#primary` token resolves to in `src/tokens/palette.ts`.
Expand Down
Loading