Storybook Instrumenter
The Storybook Instrumenter is used to patch a (3rd party) module to track and intercept function invocations for step-through debugging using the Interactions addon. In essense, the Instrumenter traverses a given object, recursively monkey-patching any functions to make them "tracked".
During normal operation, tracked functions simply call through to their original function, forwarding the return value. As a side-effect, they also emit a call
event whenever they are invoked.
Through options
, functions can be marked "interceptable", which give them another mode of operation. In this "intercept" mode, the original function is not invoked, instead the interceptable function returns a Promise
which only resolves when receiving an event to do so. This enables step-through debugging, directly in the browser. A consequence of this design is that all interceptable functions must be await
-ed, even if their original function is not asynchronous (i.e. it normally does not return a Promise).
API
The primary way to use the Storybook Instrumenter is through the instrument
function:
instrument<TObj extends Record<string, any>>(obj: TObj, options: Options): TObj
instrument
takes a plain JS object or imported ES module, and optionally an options
object. It traverses the input object, recursively iterating over object properties and arrays. Any values with typeof function
are tracked (through monkey-patching). Finally, a shallow copy of the original object is returned (with functions replaced). If the mutate: true
option is set, the original object is mutated instead of returning a shallow copy.
Options
intercept
Control which instrumented functions are interceptable.mutate
Mutate and return the input object rather than returning a shallow copy.path
Virtual object path to prepend to the actual input object function paths.retain
Retain calls across renders and when switching stories.
intercept
boolean | ((method: string, path: Array<string | CallRef>) => boolean)
Depending on the library and functions to be instrumented, you may want to configure some or all functions to be interceptable. Interceptable calls are debuggable, meaning they can be paused on. When paused, an interceptable function will not invoke it's original function, but rather return a pending Promise. This promise is only resolved when stepping over the call in the debugger. Only interceptable calls will appear as rows in the Interactions addon panel. Non-interceptable calls may appear as inline arguments to an interceptable function.
intercept
can take either a boolean (default false
) or a function which returns a boolean. This enables you to only make specific library functions interceptable. This function receives a method
and path
, referring to the name of the function and the path to that function in the object tree. Some functions may return an object which is then instrumented as well, in which case the path
will contain a "call ref", which is a plain object containing a __callId__
property referencing the originating call.
Here's an example intercept
function (from @storybook/testing-library
):
(method, path) => path[0] === 'fireEvent' || method.startsWith('findBy') || method.startsWith('waitFor'),
This means all methods under fireEvent
(an object) are instrumentable, as well as any methods which name starts with findBy
or waitFor
.
mutate
boolean
By default, instrument
creates a shallow clone of the input object, replacing functions with their tracked counterparts without affecting the original input object. This is usually the safest and most predictable behavior, but in some situations you may have to rely on mutation. By setting mutate: true
you can enable this behavior. Be careful though: mutating a Node module can lead to hard-to-debug issues.
path
Array<string | CallRef>
Storybook Interactions will automatically generate a pseudo-code representation of tracked function calls based on their metadata. For example, this call:
{ path: ['userEvent'], method: 'keyboard', args: ['hello'], ... }
Will print as userEvent.keyboard("hello")
.
By default, the call path
is determined by the hierarchy of the input object. To get the above result, your input object would have to look something like this:
{ userEvent: { keyboard: function(text) { ... } } }
The path
config option allows you to prepend elements to the normal path. So if your input object does not have a userEvent
property, but directly contains keyboard
, then you can set path: ['userEvent']
to correct for this.
retain
boolean
On rare occasions, you may have an instrumented function that's invoked outside the context of a story render or play function. One example can be found in argsEnhancers
which run when Storybook is initialized, but aren't rerun when you switch or rerun stories. Normally, the Storybook Instrumenter clears its internal record of calls when switching between stories, losing track of those function calls which happened on initialization. Set retain: true
to keep these calls on record while switching or rerunning stories.
Note that retained functions should not be interceptable.
Events
The Storybook Instrumenter uses the Storybook Channel API to send and receive events.
Emitted tracking events
The instrumenter emits two types of events for tracking function invocations ("calls"):
storybook/instrumenter/call
This event is emitted whenever a tracked function is invoked (a "call").
The event payload consists of all metadata about the function invocation, including a unique id
, any arguments, the method name and object path. However, the order of events is not guaranteed and you may receive the same call multiple times while debugging. Moreover, this event is emitted for all tracked calls, not just interceptable ones.
storybook/instrumenter/sync
This event is emitted whenever a tracked function is invoked, but the event is debounced until the next "tick", so multiple consecutive synchronous calls will trigger a single sync
event.
The event payload object contains an array of logItems
and a controlStates
object. The logItems
array represent a "normalized" log of interceptable calls. The order of calls in this log is guaranteed and step-through debugging will not append to the log but rather update it to set the proper status
for each call. The log does not contain full call metadata but only a callId
property, so this must be mapped onto received storybook/instrumenter/call
events. For the value of controlStates
, see Control states.
An example sync
payload may look like this:
{
controlStates: {
debugger: true,
start: false,
back: false,
goto: true,
next: true,
end: true,
},
logItems: [
{ callId: 'tooltip--hovered [0] hover', status: 'waiting' }
]
}
Received control events
The instrumenter listens for these control events:
storybook/instrumenter/start
- Remount the story and start the debugger at the first interceptable callstorybook/instrumenter/back
- Remount the story and start the debugger at the previous interceptable callstorybook/instrumenter/goto
- Fast-forwards to - or remounts and starts debugging at - the given interceptable callstorybook/instrumenter/next
- Resolves the Promise for the currently intercepted call, letting execution continue to the next callstorybook/instrumenter/end
- Resolves all Promises for intercepted calls, letting execution continue to the end
Remounting is achieved through emitting Storybook's forceRemount
event. In some situations, this will trigger a full page refresh (of the preview) in order to flush pending promises (e.g. long-running interactions).
Control states
Besides patching functions, the instrumenter keeps track of "control states". These indicate whether the debugger is available, and which control events are available for use:
debugger: boolean
- Whether the interactionsDebugger
feature flag is enabledstart: boolean
- Whether emitting storybook/instrumenter/start
would workback: boolean
- Whether emitting storybook/instrumenter/back
would workgoto: boolean
- Whether emitting storybook/instrumenter/goto
would worknext: boolean
- Whether emitting storybook/instrumenter/next
would workend: boolean
- Whether emitting storybook/instrumenter/end
would work
These values are provided in the controlStates
object on the storybook/instrumenter/sync
event payload.