Skip to content

Instantly share code, notes, and snippets.

@jasonLaster
Created July 26, 2023 19:16
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jasonLaster/2557621a1bf1d305555ffc5376556d7d to your computer and use it in GitHub Desktop.
Save jasonLaster/2557621a1bf1d305555ffc5376556d7d to your computer and use it in GitHub Desktop.
reactEventListeners.ts
/* Copyright 2023 Record Replay Inc. */
// The main React Event Listeners routine implementation
import {
Annotation,
MouseEvent as ReplayMouseEvent,
KeyboardEvent as ReplayKeyboardEvent,
TimeStampedPoint,
} from "@replayio/protocol";
import { Context } from "../../../shared/context";
import { Routine, RoutineEvaluationResult, RoutineSpec } from "../routine";
import { parseBuildIdComponents } from "../../../shared/utils";
import { createProtocolClient } from "../shared/protocolClient";
import { compareTimeStampedPoints } from "../shared/time";
import { ReplayClient } from "../shared/replayClient";
import { createCaches } from "../shared/caches/ProtocolDataCaches";
import { buildDateStringToDate } from "../../../shared/linkerVersion";
import { createEventCaches } from "./eventDataCaches";
import {
PointWithEventType,
processEventListenerLocation,
EventListenerProcessResult,
findEventsWithMatchingEntryPoints,
findEntryPointsForEventType,
EventWithEntryPoint,
EventListenerWithFunctionInfo,
filterResultsByValidHits,
deriveFakeSessionEventsFromEntryPoints,
} from "./eventListenerProcessing";
import { InteractionEventKind } from "./constants";
import { createReactEventMapper } from "./evaluationMappers";
/** All results from processing the events, including "no hits" */
export interface InteractionEventInfo extends TimeStampedPoint {
nextEventPoint: TimeStampedPoint;
eventKind: InteractionEventKind;
processResult: EventListenerProcessResult;
}
/** Final results, narrowed down to _only_ events with hits */
export interface EventListenerJumpLocationContents {
listenerPoint: TimeStampedPoint;
nextEventPoint: TimeStampedPoint;
eventKind: InteractionEventKind;
eventListener: EventListenerWithFunctionInfo;
}
export type FinalInteractionEventInfo = EventListenerJumpLocationContents &
TimeStampedPoint;
async function runReactEventListenersRoutine(routine: Routine, cx: Context) {
const protocolClient = createProtocolClient(routine.iface, cx);
const replayClient = new ReplayClient(protocolClient);
const cachesCollection = createCaches();
const eventCachesCollection = createEventCaches(cachesCollection);
const mouseEvents: ReplayMouseEvent[] = [];
const keyboardEvents: ReplayKeyboardEvent[] = [];
protocolClient.Session.addMouseEventsListener(entry => {
mouseEvents.push(...entry.events);
});
protocolClient.Session.addKeyboardEventsListener(entry => {
keyboardEvents.push(...entry.events);
});
// We need to load the sources and events before we can do anything else.
// Might as well load those all in parallel
const [
eventCounts,
sourcesById,
recordingTarget,
sessionEndpoint,
buildId,
] = await Promise.all([
eventCachesCollection.eventCountsCache.readAsync(replayClient, null),
cachesCollection.sourcesByIdCache.readAsync(replayClient),
cachesCollection.recordingTargetCache.readAsync(replayClient),
replayClient.getSessionEndpoint(replayClient.getSessionId()!),
replayClient.getBuildId(),
protocolClient.Session.findMouseEvents({}),
protocolClient.Session.findKeyboardEvents({}),
]);
const buildMetadata = parseBuildIdComponents(buildId)!;
const { runtime, date: dateString } = buildMetadata;
const canUseSharedProcesses = canUseSharedProcessesForEvaluations(runtime, dateString);
// We only care about click ("mousedown") and keypress events
const clickEvents = mouseEvents.filter(
event => event.kind === "mousedown"
) as PointWithEventType[];
const keypressEvents = keyboardEvents.filter(
event => event.kind === "keypress"
) as PointWithEventType[];
const searchableEventTypes: InteractionEventKind[] = ["mousedown", "keypress"];
const eventsForSearchableEventTypes: Record<
InteractionEventKind,
PointWithEventType[]
> = {
mousedown: clickEvents,
keypress: keypressEvents,
};
const clickAndKeypressResults = await Promise.all(
searchableEventTypes.map(async eventType => {
const sessionEvents = eventsForSearchableEventTypes[eventType];
// This is a relatively cheap call to `Session.findPoints` to find
// all JS event listener calls that match the given event type.
const entryPoints = await findEntryPointsForEventType(
eventCachesCollection,
replayClient,
eventType,
recordingTarget,
eventCounts,
sessionEndpoint
);
let eventsToProcess = sessionEvents;
if (sessionEvents.length === 0 && entryPoints.length > 0) {
// Our `Session.find*Events` API returned 0 results, but
// there _are_ hits of this type in the recording.
// This is likely a Cypress test. Cypress fakes events,
// so they show up in the recording, but they don't get
// handled by the browser's actual input logic.
// We can try to derive what the original session events
// _would_ have looked like, by looking at the actual JS
// event objects. These have `timeStamp` and `type` fields
// we can use to determine how many session events we'd have.
eventsToProcess = await deriveFakeSessionEventsFromEntryPoints(
eventType,
entryPoints,
sourcesById,
canUseSharedProcesses,
routine,
cx
);
}
// One we have all the entry points, we can correlate them with
// the user interaction events based on execution points,
// and filter it down to just events that had any listener run.
const eventsWithHits = findEventsWithMatchingEntryPoints(
eventsToProcess,
entryPoints,
sourcesById,
sessionEndpoint
);
// The original recorded session events occur before any actual JS runs.
// We need to map between the original session points and the
// later event listener hit points.
const eventsByEntryPointPoint: Record<string, EventWithEntryPoint> = {};
const listenerHitPointsByOriginalEventPoint: Record<string, TimeStampedPoint> = {};
for (const e of eventsWithHits) {
eventsByEntryPointPoint[e.entryPoint.point] = e;
listenerHitPointsByOriginalEventPoint[e.event.point] = {
point: e.event.point,
time: e.event.time,
};
}
const reactPropEventMapperResults: RoutineEvaluationResult[] = [];
await routine.runEvaluation(
{
points: eventsWithHits.map(e => e.entryPoint.point),
expression: createReactEventMapper(eventType),
// Evaluate in the top frame
frameIndex: 0,
// Run the eval faster if the runtime supports it
shareProcesses: canUseSharedProcesses,
// Include nested object previews as part of the pause data,
// so that we can synchronously use those during processing
// without needing to do further API calls at a given pause.
fullReturnedPropertyPreview: true,
onResult: result => {
// `onResult` needs to be synchronous - just save the results for later async processing
reactPropEventMapperResults.push(result);
},
},
cx
);
// Once we have the React prop evaluation results for each of the event
// listener hit points, we can process them in parallel.
const processedResults: InteractionEventInfo[] = await Promise.all(
reactPropEventMapperResults.map(async evalResult => {
// We either have a hit with formatted listener function details, or no hit
const processResult = await processEventListenerLocation(
replayClient,
cachesCollection,
evalResult,
eventsByEntryPointPoint,
sourcesById,
eventType
);
const eventWithEntryPoint = eventsByEntryPointPoint[evalResult.point];
return {
// Differentiate between the `Session.find*Events" point...
point: eventWithEntryPoint.event.point,
time: eventWithEntryPoint.event.time,
// And the time that the _next_ event of this type occurs.
// Note that we don't know for sure the time the
// listener itself ran - that will be determined next.
nextEventPoint: eventWithEntryPoint.nextEventPoint,
eventKind: eventType,
processResult,
};
})
);
return processedResults;
})
);
const allProcessedResults = clickAndKeypressResults
.flat()
.sort(compareTimeStampedPoints);
// We only need to save annotations for points that had an actual
// listener hit. The UI can assume that there are no hits otherwise.
const finalResultsWithValidHits = await filterResultsByValidHits(
replayClient,
allProcessedResults,
sourcesById
);
for (const { point, time, ...contents } of finalResultsWithValidHits) {
const annotationContents: EventListenerJumpLocationContents = contents;
const annotation: Annotation = {
point,
time,
kind: "event-listeners-jump-location",
contents: JSON.stringify(annotationContents),
};
cx.logger.debug("BackendEventListener", { point, time, ...contents });
routine.addAnnotation(annotation);
}
}
function canUseSharedProcessesForEvaluations(runtime: string, dateString: string) {
let canUseSharedProcesses = false;
if (runtime === "chromium") {
const date = buildDateStringToDate(dateString);
// Shared Processes support was added to Chromium in early May
const requiredMinBuildDate = new Date("2023-05-10");
canUseSharedProcesses = date >= requiredMinBuildDate;
}
return canUseSharedProcesses;
}
export const ReactEventListenersRoutine: RoutineSpec = {
name: "ReactEventListeners",
version: 2,
annotationKinds: ["event-listeners-jump-location"],
runRoutine: runReactEventListenersRoutine,
shouldRun: buildMetadata => {
const { runtime, date: dateString } = buildMetadata;
const validRuntime = runtime === "gecko" || runtime === "chromium";
let recordingIsAfterMinBuildDate = true;
if (runtime === "chromium") {
const date = buildDateStringToDate(dateString);
// Chromium Events support was added at the end of March
const requiredMinBuildDate = new Date("2023-03-30");
recordingIsAfterMinBuildDate = date >= requiredMinBuildDate;
}
return validRuntime && recordingIsAfterMinBuildDate;
},
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment