diff --git a/packages/scan/package.json b/packages/scan/package.json index 8cc4d24d..0c515331 100644 --- a/packages/scan/package.json +++ b/packages/scan/package.json @@ -1,8 +1,14 @@ { "name": "react-scan", - "version": "0.0.54", + "version": "0.0.1083", "description": "Scan your React app for renders", - "keywords": ["react", "react-scan", "react scan", "render", "performance"], + "keywords": [ + "react", + "react-scan", + "react scan", + "render", + "performance" + ], "homepage": "https://react-scan.million.dev", "bugs": { "url": "/~https://github.com/aidenybai/react-scan/issues" @@ -161,17 +167,27 @@ "types": "dist/index.d.ts", "typesVersions": { "*": { - "monitoring": ["./dist/core/monitor/index.d.ts"], - "monitoring/next": ["./dist/core/monitor/params/next.d.ts"], + "monitoring": [ + "./dist/core/monitor/index.d.ts" + ], + "monitoring/next": [ + "./dist/core/monitor/params/next.d.ts" + ], "monitoring/react-router-legacy": [ "./dist/core/monitor/params/react-router-v5.d.ts" ], "monitoring/react-router": [ "./dist/core/monitor/params/react-router-v6.d.ts" ], - "monitoring/remix": ["./dist/core/monitor/params/remix.d.ts"], - "monitoring/astro": ["./dist/core/monitor/params/astro/index.ts"], - "react-component-name/vite": ["./dist/react-component-name/vite.d.ts"], + "monitoring/remix": [ + "./dist/core/monitor/params/remix.d.ts" + ], + "monitoring/astro": [ + "./dist/core/monitor/params/astro/index.ts" + ], + "react-component-name/vite": [ + "./dist/react-component-name/vite.d.ts" + ], "react-component-name/webpack": [ "./dist/react-component-name/webpack.d.ts" ], @@ -187,15 +203,25 @@ "react-component-name/rollup": [ "./dist/react-component-name/rollup.d.ts" ], - "react-component-name/astro": ["./dist/react-component-name/astro.d.ts"] + "react-component-name/astro": [ + "./dist/react-component-name/astro.d.ts" + ] } }, "bin": "bin/cli.js", - "files": ["dist", "bin", "package.json", "README.md", "LICENSE", "auto.d.ts"], + "files": [ + "dist", + "bin", + "package.json", + "README.md", + "LICENSE", + "auto.d.ts" + ], "scripts": { "build": "npm run build:css && NODE_ENV=production tsup", "postbuild": "pnpm copy-astro && node ../../scripts/version-warning.mjs", "build:copy": "NODE_ENV=production tsup && cat dist/auto.global.js | pbcopy", + "build:copy-monitor": "NODE_ENV=production tsup && cat dist/auto-monitor.global.js | pbcopy", "copy-astro": "cp -R src/core/monitor/params/astro dist/core/monitor/params", "dev:css": "npx tailwindcss -i ./src/core/web/assets/css/styles.tailwind.css -o ./src/core/web/assets/css/styles.css --watch", "dev:tsup": "NODE_ENV=development tsup --watch", @@ -216,6 +242,7 @@ "@clack/prompts": "^0.8.2", "@preact/signals": "^1.3.1", "@rollup/pluginutils": "^5.1.3", + "@rrweb/types": "2.0.0-alpha.18", "@types/node": "^20.17.9", "bippy": "^0.0.25", "esbuild": "^0.24.0", @@ -224,6 +251,8 @@ "mri": "^1.2.0", "playwright": "^1.49.0", "preact": "^10.25.1", + "rrweb": "2.0.0-alpha.4", + "rrweb-snapshot": "2.0.0-alpha.4", "tsx": "^4.0.0" }, "devDependencies": { diff --git a/packages/scan/src/auto-monitor.ts b/packages/scan/src/auto-monitor.ts new file mode 100644 index 00000000..b563faa3 --- /dev/null +++ b/packages/scan/src/auto-monitor.ts @@ -0,0 +1,37 @@ +import 'bippy'; // implicit init RDT hook +import { Store } from 'src'; +import { scanMonitoring } from 'src/core/monitor'; +// import { initPerformanceMonitoring } from 'src/core/monitor/performance'; +import { Device } from 'src/core/monitor/types'; + +if (typeof window !== 'undefined') { + Store.monitor.value ??= { + pendingRequests: 0, + interactions: [], + session: new Promise((res) => + res({ + agent: 'mock', + branch: 'mock', + commit: 'mock', + cpu: -1, + device: Device.DESKTOP, + gpu: null, + id: 'mock', + mem: -1, + route: 'mock', + url: 'mock', + wifi: 'mock', + }), + ), + url: 'https://mock.com', + apiKey: '', + route: '', + commit: '', + branch: '', + interactionListeningForRenders: null, + }; + // scanMonitoring({ + // enabled: true, + // }); + // initPerformanceMonitoring(); +} diff --git a/packages/scan/src/auto.ts b/packages/scan/src/auto.ts index 645a0bdc..90ac8a94 100644 --- a/packages/scan/src/auto.ts +++ b/packages/scan/src/auto.ts @@ -2,7 +2,9 @@ import 'bippy'; // implicit init RDT hook import { scan } from './index'; if (typeof window !== 'undefined') { - scan(); + scan({ + dangerouslyForceRunInProduction: true, + }); window.reactScan = scan; } diff --git a/packages/scan/src/core/index.ts b/packages/scan/src/core/index.ts index a7e73e7a..108c9a56 100644 --- a/packages/scan/src/core/index.ts +++ b/packages/scan/src/core/index.ts @@ -172,6 +172,9 @@ export type MonitoringOptions = Pick< interface Monitor { pendingRequests: number; interactions: Array; + interactionListeningForRenders: + | ((fiber: Fiber, renders: Array) => void) + | null; session: ReturnType; url: string | null; route: string | null; @@ -381,12 +384,14 @@ export const reportRender = (fiber: Fiber, renders: Array) => { // Get data from both current and alternate fibers const currentData = Store.reportData.get(reportFiber); - const alternateData = fiber.alternate ? Store.reportData.get(fiber.alternate) : null; + const alternateData = fiber.alternate + ? Store.reportData.get(fiber.alternate) + : null; // More efficient null checks and Math.max const existingCount = Math.max( (currentData && currentData.count) || 0, - (alternateData && alternateData.count) || 0 + (alternateData && alternateData.count) || 0, ); // Create single shared object for both fibers @@ -395,7 +400,7 @@ export const reportRender = (fiber: Fiber, renders: Array) => { time: selfTime || 0, renders, displayName, - type: getType(fiber.type) || null + type: getType(fiber.type) || null, }; // Store in both fibers @@ -461,7 +466,12 @@ const updateScheduledOutlines = (fiber: Fiber, renders: Array) => { for (let i = 0, len = renders.length; i < len; i++) { const render = renders[i]; const domFiber = getNearestHostFiber(fiber); - if (!domFiber || !domFiber.stateNode || !(domFiber.stateNode instanceof Element)) continue; + if ( + !domFiber || + !domFiber.stateNode || + !(domFiber.stateNode instanceof Element) + ) + continue; if (ReactScanInternals.scheduledOutlines.has(fiber)) { const existingOutline = ReactScanInternals.scheduledOutlines.get(fiber)!; @@ -512,6 +522,10 @@ export const getIsProduction = () => { return isProduction; }; +export const attachReplayCanvas = () => { + startFlushOutlineInterval(); +}; + export const start = () => { if (typeof window === 'undefined') return; @@ -540,6 +554,13 @@ export const start = () => { const instrumentation = createInstrumentation('devtools', { onActive() { + const rdtHook = getRDTHook(); + for (const renderer of rdtHook.renderers.values()) { + const buildType = detectReactBuildType(renderer); + if (buildType === 'production') { + isProduction = true; + } + } const existingRoot = document.querySelector('react-scan-root'); if (existingRoot) { return; @@ -556,7 +577,9 @@ export const start = () => { void audioContext.resume(); }; - window.addEventListener('pointerdown', createAudioContextOnInteraction, { once: true }); + window.addEventListener('pointerdown', createAudioContextOnInteraction, { + once: true, + }); const container = document.createElement('div'); container.id = 'react-scan-root'; diff --git a/packages/scan/src/core/instrumentation.ts b/packages/scan/src/core/instrumentation.ts index 0174e546..40d22b1a 100644 --- a/packages/scan/src/core/instrumentation.ts +++ b/packages/scan/src/core/instrumentation.ts @@ -23,15 +23,42 @@ let lastTime = performance.now(); let frameCount = 0; let initedFps = false; -const updateFPS = () => { +let fpsListeners: Array<(fps: number) => void> = []; + +export const listenToFps = (listener: (fps: number) => void) => { + // console.log('oushed', listener); + + fpsListeners.push(listener); + + return () => { + // console.log('unsub listener'); + + fpsListeners = fpsListeners.filter( + (currListener) => currListener !== listener, + ); + }; +}; + +const updateFPS = (onChange?: (fps: number) => void) => { frameCount++; const now = performance.now(); - if (now - lastTime >= 1000) { - fps = frameCount; + const timeSinceLastUpdate = now - lastTime; + + if (timeSinceLastUpdate >= 500) { + const calculatedFPS = Math.round((frameCount / timeSinceLastUpdate) * 1000); + + if (calculatedFPS !== fps) { + for (const listener of fpsListeners) { + listener(calculatedFPS); + } + } + + fps = calculatedFPS; frameCount = 0; lastTime = now; } - requestAnimationFrame(updateFPS); + + requestAnimationFrame(() => updateFPS(onChange)); }; export const getFPS = () => { @@ -361,30 +388,30 @@ export const createInstrumentation = ( const changes: Array = []; - const propsChanges = getChangedPropsDetailed(fiber).map(change => ({ + const propsChanges = getChangedPropsDetailed(fiber).map((change) => ({ type: 'props' as const, name: change.name, value: change.value, prevValue: change.prevValue, - unstable: false + unstable: false, })); - const stateChanges = getStateChanges(fiber).map(change => ({ + const stateChanges = getStateChanges(fiber).map((change) => ({ type: 'state' as const, name: change.name, value: change.value, prevValue: change.prevValue, count: change.count, - unstable: false + unstable: false, })); - const contextChanges = getContextChanges(fiber).map(change => ({ + const contextChanges = getContextChanges(fiber).map((change) => ({ type: 'context' as const, name: change.name, value: change.value, prevValue: change.prevValue, count: change.count, - unstable: false + unstable: false, })); changes.push(...propsChanges, ...stateChanges, ...contextChanges); diff --git a/packages/scan/src/core/monitor/index.ts b/packages/scan/src/core/monitor/index.ts index 1a42968d..faa4782e 100644 --- a/packages/scan/src/core/monitor/index.ts +++ b/packages/scan/src/core/monitor/index.ts @@ -10,10 +10,9 @@ import { } from '..'; import { createInstrumentation, type Render } from '../instrumentation'; import { updateFiberRenderData } from '../utils'; -import { initPerformanceMonitoring } from './performance'; import { getSession } from './utils'; import { flush } from './network'; -import { computeRoute } from './params/utils'; +import { scanWithRecord } from 'src/core/monitor/session-replay/record'; // max retries before the set of components do not get reported (avoid memory leaks of the set of fibers stored on the component aggregation) const MAX_RETRIES_BEFORE_COMPONENT_GC = 7; @@ -50,30 +49,23 @@ export const Monitoring = ({ }: MonitoringProps) => { if (!apiKey) throw new Error('Please provide a valid API key for React Scan monitoring'); - url ??= 'https://monitoring.react-scan.com/api/v1/ingest'; - + // url ??= "https://monitoring.react-scan.com/api/v1/ingest"; Store.monitor.value ??= { pendingRequests: 0, + url: 'http://localhost:4200/api/ingest', + apiKey, interactions: [], session: getSession({ commit, branch }).catch(() => null), - url, - apiKey, route, - commit, - branch, - }; + branch: 'main', + commit: '0x00000', - // When using Monitoring without framework, we need to compute the route from the path and params - if (!route && path && params) { - Store.monitor.value.route = computeRoute(path, params); - } else if (typeof window !== 'undefined') { - Store.monitor.value.route = - route ?? path ?? new URL(window.location.toString()).pathname; // this is inaccurate on vanilla react if the path is not provided but used for session route - } + interactionListeningForRenders: null, + }; useEffect(() => { + scanWithRecord(); scanMonitoring({ enabled: true }); - return initPerformanceMonitoring(); }, []); return null; @@ -101,6 +93,7 @@ export const startMonitoring = () => { flushInterval = setInterval(() => { try { + void flush(); } catch { /* */ @@ -126,6 +119,7 @@ export const startMonitoring = () => { if (isCompositeFiber(fiber)) { aggregateComponentRenderToInteraction(fiber, renders); } + publishToListeningInteraction(fiber, renders); ReactScanInternals.options.value.onRender?.(fiber, renders); }, onCommitFinish() { @@ -151,7 +145,7 @@ const aggregateComponentRenderToInteraction = ( const displayName = getDisplayName(fiber.type); if (!displayName) return; // TODO(nisarg): it may be useful to somehow report the first ancestor with a display name instead of completely ignoring - let component = lastInteraction.components.get(displayName); // TODO(nisarg): Same names are grouped together which is wrong. + let component = lastInteraction.components.get(displayName); // TODO(rob): we can be more precise with fiber types, but display name is fine for now if (!component) { component = { @@ -181,3 +175,15 @@ const aggregateComponentRenderToInteraction = ( component.selfTime += selfTime; } }; + +const publishToListeningInteraction = ( + fiber: Fiber, + renders: Array, +) => { + const monitor = Store.monitor.value; + if (!monitor || !monitor.interactionListeningForRenders) { + return; + } + + monitor.interactionListeningForRenders(fiber, renders); +}; diff --git a/packages/scan/src/core/monitor/interaction-store.ts b/packages/scan/src/core/monitor/interaction-store.ts new file mode 100644 index 00000000..06bd0a45 --- /dev/null +++ b/packages/scan/src/core/monitor/interaction-store.ts @@ -0,0 +1,36 @@ +import { CompletedInteraction } from 'src/core/monitor/performance'; +import { BoundedArray } from 'src/core/monitor/performance-utils'; + +type Subscriber = (data: T) => void; + +export class Store { + private subscribers: Set> = new Set(); + private currentValue: T; + + constructor(initialValue: T) { + this.currentValue = initialValue; + } + + subscribe(subscriber: Subscriber): () => void { + this.subscribers.add(subscriber); + + subscriber(this.currentValue); + + return () => { + this.subscribers.delete(subscriber); + }; + } + + setState(data: T) { + this.currentValue = data; + this.subscribers.forEach((subscriber) => subscriber(data)); + } + + getCurrentState(): T { + return this.currentValue; + } +} +export const MAX_INTERACTION_BATCH = 150 +export const interactionStore = new Store>( + new BoundedArray(MAX_INTERACTION_BATCH), +); diff --git a/packages/scan/src/core/monitor/monkey-patch-iife.js b/packages/scan/src/core/monitor/monkey-patch-iife.js new file mode 100644 index 00000000..04c8d533 --- /dev/null +++ b/packages/scan/src/core/monitor/monkey-patch-iife.js @@ -0,0 +1,79 @@ +(function () { + window.__layoutDebugLog = { + calls: [], + }; + + const LOG_EVERY_CALL = false; + + function recordCall(apiName) { + const now = performance.now(); + const stack = new Error().stack + .split('\n') + .slice(2) + .map((line) => line.trim()) + .join('\n'); + + const entry = { api: apiName, time: now, stack }; + window.__layoutDebugLog.calls.push(entry); + + if (LOG_EVERY_CALL) { + console.warn( + `[LayoutDebug] ${apiName} at ${now.toFixed(2)}ms\nStack:\n${stack}`, + ); + } + } + + function patchFunction(obj, funcName) { + const original = obj[funcName]; + if (typeof original !== 'function') return; + + Object.defineProperty(obj, funcName, { + value: function patchedFunction(...args) { + recordCall(`${funcName}()`); + return original.apply(this, args); + }, + writable: true, + configurable: true, + }); + } + + function patchGetter(obj, propName) { + const desc = Object.getOwnPropertyDescriptor(obj, propName); + if (!desc) return; + const originalGet = desc.get; + if (typeof originalGet !== 'function') return; + + const newDesc = { + ...desc, + get: function patchedGetter() { + recordCall(propName); + return originalGet.call(this); + }, + }; + Object.defineProperty(obj, propName, newDesc); + } + + patchFunction(Element.prototype, 'getBoundingClientRect'); + + patchGetter(Element.prototype, 'offsetWidth'); + patchGetter(Element.prototype, 'offsetHeight'); + patchGetter(Element.prototype, 'clientWidth'); + patchGetter(Element.prototype, 'clientHeight'); + patchGetter(Element.prototype, 'scrollWidth'); + patchGetter(Element.prototype, 'scrollHeight'); + + (function patchGetComputedStyle() { + const original = window.getComputedStyle; + if (typeof original === 'function') { + window.getComputedStyle = function (...args) { + recordCall('getComputedStyle()'); + return original.apply(this, args); + }; + } + })(); + + console.log( + '%c[LayoutDebug] Patched layout APIs. Interact and then inspect window.__layoutDebugLog.calls', + 'color: green;', + ); +})(); diff --git a/packages/scan/src/core/monitor/network.ts b/packages/scan/src/core/monitor/network.ts index 25e029fd..74162056 100644 --- a/packages/scan/src/core/monitor/network.ts +++ b/packages/scan/src/core/monitor/network.ts @@ -1,194 +1,183 @@ -import { Store } from '../..'; -import { GZIP_MIN_LEN, GZIP_MAX_LEN, MAX_PENDING_REQUESTS } from './constants'; -import { getSession } from './utils'; +import { Store } from "../.."; +import { GZIP_MIN_LEN, GZIP_MAX_LEN, MAX_PENDING_REQUESTS } from "./constants"; +import { getSession } from "./utils"; import type { Interaction, IngestRequest, InternalInteraction, Component, -} from './types'; + Session, +} from "./types"; +import { performanceEntryChannels } from "src/core/monitor/performance-store"; +import { + interactionStore, + MAX_INTERACTION_BATCH, +} from "src/core/monitor/interaction-store"; +import { BoundedArray } from "src/core/monitor/performance-utils"; +import { CompletedInteraction } from "./performance"; + +let afterFlushListeners: Array<() => void> = []; +export const addAfterFlushListener = ( + cb: () => void, + opts?: { once?: boolean } +) => { + afterFlushListeners.push(() => { + cb(); + if (opts?.once) { + afterFlushListeners = afterFlushListeners.filter( + (listener) => listener !== cb + ); + } + }); +}; + +export type InteractionWithArrayParents = { + detailedTiming: Omit< + CompletedInteraction["detailedTiming"], + "fiberRenders" + > & { + fiberRenders: { + [key: string]: { + renderCount: number; + parents: string[]; + selfTime: number; + }; + }; + }; + latency: number; + completedAt: number; + flushNeeded: boolean; +}; + +export const convertInteractionFiberRenderParents = ( + interaction: CompletedInteraction +): InteractionWithArrayParents => ({ + ...interaction, + detailedTiming: { + ...interaction.detailedTiming, + fiberRenders: Object.fromEntries( + Object.entries(interaction.detailedTiming.fiberRenders).map( + ([key, value]) => [ + key, + { + ...value, + parents: Array.from(value.parents), + }, + ] + ) + ), + }, +}); const INTERACTION_TIME_TILL_COMPLETED = 4000; -const truncate = (value: number, decimalPlaces = 4) => - Number(value.toFixed(decimalPlaces)); +// TODO: truncate floats for clickhouse +// const truncate = (value: number, decimalPlaces = 4) => +// Number(value.toFixed(decimalPlaces)); +let pendingInteractionUUIDS: Array = []; export const flush = async (): Promise => { const monitor = Store.monitor.value; if ( !monitor || - !navigator.onLine || + // // !navigator.onLine || !monitor.url || - !monitor.interactions.length + // // !monitor.interactions.length + !interactionStore.getCurrentState().length ) { return; } - const now = performance.now(); - // We might trigger flush before the interaction is completed, - // so we need to split them into pending and completed by an arbitrary time. - const pendingInteractions = new Array(); - const completedInteractions = new Array(); - - const interactions = monitor.interactions; - for (let i = 0; i < interactions.length; i++) { - const interaction = interactions[i]; - const timeSinceStart = now - interaction.performanceEntry.startTime; - // these interactions were retried enough and should be discarded to avoid mem leak - if (timeSinceStart > 30000) { - continue; - } else if (timeSinceStart <= INTERACTION_TIME_TILL_COMPLETED) { - pendingInteractions.push(interaction); - } else { - completedInteractions.push(interaction); - } - } - - // nothing to flush - if (!completedInteractions.length) return; - // idempotent const session = await getSession({ - commit: monitor.commit, - branch: monitor.branch, + commit: "mock", + branch: "mock", }).catch(() => null); - if (!session) return; - - const aggregatedComponents = new Array(); - const aggregatedInteractions = new Array(); - for (let i = 0; i < completedInteractions.length; i++) { - const interaction = completedInteractions[i]; - - // META INFORMATION IS FOR DEBUGGING THIS MUST BE REMOVED SOON - const { - duration, - entries, - id, - inputDelay, - latency, - presentationDelay, - processingDuration, - processingEnd, - processingStart, - referrer, - startTime, - timeOrigin, - timeSinceTabInactive, - timestamp, - type, - visibilityState, - } = interaction.performanceEntry; - aggregatedInteractions.push({ - id: i, - path: interaction.componentPath, - name: interaction.componentName, - time: truncate(duration), - timestamp, - type, - // fixme: we can aggregate around url|route|commit|branch better to compress payload - url: interaction.url, - route: interaction.route, - commit: interaction.commit, - branch: interaction.branch, - uniqueInteractionId: interaction.uniqueInteractionId, - meta: { - performanceEntry: { - id, - inputDelay: truncate(inputDelay), - latency: truncate(latency), - presentationDelay: truncate(presentationDelay), - processingDuration: truncate(processingDuration), - processingEnd, - processingStart, - referrer, - startTime, - timeOrigin, - timeSinceTabInactive, - visibilityState, - duration: truncate(duration), - entries: entries.map((entry) => { - const { - duration, - entryType, - interactionId, - name, - processingEnd, - processingStart, - startTime, - } = entry; - return { - duration: truncate(duration), - entryType, - interactionId, - name, - processingEnd, - processingStart, - startTime, - }; - }), - }, - }, - }); - - const components = Array.from(interaction.components.entries()); - for (let j = 0; j < components.length; j++) { - const [name, component] = components[j]; - aggregatedComponents.push({ - name, - instances: component.fibers.size, - interactionId: i, - renders: component.renders, - selfTime: - typeof component.selfTime === 'number' - ? truncate(component.selfTime) - : component.selfTime, - totalTime: - typeof component.totalTime === 'number' - ? truncate(component.totalTime) - : component.totalTime, - }); - } + if (!session) { + return; + } + + const completedInteractions = interactionStore + .getCurrentState() + .filter( + (interaction) => + !pendingInteractionUUIDS.includes( + interaction.detailedTiming.interactionUUID + ) && interaction.flushNeeded + ); + if (!completedInteractions.length) { + return; } - const payload: IngestRequest = { - interactions: aggregatedInteractions, - components: aggregatedComponents, - session: { - ...session, - url: window.location.toString(), - route: monitor.route, // this might be inaccurate but used to caculate which paths all the unique sessions are coming from without having to join on the interactions table (expensive) - }, + const payload: { + interactions: InteractionWithArrayParents[]; + session: Session; + } = { + interactions: completedInteractions.map( + convertInteractionFiberRenderParents + ), + session, }; monitor.pendingRequests++; - monitor.interactions = pendingInteractions; + + pendingInteractionUUIDS.push( + ...completedInteractions.map((interaction) => { + interaction.flushNeeded = false; + return interaction.detailedTiming.interactionUUID; + }) + ); try { transport(monitor.url, payload) .then(() => { + performanceEntryChannels.publish( + payload.interactions.map( + (interaction) => interaction.detailedTiming.interactionUUID + ), + "flushed-interactions" + ); monitor.pendingRequests--; - // there may still be renders associated with these interaction, so don't flush just yet + afterFlushListeners.forEach((cb) => { + cb(); + }); }) - .catch(async () => { + .catch(async (e) => { // we let the next interval handle retrying, instead of explicitly retrying - monitor.interactions = monitor.interactions.concat( - completedInteractions, + // monitor.interactions = monitor.interactions.concat( + // completedInteractions, + // ); + completedInteractions.forEach((interaction) => { + interaction.flushNeeded = true; + }); + interactionStore.setState( + BoundedArray.fromArray( + interactionStore.getCurrentState().concat(completedInteractions), + MAX_INTERACTION_BATCH + ) + ); + }) + .finally(() => { + pendingInteractionUUIDS = pendingInteractionUUIDS.filter( + (uuid) => + !completedInteractions.some( + (interaction) => + interaction.detailedTiming.interactionUUID === uuid + ) ); }); } catch { /* */ } - // Keep only recent interactions - monitor.interactions = pendingInteractions; }; -const CONTENT_TYPE = 'application/json'; -const supportsCompression = typeof CompressionStream === 'function'; +const CONTENT_TYPE = "application/json"; +const supportsCompression = typeof CompressionStream === "function"; export const compress = async (payload: string): Promise => { const stream = new Blob([payload], { type: CONTENT_TYPE }) .stream() - .pipeThrough(new CompressionStream('gzip')); + .pipeThrough(new CompressionStream("gzip")); return new Response(stream).arrayBuffer(); }; @@ -199,29 +188,29 @@ export const compress = async (payload: string): Promise => { */ export const transport = async ( url: string, - payload: IngestRequest, + payload: IngestRequest ): Promise<{ ok: boolean }> => { const fail = { ok: false }; const json = JSON.stringify(payload); // gzip may not be worth it for small payloads, // only use it if the payload is large enough - const shouldCompress = json.length > GZIP_MIN_LEN; + const shouldCompress = false; //TODO CHANGE THIS BACK ITS JUST TO MAKE DEBUGGING EASIER const body = shouldCompress && supportsCompression ? await compress(json) : json; if (!navigator.onLine) return fail; const headers: any = { - 'Content-Type': CONTENT_TYPE, - 'Content-Encoding': shouldCompress ? 'gzip' : undefined, - 'x-api-key': Store.monitor.value?.apiKey, + "Content-Type": CONTENT_TYPE, + "Content-Encoding": shouldCompress ? "gzip" : undefined, + "x-api-key": Store.monitor.value?.apiKey, }; - if (shouldCompress) url += '?z=1'; - const size = typeof body === 'string' ? body.length : body.byteLength; + if (shouldCompress) url += "?z=1"; + const size = typeof body === "string" ? body.length : body.byteLength; return fetch(url, { body, - method: 'POST', - referrerPolicy: 'origin', + method: "POST", + referrerPolicy: "origin", /** * Outgoing requests are usually cancelled when navigating to a different page, causing a "TypeError: Failed to * fetch" error and sending a "network_error" client-outcome - in Chrome, the request status shows "(cancelled)". @@ -243,8 +232,9 @@ export const transport = async ( keepalive: GZIP_MAX_LEN > size && MAX_PENDING_REQUESTS > (Store.monitor.value?.pendingRequests ?? 0), - priority: 'low', + priority: "low", // mode: 'no-cors', headers, + mode: "no-cors", // this fixes cors, but will need to actually fix correctly later }); }; diff --git a/packages/scan/src/core/monitor/performance-store.ts b/packages/scan/src/core/monitor/performance-store.ts new file mode 100644 index 00000000..727161fd --- /dev/null +++ b/packages/scan/src/core/monitor/performance-store.ts @@ -0,0 +1,115 @@ +import { BoundedArray } from 'src/core/monitor/performance-utils'; +import { + InternalInteraction, + PerformanceInteraction, +} from 'src/core/monitor/types'; + +// forgot why start time was here ngl +type Item = any; +type UnSubscribe = () => void; +type Callback = (item: Item) => void; +type Updater = (state: BoundedArray) => BoundedArray; +type ChanelName = string; + +type PerformanceEntryChannelsType = { + subscribe: (to: ChanelName, cb: Callback) => UnSubscribe; + publish: ( + item: Item, + to: ChanelName, + dropFirst: boolean, + createIfNoChannel: boolean, + ) => void; + channels: Record< + ChanelName, + { callbacks: BoundedArray; state: BoundedArray } + >; + getAvailableChannels: () => BoundedArray; + updateChannelState: ( + channel: ChanelName, + updater: Updater, + createIfNoChannel: boolean, + ) => void; +}; + +export const MAX_CHANNEL_SIZE = 50 +// a set of entities communicate to each other through channels +// the state in the channel is persisted until the receiving end consumes it +// multiple subscribes to the same channel will likely lead to unintended behavior if the subscribers are separate entities +class PerformanceEntryChannels implements PerformanceEntryChannelsType { + channels: PerformanceEntryChannelsType['channels'] = {}; + publish(item: Item, to: ChanelName, createIfNoChannel = true) { + const existingChannel = this.channels[to]; + if (!existingChannel) { + if (!createIfNoChannel) { + return; + } + this.channels[to] = { + callbacks: new BoundedArray(MAX_CHANNEL_SIZE), + state: new BoundedArray(MAX_CHANNEL_SIZE), + }; + this.channels[to].state.push(item); + return; + } + + existingChannel.state.push(item); + existingChannel.callbacks.forEach((cb) => cb(item)); + } + + getAvailableChannels() { + return BoundedArray.fromArray(Object.keys(this.channels), MAX_CHANNEL_SIZE); + } + subscribe(to: ChanelName, cb: Callback, dropFirst: boolean = false) { + const defer = () => { + if (!dropFirst) { + this.channels[to].state.forEach((item) => { + cb(item); + }); + } + return () => { + const filtered = this.channels[to].callbacks.filter( + (subscribed) => subscribed !== cb + ); + this.channels[to].callbacks = BoundedArray.fromArray(filtered, MAX_CHANNEL_SIZE); + }; + }; + const existing = this.channels[to]; + if (!existing) { + this.channels[to] = { + callbacks: new BoundedArray(MAX_CHANNEL_SIZE), + state: new BoundedArray(MAX_CHANNEL_SIZE), + }; + this.channels[to].callbacks.push(cb); + return defer(); + } + + existing.callbacks.push(cb); + return defer(); + } + updateChannelState( + channel: ChanelName, + updater: Updater, + createIfNoChannel = true, + ) { + const existingChannel = this.channels[channel]; + if (!existingChannel) { + if (!createIfNoChannel) { + return; + } + + const state = new BoundedArray(MAX_CHANNEL_SIZE) + const newChannel = { callbacks: new BoundedArray(MAX_CHANNEL_SIZE), state }; + + this.channels[channel] = newChannel; + newChannel.state = updater(state); + return; + } + + existingChannel.state = updater(existingChannel.state); + } + + getChannelState(channel: ChanelName) { + return this.channels[channel].state ?? new BoundedArray(MAX_CHANNEL_SIZE); + } +} + +export const performanceEntryChannels = new PerformanceEntryChannels(); diff --git a/packages/scan/src/core/monitor/performance-utils.ts b/packages/scan/src/core/monitor/performance-utils.ts new file mode 100644 index 00000000..37061502 --- /dev/null +++ b/packages/scan/src/core/monitor/performance-utils.ts @@ -0,0 +1,103 @@ +import { Fiber } from 'react-reconciler'; + +export const getChildrenFromFiberLL = (fiber: Fiber) => { + const children: Array = []; + + let curr: typeof fiber.child = fiber.child; + + while (curr) { + children.push(curr); + + curr = curr.sibling; + } + + return children; +}; + +type Node = Map< + Fiber, + { children: Array; parent: Fiber | null; isRoot: boolean } +>; + +export const createChildrenAdjacencyList = (root: Fiber) => { + const tree: Node = new Map([]); + + const queue: Array<[node: Fiber, parent: Fiber | null]> = []; + const visited = new Set(); + + queue.push([root, root.return]); + + while (queue.length) { + const [node, parent] = queue.pop()!; + const children = getChildrenFromFiberLL(node); + + tree.set(node, { + children: [], + parent, + isRoot: node === root, + }); + + for (const child of children) { + // this isn't needed since the fiber tree is a TREE, not a graph, but it makes me feel safer + if (visited.has(child)) { + continue; + } + visited.add(child); + tree.get(node)?.children.push(child); + queue.push([child, node]); + } + } + return tree; +}; + +const isProduction: boolean = process.env.NODE_ENV === 'production'; +const prefix: string = 'Invariant failed'; + +// FIX ME THIS IS PRODUCTION INVARIANT LOL +export function devInvariant( + condition: any, + message?: string | (() => string), +): asserts condition { + if (condition) { + return; + } + + if (isProduction) { + throw new Error(prefix); + } + + const provided: string | undefined = + typeof message === 'function' ? message() : message; + + const value: string = provided ? `${prefix}: ${provided}` : prefix; + throw new Error(value); +} + +// yes this is actually a production error, temporary since i test production builds +export const devError = (message: string | undefined) => { + if (isProduction) { + throw new Error(message); + } +}; + +export const iife = (fn: () => T): T => fn(); + +export class BoundedArray extends Array { + constructor(private capacity: number = 25) { + super(); + } + + push(...items: T[]): number { + const result = super.push(...items); + while (this.length > this.capacity) { + this.shift(); + } + return result; + } + + static fromArray(array: Array, capacity: number) { + const arr = new BoundedArray(capacity); + arr.push(...array); + return arr + } +} diff --git a/packages/scan/src/core/monitor/performance.ts b/packages/scan/src/core/monitor/performance.ts index 5bec91b8..26a10e0f 100644 --- a/packages/scan/src/core/monitor/performance.ts +++ b/packages/scan/src/core/monitor/performance.ts @@ -1,11 +1,27 @@ -import { getDisplayName } from 'bippy'; +import { getDisplayName, getTimings } from 'bippy'; import { type Fiber } from 'react-reconciler'; -import { Store } from '../..'; -import { getCompositeComponentFromElement } from '../web/inspect-element/utils'; +import { start, Store } from '../..'; +import { + getCompositeComponentFromElement, + getFiberFromElement, + getParentCompositeFiber, +} from '../web/inspect-element/utils'; import type { + InternalInteraction, PerformanceInteraction, PerformanceInteractionEntry, } from './types'; +import { + BoundedArray, + createChildrenAdjacencyList, + devError, + devInvariant, + iife, +} from 'src/core/monitor/performance-utils'; +import { + MAX_CHANNEL_SIZE, + performanceEntryChannels, +} from 'src/core/monitor/performance-store'; interface PathFilters { skipProviders: boolean; @@ -25,6 +41,7 @@ const DEFAULT_FILTERS: PathFilters = { skipBoundaries: true, }; + const FILTER_PATTERNS = { providers: [/Provider$/, /^Provider$/, /^Context$/], hocs: [/^with[A-Z]/, /^forward(?:Ref)?$/i, /^Forward(?:Ref)?\(/], @@ -93,23 +110,26 @@ export const getInteractionPath = ( fiber: Fiber | null, filters: PathFilters = DEFAULT_FILTERS, ): Array => { - if (!fiber) return []; - - const currentName = getDisplayName(fiber.type); - if (!currentName) return []; + if (!fiber) { + return []; + } const stack = new Array(); - while (fiber.return) { - const name = getCleanComponentName(fiber.type); + let currentFiber = fiber; + while (currentFiber.return) { + const name = getCleanComponentName(currentFiber.type); + if (name && !isMinified(name) && shouldIncludeInPath(name, filters)) { stack.push(name); } - fiber = fiber.return; + currentFiber = currentFiber.return; } + const fullPath = new Array(stack.length); for (let i = 0; i < stack.length; i++) { fullPath[i] = stack[stack.length - i - 1]; } + return fullPath; }; @@ -158,6 +178,20 @@ const getFirstNamedAncestorCompositeFiber = (element: Element) => { return parentCompositeFiber; }; +const getFirstNameFromAncestor = (fiber: Fiber) => { + let curr: Fiber | null = fiber; + + while (curr) { + const currName = getDisplayName(curr.type); + if (currName) { + return currName; + } + + curr = curr.return; + } + return null; +}; + let unsubscribeTrackVisibilityChange: (() => void) | undefined; // fixme: compress me if this stays here for bad interaction time checks let lastVisibilityHiddenAt: number | 'never-hidden' = 'never-hidden'; @@ -175,74 +209,143 @@ const trackVisibilityChange = () => { document.removeEventListener('visibilitychange', onVisibilityChange); }; }; +export type FiberRenders = Map< + Fiber, + { + renderCount: number; + // add self time/ total time later + } +>; + +type InteractionStartStage = { + kind: 'interaction-start'; + interactionType: 'pointer' | 'keyboard'; + rrwebId: number; + interactionUUID: string; + interactionStartDetail: number; + blockingTimeStart: number; + componentPath: Array; + componentName: string; + childrenTree: Record< + string, + { children: Array; firstNamedAncestor: string; isRoot: boolean } + >; + fiberRenders: Record< + string, + { + renderCount: number; + parents: Set; + selfTime: number; + } + >; + stopListeningForRenders: () => void; +}; -// todo: update monitoring api to expose filters for component names -export function initPerformanceMonitoring(options?: Partial) { - const filters = { ...DEFAULT_FILTERS, ...options }; - const monitor = Store.monitor.value; - if (!monitor) return; +type JSEndStage = Omit & { + kind: 'js-end-stage'; + jsEndDetail: number; +}; - document.addEventListener('mouseover', handleMouseover); - const disconnectPerformanceListener = setupPerformanceListener((entry) => { - const target = - entry.target ?? (entry.type === 'pointer' ? currentMouseOver : null); - if (!target) { - // most likely an invariant that we should log if its violated - return; - } - const parentCompositeFiber = getFirstNamedAncestorCompositeFiber(target); - if (!parentCompositeFiber) { - return; - } - const displayName = getDisplayName(parentCompositeFiber.type); - if (!displayName || isMinified(displayName)) { - // invariant, we know its named based on getFirstNamedAncestorCompositeFiber implementation - return; - } - const path = getInteractionPath(parentCompositeFiber, filters); - - monitor.interactions.push({ - componentName: displayName, - componentPath: path, - performanceEntry: entry, - components: new Map(), - url: window.location.toString(), - route: - Store.monitor.value?.route ?? new URL(window.location.href).pathname, - commit: Store.monitor.value?.commit ?? null, - branch: Store.monitor.value?.branch ?? null, - uniqueInteractionId: entry.id, - }); - }); +type RAFStage = Omit & { + kind: 'raf-stage'; + rafStart: number; +}; - return () => { - disconnectPerformanceListener(); - document.removeEventListener('mouseover', handleMouseover); - }; -} +export type TimeoutStage = Omit & { + kind: 'timeout-stage'; + commitEnd: number; + blockingTimeEnd: number; +}; + +export type CompletedInteraction = { + detailedTiming: TimeoutStage; + latency: number; + completedAt: number; + flushNeeded: boolean; +}; +type UnInitializedStage = { + kind: 'uninitialized-stage'; + interactionUUID: string; + interactionType: 'pointer' | 'keyboard'; +}; + +type CurrentInteraction = { + kind: 'pointer' | 'keyboard'; + interactionUUID: string; + pointerUpStart: number; + // needed for when inputs that can be clicked and trigger on change (like checkboxes) + clickChangeStart: number | null; + clickHandlerMicroTaskEnd: number | null; + rafStart: number | null; + commmitEnd: number | null; + timeorigin: number; + + // for now i don't trust performance now timing for UTC time... + blockingTimeStart: number; + blockingTimeEnd: number | null; + fiberRenders: Map< + string, + { + renderCount: number; + parents: Set; + selfTime: number; + } + >; + componentPath: Array; + componentName: string; + childrenTree: Record< + string, + { children: Array; firstNamedAncestor: string; isRoot: boolean } + >; +}; + +export let currentInteractions: Array = []; +export const fastHash = (str: string): string => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return hash.toString(36); +}; const getInteractionType = ( eventName: string, ): 'pointer' | 'keyboard' | null => { - if (['pointerdown', 'pointerup', 'click'].includes(eventName)) { + // todo: track pointer down, but tends to not house expensive logic so not very high priority + if (['pointerup', 'click'].includes(eventName)) { return 'pointer'; } + if (eventName.includes('key')) { + // console.log('event', eventName); + } if (['keydown', 'keyup'].includes(eventName)) { return 'keyboard'; } return null; }; +export const getInteractionId = (interaction: any) => { + return `${interaction.performanceEntry.type}::${normalizePath(interaction.componentPath)}::${interaction.url}`; +}; +export function normalizePath(path: string[]): string { + const cleaned = path.filter(Boolean); + const deduped = cleaned.filter((name, i) => name !== cleaned[i - 1]); + + return deduped.join('.'); +} +let onEntryAnimationId: number | null = null; const setupPerformanceListener = ( onEntry: (interaction: PerformanceInteraction) => void, ) => { trackVisibilityChange(); - const longestInteractionMap = new Map(); + const interactionMap = new Map(); const interactionTargetMap = new Map(); const processInteractionEntry = (entry: PerformanceInteractionEntry) => { - if (!(entry.interactionId || entry.entryType === 'first-input')) return; + if (!entry.interactionId) return; if ( entry.interactionId && @@ -252,7 +355,7 @@ const setupPerformanceListener = ( interactionTargetMap.set(entry.interactionId, entry.target); } - const existingInteraction = longestInteractionMap.get(entry.interactionId); + const existingInteraction = interactionMap.get(entry.interactionId); if (existingInteraction) { if (entry.duration > existingInteraction.latency) { @@ -266,7 +369,9 @@ const setupPerformanceListener = ( } } else { const interactionType = getInteractionType(entry.name); - if (!interactionType) return; + if (!interactionType) { + return; + } const interaction: PerformanceInteraction = { id: entry.interactionId, @@ -275,6 +380,7 @@ const setupPerformanceListener = ( target: entry.target, type: interactionType, startTime: entry.startTime, + endTime: Date.now(), processingStart: entry.processingStart, processingEnd: entry.processingEnd, duration: entry.duration, @@ -291,9 +397,27 @@ const setupPerformanceListener = ( timeOrigin: performance.timeOrigin, referrer: document.referrer, }; - longestInteractionMap.set(interaction.id, interaction); - - onEntry(interaction); + // + interactionMap.set(interaction.id, interaction); + + + /** + * This seems odd, but it gives us determinism that we will receive an entry AFTER our detailed timing collection + * runs because browser semantics (raf(() => setTimeout) will always run before a doubleRaf) + * + * this also handles the case where multiple entries are dispatched for semantically the same interaction, + * they will get merged into a single interaction, where the largest latency is recorded, which is what + * we are interested in this application + */ + + if (!onEntryAnimationId) { + onEntryAnimationId = requestAnimationFrame(() => { + requestAnimationFrame(() => { + onEntry(interactionMap.get(interaction.id)!); + onEntryAnimationId = null; + }); + }); + } } }; @@ -321,3 +445,555 @@ const setupPerformanceListener = ( return () => po.disconnect(); }; + +export const setupPerformancePublisher = () => { + return setupPerformanceListener((entry) => { + + performanceEntryChannels.publish(entry, 'recording'); + }); +}; + +// we should actually only feed it the information it needs to complete so we can support safari +type Task = { + completeInteraction: (entry: PerformanceInteraction) => CompletedInteraction; + startDateTime: number; + endDateTime: number; + type: 'keyboard' | 'pointer'; +}; +export const MAX_INTERACTION_TASKS = 25; + +let tasks = new BoundedArray(MAX_INTERACTION_TASKS); + +const getAssociatedDetailedTimingInteraction = ( + entry: PerformanceInteraction, + activeTasks: Array, +) => { + let closestTask: Task | null = null; + for (const task of activeTasks) { + if (task.type !== entry.type) { + continue; + } + + if (closestTask === null) { + closestTask = task; + continue; + } + + const getAbsoluteDiff = (task: Task, entry: PerformanceInteraction) => + Math.abs(task.startDateTime) - (entry.startTime + entry.timeOrigin); + + if (getAbsoluteDiff(task, entry) < getAbsoluteDiff(closestTask, entry)) { + closestTask = task; + } + } + + return closestTask; +}; + +// this would be cool if it listened for merge, so it had to be after +export const listenForPerformanceEntryInteractions = ( + onComplete: (completedInteraction: CompletedInteraction) => void, +) => { + // we make the assumption that the detailed timing will be ready before the performance timing + const unsubscribe = performanceEntryChannels.subscribe( + 'recording', + (entry: PerformanceInteraction) => { + const associatedDetailedInteraction = + getAssociatedDetailedTimingInteraction(entry, tasks); + + // REMINDME: this likely means we clicked a non interactable thing but our handler still ran + // so we shouldn't treat this as an invariant, but instead use it to verify if we clicked + // something interactable + if (!associatedDetailedInteraction) { + console.log('dropped performance entry'); + + return; + } + // handles the case where we capture an event the browser doesn't + tasks = new BoundedArray(MAX_INTERACTION_TASKS); + performanceEntryChannels.updateChannelState( + 'recording', + (state: BoundedArray) => { + return BoundedArray.fromArray( + BoundedArray.fromArray( + // kill the previous detailed interactions not tracked by performance entry + state.filter( + (item) => + item.startTime + item.timeOrigin > + entry.startTime + entry.timeOrigin, + ), + MAX_CHANNEL_SIZE, + ), + MAX_CHANNEL_SIZE, + ); + }, + ); + const completedInteraction = + associatedDetailedInteraction.completeInteraction(entry); + onComplete(completedInteraction); + }, + ); + + return unsubscribe; +}; + + +type ShouldContinue = boolean; +const trackDetailedTiming = ({ + onMicroTask, + onRAF, + onTimeout, + abort, +}: { + onMicroTask: () => ShouldContinue; + onRAF: () => ShouldContinue; + onTimeout: () => void; + abort?: () => boolean; +}) => { + queueMicrotask(() => { + if (abort?.() === true) { + return; + } + if (!onMicroTask()) { + return; + } + requestAnimationFrame(() => { + if (abort?.() === true) { + return; + } + if (!onRAF()) { + return; + } + setTimeout(() => { + if (abort?.() === true) { + return; + } + onTimeout(); + }, 0); + }); + }); +}; + +const getTargetInteractionDetails = (target: Element) => { + const associatedFiber = getFiberFromElement(target); + if (!associatedFiber) { + return; + } + + // TODO: if element is minified, squash upwards till first non minified ancestor, and set name as ChildOf() + let componentName = associatedFiber + ? getDisplayName(associatedFiber?.type) + : 'N/A'; + + if (!componentName) { + componentName = getFirstNameFromAncestor(associatedFiber) ?? 'N/A'; + } + + if (!componentName) { + return; + } + + const componentPath = getInteractionPath(associatedFiber); + const childrenTree = collectFiberSubtree(associatedFiber); + + return { + componentPath, + childrenTree, + componentName, + }; +}; + +type LastInteractionRef = { + current: ( + | InteractionStartStage + | JSEndStage + | RAFStage + | TimeoutStage + | UnInitializedStage + ) & { stageStart: number }; +}; + +/** + * + * handles tracking event timings for arbitrarily overlapping handlers with cancel logic + */ +export const setupDetailedPointerTimingListener = ( + kind: 'pointer' | 'keyboard', + options: { + onStart?: (interactionUUID: string) => void; + onComplete?: ( + interactionUUID: string, + finalInteraction: { + detailedTiming: TimeoutStage; + latency: number; + completedAt: number; + flushNeeded: boolean; + }, + ) => void; + onError?: (interactionUUID: string) => void; + getNodeID: (node: Node) => number; + }, +) => { + let instrumentationIdInControl: string | null = null; + + const getEvent = ( + info: { phase: 'start' } | { phase: 'end'; target: Element }, + ) => { + switch (kind) { + case 'pointer': { + if (info.phase === 'start') { + return 'pointerup'; + } + if ( + info.target instanceof HTMLInputElement || + info.target instanceof HTMLSelectElement + ) { + return 'change'; + } + return 'click'; + } + case 'keyboard': { + if (info.phase === 'start') { + return 'keydown'; + } + + return 'change'; + } + } + }; + const [startEvent, endEvent] = + kind === 'pointer' ? ['pointerup', 'click'] : ['keydown', 'change']; + + console.log('[Performance] Setting up timing listener', { + kind, + startEvent, + endEvent, + instrumentationIdInControl, + }); + + + // this implementation does not allow for overlapping interactions + // getter/setter for debugging, doesn't do anything functional + const lastInteractionRef: LastInteractionRef = { + // @ts-expect-error + _current: { + kind: 'uninitialized-stage', + interactionUUID: crypto.randomUUID(), // the first interaction uses this + stageStart: Date.now(), + interactionType: kind, + }, + get current() { + // @ts-expect-error + return this._current; + }, + set current(value) { + // @ts-expect-error + this._current = value; + }, + }; + + const onInteractionStart = (e: { target: Element }) => { + + if (Date.now() - lastInteractionRef.current.stageStart > 2000) { + console.log('[Performance] Resetting stale state (>2s old)', { + staleDuration: Date.now() - lastInteractionRef.current.stageStart, + previousUUID: lastInteractionRef.current.interactionUUID, + instrumentationIdInControl, + fullState: lastInteractionRef.current, + }); + lastInteractionRef.current = { + kind: 'uninitialized-stage', + interactionUUID: crypto.randomUUID(), + stageStart: Date.now(), + interactionType: kind, + }; + } + + if (lastInteractionRef.current.kind !== 'uninitialized-stage') { + return; + } + + const pointerUpStart = performance.now(); + + options?.onStart?.(lastInteractionRef.current.interactionUUID); + const details = getTargetInteractionDetails(e.target as HTMLElement); + + if (!details) { + options?.onError?.(lastInteractionRef.current.interactionUUID); + return; + } + + const fiberRenders: InteractionStartStage['fiberRenders'] = {}; + const stopListeningForRenders = listenForRenders(fiberRenders); + lastInteractionRef.current = { + ...lastInteractionRef.current, + interactionType: kind, + blockingTimeStart: Date.now(), + childrenTree: details.childrenTree, + componentName: details.componentName, + componentPath: details.componentPath, + fiberRenders, + kind: 'interaction-start', + interactionStartDetail: pointerUpStart, + rrwebId: options.getNodeID(e.target), + stopListeningForRenders, + }; + + + const event = getEvent({ phase: 'end', target: e.target }); + document.addEventListener(event, onLastJS as any, { + once: true, + }); + + // this is an edge case where a click event is not fired after a pointerdown + // im not sure why this happens, but it seems to only happen on non intractable elements + // it causes the event handler to stay alive until a future interaction, which can break timing (looks super long) + // or invariants (the start metadata was removed, so now its an end metadata with no start) + requestAnimationFrame(() => { + document.removeEventListener(event as any, onLastJS as any); + }); + }; + + document.addEventListener( + getEvent({ phase: 'start' }), + onInteractionStart as any, + { + capture: true, + }, + ); + + const onLastJS = ( + e: { target: Element }, + instrumentationId: string, + abort: () => boolean, + ) => { + + if ( + lastInteractionRef.current.kind !== 'interaction-start' && + instrumentationId === instrumentationIdInControl + ) { + if (kind === 'pointer' && e.target instanceof HTMLSelectElement) { + lastInteractionRef.current = { + kind: 'uninitialized-stage', + interactionUUID: crypto.randomUUID(), + stageStart: Date.now(), + interactionType: kind, + }; + return; + } + + options?.onError?.(lastInteractionRef.current.interactionUUID); + lastInteractionRef.current = { + kind: 'uninitialized-stage', + interactionUUID: crypto.randomUUID(), + stageStart: Date.now(), + interactionType: kind, + }; + devError('pointer -> click'); + return; + } + + instrumentationIdInControl = instrumentationId; + + trackDetailedTiming({ + abort, + onMicroTask: () => { + + if (lastInteractionRef.current.kind === 'uninitialized-stage') { + return false; + } + + lastInteractionRef.current = { + ...lastInteractionRef.current, + kind: 'js-end-stage', + jsEndDetail: performance.now(), + }; + return true; + }, + onRAF: () => { + if ( + lastInteractionRef.current.kind !== 'js-end-stage' && + lastInteractionRef.current.kind !== 'raf-stage' + ) { + console.log('[Performance] Invalid state in RAF', { + currentStage: lastInteractionRef.current.kind, + expectedStage: 'js-end-stage', + interactionUUID: lastInteractionRef.current.interactionUUID, + instrumentationIdInControl, + fullState: lastInteractionRef.current, + }); + options?.onError?.(lastInteractionRef.current.interactionUUID); + devError('bad transition to raf'); + lastInteractionRef.current = { + kind: 'uninitialized-stage', + interactionUUID: crypto.randomUUID(), + stageStart: Date.now(), + interactionType: kind, + }; + return false; + } + + lastInteractionRef.current = { + ...lastInteractionRef.current, + kind: 'raf-stage', + rafStart: performance.now(), + }; + + return true; + }, + onTimeout: () => { + if (lastInteractionRef.current.kind !== 'raf-stage') { + console.log('[Performance] Invalid state in timeout', { + currentStage: lastInteractionRef.current.kind, + expectedStage: 'raf-stage', + interactionUUID: lastInteractionRef.current.interactionUUID, + instrumentationIdInControl, + fullState: lastInteractionRef.current, + }); + options?.onError?.(lastInteractionRef.current.interactionUUID); + lastInteractionRef.current = { + kind: 'uninitialized-stage', + interactionUUID: crypto.randomUUID(), + stageStart: Date.now(), + interactionType: kind, + }; + devError('raf->timeout'); + return; + } + const now = Date.now(); + const timeoutStage: TimeoutStage = Object.freeze({ + ...lastInteractionRef.current, + kind: 'timeout-stage', + blockingTimeEnd: now, + commitEnd: performance.now(), + }); + + lastInteractionRef.current = { + kind: 'uninitialized-stage', + interactionUUID: crypto.randomUUID(), + stageStart: now, + interactionType: kind, + }; + + + // this has to be the problem, i don't get how it's happening but timeOutStage is being mutated + tasks.push({ + completeInteraction: (entry) => { + console.log('[Performance] Completing interaction', { + interactionUUID: timeoutStage.interactionUUID, + latency: entry.latency, + completedAt: Date.now(), + instrumentationIdInControl, + fullState: timeoutStage, + }); + + const finalInteraction = { + detailedTiming: timeoutStage, + latency: entry.latency, + completedAt: Date.now(), + flushNeeded: true, + }; + options?.onComplete?.(timeoutStage.interactionUUID, finalInteraction); + + return finalInteraction; + }, + endDateTime: Date.now(), + startDateTime: timeoutStage.blockingTimeStart, + type: kind, + }); + }, + }); + }; + + // const onBase = (e: { target: Element }) => { + // const id = crypto.randomUUID(); + // onLastJS(e, id, () => id !== instrumentationIdInControl); + // }; + // i forgot why this was needed, i needed to remember and document it + // something about that event only sometimes firing, but it being later? I think? + const onKeyPress = (e: { target: Element }) => { + const id = crypto.randomUUID(); + onLastJS(e, id, () => id !== instrumentationIdInControl); + }; + + // document.addEventListener(getEvent({ phase: "start" }), onBase as any); + if (kind === 'keyboard') { + document.addEventListener('keypress', onKeyPress as any); + } + + return () => { + document.removeEventListener( + getEvent({ phase: 'start' }), + onInteractionStart as any, + { + capture: true, + }, + ); + // document.removeEventListener(getEvent({}), onLastJS as any); + document.removeEventListener('keypress', onKeyPress as any); + }; +}; + +const collectFiberSubtree = (fiber: Fiber) => { + const adjacencyList = createChildrenAdjacencyList(fiber).entries(); + const fiberToNames = Array.from(adjacencyList).map( + ([fiber, { children, parent, isRoot }]) => [ + getDisplayName(fiber.type) ?? 'N/A', + { + children: children.map((fiber) => getDisplayName(fiber.type) ?? 'N/A'), + firstNamedAncestor: parent + ? (getFirstNameFromAncestor(parent) ?? 'No Parent') + : 'No Parent', + isRoot, + }, + ], + ); + + return Object.fromEntries(fiberToNames); +}; + +const listenForRenders = ( + fiberRenders: InteractionStartStage['fiberRenders'], +) => { + const listener = (fiber: Fiber) => { + const displayName = getDisplayName(fiber.type); + if (!displayName) { + return; + } + const existing = fiberRenders[displayName]; + if (!existing) { + const parents = new Set(); + const parentCompositeName = getDisplayName( + getParentCompositeFiber(fiber), + ); + if (parentCompositeName) { + parents.add(parentCompositeName); + } + const { selfTime, totalTime } = getTimings(fiber); + fiberRenders[displayName] = { + renderCount: 1, + parents: parents, + selfTime, + }; + return; + } + const parentType = getParentCompositeFiber(fiber)?.[0]?.type; + if (parentType) { + const parentCompositeName = getDisplayName(parentType); + if (parentCompositeName) { + existing.parents.add(parentCompositeName); + } + } + const { selfTime } = getTimings(fiber); + existing.renderCount += 1; + existing.selfTime += selfTime; + }; + // todo: we need a general listener + Store.monitor.value!.interactionListeningForRenders = listener; + + return () => { + if (Store.monitor.value?.interactionListeningForRenders === listener) { + Store.monitor.value.interactionListeningForRenders = null; + } + }; +}; diff --git a/packages/scan/src/core/monitor/session-replay/record.ts b/packages/scan/src/core/monitor/session-replay/record.ts new file mode 100644 index 00000000..f14a2737 --- /dev/null +++ b/packages/scan/src/core/monitor/session-replay/record.ts @@ -0,0 +1,1046 @@ +import { eventWithTime } from "@rrweb/types"; +import { OutlineKey, ReactScanInternals } from "../.."; +import { Fiber } from "react-reconciler"; +import { createInstrumentation, listenToFps } from "../../instrumentation"; +import { getDisplayName, getNearestHostFiber, getTimings } from "bippy"; +import { onIdle } from "@web-utils/helpers"; +import { + CompletedInteraction, + listenForPerformanceEntryInteractions, + setupDetailedPointerTimingListener, + setupPerformancePublisher, + TimeoutStage, +} from "src/core/monitor/performance"; +import { + MAX_CHANNEL_SIZE, + performanceEntryChannels, +} from "src/core/monitor/performance-store"; +import { + BoundedArray, + devInvariant, + iife, +} from "src/core/monitor/performance-utils"; +import { PerformanceInteraction } from "src/core/monitor/types"; +import { + interactionStore, + MAX_INTERACTION_BATCH, +} from "src/core/monitor/interaction-store"; +import { + convertInteractionFiberRenderParents, + InteractionWithArrayParents, +} from "../network"; + +export let rrwebEvents: Array = []; +let timing: { startTime: number } = { + startTime: Date.now(), +}; + +const PLUGIN = 6; + +type BufferedScheduledOutline = { + renderCount: number; + selfTime: number; + fiber: Fiber; +}; + +const bufferedFiberRenderMap = new Map(); + +const addToBufferedFiberRenderMap = (fiber: Fiber) => { + const fiberId = getFiberId(fiber); + const existing = bufferedFiberRenderMap.get(fiberId); + const { selfTime } = getTimings(fiber); + + if (!existing) { + bufferedFiberRenderMap.set(fiberId, { + renderCount: 1, + selfTime: selfTime, + fiber, + }); + return; + } + + bufferedFiberRenderMap.set(fiberId, { + renderCount: existing.renderCount + 1, + selfTime: existing.selfTime + selfTime, + fiber, + }); +}; + +export const makeEvent = ( + payload: unknown, + plugin?: string +): eventWithTime => ({ + type: PLUGIN, + data: { + plugin: plugin ?? "react-scan-plugin", + payload, + }, + timestamp: Date.now(), +}); +type listenerHandler = () => void; +let recordingInstance: listenerHandler | undefined = undefined; +let record: (typeof import("rrweb"))["record"] | null = null; + +export const getEvents = () => rrwebEvents; + +const initRRWeb = async () => { + if (!record) { + const rrwebPkg = await import("rrweb"); + record = rrwebPkg.record; + } + return record; +}; + +export const startNewRecording = async () => { + console.log("new recording start"); + + lastRecordTime = Date.now(); + + const recordFn = await initRRWeb(); + + if (recordingInstance) { + recordingInstance(); + } + rrwebEvents.length = 0; + timing.startTime = Date.now(); + + recordingInstance = recordFn({ + emit: (event: eventWithTime) => { + rrwebEvents.push(event); + }, + }); +}; +type ReplayAggregatedRender = { + name: ComponentName; + frame: number | null; + computedKey: OutlineKey | null; + aggregatedCount: number; +}; + +type FiberId = number; + +type ComponentName = string; +export type ReactScanPayload = Array<{ + rrwebDomId: number; + renderCount: number; + fiberId: number; + componentName: string; + + selfTime: number; + + groupedAggregatedRender?: Map; + + /* Rects for interpolation */ + current?: DOMRect; + target?: DOMRect; + /* This value is computed before the full rendered text is shown, so its only considered an estimate */ + estimatedTextWidth?: number; // todo: estimated is stupid just make it the actual + + alpha?: number | null; + totalFrames?: number | null; +}>; + +let lastFiberId = 0; +const fiberIdMap = new Map(); +const getFiberId = (fiber: Fiber) => { + const existing = fiberIdMap.get(fiber); + + const inc = () => { + lastFiberId++; + fiberIdMap.set(fiber, lastFiberId); + + return lastFiberId; + }; + if (existing) { + return inc(); + } + + if (fiber.alternate) { + const existing = fiberIdMap.get(fiber.alternate); + if (existing) { + return inc(); + } + } + + lastFiberId++; + + fiberIdMap.set(fiber, lastFiberId); + + return lastFiberId; +}; + +const makeReactScanPayload = ( + buffered: Map +): ReactScanPayload => { + const payload: ReactScanPayload = []; + buffered.forEach(({ renderCount, fiber, selfTime }) => { + const stateNode = getNearestHostFiber(fiber)?.stateNode; + if (!stateNode) { + return; + } + + const rrwebId = record!.mirror.getId(stateNode); + if (rrwebId === -1) { + return; + } + + payload.push({ + renderCount, + rrwebDomId: rrwebId, + componentName: getDisplayName(fiber.type) ?? "N/A", + fiberId: getFiberId(fiber), + selfTime, + }); + }); + return payload; +}; + +let interval: ReturnType; +const startFlushRenderBufferInterval = () => { + clearInterval(interval); + interval = setInterval(() => { + if (bufferedFiberRenderMap.size === 0) { + return; + } + + const payload = makeReactScanPayload(bufferedFiberRenderMap); + + rrwebEvents.push(makeEvent(payload)); + bufferedFiberRenderMap.clear(); + }, 75); +}; + +const fpsDiffs: Array<[fps: number, changedAt: number]> = []; + +let lastInteractionTime: null | number = null; + +type ReactScanMetaPayload = + | { + kind: "interaction-start"; + interactionUUID: string; + dateNowTimestamp: number; + } + | { + kind: "interaction-end"; + interactionUUID: string; + dateNowTimestamp: number; + memoryUsage: number | null; + } + | { + kind: "fps-update"; + dateNowTimestamp: number; + fps: number; + }; + +export const makeMetaEvent = (payload: ReactScanMetaPayload) => + makeEvent(payload, "react-scan-meta"); + +export type SerializableFlushPayload = Omit & { + interactions: Array; +}; + +type FlushPayload = { + events: Array; + interactions: Array; + fpsDiffs: any; + startAt: number; + completedAt: number; +}; + +export const flushInvariant = (payload: SerializableFlushPayload) => { + // devInvariant( + // payload.events[0].type === 4, + // "first rrweb event must be meta event" + // ); + if (payload.events[0].type !== 4) { + console.warn("hm", payload); + } + // todo: reset state when invariant is violated + payload.interactions.forEach(() => { + const startEndEvents = payload.events.filter((event) => { + if (event.type !== 6) { + return; + } + if (event.data.plugin !== "react-scan-meta") { + return; + } + const payload = event.data.payload as ReactScanMetaPayload; + if (payload.kind === "fps-update") { + return; + } + return true; + }); + + const eventMapping = new Map<`${string}$${string}`, number>(); + + startEndEvents.forEach((value) => { + const data = (value.data as any).payload as ReactScanMetaPayload; + if (data.kind === "fps-update") { + return; + } + + let existingCount = eventMapping.get( + `${data.kind}$${data.interactionUUID}` + ); + if (existingCount) { + eventMapping.set( + `${data.kind}$${data.interactionUUID}`, + existingCount + 1 + ); + return; + } + + eventMapping.set(`${data.kind}$${data.interactionUUID}`, 1); + }); + + console.log("Event mapping data", eventMapping); + + eventMapping.forEach((value, key) => { + // devInvariant(value === 1); + const [kind, uuid] = key.split("$"); + + const associatedKind = + kind === "interaction-start" ? "interaction-end" : "interaction-start"; + + const associatedEvent = eventMapping.get(`${associatedKind}$${uuid}`); + }); + }); +}; + +/** + * we push a task to flush the recording + * + * that means we should use the store and channels + */ + +const recordingTasks: Array<{ + flush: () => void; + interactionUUIDs: Array; +}> = []; + +// this function handles flushing any tasks that are ready to be sent +const flushUploadedRecordingTasks = (tasks: typeof recordingTasks) => { + const completedInteractions = ( + performanceEntryChannels.getChannelState("flushed-interactions") as Array< + Array + > + ).flat(); + + tasks.forEach(({ interactionUUIDs, flush }) => { + if ( + interactionUUIDs.every((uuid) => completedInteractions.includes(uuid)) + ) { + // no longer needed, so we can clean them up from the channel state + performanceEntryChannels.updateChannelState( + "flushed-interactions", + (state: BoundedArray>) => { + return BoundedArray.fromArray( + state.map((inner) => + BoundedArray.fromArray( + inner.filter((uuid) => !interactionUUIDs.includes(uuid)), + MAX_CHANNEL_SIZE + ) + ), + MAX_CHANNEL_SIZE + ); + } + ); + + flush(); + } + }); +}; + +const setupFlushedInteractionsListener = () => { + return performanceEntryChannels.subscribe("flushed-interactions", () => { + flushUploadedRecordingTasks(recordingTasks); + }); +}; + +const getMemoryMB = () => { + if (!("memory" in performance)) { + return; + } + try { + // @ts-expect-error + if (performance?.memory?.usedJSHeapSize > 0) { + // @ts-expect-error + return Math.round(performance.memory.usedJSHeapSize / 1048576); + } + return null; + } catch { + return null; + } +}; + +export const scanWithRecord = () => { + const unSubPerformance = setupPerformancePublisher(); + const unSubFlushedInteractions = setupFlushedInteractionsListener(); + const onStart = (interactionUUID: string) => { + rrwebEvents.push( + makeMetaEvent({ + kind: "interaction-start", + interactionUUID, + dateNowTimestamp: Date.now(), // should use performance now but easier to get that wrong, so that's a future optimization + }) + ); + console.log("pushed rrweb start event", rrwebEvents); + countMeta(rrwebEvents); + }; + + const onComplete = async ( + interactionUUID: string, + finalInteraction: { + detailedTiming: TimeoutStage; + latency: number; + completedAt: number; + flushNeeded: boolean; + } + ) => { + lastInteractionTime = Date.now(); + const existingCompletedInteractions = + performanceEntryChannels.getChannelState( + "recording" + ) as Array; + + finalInteraction.detailedTiming.stopListeningForRenders(); + + rrwebEvents.push( + makeMetaEvent({ + kind: "interaction-end", + interactionUUID, + dateNowTimestamp: Date.now(), // should use performance now but easier to get that wrong, so that's a future optimization + memoryUsage: getMemoryMB() ?? null, + }) + ); + + console.log("pushed rrweb end event", rrwebEvents); + countMeta(rrwebEvents); + if (existingCompletedInteractions.length) { + // then performance entry and our detailed timing handlers are out of sync, we disregard that entry + // it may be possible the performance entry returned before detailed timing. If that's the case we should update + // assumptions and deal with mapping the entry back to the detailed timing here + performanceEntryChannels.updateChannelState( + "recording", + () => new BoundedArray(MAX_CHANNEL_SIZE) + ); + } + }; + const unSubDetailedPointerTiming = setupDetailedPointerTimingListener( + "pointer", + { + onStart, + onComplete, + getNodeID: (node) => record?.mirror.getId(node)!, + } + ); + const unSubDetailedKeyboardTiming = setupDetailedPointerTimingListener( + "keyboard", + { + onStart, + onComplete, + getNodeID: (node) => record?.mirror.getId(node)!, + } + ); + + const unSubFps = listenToFps((fps) => { + const event = makeMetaEvent({ + kind: "fps-update", + dateNowTimestamp: Date.now(), + fps, + }); + + rrwebEvents.push(event); + logFPSUpdates(rrwebEvents); + }); + const unSubInteractions = listenForPerformanceEntryInteractions( + (completedInteraction) => { + interactionStore.setState( + BoundedArray.fromArray( + interactionStore.getCurrentState().concat(completedInteraction), + MAX_INTERACTION_BATCH + ) + ); + countMeta(rrwebEvents); + } + ); + + startNewRecording(); + + startFlushRenderBufferInterval(); + + ReactScanInternals.instrumentation = createInstrumentation( + "react-scan-session-replay", + { + onRender: (fiber) => { + addToBufferedFiberRenderMap(fiber); + }, + isValidFiber: () => true, + + onCommitStart() { + ReactScanInternals.options.value.onCommitStart?.(); + }, + onCommitFinish() { + ReactScanInternals.options.value.onCommitFinish?.(); + }, + + onError: () => {}, + trackChanges: false, + forceAlwaysTrackRenders: true, + } + ); + + const stopInterval = setDebouncedInterval( + () => { + countMeta(rrwebEvents, "prequeue flush start"); + logFPSUpdates(rrwebEvents); + console.log( + "[Interval Callback]: before filters", + interactionStore.getCurrentState(), + rrwebEvents + ); + rrwebEvents = removeUnusableMetadata(rrwebEvents); + countMeta(rrwebEvents, "post filter pre quue flush start"); + interactionStore.setState( + BoundedArray.fromArray( + removeUnusableInteractions( + interactionStore.getCurrentState(), + rrwebEvents[0].timestamp + ), + MAX_INTERACTION_BATCH + ) + ); + console.log( + "[Interval Callback]: running", + rrwebEvents, + interactionStore.getCurrentState() + ); + + queueFlush(); + }, + () => { + const now = Date.now(); + rrwebEvents = removeOldUnmatchedInteractionsFromMetadata(rrwebEvents); + removeInteractionsBeforeRecordingStart(); + if (!rrwebEvents.length) { + console.log("[Interval Early Return]: no events"); + + // at minimum we want to wait for 3 seconds before and after the clip, so we should debounce for >6000ms + return 7000; + } /** + * NOTE: this may seem incorrect since the session replay is lastEventTimeStamp - firstEventTimestamp long, but + * we will fill the time difference with white space in puppeteer. It's only valid to add this white space if + * we are sure there is inactivity, and in this case we are since the difference between Date.now() and last event + * is the inactive time + */ + const recordStart = rrwebEvents[0].timestamp; + console.log("the first event is", rrwebEvents[0]); + + if (now - recordStart > 25_000) { + onIdle(() => { + startNewRecording(); + }); + + return false; + } + if (!lastInteractionTime) { + console.log("[Interval Early Return]: no interaction"); + return 7000; + } + + if (now - lastInteractionTime < 2000) { + console.log("[Interval Early Return]: interaction too close"); + return 5000; + } + // always flush if the recording is 25 seconds old + + // if we can flush at 15 seconds we wil, but if an interaction happens at the end + // of the recording we will keep pushing back the recording till it hits 25 seconds + if (now - recordStart > 15_000) { + console.log("time since record", now - recordStart); + + if (now - lastInteractionTime < 3000) { + console.log("[Interval Early Return]: push window back 3 seconds"); + return 3000; + } + + // ; + + return false; + } + + // it's possible an interaction is on going since the main thread could be unblocked before the frame is drawn + // if instead there are interaction details on a fake interaction, then the GC at the start of the interval will clean it up in due time + const ongoingInteraction = iife(() => { + let startCount = 0; + let endCount = 0; + for (const event of rrwebEvents) { + ifScanMeta(event, (payload) => { + switch (payload.kind) { + case "interaction-start": { + startCount++; + return; + } + case "interaction-end": { + endCount++; + return; + } + } + }); + } + return startCount !== endCount; + }); + + if (ongoingInteraction) { + console.log("[Interval Early Return]: ongoing interaction"); + return 4000; + } + + const timeSinceLastInteractionComplete = now - lastInteractionTime; + if (timeSinceLastInteractionComplete < 4000) { + console.log( + "[Interval Early Return]: capturing more of end window for clip" + ); + return timeSinceLastInteractionComplete; + } + + console.log("[Interval Early Return]: just flush then"); + return false; + } + ); + + return () => { + unSubPerformance(); + unSubFlushedInteractions(); + unSubDetailedPointerTiming(); + unSubFps(); + unSubInteractions(); + unSubFlushedInteractions(); + stopInterval(); + unSubDetailedKeyboardTiming(); + }; +}; + +const logFPSUpdates = (events: Array) => { + // console.group("fps-updates"); + // const fps = events.filter((event) => { + // if (event.type !== 6 || event.data.plugin !== "react-scan-meta") { + // return; + // } + // const payload = event.data.payload as ReactScanMetaPayload; + // if (payload.kind === "fps-update") { + // console.log(event); + // return true; + // } + // }); + // if (fps.length === 0) { + // console.log("no fps updates"); + // } + // console.groupEnd(); +}; + +// todo, slight race in timing when really close to start or end, so should give leniancy in the race +const logVideoAndInteractionMetadata = ( + interactions: Array, + events: Array +) => { + console.group("log-video-meta"); + + const start = events[0].timestamp; + console.log("video is:", events.at(-1)?.timestamp! - start, "ms long"); + console.log("inactivity time is:", Date.now() - events.at(-1)?.timestamp!); + console.log("the first event is", events[0]); + console.log( + "just incase, here is the delay", + events[0].delay, + events.at(-1)?.delay + ); + + interactions.forEach((interaction, index) => { + console.log( + "The", + index + 1, + "interaction occurred at", + interaction.completedAt - start, + "ms in the video" + ); + }); + + console.groupEnd(); +}; + +// turn this into a recursive timeout so we can run onIdle and then call 10 seconds later +let lastRecordTime: number = Date.now(); // this is a valid initializer since it represents the earliest time flushing was possible, and lets us calculate the amount of time has passed since it's been possible to flush +type DebounceTime = number; + +export const ifScanMeta = ( + event: eventWithTime, + then?: (payload: ReactScanMetaPayload) => T, + otherwise?: R +): T | R | undefined => { + if (event.type === 6 && event.data.plugin === "react-scan-meta") { + return then?.(event.data.payload as ReactScanMetaPayload); + } + + return otherwise; +}; +export const removeUnusableInteractions = ( + interactions: Array, + recordStart: number +) => { + return interactions.filter((interaction) => { + if (interaction.completedAt < recordStart) { + console.log( + "[Interaction Filter]: interaction happened before recording", + interaction, + recordStart + ); + + return false; + } + + if (interaction.completedAt - recordStart < 2000) { + console.log( + "[Interaction Filter]: interaction happened less than 2 seconds from start", + interaction, + recordStart + ); + return false; + } + if (Date.now() - interaction.completedAt < 2000) { + console.log( + "[Interaction Filter]: interaction happened less than 2 seconds from the current point in time", + interaction, + recordStart + ); + return false; + } + return true; + }); +}; + +const eventIsUnmatched = ( + event: eventWithTime, + events: Array +) => { + if (event.type !== 6 || event.data.plugin !== "react-scan-meta") { + return false; + } + + const payload = event.data.payload as ReactScanMetaPayload; + if (payload.kind === "fps-update") { + return false; + } + + const hasMatch = events.some((matchSearchEvent) => { + if ( + matchSearchEvent.type !== 6 || + matchSearchEvent.data.plugin !== "react-scan-meta" + ) { + return false; + } + const matchSearchPayload = matchSearchEvent.data + .payload as ReactScanMetaPayload; + + switch (payload.kind) { + case "interaction-start": { + if (matchSearchPayload.kind !== "interaction-end") { + return false; + } + + if (matchSearchPayload.interactionUUID !== payload.interactionUUID) { + return false; + } + return true; + } + // a valid example an interaction end might go unmatched is: + // very long task -> record force restart -> interaction end + // now there is an interaction end not matched to a start + case "interaction-end": { + if (matchSearchPayload.kind !== "interaction-start") { + return false; + } + + if (matchSearchPayload.interactionUUID !== payload.interactionUUID) { + return false; + } + return true; + } + } + }); + + return !hasMatch; +}; + +export const removeOldUnmatchedInteractionsFromMetadata = ( + events: Array +) => { + return events.filter((event) => { + // don't inline makes it easier to read + if ( + Date.now() - event.timestamp > 3000 && + eventIsUnmatched(event, events) + ) { + if (event.type !== 6) { + return true; + } + if (event.data.plugin !== "react-scan-meta") { + return true; + } + const payload = event.data.payload as ReactScanMetaPayload; + if (payload.kind === "fps-update") { + return true; + } + console.log( + `[Remove Old Unmatched] Removing ${payload.kind} for interaction ${payload.interactionUUID} - ` + + `${Date.now() - event.timestamp}ms old and unmatched` + ); + return false; + } + return true; + }); +}; + +export const removeInteractionsBeforeRecordingStart = () => { + const recordingStart = rrwebEvents.at(0)?.timestamp; + if (!recordingStart) { + return; + } + interactionStore.setState( + BoundedArray.fromArray( + interactionStore + .getCurrentState() + .filter((interaction) => interaction.completedAt >= recordingStart), + MAX_INTERACTION_BATCH + ) + ); + if (lastInteractionTime && lastInteractionTime < recordingStart) { + lastInteractionTime = null; + } +}; + +export const removeUnusableMetadata = (events: Array) => { + // me must make sure to unconditionally removed the associated end if the start is removed + const removedInteractions: Array = []; + + const recordStart = events.at(0)?.timestamp; + + if (recordStart === undefined) { + return []; + } + + events.forEach((event) => { + if (event.type !== 6) { + return; + } + + if (event.data.plugin !== "react-scan-meta") { + return; + } + const payload = event.data.payload as ReactScanMetaPayload; + if (payload.kind === "fps-update") { + return; + } + + if (payload.kind !== "interaction-start") { + return; + } + + if (Date.now() - payload.dateNowTimestamp < 2000) { + console.log( + `[Removal] Interaction ${payload.interactionUUID} removed - Too recent (${Date.now() - payload.dateNowTimestamp}ms from now)` + ); + // then its too close to the end of the interaction + removedInteractions.push(payload.interactionUUID); + return; + } + + if (payload.dateNowTimestamp - recordStart <= 2000) { + // then its too close to the start of the interaction + removedInteractions.push(payload.interactionUUID); + return; + } + }); + + const eventsFilteredForTimeInvariants = events.filter((event) => { + if (event.type !== 6) { + return true; + } + + if (event.data.plugin !== "react-scan-meta") { + return true; + } + + const payload = event.data.payload as ReactScanMetaPayload; + + if (payload.kind === "fps-update") { + return true; + } + if (removedInteractions.includes(payload.interactionUUID)) { + return false; + } + + return true; + }); + + countMeta(eventsFilteredForTimeInvariants, "first filter"); + const alwaysMatchingMetadata = eventsFilteredForTimeInvariants.filter( + (event) => { + const matches = !eventIsUnmatched(event, eventsFilteredForTimeInvariants); + if ( + !matches && + event.type === 6 && + event.data.plugin === "react-scan-meta" && + (event.data.payload as ReactScanMetaPayload).kind !== "fps-update" + ) { + } + return matches; + } + ); + countMeta(alwaysMatchingMetadata, "second filter"); + + return alwaysMatchingMetadata; +}; + +const countMeta = (events: Array, message?: string) => { + const count = { + start: 0, + end: 0, + raw: [] as any[], + }; + events.forEach((e) => { + ifScanMeta(e, (payload) => { + if (payload.kind === "interaction-start") { + count.start += 1; + count.raw.push(payload); + return; + } + if (payload.kind === "interaction-end") { + count.end += 1; + count.raw.push(payload); + return; + } + }); + }); +}; + +let currentTimeOut: ReturnType; +export const setDebouncedInterval = ( + callback: () => void, + shouldDebounce: () => DebounceTime | false, + baseTime = 4000 +) => { + const debounce = shouldDebounce(); + + if (debounce === false) { + callback(); + currentTimeOut = setTimeout(() => { + setDebouncedInterval(callback, shouldDebounce, baseTime); + }, baseTime); + return () => { + clearTimeout(currentTimeOut); + }; + } + currentTimeOut = setTimeout(() => { + setDebouncedInterval(callback, shouldDebounce, debounce); + }, debounce); + + return () => { + clearTimeout(currentTimeOut); + }; +}; + +export const queueFlush = async () => { + try { + let fpsUpdateCount = 0; + for (const event of rrwebEvents) { + if (event.type === 6 && event.data.plugin === "react-scan-meta") { + const payload = event.data.payload as ReactScanMetaPayload; + if (payload.kind === "fps-update") { + fpsUpdateCount++; + } + } + } + + countMeta(rrwebEvents, "queue flush start"); + const completedInteractions = interactionStore.getCurrentState(); + if (!completedInteractions.length) { + return; + } + + if (!rrwebEvents.length) { + return; + } + + const payload: SerializableFlushPayload = { + events: rrwebEvents, + interactions: completedInteractions.map( + convertInteractionFiberRenderParents + ), + fpsDiffs: [...fpsDiffs], + // should verify lastRecordTime is always correct + startAt: rrwebEvents[0].timestamp, + completedAt: Date.now(), + }; + logVideoAndInteractionMetadata(payload.interactions, payload.events); + + try { + flushInvariant(payload); + } catch (e) { + console.error(e); + } + const body = JSON.stringify(payload); + const completedUUIDS = payload.interactions.map( + (interaction) => interaction.detailedTiming.interactionUUID + ); + fpsDiffs.length = 0; + logFPSUpdates(rrwebEvents); + recordingTasks.push({ + flush: () => { + console.debug("[Recording] Sending payload to server"); + fetch("http://localhost:4200/api/replay", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body, + }).catch(() => null); + + interactionStore.setState( + BoundedArray.fromArray( + interactionStore + .getCurrentState() + .filter( + (interaction) => + !completedUUIDS.includes( + interaction.detailedTiming.interactionUUID + ) + ), + MAX_INTERACTION_BATCH + ) + ); + }, + interactionUUIDs: payload.interactions.map( + (interaction) => interaction.detailedTiming.interactionUUID + ), + }); + // we continue recording as there's benefit to continue recording for as long as possible + // but we no longer need to keep the interaction metadata related to pending flushed interactions + rrwebEvents = rrwebEvents.filter((event) => { + if (event.type !== 6 || event.data.plugin !== "react-scan-meta") { + return true; + } + const payload = event.data.payload as ReactScanMetaPayload; + if (payload.kind === "fps-update") { + return true; + } + + return !completedUUIDS.includes(payload.interactionUUID); + }); + + flushUploadedRecordingTasks(recordingTasks); + interactionStore.setState(new BoundedArray(MAX_INTERACTION_BATCH)); + } catch (e) { + console.error("[Recording] Error during flush:", e); + } +}; diff --git a/packages/scan/src/core/monitor/session-replay/replay-v2.ts b/packages/scan/src/core/monitor/session-replay/replay-v2.ts new file mode 100644 index 00000000..b8a2a00b --- /dev/null +++ b/packages/scan/src/core/monitor/session-replay/replay-v2.ts @@ -0,0 +1,1329 @@ +// todos + +import { + applyLabelTransform, + batchGetBoundingRects, + flushOutlines, + getBoundingRect, + getIsOffscreen, + getOverlapArea, + measureTextCached, + mergeOverlappingLabels, + Outline, + OutlineLabel, + pickColorClosestToStartStage, +} from '@web-utils/outline'; +import { OutlineKey } from 'src/core'; +import { ReactScanPayload } from 'src/core/monitor/session-replay/record'; + +// copy activate outline exactly + +// goal is to map the input datastructure what to schedueld outlines looks +// like exactly + +/** + * then we have a new scheduled outlines datastructure + * + * + * that datastructure will be plopped into the activateOutline function + * + * + * then we just reply fiber alternate stuff with the actual reference + * + * + * we collect the dom nodes for the scheduled outlines first + * + * + * then we simply just use exact naming and swap + * + * + * then we chop the properties we don't need + * + * + * then later we can optimize for the use case if some things aren't needed + */ + +type FiberId = number; + +type ComponentName = string; +export interface ReplayOutline { + timestamp: number; + domNodeId: number; + /** Aggregated render info */ // TODO: Flatten AggregatedRender into Outline to avoid re-creating objects + // this render is useless when in active outlines (confirm this rob) + aggregatedRender: ReplayAggregatedRender; // maybe we should set this to null when its useless + + /* Active Info- we re-use the Outline object to avoid over-allocing objects, which is why we have a singular aggregatedRender and collection of it (groupedAggregatedRender) */ + alpha: number | null; + totalFrames: number | null; + /* + - Invariant: This scales at a rate of O(unique components rendered at the same (x,y) coordinates) + - renders with the same x/y position but different fibers will be a different fiber -> aggregated render entry. + */ + groupedAggregatedRender: Map | null; + + /* Rects for interpolation */ + current: DOMRect | null; + target: DOMRect | null; + /* This value is computed before the full rendered text is shown, so its only considered an estimate */ + estimatedTextWidth: number | null; // todo: estimated is stupid just make it the actual +} +export interface ReplayAggregatedRender { + name: ComponentName; + frame: number | null; + // phase: Set<'mount' | 'update' | 'unmount'>; + time: number | null; // maybe... + aggregatedCount: number; + // forget: boolean; + // changes: AggregatedChange; + // unnecessary: boolean | null; + // didCommit: boolean; + // fps: number; + + computedKey: OutlineKey | null; + computedCurrent: DOMRect | null; // reference to dom rect to copy over to new outline made at new position +} + +const MAX_FRAME = 45; +type ScheduledReplayOutlines = Map; + +// const payloadToScheduledReplayOutlines = ( +// payload: ReactScanPayload, +// replayer: { +// getMirror: () => { getNode: (nodeId: number) => HTMLElement | null }; +// }, +// ): ScheduledReplayOutlines => { +// const scheduledReplayOutlines = new Map(); + +// for (const event of payload) { +// const domNode = replayer.getMirror().getNode(event.rrwebDomId); +// if (!domNode) { +// continue; +// } +// scheduledReplayOutlines.set(event.fiberId, { +// aggregatedRender: { +// aggregatedCount: event.renderCount, +// computedCurrent: null, +// computedKey: null, +// frame: 45, +// name: event.componentName, +// time: null, +// }, +// alpha: null, +// groupedAggregatedRender: null, +// target: null, +// current: null, +// totalFrames: null, +// estimatedTextWidth: null, +// domNode, +// }); +// } + +// return scheduledReplayOutlines; +// }; + +// const activateOutlines = async (scheduledOutlines: ScheduledReplayOutlines) => { +// }; + +/** + * + * now we can transform the payload into scheduled, and scheduled into active + * + * + * now we should make this all a plugin so we can actually access the replayer + * + * + * there will need to be an interval etc all globals tied to the plugin, not the global scope anymore + * + * + * so we init on the plugin, and replay like a boss + */ +import { EventType, eventWithTime } from '@rrweb/types'; +import { ReplayPlugin } from 'rrweb/typings/types'; +import { ReactScanInternals } from '../..'; +import { signal } from '@preact/signals'; +import { getLabelText } from 'src/core/utils'; +import { LRUMap } from '@web-utils/lru'; + +/** + * Deep clones an object, skipping non-cloneable values like functions, DOM nodes, etc. + * @param obj The object to clone + * @returns A deep clone of the object with non-cloneable values omitted + */ +export const safeDeepClone = (obj: T): T => { + if (obj === null || obj === undefined) { + return obj; + } + + // Handle primitive types + if (typeof obj !== 'object') { + return obj; + } + + // Handle arrays + if (Array.isArray(obj)) { + return obj.map((item) => safeDeepClone(item)) as unknown as T; + } + + // Handle dates + if (obj instanceof Date) { + return new Date(obj.getTime()) as unknown as T; + } + + // Create new object of same type + const clone = Object.create(Object.getPrototypeOf(obj)); + + // Clone each property + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + try { + const val = obj[key]; + // Skip if value is not cloneable (functions, DOM nodes etc) + if ( + val === null || + typeof val !== 'object' || + val instanceof Node || + typeof val === 'function' + ) { + clone[key] = val; + } else { + clone[key] = safeDeepClone(val); + } + } catch (e) { + // Skip any values that error during cloning + clone[key] = obj[key]; + } + } + } + + return clone; +}; + +export interface RRWebPlayer { + play: () => void; + pause: () => void; + getCurrentTime: () => number; + getMirror: () => { + getNode: (id: number) => HTMLElement | null; + }; + iframe: HTMLIFrameElement; + // addEventListener: (event: string, handler: Function) => void; + // removeEventListener: (event: string, handler: Function) => void; + // getReplayer: () => unknown; +} + +/** + * we should scope all data to this plugin, including the interval + * + * then we simply flush to the canvas we are going to make + */ + +export class ReactScanReplayPlugin implements ReplayPlugin { + private canvas: HTMLCanvasElement | null = null; + private ctx: CanvasRenderingContext2D | null = null; + private replayer: RRWebPlayer | null = null; + private activeOutlines = new Map(); + private scale: number = 1; + private translateX: number = 0; + private translateY: number = 0; + private resizeObserver: ResizeObserver | null = null; + private skipBefore: number | null = null; + + constructor(skipBefore?: number) { + console.log('da constructor'); + + if (skipBefore) { + this.skipBefore = skipBefore; + } + } + + // public async makeThumbnail( + // mode: + // | { kind: 'thumbnail'; payload: ReactScanPayload } + // | { kind: 'replay' } = { kind: 'replay' }, + // ) { + // this.mode = mode; + // try { + // console.log('calling', this.replayer, '<-'); + + // if (!this.canvas) { + // console.log('initing'); + + // this.initCanvas(this.replayer!.iframe); + // } + // console.log('okay...', this.mode.kind); + + // switch (this.mode.kind) { + // case 'replay': { + // console.log('well no...'); + + // return 'fnldaskjfl;kads'; + // } + // case 'thumbnail': { + // console.log('drawing payl'); + + // const scheduledOutlines = this.payloadToScheduledReplayableOutlines( + // this.mode.payload, + // -1 + // ); + // console.log('outlines', scheduledOutlines); + + // if (!scheduledOutlines) { + // // invariant + // console.log('big el'); + + // return; + // } + // console.log('aw man'); + + // await this.activateOutlines(scheduledOutlines); + // // .catch(() => { + // // console.log('big wooper'); + // // }); + // console.log('active', this, this.activeOutlines); + // drawThumbnailOutlines( + // this.ctx!, + // this.activeOutlines, + // this.translateX, + // this.translateY, + // ); + // return 'donso!'; + // } + // } + // } catch (e) { + // console.log('e', e); + // } + // } + + async handler( + event: eventWithTime, + isSync: boolean, + context: { replayer: any }, + ) { + if (!this.replayer) { + this.replayer = context.replayer; + } + + if ( + event.type === EventType.Plugin && + event.data.plugin === 'react-scan-plugin' + ) { + // console.log('running yipee'); + + const payload = event.data.payload as ReactScanPayload; + // const nodeId = data.payload.nodeId; + // const mirror = this.replayer!.getMirror(); + // const node = mirror.getNode(nodeId) as HTMLElement | null; + + // if (!node) { + // return; + // } + + if (!this.canvas) { + this.initCanvas(this.replayer!.iframe); + } + + // console.log( + // 'activated', + // safeDeepClone(Array.from(this.activeOutlines.values())), + // ); + + // flushOutlines(); + // this is really stupid but its fine + const scheduledOutlines = this.payloadToScheduledReplayableOutlines( + payload, + event.timestamp, + ); + + if (!scheduledOutlines) { + // invariant + // console.log('big el'); + + return; + } + await this.activateOutlines(scheduledOutlines); + if (!animationFrameId) { + // console.log('fading out'); + + animationFrameId = requestAnimationFrame(() => + fadeOutOutlineReplay( + this.ctx!, + this.activeOutlines, + this.translateX, + this.translateY, + this.skipBefore, + ), + ); + } + // switch (this.mode.kind) { + // case 'replay': { + + // return; + // } + // case 'thumbnail': { + // console.log('drawing payl'); + + // const scheduledOutlines = this.payloadToScheduledReplayableOutlines( + // this.mode.payload, + // ); + // console.log('outlines', scheduledOutlines); + + // if (!scheduledOutlines) { + // // invariant + // // console.log('big el'); + + // return; + // } + + // await this.activateOutlines(scheduledOutlines); + // console.log('active', this, this.activateOutlines); + + // drawThumbnailOutlines( + // this.ctx!, + // this.activeOutlines, + // this.translateX, + // this.translateY, + // ); + // } + // } + // if (this.mode === '') + + // here we recieve the replayer + + // ReactScanInternals.scheduledOutlines.push({ + // domNode: node, + // rect: node.getBoundingClientRect(), + // renders: data.payload.outline.renders, + // }); + + // flushOutlines(this.ctx!, previousOutlines); + } + } + + // this probably can be simplified? maybe? + private initCanvas(iframe: HTMLIFrameElement) { + this.canvas = document.createElement('canvas'); + const iframeRect = iframe.getBoundingClientRect(); + const dpi = window.devicePixelRatio || 1; + let wrapper = iframe.parentElement; + if (wrapper) { + wrapper.style.position = 'relative'; + } + + const updateCanvasSize = () => { + const newRect = iframe.getBoundingClientRect(); + const parentRect = wrapper?.getBoundingClientRect() || newRect; + + // Position relative to parent + this.canvas!.style.cssText = ` + position: absolute; + top: ${newRect.top - parentRect.top}px; + left: ${newRect.left - parentRect.left}px; + width: ${newRect.width}px; + height: ${newRect.height}px; + pointer-events: none; + z-index: 2147483647; + background: transparent; + opacity: 1 !important; + visibility: visible !important; + display: block !important; + `; + + // Update canvas dimensions + this.canvas!.width = newRect.width * dpi; + this.canvas!.height = newRect.height * dpi; + + if (this.ctx) { + this.ctx.scale(dpi, dpi); + } + + // Get both scale and translation from the transform matrix + const transform = iframe.style.transform; + const matrix = new DOMMatrix(transform); + this.scale = matrix.a; + this.translateX = matrix.e; + this.translateY = matrix.f; + }; + + // Initial setup + updateCanvasSize(); + iframe.parentElement?.appendChild(this.canvas); + this.ctx = this.canvas.getContext('2d', { + willReadFrequently: true, + }); + + if (this.ctx) { + this.ctx.imageSmoothingEnabled = false; // Disable antialiasing + } + + // Setup resize observer + this.resizeObserver = new ResizeObserver(() => { + updateCanvasSize(); + }); + this.resizeObserver.observe(iframe); + } + + // Add cleanup method + public destroy() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + if (this.canvas) { + this.canvas.remove(); + this.canvas = null; + } + this.ctx = null; + } + + private payloadToScheduledReplayableOutlines( + payload: ReactScanPayload, + timestamp: number, + ) { + const scheduledReplayOutlines = new Map(); + if (!this.replayer) { + // invariant + // console.log('NOPEE'); + + return; + } + for (const event of payload) { + // const domNode = this.replayer.getMirror().getNode(event.rrwebDomId); + // if (!domNode) { + // continue; + // } + scheduledReplayOutlines.set(event.fiberId, { + aggregatedRender: { + aggregatedCount: event.renderCount, + computedCurrent: null, + computedKey: null, + frame: 0, + name: event.componentName, + time: null, + }, + alpha: null, + groupedAggregatedRender: null, + target: null, + current: null, + totalFrames: null, + estimatedTextWidth: null, + domNodeId: event.rrwebDomId, + timestamp, + }); + } + // console.log('scheduled', scheduledReplayOutlines, payload.length); + + return scheduledReplayOutlines; + } + private async activateOutlines(scheduledOutlines: ScheduledReplayOutlines) { + const domNodes: Array = []; + // const scheduledOutlines = ReactScanInternals.scheduledOutlines; + // const activeOutlines = ReactScanInternals.activeOutlines; + const activeFibers = new Map(); + + // fiber alternate merging and activeFiber tracking + // shouldn't need this anymore since alternate logic is handled + for (const activeOutline of this.activeOutlines.values()) { + if (!activeOutline.groupedAggregatedRender) { + continue; + } + for (const [ + fiber, + aggregatedRender, + ] of activeOutline.groupedAggregatedRender) { + // if (fiber.alternate && activeFibers.has(fiber.alternate)) { + // // if it already exists, copy it over + // const alternateAggregatedRender = activeFibers.get(fiber.alternate); + + // if (alternateAggregatedRender) { + // joinAggregations({ + // from: alternateAggregatedRender, + // to: aggregatedRender, + // }); + // } + // // fixme: this seems to leave a label/outline alive for an extra frame in some cases + // activeOutline.groupedAggregatedRender?.delete(fiber); + // activeFibers.delete(fiber.alternate); + // } + // match the current render to its fiber + activeFibers.set(fiber, aggregatedRender); + } + } + // handles the case where the fiber already is in a position group, and the data + // simply needs to be merged in the existing entry + for (const [fiberId, outline] of scheduledOutlines) { + const existingAggregatedRender = activeFibers.get(fiberId); + if (existingAggregatedRender) { + // joinAggregations({ + // to: existingAggregatedRender, + // from: outline.aggregatedRender, + // }); + // existing (10count) -> incoming (100count) -> should be (110count) + existingAggregatedRender.aggregatedCount += + outline.aggregatedRender.aggregatedCount; + existingAggregatedRender.frame = 0; + } + // else, the later logic will handle adding the entry + const domNode = this.replayer!.getMirror().getNode(outline.domNodeId); + if (!domNode) { + console.log('get fucked 2', outline.domNodeId); + + continue; + } + domNodes.push(domNode); + } + + const rects = await batchGetBoundingRects(domNodes); // todo + const totalFrames = 45; + const alpha = 0.8; + + /** + * - handles calculating + updating rects, adding new outlines to a groupedAggregatedRender, and moving fibers to new position groups if their rect moved + * + * - this logic makes sense together since we can only determine if an outline should be created OR moved after we calculate the latest + * rect for the scheduled outline since that allows us to compute the position key- first level of aggregation we do on aggregations + * (note: we aggregate on position because we will always merge outlines with the same rect. Within the position based aggregation we + * aggregate based on fiber because we want re-renders for a fibers outline to stay consistent between frames, and gives us tight + * control over animation restart/cancel + interpolation) + */ + for (const [fiberId, outline] of scheduledOutlines) { + // todo: put this behind config to use intersection observer or update speed + // outlineUpdateSpeed: throttled | synchronous // "using synchronous updates will result in smoother animations, but add more overhead to react-scan" + const domNode = this.replayer!.getMirror().getNode(outline.domNodeId); + if (!domNode) { + console.log('get fucked1 ', outline.domNodeId); + + continue; + } + const rect = rects.get(domNode); + // const rect = domNode.getBoundingClientRect(); + if (!rect) { + console.log('no rect lol'); + + // intersection observer could not get a rect, so we have nothing to paint/activate + continue; + } + + if (rect.top === rect.bottom || rect.left === rect.right) { + console.log('poopeoo'); + + continue; + } + + const prevAggregatedRender = activeFibers.get(fiberId); + + const isOffScreen = getIsOffscreen(rect); + if (isOffScreen) { + // console.log('off screen see yeah'); + + continue; + } + + const key = `${rect.x}-${rect.y}` as const; + let existingOutline = this.activeOutlines.get(key); + + if (!existingOutline) { + existingOutline = outline; // re-use the existing object to avoid GC time + + existingOutline.target = rect; + existingOutline.totalFrames = totalFrames; + + existingOutline.groupedAggregatedRender = new Map([ + [fiberId, outline.aggregatedRender], + ]); + existingOutline.aggregatedRender.aggregatedCount = + prevAggregatedRender?.aggregatedCount ?? 1; + + existingOutline.alpha = alpha; + + existingOutline.aggregatedRender.computedKey = key; + + // handles canceling the animation of the associated render that was painted at a different location + if (prevAggregatedRender?.computedKey) { + const groupOnKey = this.activeOutlines.get( + prevAggregatedRender.computedKey, + ); + groupOnKey?.groupedAggregatedRender?.forEach( + (value, prevStoredFiberId) => { + if (prevStoredFiberId === fiberId) { + value.frame = 45; // todo: make this max frame, not hardcoded + + // for interpolation reference equality + if (existingOutline) { + existingOutline.current = value.computedCurrent!; + } + } + }, + ); + } + // console.log('setting here tho'); + + this.activeOutlines.set(key, existingOutline); + } else if (!prevAggregatedRender) { + // console.log('setting', outline.aggregatedRender); + + existingOutline.alpha = outline.alpha; + existingOutline.groupedAggregatedRender?.set( + fiberId, + outline.aggregatedRender, + ); + } + // if there's an aggregation at the rect position AND a previously computed render + // the previous fiber joining logic handles merging render aggregations with updated data + + // FIXME(Alexis): `|| 0` just for tseslint to shutup + // existingOutline.alpha = Math.max( + // existingOutline.alpha || 0, + // outline.alpha || 0, + // ); + + existingOutline.totalFrames = Math.max( + existingOutline.totalFrames || 0, + outline.totalFrames || 0, + ); + } + } +} + +// let animationFrameId: ReturnType + +// export const flushOutlines = async ( +// ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, +// ) => { +// if ( +// !ReactScanInternals.scheduledOutlines.size && +// !ReactScanInternals.activeOutlines.size +// ) { +// return; +// } + +// const flattenedScheduledOutlines = Array.from( +// ReactScanInternals.scheduledOutlines.values(), +// ); + +// await activateOutlines(); + +// recalcOutlines(); + +// ReactScanInternals.scheduledOutlines = new Map(); + +// const { options } = ReactScanInternals; + +// options.value.onPaintStart?.(flattenedScheduledOutlines); + +// if (!animationFrameId) { +// animationFrameId = requestAnimationFrame(() => fadeOutOutline(ctx)); +// } +// }; + +let animationFrameId: number | null = null; + +const shouldSkipInterpolation = (rect: DOMRect) => { + // animations tend to transform out of screen/ to a very tiny size, those are noisy so we don't lerp them + if ( + rect.top >= window.innerHeight || // completely below viewport + rect.bottom <= 0 || // completely above viewport + rect.left >= window.innerWidth || // completely right of viewport + rect.right <= 0 // completely left of viewport + ) { + return true; + } + + return !ReactScanInternals.options.value.smoothlyAnimateOutlines; +}; + +const DEFAULT_THROTTLE_TIME = 32; // 2 frames + +const START_COLOR = { r: 115, g: 97, b: 230 }; +const END_COLOR = { r: 185, g: 49, b: 115 }; +const MONO_FONT = + 'Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace'; + +export const getOutlineKey = (rect: DOMRect): string => { + return `${rect.top}-${rect.left}-${rect.width}-${rect.height}`; +}; +const enum Reason { + Commit = 0b001, + Unstable = 0b010, + Unnecessary = 0b100, +} +export interface ReplayOutlineLabel { + alpha: number; + color: { r: number; g: number; b: number }; + // reasons: number; // based on Reason enum + labelText: string; + textWidth: number; + activeOutline: ReplayOutline; +} + +export const drawThumbnailOutlines = ( + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, + activeOutlines: Map, + translateX: number = 0, + translateY: number = 0, +) => { + const dpi = window.devicePixelRatio || 1; + ctx.clearRect(0, 0, ctx.canvas.width / dpi, ctx.canvas.height / dpi); + ctx.save(); + + // Draw black rectangle over entire canvas + // const dpi = window.devicePixelRatio || 1; + const width = ctx.canvas.width / dpi; + const height = ctx.canvas.height / dpi; + + // console.log('Drawing black rectangle:', { + // x: 0, + // y: 0, + // width, + // height, + // }); + + // ctx.fillStyle = 'black'; + // ctx.fillRect(0, 0, width, height); + + // // Test if canvas context is working + // ctx.fillStyle = 'red'; + // ctx.fillRect(100, 100, 200, 200); + // console.log('Drew test rectangle'); + + // Track positions for label merging + const labelPositions: Array<{ + x: number; + y: number; + text: string; + alpha: number; + color: { r: number; g: number; b: number }; + }> = []; + console.log('about to draw demon mode', ctx); + + for (const [key, activeOutline] of activeOutlines) { + // console.log('active outline', activeOutline); + + // invariant: active outline has "active" info non nullable at this point of the program b/c they must be activated + const invariantActiveOutline = activeOutline as { + [K in keyof ReplayOutline]: NonNullable; + }; + + const color = START_COLOR; + const alpha = 0.8; + const fillAlpha = alpha * 0.1; + const target = invariantActiveOutline.target; + + // For screenshot, we always use target rect directly + invariantActiveOutline.current = target; + invariantActiveOutline.groupedAggregatedRender.forEach((v) => { + v.computedCurrent = target; + }); + + // Draw rectangle + const rect = invariantActiveOutline.current; + const rgb = `${color.r},${color.g},${color.b}`; + ctx.strokeStyle = `rgba(${rgb},${alpha})`; + ctx.lineWidth = 1; + ctx.fillStyle = `rgba(${rgb},${fillAlpha})`; + + ctx.beginPath(); + // console.log('drawing at', rect); + + ctx.rect(rect.x + translateX, rect.y + translateY, rect.width, rect.height); + ctx.stroke(); + ctx.fill(); + + // Get label text + const labelText = getReplayLabelText( + Array.from(invariantActiveOutline.groupedAggregatedRender.values()), + ); + + if (labelText) { + labelPositions.push({ + x: rect.x + translateX, + y: rect.y + translateY, + text: labelText, + alpha, + color, + }); + } + } + + // Draw labels + ctx.font = `11px ${MONO_FONT}`; + const textHeight = 11; + const padding = 4; + + // Sort labels by position for merging + labelPositions.sort((a, b) => { + if (Math.abs(a.y - b.y) < textHeight * 2) { + return a.x - b.x; + } + return a.y - b.y; + }); + + // Merge and draw labels + let currentGroup: typeof labelPositions = []; + let lastY = -Infinity; + + for (const label of labelPositions) { + if (Math.abs(label.y - lastY) > textHeight * 2) { + // Draw current group + if (currentGroup.length > 0) { + const mergedText = currentGroup.map((l) => l.text).join(', '); + const { x, y, alpha, color } = currentGroup[0]; + const textMetrics = ctx.measureText(mergedText); + + // Background + ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},${alpha})`; + ctx.fillRect( + x, + y - textHeight - padding, + textMetrics.width + padding * 2, + textHeight + padding * 2, + ); + + // Text + ctx.fillStyle = `rgba(255,255,255,${alpha})`; + ctx.fillText(mergedText, x + padding, y - padding); + } + + // Start new group + currentGroup = [label]; + lastY = label.y; + } else { + currentGroup.push(label); + } + } + + // Draw final group + if (currentGroup.length > 0) { + const mergedText = currentGroup.map((l) => l.text).join(', '); + const { x, y, alpha, color } = currentGroup[0]; + const textMetrics = ctx.measureText(mergedText); + + ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},${1})`; + ctx.fillRect( + x, + y - textHeight - padding, + textMetrics.width + padding * 2, + textHeight + padding * 2, + ); + + ctx.fillStyle = `rgba(255,255,255,${1})`; + ctx.fillText(mergedText, x + padding, y - padding); + } + + ctx.restore(); +}; + +export const fadeOutOutlineReplay = ( + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, + activeOutlines: Map, + translateX: number = 0, + translateY: number = 0, + skipBefore: number | null, +) => { + const dpi = window.devicePixelRatio || 1; + ctx.clearRect(0, 0, ctx.canvas.width / dpi, ctx.canvas.height / dpi); + ctx.save(); + + // Track positions for label merging + const labelPositions: Array<{ + x: number; + y: number; + text: string; + alpha: number; + color: { r: number; g: number; b: number }; + }> = []; + + console.log('active', activeOutlines); + + for (const [key, activeOutline] of activeOutlines) { + // invariant: active outline has "active" info non nullable at this point of the program b/c they must be activated + const invariantActiveOutline = activeOutline as { + [K in keyof ReplayOutline]: NonNullable; + }; + let frame; + + for (const aggregatedRender of invariantActiveOutline.groupedAggregatedRender.values()) { + aggregatedRender.frame! += 1; + frame = frame + ? Math.max(aggregatedRender.frame!, frame) + : aggregatedRender.frame!; + } + + if (!frame) { + activeOutlines.delete(key); + continue; + } + + const t = 0; + const r = Math.round(START_COLOR.r + t * (END_COLOR.r - START_COLOR.r)); + const g = Math.round(START_COLOR.g + t * (END_COLOR.g - START_COLOR.g)); + const b = Math.round(START_COLOR.b + t * (END_COLOR.b - START_COLOR.b)); + const color = { r, g, b }; + + const alphaScalar = 0.8; + invariantActiveOutline.alpha = + alphaScalar * Math.max(0, 1 - frame / invariantActiveOutline.totalFrames); + + const alpha = invariantActiveOutline.alpha; + const fillAlpha = alpha * 0.1; + const target = invariantActiveOutline.target; + + const shouldSkip = shouldSkipInterpolation(target); + if (shouldSkip) { + invariantActiveOutline.current = target; + invariantActiveOutline.groupedAggregatedRender.forEach((v) => { + v.computedCurrent = target; + }); + } else { + if (!invariantActiveOutline.current) { + invariantActiveOutline.current = new DOMRect( + target.x, + target.y, + target.width, + target.height, + ); + } + + const INTERPOLATION_SPEED = 0.2; + const current = invariantActiveOutline.current; + + const lerp = (start: number, end: number) => { + return start + (end - start) * INTERPOLATION_SPEED; + }; + + const computedCurrent = new DOMRect( + lerp(current.x, target.x), + lerp(current.y, target.y), + lerp(current.width, target.width), + lerp(current.height, target.height), + ); + + invariantActiveOutline.current = computedCurrent; + + invariantActiveOutline.groupedAggregatedRender.forEach((v) => { + v.computedCurrent = computedCurrent; + }); + } + + // Draw rectangle + const rect = invariantActiveOutline.current; + const rgb = `${color.r},${color.g},${color.b}`; + ctx.strokeStyle = `rgba(${rgb},${alpha})`; + ctx.lineWidth = 1; + ctx.fillStyle = `rgba(${rgb},${fillAlpha})`; + + ctx.beginPath(); + ctx.rect(rect.x + translateX, rect.y + translateY, rect.width, rect.height); + ctx.stroke(); + ctx.fill(); + + // Get label text + const labelText = getReplayLabelText( + Array.from(invariantActiveOutline.groupedAggregatedRender.values()), + ); + + if (labelText) { + labelPositions.push({ + x: rect.x + translateX, + y: rect.y + translateY, + text: labelText, + alpha, + color, + }); + } + + const totalFrames = invariantActiveOutline.totalFrames; + for (const [ + fiber, + aggregatedRender, + ] of invariantActiveOutline.groupedAggregatedRender) { + if (aggregatedRender.frame! >= totalFrames) { + invariantActiveOutline.groupedAggregatedRender.delete(fiber); + } + } + + if (invariantActiveOutline.groupedAggregatedRender.size === 0) { + activeOutlines.delete(key); + } + } + + // Draw labels + ctx.font = `11px ${MONO_FONT}`; + const textHeight = 11; + const padding = 4; + + // Sort labels by position for merging + labelPositions.sort((a, b) => { + if (Math.abs(a.y - b.y) < textHeight * 2) { + return a.x - b.x; + } + return a.y - b.y; + }); + + let currentGroup: typeof labelPositions = []; + let lastY = -Infinity; + + for (const label of labelPositions) { + if (Math.abs(label.y - lastY) > textHeight * 2) { + // Draw current group + if (currentGroup.length > 0) { + const mergedText = currentGroup.map((l) => l.text).join(', '); + const { x, y, alpha, color } = currentGroup[0]; + const textMetrics = ctx.measureText(mergedText); + + // Background + ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},${alpha})`; + ctx.fillRect( + x, + y - textHeight - padding, + textMetrics.width + padding * 2, + textHeight + padding * 2, + ); + + // Text + ctx.fillStyle = `rgba(255,255,255,${alpha})`; + ctx.fillText(mergedText, x + padding, y - padding); + } + + // Start new group + currentGroup = [label]; + lastY = label.y; + } else { + currentGroup.push(label); + } + } + + if (currentGroup.length > 0) { + const mergedText = currentGroup.map((l) => l.text).join(', '); + const { x, y, alpha, color } = currentGroup[0]; + const textMetrics = ctx.measureText(mergedText); + + ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},${alpha})`; + ctx.fillRect( + x, + y - textHeight - padding, + textMetrics.width + padding * 2, + textHeight + padding * 2, + ); + + ctx.fillStyle = `rgba(255,255,255,${alpha})`; + ctx.fillText(mergedText, x + padding, y - padding); + } + + ctx.restore(); + + if (activeOutlines.size) { + animationFrameId = requestAnimationFrame(() => + fadeOutOutlineReplay( + ctx, + activeOutlines, + translateX, + translateY, + skipBefore, + ), + ); + } else { + animationFrameId = null; + } +}; +const textMeasurementCache = new LRUMap(100); + +export const measureTextCachedReplay = ( + text: string, + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, +): TextMetrics => { + if (textMeasurementCache.has(text)) { + return textMeasurementCache.get(text)!; + } + ctx.font = `11px ${MONO_FONT}`; + const metrics = ctx.measureText(text); + textMeasurementCache.set(text, metrics); + return metrics; +}; + +export const getReplayLabelText = ( + groupedAggregatedRenders: Array, +) => { + let labelText = ''; + + const componentsByCount = new Map< + number, + Array<{ name: string; time: number }> + >(); + + for (const aggregatedRender of groupedAggregatedRenders) { + const { time, aggregatedCount, name } = aggregatedRender; + if (!componentsByCount.has(aggregatedCount)) { + componentsByCount.set(aggregatedCount, []); + } + componentsByCount.get(aggregatedCount)!.push({ name, time: time ?? 0 }); + } + + const sortedCounts = Array.from(componentsByCount.keys()).sort( + (a, b) => b - a, + ); + + const parts: Array = []; + let cumulativeTime = 0; + for (const count of sortedCounts) { + const componentGroup = componentsByCount.get(count)!; + const names = componentGroup + .slice(0, 4) + .map(({ name }) => name) + .join(', '); + let text = names; + + const totalTime = componentGroup.reduce((sum, { time }) => sum + time, 0); + + cumulativeTime += totalTime; + + if (componentGroup.length > 4) { + text += '…'; + } + + if (count > 1) { + text += ` ×${count}`; + } + + parts.push(text); + } + + labelText = parts.join(', '); + + if (!labelText.length) return null; + + if (labelText.length > 40) { + labelText = `${labelText.slice(0, 40)}…`; + } + + if (cumulativeTime >= 0.01) { + labelText += ` (${Number(cumulativeTime.toFixed(2))}ms)`; + } + + return labelText; +}; + +export interface MergedReplayOutlineLabel { + alpha: number; + color: { r: number; g: number; b: number }; + // reasons: number; + groupedAggregatedRender: Array; + rect: DOMRect; +} + +function toMergedReplayLabel( + label: ReplayOutlineLabel, + rectOverride?: DOMRect, +): MergedReplayOutlineLabel { + const baseRect = rectOverride ?? label.activeOutline.current!; + const rect = applyLabelTransform(baseRect, label.textWidth); + const groupedArray = Array.from( + label.activeOutline.groupedAggregatedRender!.values(), + ); + return { + alpha: label.alpha, + color: label.color, + groupedAggregatedRender: groupedArray, + rect, + }; +} +// todo: optimize me so this can run always +// note: this can be implemented in nlogn using https://en.wikipedia.org/wiki/Sweep_line_algorithm +export const replayMergeOverlappingLabels = ( + labels: Array, +): Array => { + if (labels.length > 1500) { + return labels.map((label) => toMergedReplayLabel(label)); + } + + const transformed = labels.map((label) => ({ + original: label, + rect: applyLabelTransform( + new DOMRect( + label.activeOutline.current!.x, // Don't scale here + label.activeOutline.current!.y, + label.activeOutline.current!.width, + label.activeOutline.current!.height, + ), + label.textWidth, + ), + })); + + // Sort by y position first, then x + transformed.sort((a, b) => { + if (Math.abs(a.rect.y - b.rect.y) < 20) { + // Group labels within 20px vertical distance + return a.rect.x - b.rect.x; + } + return a.rect.y - b.rect.y; + }); + + const mergedLabels: Array = []; + const mergedSet = new Set(); + + for (let i = 0; i < transformed.length; i++) { + if (mergedSet.has(i)) continue; + + let currentMerged = toMergedReplayLabel( + transformed[i].original, + transformed[i].rect, + ); + let currentRight = currentMerged.rect.x + currentMerged.rect.width; + let currentBottom = currentMerged.rect.y + currentMerged.rect.height; + + for (let j = i + 1; j < transformed.length; j++) { + if (mergedSet.has(j)) continue; + + const nextRect = transformed[j].rect; + + // Check if labels are close enough vertically and overlapping horizontally + if ( + Math.abs(nextRect.y - transformed[i].rect.y) < 20 && + nextRect.x <= currentRight + 10 + ) { + // Allow small gap between labels + const nextLabel = toMergedReplayLabel( + transformed[j].original, + nextRect, + ); + currentMerged = mergeTwoReplayLabels(currentMerged, nextLabel); + mergedSet.add(j); + + currentRight = Math.max( + currentRight, + currentMerged.rect.x + currentMerged.rect.width, + ); + currentBottom = Math.max( + currentBottom, + currentMerged.rect.y + currentMerged.rect.height, + ); + } + } + + mergedLabels.push(currentMerged); + } + + return mergedLabels; +}; + +function mergeTwoReplayLabels( + a: MergedReplayOutlineLabel, + b: MergedReplayOutlineLabel, +): MergedReplayOutlineLabel { + const mergedRect = getBoundingRect(a.rect, b.rect); + + const mergedGrouped = a.groupedAggregatedRender.concat( + b.groupedAggregatedRender, + ); + + // const mergedReasons = a.reasons | b.reasons; + + return { + alpha: Math.max(a.alpha, b.alpha), + + ...pickColorClosestToStartStage(a, b), // kinda wrong, should pick color in earliest stage + // reasons: mergedReasons, + groupedAggregatedRender: mergedGrouped, + rect: mergedRect, + }; +} diff --git a/packages/scan/src/core/monitor/types-v2.ts b/packages/scan/src/core/monitor/types-v2.ts new file mode 100644 index 00000000..868c63d5 --- /dev/null +++ b/packages/scan/src/core/monitor/types-v2.ts @@ -0,0 +1,158 @@ +import { type Fiber } from 'react-reconciler'; + +export enum Device { + DESKTOP = 0, + TABLET = 1, + MOBILE = 2, +} + +export interface Session { + id: string; + device: Device; + agent: string; + wifi: string; + cpu: number; + gpu: string | null; + mem: number; + url: string; + route: string | null; + commit: string | null; + branch: string | null; +} + +export interface Interaction { + id: string | number; // index of the interaction in the batch at ingest | server converts to a hashed string from route, type, name, path + path: Array; // the path of the interaction + name: string; // name of interaction + type: string; // type of interaction i.e pointer + time: number; // time of interaction in ms + timestamp: number; + url: string; + route: string | null; // the computed route that handles dynamic params + + // Regression tracking + commit: string | null; + branch: string | null; + + interactionStartAt: number; + interactionEndAt: number; + + componentRenderData: Record< + string, + { + renderCount: number; + parents: Array; + selfTime?: number | null; + // totalTime: number; + } + >; + + // clickhouse + ingest specific types + projectId?: string; + sessionId?: string; + uniqueInteractionId: string; + + meta?: { + performanceEntry: { + id: string; + inputDelay: number; + latency: number; + presentationDelay: number; + processingDuration: number; + processingEnd: number; + processingStart: number; + referrer: string; + startTime: number; + timeOrigin: number; + timeSinceTabInactive: number | 'never-hidden'; + visibilityState: DocumentVisibilityState; + duration: number; + entries: Array<{ + duration: number; + entryType: string; + interactionId: string; + name: string; + processingEnd: number; + processingStart: number; + startTime: number; + }>; + detailedTiming?: { + jsHandlersTime: number; + prePaintTime: number; + paintTime: number; + compositorTime: number; + }; + }; + }; +} + +export interface Component { + interactionId: string | number; // grouping components by interaction + name: string; + renders: number; // how many times it re-rendered / instances (normalized) + instances: number; // instances which will be used to get number of total renders by * by renders + totalTime?: number; + selfTime?: number; +} + +export interface IngestRequest { + interactions: Array; + components: Array; + session: Session; +} + +// used internally in runtime for interaction tracking. converted to Interaction when flushed +export interface InternalInteraction { + performanceEntry: PerformanceInteraction, + route:string, + url:string, + branch: string, + commit: string, +} +interface InternalComponentCollection { + uniqueInteractionId: string; + name: string; + renders: number; // re-renders associated with the set of components in this collection + totalTime?: number; + selfTime?: number; + fibers: Set; // no references will exist to this once array is cleared after flush, so we don't have to worry about memory leaks + retiresAllowed: number; // if our server is down and we can't collect fibers/ user has no network, it will memory leak. We need to only allow a set amount of retries before it gets gcd +} + +export interface PerformanceInteractionEntry extends PerformanceEntry { + interactionId: string; + target: Element; + name: string; + duration: number; + startTime: number; + processingStart: number; + processingEnd: number; + entryType: string; +} +export interface PerformanceInteraction { + id: string; + latency: number; + entries: Array; + target: Element | null; + type: 'pointer' | 'keyboard'; + startTime: number; + endTime: number; + processingStart: number; + processingEnd: number; + duration: number; + inputDelay: number; + processingDuration: number; + presentationDelay: number; + timestamp: number; + timeSinceTabInactive: number | 'never-hidden'; + visibilityState: DocumentVisibilityState; + timeOrigin: number; + referrer: string; + // Detailed timing breakdown + detailedTiming?: { + jsHandlersTime: number; // pointerup -> click + prePaintTime: number; // click -> RAF + paintTime: number; // RAF -> setTimeout + compositorTime: number; // remaining duration + }; +} diff --git a/packages/scan/src/core/monitor/types.ts b/packages/scan/src/core/monitor/types.ts index 95beb7e4..3b59179d 100644 --- a/packages/scan/src/core/monitor/types.ts +++ b/packages/scan/src/core/monitor/types.ts @@ -1,4 +1,5 @@ import { type Fiber } from 'react-reconciler'; +import { CompletedInteraction } from 'src/core/monitor/performance'; export enum Device { DESKTOP = 0, @@ -34,12 +35,56 @@ export interface Interaction { commit: string | null; branch: string | null; + interactionStartAt: number; + interactionEndAt: number; + + componentRenderData: Record< + string, + { + renderCount: number; + parents: Array; + selfTime?: number | null; + // totalTime: number; + } + >; + // clickhouse + ingest specific types projectId?: string; sessionId?: string; uniqueInteractionId: string; - meta?: unknown; + meta?: { + performanceEntry: { + id: string; + inputDelay: number; + latency: number; + presentationDelay: number; + processingDuration: number; + processingEnd: number; + processingStart: number; + referrer: string; + startTime: number; + timeOrigin: number; + timeSinceTabInactive: number | 'never-hidden'; + visibilityState: DocumentVisibilityState; + duration: number; + entries: Array<{ + duration: number; + entryType: string; + interactionId: string; + name: string; + processingEnd: number; + processingStart: number; + startTime: number; + }>; + detailedTiming?: { + jsHandlersTime: number; + prePaintTime: number; + paintTime: number; + compositorTime: number; + }; + }; + }; } export interface Component { @@ -51,14 +96,38 @@ export interface Component { selfTime?: number; } -export interface IngestRequest { - interactions: Array; - components: Array; +export type IngestRequest = ReplaceSetWithArray<{ + interactions: Array; session: Session; -} +}> +export type ReplaceSetWithArray = T extends Set + ? Array + : T extends Array + ? Array> + : T extends { [key: string]: any } + ? { [K in keyof T]: ReplaceSetWithArray } + : T extends object + ? { [K in keyof T]: ReplaceSetWithArray } + : T; // used internally in runtime for interaction tracking. converted to Interaction when flushed export interface InternalInteraction { + componentRenderData: Record< + string, + { + renderCount: number; + parents: Array; + selfTime?: number | null; + // totalTime: number; + } + >; + childrenTree: Record< + string, + { children: Array; firstNamedAncestor: string; isRoot: boolean } + > + + listeningForComponentRenders: boolean; + componentName: string; url: string; route: string | null; @@ -68,6 +137,29 @@ export interface InternalInteraction { componentPath: Array; performanceEntry: PerformanceInteraction; components: Map; + interactionUUID: string; + detailedTiming?: { + // // jsHandlersTime: number; + // clickHandlerEnd: number; + // pointerUpStart: number; + // commmitEnd: number; + // // prepaintLayerizeTimeStart: number; + // clickChangeStart: number; + // prePaintTime: number; + // paintTime: number; + // compositorTime: number; + + clickHandlerMicroTaskEnd: number; + clickChangeStart: number | null; + pointerUpStart: number; + // interactionId: lastInteractionId, + commmitEnd: number; + rafStart: number; + timeorigin: number; + }; + + // interactionStartAt: number + // interactionEndAt: number } interface InternalComponentCollection { uniqueInteractionId: string; @@ -93,9 +185,10 @@ export interface PerformanceInteraction { id: string; latency: number; entries: Array; - target: Element; + target: Element | null; type: 'pointer' | 'keyboard'; startTime: number; + endTime: number; processingStart: number; processingEnd: number; duration: number; @@ -107,4 +200,11 @@ export interface PerformanceInteraction { visibilityState: DocumentVisibilityState; timeOrigin: number; referrer: string; + // Detailed timing breakdown + detailedTiming?: { + jsHandlersTime: number; // pointerup -> click + prePaintTime: number; // click -> RAF + paintTime: number; // RAF -> setTimeout + compositorTime: number; // remaining duration + }; } diff --git a/packages/scan/src/core/web/overlay.ts b/packages/scan/src/core/web/overlay.ts index a1e87707..5c331d83 100644 --- a/packages/scan/src/core/web/overlay.ts +++ b/packages/scan/src/core/web/overlay.ts @@ -1,8 +1,20 @@ import { recalcOutlines } from '@web-utils/outline'; import { outlineWorker } from '@web-utils/outline-worker'; -export const initReactScanOverlay = () => { - const container = document.getElementById('react-scan-root'); +export const initReactScanOverlay = ( + root?: HTMLElement, + customStyle?: { + position?: string; + top?: string; + left?: string; + width?: string; + height?: string; + pointerEvents?: string; + zIndex?: string; + + }, +) => { + const container = root ?? document.getElementById('react-scan-root'); const shadow = container?.shadowRoot; if (!shadow) { @@ -12,13 +24,14 @@ export const initReactScanOverlay = () => { const overlayElement = document.createElement('canvas'); overlayElement.id = 'react-scan-overlay'; - overlayElement.style.position = 'fixed'; - overlayElement.style.top = '0'; - overlayElement.style.left = '0'; - overlayElement.style.width = '100vw'; - overlayElement.style.height = '100vh'; - overlayElement.style.pointerEvents = 'none'; - overlayElement.style.zIndex = '2147483646'; + overlayElement.style.position = customStyle?.position ?? 'fixed'; + overlayElement.style.top = customStyle?.top ?? '0'; + overlayElement.style.left = customStyle?.left ?? '0'; + overlayElement.style.width = customStyle?.width ?? '100vw'; + overlayElement.style.height = customStyle?.height ?? '100vh'; + overlayElement.style.pointerEvents = customStyle?.pointerEvents ?? 'none'; + overlayElement.style.zIndex = customStyle?.zIndex ?? '2147483646'; + overlayElement.setAttribute('aria-hidden', 'true'); shadow.appendChild(overlayElement); diff --git a/packages/scan/src/core/web/utils/outline-worker.ts b/packages/scan/src/core/web/utils/outline-worker.ts index eabf162b..bba60495 100644 --- a/packages/scan/src/core/web/utils/outline-worker.ts +++ b/packages/scan/src/core/web/utils/outline-worker.ts @@ -38,7 +38,7 @@ export type OutlineWorkerAction = }; }; -function setupOutlineWorker(): (action: OutlineWorkerAction) => Promise { +export function setupOutlineWorker(): (action: OutlineWorkerAction) => Promise { const MONO_FONT = 'Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace'; let ctx: OffscreenCanvasRenderingContext2D | undefined; diff --git a/packages/scan/src/core/web/utils/outline.ts b/packages/scan/src/core/web/utils/outline.ts index 11b4a9ea..488b1170 100644 --- a/packages/scan/src/core/web/utils/outline.ts +++ b/packages/scan/src/core/web/utils/outline.ts @@ -632,7 +632,7 @@ function mergeTwoLabels( }; } -function getBoundingRect(r1: DOMRect, r2: DOMRect): DOMRect { +export function getBoundingRect(r1: DOMRect, r2: DOMRect): DOMRect { const x1 = Math.min(r1.x, r2.x); const y1 = Math.min(r1.y, r2.y); const x2 = Math.max(r1.x + r1.width, r2.x + r2.width); @@ -640,9 +640,9 @@ function getBoundingRect(r1: DOMRect, r2: DOMRect): DOMRect { return new DOMRect(x1, y1, x2 - x1, y2 - y1); } -function pickColorClosestToStartStage( - a: MergedOutlineLabel, - b: MergedOutlineLabel, +export function pickColorClosestToStartStage( + a: {color: MergedOutlineLabel['color']}, + b: {color: MergedOutlineLabel['color']}, ) { // stupid hack to always take the gray value when the render is unnecessary (we know the gray value has equal rgb) if (a.color.r === a.color.g && a.color.g === a.color.b) { @@ -655,7 +655,7 @@ function pickColorClosestToStartStage( return { color: a.color.r <= b.color.r ? a.color : b.color }; } -function getOverlapArea(rect1: DOMRect, rect2: DOMRect): number { +export function getOverlapArea(rect1: DOMRect, rect2: DOMRect): number { if (rect1.right <= rect2.left || rect2.right <= rect1.left) { return 0; } @@ -672,7 +672,7 @@ function getOverlapArea(rect1: DOMRect, rect2: DOMRect): number { return xOverlap * yOverlap; } -function applyLabelTransform( +export function applyLabelTransform( rect: DOMRect, estimatedTextWidth: number, ): DOMRect { diff --git a/packages/scan/src/index.ts b/packages/scan/src/index.ts index 44916ab8..ad3f995e 100644 --- a/packages/scan/src/index.ts +++ b/packages/scan/src/index.ts @@ -1,3 +1,5 @@ import 'bippy'; // implicit init RDT hook export * from './core/index'; + +export { ReactScanReplayPlugin } from './core//monitor/session-replay/replay-v2'; diff --git a/packages/scan/test-video.mp4 b/packages/scan/test-video.mp4 new file mode 100644 index 00000000..d8591aa7 Binary files /dev/null and b/packages/scan/test-video.mp4 differ diff --git a/packages/scan/tsup.config.ts b/packages/scan/tsup.config.ts index cdd93641..8929318e 100644 --- a/packages/scan/tsup.config.ts +++ b/packages/scan/tsup.config.ts @@ -85,7 +85,12 @@ void (async () => { export default defineConfig([ { - entry: ['./src/auto.ts', './src/install-hook.ts'], + entry: [ + './src/auto.ts', + './src/install-hook.ts', + './src/auto-monitor.ts', + './src/index.ts', + ], outDir: DIST_PATH, banner: { js: banner, @@ -94,11 +99,13 @@ export default defineConfig([ clean: false, sourcemap: false, format: ['iife'], - target: 'esnext', + target: 'es2017', platform: 'browser', treeshake: true, dts: true, - minify: process.env.NODE_ENV === 'production' ? 'terser' : false, + globalName: 'ReactScan', + // minify: process.env.NODE_ENV === 'production' ? 'terser' : false, + minify: false, env: { NODE_ENV: process.env.NODE_ENV ?? 'development', @@ -143,6 +150,7 @@ export default defineConfig([ treeshake: false, dts: true, watch: process.env.NODE_ENV === 'development', + noExternal: ['rrweb'], async onSuccess() { await Promise.all([ addDirectivesToChunkFiles(DIST_PATH), diff --git a/packages/website/app/api/waitlist/route.ts b/packages/website/app/api/waitlist/route.ts index 6895d86a..49e69e3f 100644 --- a/packages/website/app/api/waitlist/route.ts +++ b/packages/website/app/api/waitlist/route.ts @@ -1,5 +1,5 @@ -import { NextResponse } from 'next/server'; -import { z } from 'zod'; +import { NextResponse } from "next/server"; +import { z } from "zod"; const schema = z.object({ email: z.string().email(), @@ -22,36 +22,40 @@ export async function POST(request: Request) { const body = await request.json(); const { email, name } = schema.parse(body); const options = { - method: 'POST', + method: "POST", headers: { Authorization: `Bearer ${process.env.LOOPS_API_KEY}`, - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ email, - firstName: name?.split(' ')[0], - lastName: name?.split(' ')[1], - source: 'monitoring waitlist', + firstName: name?.split(" ")[0], + lastName: name?.split(" ")[1], + source: "monitoring waitlist", }), }; const response = await fetch( - 'https://app.loops.so/api/v1/contacts/create', - options, + "https://app.loops.so/api/v1/contacts/create", + options ); const data = await response.json(); if (!data.success) { + console.log("error", data); + return NextResponse.json({ error: data.message }, { status: 500 }); } return NextResponse.json({ ok: true }); } catch (error) { + console.log("the error", error); + if (error instanceof z.ZodError) { return NextResponse.json({ error: error.message }, { status: 400 }); } return NextResponse.json( - { error: 'Failed to add to waitlist' }, - { status: 500 }, + { error: "Failed to add to waitlist" }, + { status: 500 } ); } } diff --git a/packages/website/app/globals.css b/packages/website/app/globals.css index b5c61c95..ce807a10 100644 --- a/packages/website/app/globals.css +++ b/packages/website/app/globals.css @@ -1,3 +1,111 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes modalIn { + 0% { + opacity: 0; + transform: scale(0.98) translateY(4px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes modalOut { + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.98); + } +} + +.animate-fade-in { + animation: fadeIn 0.15s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.animate-fade-out { + animation: fadeOut 0.15s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.animate-modal-in { + animation: modalIn 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.animate-modal-out { + animation: modalOut 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +/* Custom Video Player Styles */ +.custom-video-player { + --video-progress-color: #4B4DB3; +} + +.custom-video-player video::-webkit-media-controls-panel { + display: none !important; +} + +.custom-video-player video::-webkit-media-controls { + display: none !important; +} + +.custom-video-player .progress-bar { + @apply absolute bottom-0 left-0 right-0 h-1.5 bg-white/10 cursor-pointer transition-all duration-300; + transform-origin: bottom; + user-select: none; +} + +.custom-video-player:hover .progress-bar { + @apply h-2.5; +} + +.custom-video-player .progress-fill { + @apply h-full bg-[var(--video-progress-color)] origin-left transition-transform duration-100; + position: relative; +} + +.custom-video-player .progress-fill::after { + content: ''; + position: absolute; + right: -4px; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--video-progress-color); + opacity: 0; + transition: opacity 0.2s; +} + +.custom-video-player:hover .progress-fill::after { + opacity: 1; +} + +.custom-video-player .progress-bar:active .progress-fill::after { + width: 12px; + height: 12px; + opacity: 1; +} diff --git a/packages/website/app/layout.tsx b/packages/website/app/layout.tsx index 85c53b3c..c1ff6917 100644 --- a/packages/website/app/layout.tsx +++ b/packages/website/app/layout.tsx @@ -1,25 +1,25 @@ -import './globals.css'; -import localFont from 'next/font/local'; -import Script from 'next/script'; -import { Analytics } from '@vercel/analytics/next'; -import { SpeedInsights } from '@vercel/speed-insights/next'; -import Header from '../components/header'; -import Footer from '../components/footer'; +import "./globals.css"; +import localFont from "next/font/local"; +import Script from "next/script"; +import { Analytics } from "@vercel/analytics/next"; +import { SpeedInsights } from "@vercel/speed-insights/next"; +import Header from "../components/header"; +import Footer from "../components/footer"; const geistSans = localFont({ - src: './fonts/GeistVF.woff', - variable: '--font-geist-sans', - weight: '100 900', + src: "./fonts/GeistVF.woff", + variable: "--font-geist-sans", + weight: "100 900", }); const geistMono = localFont({ - src: './fonts/GeistMonoVF.woff', - variable: '--font-geist-mono', - weight: '100 900', + src: "./fonts/GeistMonoVF.woff", + variable: "--font-geist-mono", + weight: "100 900", }); export const metadata = { - title: 'React Scan', - description: 'scan ur app', + title: "React Scan", + description: "scan ur app", }; export default function RootLayout({ @@ -77,7 +77,7 @@ export default function RootLayout({ -
+
{children}
diff --git a/packages/website/components/header.tsx b/packages/website/components/header.tsx index 94dc7459..457df300 100644 --- a/packages/website/components/header.tsx +++ b/packages/website/components/header.tsx @@ -1,10 +1,54 @@ -import React from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; +"use client"; +import React, { useState, useEffect, useRef } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { usePathname } from "next/navigation"; export default function Header() { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const pathname = usePathname(); + const menuRef = useRef(null); + const buttonRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + isMenuOpen && + menuRef.current && + buttonRef.current && + !menuRef.current.contains(event.target as Node) && + !buttonRef.current.contains(event.target as Node) + ) { + setIsMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isMenuOpen]); + + const isActive = (path: string) => { + return pathname === path; + }; + + const linkClass = (path: string, isMobile = false) => { + const baseClass = isMobile + ? "block px-4 py-2 hover:bg-gray-100" + : "underline hover:text-black"; + const activeClass = isMobile + ? "bg-[#4B4DB3]/5 text-[#4B4DB3]" + : "text-[#4B4DB3]"; + const inactiveClass = isMobile + ? "text-neutral-600" + : "text-neutral-600"; + + return `${baseClass} ${isActive(path) ? activeClass : inactiveClass}`; + }; + return ( -