Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ src/extension/build.pem
bower_components
sandboxes/manual-tests/NextJS/.next
.vscode
.cursor
package-lock.json
yarn.lock
docs/**/*
Expand Down
3 changes: 2 additions & 1 deletion src/app/containers/MainContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ function MainContainer(): JSX.Element {
break;
}
case 'sendSnapshots': {
dispatch(setTab(payload));
// sourceTab is the tab that sent the snapshot; setTab expects tabId, not tabsObj
if (typeof sourceTab === 'number') dispatch(setTab(sourceTab));
dispatch(addNewSnapshots(payload));
break;
}
Expand Down
22 changes: 14 additions & 8 deletions src/app/slices/mainSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,14 @@ export const mainSlice = createSlice({
const currSnapshot = tabs[currentTab].snapshots[tabs[currentTab].currLocation.index]; // current snapshot
const currAxSnapshot = tabs[currentTab].axSnapshots[tabs[currentTab].currLocation.index]; // current accessibility tree snapshot

tabs[currentTab].hierarchy.stateSnapshot = { ...currSnapshot }; // resets hierarchy to current snapshot
// Strip lastUserEvent so jumpToSnap (from changeSlider) won't re-show the pointer
const cleanSnapshot = { ...currSnapshot };
delete (cleanSnapshot as { lastUserEvent?: unknown }).lastUserEvent;

tabs[currentTab].hierarchy.stateSnapshot = { ...cleanSnapshot }; // resets hierarchy to current snapshot
tabs[currentTab].hierarchy.axSnapshot = { ...currAxSnapshot }; // resets hierarchy to current accessibility tree snapshot
tabs[currentTab].hierarchy.children = []; // resets hierarchy
tabs[currentTab].snapshots = [currSnapshot]; // resets snapshots to current snapshot
tabs[currentTab].snapshots = [cleanSnapshot]; // resets snapshots to current snapshot (no lastUserEvent)
tabs[currentTab].axSnapshots = [currAxSnapshot]; // resets snapshots to current snapshot

// resets currLocation to current snapshot
Expand Down Expand Up @@ -200,19 +204,21 @@ export const mainSlice = createSlice({
const { port, currentTab, tabs } = state;
const { hierarchy, snapshots } = tabs[currentTab] || {};

// finds the name by the action.payload parsing through the hierarchy to send to background.js the current name in the jump action
const nameFromIndex = findName(action.payload, hierarchy);
// nameFromIndex is a number based on which jump button is pushed
const index = typeof action.payload === 'object' ? action.payload.index : action.payload;

// finds the name by the index parsing through the hierarchy to send to background.js the current name in the jump action
const nameFromIndex = findName(index, hierarchy);

// Always pass full snapshot so laser pointer shows when snapshot has lastUserEvent
port.postMessage({
action: 'jumpToSnap',
payload: snapshots[action.payload],
index: action.payload,
payload: snapshots[index],
index,
name: nameFromIndex,
tabId: currentTab,
});

tabs[currentTab].sliderIndex = action.payload;
tabs[currentTab].sliderIndex = index;
},

setCurrentTabInApp: (state, action) => {
Expand Down
39 changes: 39 additions & 0 deletions src/backend/controllers/userEventCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Captures the last user interaction (click) in the target page so we can
* attach it to snapshots and show a "laser pointer" replay when time traveling.
* Uses viewport coordinates (clientX, clientY) for the overlay.
*/

export interface LastUserEvent {
type: 'click';
x: number;
y: number;
timestamp: number;
}

let lastUserEvent: LastUserEvent | null = null;

function handleClick(e: MouseEvent): void {
lastUserEvent = {
type: 'click',
x: e.clientX,
y: e.clientY,
timestamp: Date.now(),
};
}

/**
* Returns the most recent user click event (viewport coordinates).
* Used when building a snapshot payload so we can show where the user clicked.
*/
export function getLastUserEvent(): LastUserEvent | null {
return lastUserEvent ? { ...lastUserEvent } : null;
}

/**
* Attach document-level click listener. Call once when the backend initializes.
*/
export function initUserEventCapture(): void {
if (typeof document === 'undefined') return;
document.addEventListener('click', handleClick, true);
}
5 changes: 4 additions & 1 deletion src/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import 'regenerator-runtime/runtime';
import linkFiber from './routers/linkFiber';
// timeJumpInitialization (actually uses the function timeJumpInitiation but is labeled here as linkFiberInitialization, returns a function) returns a function that sets jumping to false and handles timetravel feature
import timeJumpInitialization from './controllers/timeJump';
import { Snapshot, Status, MsgData } from './types/backendTypes';
import { initUserEventCapture } from './controllers/userEventCapture';
import { Status, MsgData } from './types/backendTypes';
import routes from './models/routes';

// -------------------------INITIALIZE MODE--------------------------
Expand All @@ -31,6 +32,8 @@ const timeJump = timeJumpInitialization(mode);
* 3. Send a snapshot of ReactFiber Tree to frontend/Chrome Extension
*/
linkFiberInit();
// Capture user clicks so we can show "laser pointer" replay when time traveling
initUserEventCapture();

// --------------INITIALIZE EVENT LISTENER FOR TIME TRAVEL----------------------
/**
Expand Down
18 changes: 11 additions & 7 deletions src/backend/routers/snapShot.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { Snapshot, FiberRoot } from '../types/backendTypes';
import { FiberRoot } from '../types/backendTypes';
import componentActionsRecord from '../models/masterState';
import routes from '../models/routes';
import createTree from '../controllers/createTree';
import { getLastUserEvent } from '../controllers/userEventCapture';

// -------------------------UPDATE & SEND TREE SNAP SHOT------------------------
/**
* This function creates a new `snapShot` fiber tree with the provided `fiberRoot`, then send the updated snapshot to front end.
* This runs after every Fiber commit if mode is not jumping.
* This
* @param snapshot The current snapshot of the fiber tree
* @param fiberRoot The `fiberRootNode`, which is the root node of the fiber tree is stored in the current property of the fiber root object which we can use to traverse the tree
* Creates a new snapshot of the fiber tree and sends it to the front end.
* Runs after every Fiber commit when not in jumping mode.
* @param fiberRoot - The fiber root; the root node is in its `current` property.
*/
// updating tree depending on current mode on the panel (pause, etc)
export default function updateAndSendSnapShotTree(fiberRoot: FiberRoot): void {
// This is the currently active root fiber(the mutable root of the tree)
const { current } = fiberRoot;
Expand All @@ -22,6 +20,12 @@ export default function updateAndSendSnapShotTree(fiberRoot: FiberRoot): void {
const payload = createTree(current);
// Save the current window url to route
payload.route = routes.addRoute(window.location.href);
// Attach last user click so the extension can show "laser pointer" replay when time traveling
const lastEvent = getLastUserEvent();
if (lastEvent) {
// eslint-disable-next-line no-param-reassign -- attaching replay metadata to snapshot payload
(payload as { lastUserEvent?: ReturnType<typeof getLastUserEvent> }).lastUserEvent = lastEvent;
}
// method safely enables cross-origin communication between Window objects;
// e.g., between a page and a pop-up that it spawned, or between a page
// and an iframe embedded within it.
Expand Down
35 changes: 35 additions & 0 deletions src/extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,12 @@ chrome.runtime.onConnect.addListener(async (port) => {
tabsObj[tabId].currBranch = 1; // reset currBranch
tabsObj[tabId].currLocation = tabsObj[tabId].hierarchy; // reset currLocation

// Hide click-replay visualization since snapshots were cleared
try {
chrome.tabs.sendMessage(tabId, { action: 'hideClickReplay' });
} catch (err) {
// Tab may be closed or content script not loaded
}
return true; // return true so that port remains open

case 'setPause': // Pause = lock on tab
Expand Down Expand Up @@ -733,6 +739,12 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
);
}

// User action created new snapshot; hide click-replay visualization
try {
chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' });
} catch (err) {
/* tab may be closed */
}
break;
}

Expand All @@ -756,15 +768,29 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {

if (isDuplicateSnapshot(previousSnap, incomingSnap)) {
console.warn('Duplicate snapshot detected, skipping');
// Still hide pointer - user interaction triggered a snapshot even if we skipped it
try {
chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' });
} catch (err) {
/* tab may be closed */
}
break;
}

// Or if it is a snapShot after a jump, we don't record it.
let didAddSnapshot = false;
if (reloaded[tabId]) {
// don't add anything to snapshot storage if tab is reloaded for the initial snapshot
reloaded[tabId] = false;
// Still hide pointer - snapshot after jump, user has moved on
try {
chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' });
} catch (err) {
/* tab may be closed */
}
} else {
tabsObj[tabId].snapshots.push(request.payload);
didAddSnapshot = true;
// INVOKING buildHierarchy FIGURE OUT WHAT TO PASS IN
if (!tabsObj[tabId][index]) {
// check if accessibility recording has been toggled on
Expand Down Expand Up @@ -799,6 +825,15 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
} else {
console.warn('No active ports to send snapshots to.');
}

// User action created new snapshot; hide click-replay visualization
if (didAddSnapshot) {
try {
chrome.tabs.sendMessage(sourceTab, { action: 'hideClickReplay' });
} catch (err) {
/* tab may be closed */
}
}
}
default:
break;
Expand Down
129 changes: 128 additions & 1 deletion src/extension/contentScript.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// Web vital metrics calculated by 'web-vitals' npm package to be displayed
// in Web Metrics tab of Reactime app.
import { current } from '@reduxjs/toolkit';
import { onTTFB, onLCP, onFID, onFCP, onCLS, onINP } from 'web-vitals';

const MAX_RECONNECT_ATTEMPTS = 5;
Expand Down Expand Up @@ -139,6 +138,130 @@ window.addEventListener('message', (msg) => {
}
});

// User input visualization: show click position when time traveling (see docs/USER_INPUT_VISUALIZATION_IMPLEMENTATION.md)
const REACTIME_POINTER_OVERLAY_ID = 'reactime-pointer-overlay';
const REACTIME_POINTER_STYLES_ID = 'reactime-pointer-styles';
const REACTIME_POINTER_VISIBLE_CLASS = 'reactime-pointer-visible';

/** Cached refs to avoid repeated DOM lookups after first use */
let pointerOverlayRef: HTMLElement | null = null;
let pointerDotRef: HTMLElement | null = null;
let pointerRippleRef: HTMLElement | null = null;

const REACTIME_POINTER_STYLES = `
#${REACTIME_POINTER_OVERLAY_ID} {
position: fixed; inset: 0; pointer-events: none; z-index: 2147483647;
}
#${REACTIME_POINTER_OVERLAY_ID} .reactime-pointer-dot {
position: fixed; width: 22px; height: 22px; border-radius: 50%;
background: #0d9488; border: 3px solid #fff;
box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 0 20px 4px rgba(13,148,136,0.5);
transform: translate(-50%, -50%);
}
#${REACTIME_POINTER_OVERLAY_ID} .reactime-pointer-ripple {
position: fixed; width: 22px; height: 22px; border-radius: 50%;
border: 3px solid #14b8a6; transform: translate(-50%, -50%); opacity: 0;
}
#${REACTIME_POINTER_OVERLAY_ID}.${REACTIME_POINTER_VISIBLE_CLASS} .reactime-pointer-dot {
animation: reactime-dot-pulse 2s ease-in-out; animation-iteration-count: infinite;
}
#${REACTIME_POINTER_OVERLAY_ID}.${REACTIME_POINTER_VISIBLE_CLASS} .reactime-pointer-ripple {
animation: reactime-ripple 1.2s ease-out; animation-iteration-count: infinite;
}
@keyframes reactime-dot-pulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 0 20px 4px rgba(13,148,136,0.5); }
10% { transform: translate(-50%, -50%) scale(1); opacity: 1; box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 0 20px 4px rgba(13,148,136,0.5); }
50% { transform: translate(-50%, -50%) scale(1.2); opacity: 1; box-shadow: 0 0 0 1px rgba(0,0,0,0.2), 0 0 28px 8px rgba(13,148,136,0.7); }
}
@keyframes reactime-ripple {
0% { transform: translate(-50%, -50%) scale(0.6); opacity: 0.7; }
100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
#${REACTIME_POINTER_OVERLAY_ID}.${REACTIME_POINTER_VISIBLE_CLASS} .reactime-pointer-dot {
animation: reactime-dot-in 0.25s ease-out;
}
#${REACTIME_POINTER_OVERLAY_ID}.${REACTIME_POINTER_VISIBLE_CLASS} .reactime-pointer-ripple {
animation: none; opacity: 0;
}
}
@keyframes reactime-dot-in {
from { transform: translate(-50%, -50%) scale(0); opacity: 0; }
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
`;

/**
* Returns the pointer overlay element, creating it (and injecting styles) only on first use.
* Reuses cached refs to avoid repeated DOM lookups.
*/
function getOrCreatePointerOverlay(): HTMLElement {
if (pointerOverlayRef) return pointerOverlayRef;

if (!document.getElementById(REACTIME_POINTER_STYLES_ID)) {
const style = document.createElement('style');
style.id = REACTIME_POINTER_STYLES_ID;
style.textContent = REACTIME_POINTER_STYLES;
(document.head || document.documentElement).appendChild(style);
}

const overlay = document.createElement('div');
overlay.id = REACTIME_POINTER_OVERLAY_ID;
overlay.setAttribute('aria-hidden', 'true');
const ripple = document.createElement('div');
ripple.className = 'reactime-pointer-ripple';
const dot = document.createElement('div');
dot.className = 'reactime-pointer-dot';
overlay.appendChild(ripple);
overlay.appendChild(dot);
overlay.style.display = 'none';
(document.body || document.documentElement).appendChild(overlay);

pointerOverlayRef = overlay;
pointerDotRef = dot;
pointerRippleRef = ripple;
return overlay;
}

/** Payload shape we use for click replay (snapshot may include lastUserEvent from backend). */
interface ClickReplayPayload {
lastUserEvent?: { x: number; y: number } | null;
}

/**
* Shows or hides the click-replay pointer on the page based on snapshot payload.
* Uses cached overlay/dot/ripple refs after first run to avoid repeated DOM queries.
*/
function updateClickReplayPointer(payload: ClickReplayPayload | undefined): void {
const overlay = getOrCreatePointerOverlay();
const dot = pointerDotRef;
const ripple = pointerRippleRef;
if (!dot) return;

const event = payload?.lastUserEvent;
const hasValidEvent =
event != null && typeof event.x === 'number' && typeof event.y === 'number';

if (hasValidEvent) {
const left = `${event.x}px`;
const top = `${event.y}px`;
dot.style.left = left;
dot.style.top = top;
if (ripple) {
ripple.style.left = left;
ripple.style.top = top;
}
overlay.style.display = '';
overlay.classList.remove(REACTIME_POINTER_VISIBLE_CLASS);
requestAnimationFrame(() => {
overlay.classList.add(REACTIME_POINTER_VISIBLE_CLASS);
});
} else {
overlay.classList.remove(REACTIME_POINTER_VISIBLE_CLASS);
overlay.style.display = 'none';
}
}

// FROM BACKGROUND TO CONTENT SCRIPT
// Listening for messages from the UI of the Reactime extension.
chrome.runtime.onMessage.addListener((request) => {
Expand All @@ -151,12 +274,16 @@ chrome.runtime.onMessage.addListener((request) => {
}
// this is only listening for Jump toSnap
if (action === 'jumpToSnap') {
updateClickReplayPointer(request.payload);
chrome.runtime.sendMessage(request);
// After the jumpToSnap action has been sent back to background js,
// it will send the same action to backend files (index.ts) for it execute the jump feature
// '*' == target window origin required for event to be dispatched, '*' = no preference
window.postMessage(request, '*');
}
if (action === 'hideClickReplay') {
updateClickReplayPointer(undefined);
}
if (action === 'portDisconnect' && !currentPort && !isAttemptingReconnect) {
console.log('Received disconnect message, initiating reconnection');
// When we receive a port disconnection message, relay it to the window
Expand Down