diff --git a/docs/codebase-notes/replay-preferences-implementations.md b/docs/codebase-notes/replay-preferences-implementations.md index 08b12c13dc7..4789c523337 100644 --- a/docs/codebase-notes/replay-preferences-implementations.md +++ b/docs/codebase-notes/replay-preferences-implementations.md @@ -134,7 +134,6 @@ export type ExperimentalUserSettings = { apiKeys: ApiKey[]; defaultWorkspaceId: null | string; - disableLogRocket: boolean; enableTeams: boolean; enableLargeText: boolean; }; @@ -164,7 +163,6 @@ export type CombinedExperimentalUserSettings = ExperimentalUserSettings & - `ui/actions/session.ts`: `disableCache`, `listenForMetrics` - `ui/components/DevTools.tsx`: `sidePanelSize` -- `ui/components/Redacted.tsx`: `showRedactions` - `ui/components/SkeletonLoader.tsx`: `sidePanelSize` - `ui/components/Viewer.tsx`: `secondaryPanelHeight`, `sidePanelSize`, `toolboxSize` - `ui/hooks/settings.ts`: all, passed as key to `useStringPref` and `useBoolPref` diff --git a/package.json b/package.json index 506819dbf1b..fa7a3e88b1a 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,6 @@ "launchdarkly-js-client-sdk": "^3.0.0", "lexical": "^0.6.5", "lodash": "^4.17.21", - "logrocket": "^2.2.1", - "logrocket-react": "^5.0.1", "memoize-one": "^6.0.0", "minimatch": "^9.0.3", "mixpanel-browser": "^2.43.0", @@ -130,7 +128,6 @@ "@types/isomorphic-fetch": "^0.0.36", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.181", - "@types/logrocket-react": "^3.0.0", "@types/mixpanel-browser": "^2.35.6", "@types/node": "^18.0.0", "@types/react": "^18.0.12", diff --git a/packages/e2e-tests/examples.json b/packages/e2e-tests/examples.json index 09df2d4646d..b19aea22bde 100644 --- a/packages/e2e-tests/examples.json +++ b/packages/e2e-tests/examples.json @@ -13,8 +13,8 @@ "buildId": "linux-chromium-20240418-4747c93165f9-4699f489a2b6" }, "breakpoints-01": { - "recording": "0dc24b18-85c1-44d0-97bc-5025e80b9250", - "buildId": "linux-chromium-20240208-e6ff39de2177-11bb013e140b", + "recording": "c44d99c0-6eeb-4dda-bf00-bfed2a4710ea", + "buildId": "linux-chromium-20240614-d742f2be668d-7ad782ed38d0", "requiresManualUpdate": true }, "cra/dist/index.html": { @@ -22,8 +22,8 @@ "buildId": "linux-chromium-20240418-4747c93165f9-4699f489a2b6" }, "cypress-realworld/bankaccounts.spec.js": { - "recording": "adbd6030-7338-4768-a4a8-4286cacacf12", - "buildId": "linux-chromium-20240123-609ec6caa105-50d01be708a2", + "recording": "d6245b53-b3fe-4e8c-9044-4d518d92805a", + "buildId": "linux-chromium-20240627-ae45befcc072-6c7e9991dcf5", "requiresManualUpdate": true }, "deleted-replay": { @@ -35,6 +35,10 @@ "recording": "e1eba430-f744-47b3-a097-e0e26c9992fc", "buildId": "linux-chromium-20240418-4747c93165f9-4699f489a2b6" }, + "doc_async_stack.html": { + "recording": "02747835-34e8-4ea0-9ed5-432a133aa785", + "buildId": "linux-chromium-20240614-d742f2be668d-7ad782ed38d0" + }, "doc_control_flow.html": { "recording": "4762689b-f2c2-4205-8229-377992d54b6b", "buildId": "linux-chromium-20240418-4747c93165f9-4699f489a2b6" diff --git a/packages/e2e-tests/helpers/pause-information-panel.ts b/packages/e2e-tests/helpers/pause-information-panel.ts index b75a7b6cdbb..168d326e569 100644 --- a/packages/e2e-tests/helpers/pause-information-panel.ts +++ b/packages/e2e-tests/helpers/pause-information-panel.ts @@ -243,6 +243,21 @@ export async function stepOver(page: Page): Promise { await clickCommandBarButton(page, "Step Over"); } +export function waitForAllFramesToLoad(page: Page) { + return waitFor(async () => { + expect(await page.locator('[data-test-name="FramesLoading"]').count()).toBe(0); + }); +} + +export function getAsyncParentCount(page: Page) { + return page.locator('[data-test-name="AsyncParentLabel"]').count(); +} + +export async function isAsyncParentUnavailable(page: Page) { + const asyncParentUnavailable = page.locator('[data-test-name="AsyncParentUnavailable"]'); + return (await asyncParentUnavailable.count()) > 0; +} + export async function verifyFramesCount(page: Page, expectedCount: number) { const framesPanel = getFramesPanel(page); return waitFor(async () => { diff --git a/packages/e2e-tests/helpers/source-panel.ts b/packages/e2e-tests/helpers/source-panel.ts index d9e5b8aed52..a8c51b5f101 100644 --- a/packages/e2e-tests/helpers/source-panel.ts +++ b/packages/e2e-tests/helpers/source-panel.ts @@ -442,6 +442,35 @@ export async function getSelectedLineNumber( return parseInt(textContent, 10); } +export async function verifyJumpToCodeResults( + page: Page, + filename: string, + lineNumber: number, + expectedHits?: { current: number; total: number } +) { + await waitForSelectedSource(page, filename); + // Should highlight the line that ran + await waitFor(async () => { + const lineNumber = await getSelectedLineNumber(page, true); + expect(lineNumber).toBe(lineNumber); + }); + + if (expectedHits) { + // Should also have jumped in time. Since this can vary (slightly different progress % + // based on timing differences), we'll add a log statement and verify _which_ hit we're at. + await addLogpoint(page, { + url: filename, + lineNumber, + }); + + const { current, total } = expectedHits; + + // Should have paused on the handler for the first valid keystroke + await verifyLogpointStep(page, `${current}/${total}`, { url: filename, lineNumber }); + await removeLogPoint(page, { url: filename, lineNumber }); + } +} + export function getSourceLocator(page: Page, sourceId: string): Locator { return page.locator(getSourceSelector(sourceId)); } diff --git a/packages/e2e-tests/scripts/buildkite_run_fe_tests.ts b/packages/e2e-tests/scripts/buildkite_run_fe_tests.ts index cc134308f89..2b1f7cacbe1 100644 --- a/packages/e2e-tests/scripts/buildkite_run_fe_tests.ts +++ b/packages/e2e-tests/scripts/buildkite_run_fe_tests.ts @@ -255,6 +255,7 @@ export default async function run_fe_tests( } // process.env.RECORD_REPLAY_DIRECTORY = + process.env.REPLAY_ENABLE_ASSERTS = process.env.RECORD_REPLAY_ENABLE_ASSERTS = "1"; process.env.HASURA_ADMIN_SECRET ||= getSecret("prod/hasura-admin-secret", "us-east-2"); process.env.DISPATCH_ADDRESS ||= "wss://dispatch.replay.io"; process.env.AUTHENTICATED_TESTS_WORKSPACE_API_KEY = process.env.RECORD_REPLAY_API_KEY; diff --git a/packages/e2e-tests/tests/async-stack.test.ts b/packages/e2e-tests/tests/async-stack.test.ts new file mode 100644 index 00000000000..a8ceef35564 --- /dev/null +++ b/packages/e2e-tests/tests/async-stack.test.ts @@ -0,0 +1,34 @@ +import { openDevToolsTab, startTest } from "../helpers"; +import { warpToMessage } from "../helpers/console-panel"; +import { + getAsyncParentCount, + isAsyncParentUnavailable, + waitForAllFramesToLoad, +} from "../helpers/pause-information-panel"; +import { setFocusRange } from "../helpers/timeline"; +import test, { expect } from "../testFixture"; + +test.use({ exampleKey: "doc_async_stack.html" }); + +test(`async-stack: should detect async stacks outside the focus window`, async ({ + pageWithMeta: { page, recordingId, testScope }, + exampleKey, +}) => { + await startTest(page, recordingId, testScope); + await openDevToolsTab(page); + + await warpToMessage(page, "Starting", 7); + await waitForAllFramesToLoad(page); + expect(await getAsyncParentCount(page)).toBe(0); + expect(await isAsyncParentUnavailable(page)).toBe(false); + + await warpToMessage(page, "ExampleFinished", 9); + await waitForAllFramesToLoad(page); + expect(await getAsyncParentCount(page)).toBe(1); + expect(await isAsyncParentUnavailable(page)).toBe(false); + + await setFocusRange(page, { startTimeString: "00:01" }); + await waitForAllFramesToLoad(page); + expect(await getAsyncParentCount(page)).toBe(1); + expect(await isAsyncParentUnavailable(page)).toBe(true); +}); diff --git a/packages/e2e-tests/tests/cypress-05_hover-dom-previews.test.ts b/packages/e2e-tests/tests/cypress-05_hover-dom-previews.test.ts index fbed0a2cee0..5ace912684a 100644 --- a/packages/e2e-tests/tests/cypress-05_hover-dom-previews.test.ts +++ b/packages/e2e-tests/tests/cypress-05_hover-dom-previews.test.ts @@ -6,7 +6,7 @@ import { getTestSuitePanel, openCypressTestPanel, } from "../helpers/testsuites"; -import { debugPrint, waitFor } from "../helpers/utils"; +import { debugPrint, getByTestName, waitFor } from "../helpers/utils"; import test, { expect } from "../testFixture"; test.use({ exampleKey: "cypress-realworld/bankaccounts.spec.js" }); @@ -79,6 +79,28 @@ test("cypress-05: Test DOM node preview on user action step hover", async ({ await waitFor(async () => expect(await firstClickStep.getAttribute("data-selected")).toBe("true") ); + + debugPrint(page, "Checking recorded cursor location for a click"); + const recordedCursor = getByTestName(page, "recorded-cursor"); + + function getCursorAttributes(node: HTMLElement) { + return { + cursorDisplay: node.dataset.cursorDisplay, + clickDisplay: node.dataset.clickDisplay, + clientX: node.dataset.clientX, + clientY: node.dataset.clientY, + }; + } + const clickCursorAttributes = await recordedCursor.evaluate(getCursorAttributes); + + expect(clickCursorAttributes).toEqual({ + cursorDisplay: "true", + clickDisplay: "true", + // Read directly from the mouse event in this test + clientX: "323", + clientY: "245", + }); + // Make the highlighter go away await firstStep.hover(); await highlighter.waitFor({ state: "hidden" }); @@ -86,6 +108,26 @@ test("cypress-05: Test DOM node preview on user action step hover", async ({ await firstClickStep.hover(); await highlighter.waitFor({ state: "visible" }); + debugPrint(page, "Checking recorded cursor location after a click has finished"); + + const openedBankAccountsStep = steps + .filter({ + hasText: "Opened http://localhost:3000/bankaccounts", + }) + .first(); + await openedBankAccountsStep.hover(); + + const afterClickCursorAttributes = await recordedCursor.evaluate(getCursorAttributes); + + expect(afterClickCursorAttributes).toEqual({ + cursorDisplay: "true", + // No click display after the click has finished + clickDisplay: "false", + // Read directly from the mouse event in this test + clientX: "323", + clientY: "245", + }); + debugPrint(page, "Checking highlighting for multiple nodes"); // Should also handle multiple found DOM nodes diff --git a/packages/e2e-tests/tests/jump-to-code-01_basic.test.ts b/packages/e2e-tests/tests/jump-to-code-01_basic.test.ts index 921d03e40ec..33873c48435 100644 --- a/packages/e2e-tests/tests/jump-to-code-01_basic.test.ts +++ b/packages/e2e-tests/tests/jump-to-code-01_basic.test.ts @@ -10,6 +10,7 @@ import { openSourceExplorerPanel } from "../helpers/source-explorer-panel"; import { addLogpoint, getSelectedLineNumber, + verifyJumpToCodeResults, verifyLogpointStep, waitForSelectedSource, } from "../helpers/source-panel"; @@ -71,7 +72,7 @@ test(`jump-to-code-01: Test basic jumping functionality`, async ({ const queryParams = new URLSearchParams(); // Force this test to always re-run the Event Listeners (and other) routines // See pref names in packages/shared/user-data/GraphQL/config.ts - // queryParams.set("features", "backend_rerunRoutines"); + queryParams.set("features", "backend_rerunRoutines"); await startTest(page, recordingId, testScope, undefined, queryParams); await openDevToolsTab(page); @@ -140,22 +141,11 @@ test(`jump-to-code-01: Test basic jumping functionality`, async ({ debugPrint(page, "Checking that the first keypress J2C jumps to the correct line"); await firstValidKeypressJumpButton.click(); - await waitForSelectedSource(page, "Header.tsx"); - // Should highlight the line that ran - await waitFor(async () => { - const lineNumber = await getSelectedLineNumber(page, true); - expect(lineNumber).toBe(12); - }); + // Should have paused on the handler for the first valid keystroke. // Should also have jumped in time. Since this can vary (slightly different progress % // based on timing differences), we'll add a log statement and verify _which_ hit we're at. - await addLogpoint(page, { - url: "Header.tsx", - lineNumber: 12, - }); - - // Should have paused on the handler for the first valid keystroke - await verifyLogpointStep(page, "1/22", { url: "Header.tsx", lineNumber: 12 }); + await verifyJumpToCodeResults(page, "Header.tsx", 12, { current: 1, total: 22 }); // the next clicks were on real buttons, so there is a handler debugPrint(page, "Checking for an enabled click 'Jump' button"); @@ -164,24 +154,6 @@ test(`jump-to-code-01: Test basic jumping functionality`, async ({ debugPrint(page, "Checking that the first click J2C jumps to the correct line"); await firstValidClickJumpButton.click(); - await waitForSelectedSource(page, "TodoListItem.tsx"); - // Should highlight the line that ran - await waitFor(async () => { - const lineNumber = await getSelectedLineNumber(page, true); - expect(lineNumber).toBe(22); - }); - // Should also have jumped in time - // Should also have jumped in time. Since this can vary (slightly different progress % - // based on timing differences), we'll add a log statement and verify _which_ hit we're at. - await addLogpoint(page, { - url: "TodoListItem.tsx", - lineNumber: 22, - }); - - // Should have paused on the handler for the first valid click - await verifyLogpointStep(page, "1/2", { - url: "TodoListItem.tsx", - lineNumber: 22, - }); + await verifyJumpToCodeResults(page, "TodoListItem.tsx", 22, { current: 1, total: 2 }); }); diff --git a/packages/e2e-tests/tests/jump-to-code-02_redux-j2c.test.ts b/packages/e2e-tests/tests/jump-to-code-02_redux-j2c.test.ts new file mode 100644 index 00000000000..34e861b9d87 --- /dev/null +++ b/packages/e2e-tests/tests/jump-to-code-02_redux-j2c.test.ts @@ -0,0 +1,97 @@ +import { Locator, Page, expect } from "@playwright/test"; + +import { openDevToolsTab, startTest } from "../helpers"; +import { getEventJumpButton } from "../helpers/info-event-panel"; +import { getReduxActions, openReduxDevtoolsPanel } from "../helpers/redux-devtools-panel"; +import { closeSource, verifyJumpToCodeResults } from "../helpers/source-panel"; +import { getByTestName, waitFor } from "../helpers/utils"; +import test from "../testFixture"; + +// trunk-ignore(gitleaks/generic-api-key) +test.use({ exampleKey: "breakpoints-01" }); + +async function checkForJumpButton(actionListItem: Locator, shouldBeEnabled: boolean) { + const jumpButton = getEventJumpButton(actionListItem); + expect(await jumpButton.isVisible()).toBe(true); + await jumpButton.hover(); + + await waitFor(async () => { + const buttonText = await getByTestName(jumpButton, "JumpToCodeButtonLabel").innerText(); + const expectedText = shouldBeEnabled ? "Jump to code" : "No results"; + expect(buttonText).toBe(expectedText); + }); + + return jumpButton; +} + +async function clickReduxActionJumpButton(page: Page, actionListItem: Locator) { + await actionListItem.scrollIntoViewIfNeeded(); + await actionListItem.hover(); + const jumpButton = await checkForJumpButton(actionListItem, true); + await jumpButton.click(); +} + +async function jumpToReduxDispatch(page: Page, actionType: string, index = 0) { + const reduxListItemsLocator = getReduxActions(page); + const reduxSearchInput = page.locator("#redux-searchbox"); + + await reduxSearchInput.fill(actionType); + const actionListItem = reduxListItemsLocator.filter({ hasText: actionType }).nth(index); + await clickReduxActionJumpButton(page, actionListItem); +} + +test(`jump-to-code-02: Redux J2C functionality`, async ({ + pageWithMeta: { page, recordingId, testScope }, + exampleKey, +}) => { + await startTest(page, recordingId, testScope); + await openDevToolsTab(page); + + await openReduxDevtoolsPanel(page); + + const reduxListItemsLocator = getReduxActions(page); + + await waitFor(async () => { + const numListItems = await reduxListItemsLocator.count(); + expect(numListItems).toBeGreaterThan(0); + }); + + // Inside of a thunk + await jumpToReduxDispatch(page, "app/setRecordingId"); + await verifyJumpToCodeResults(page, "session.ts", 170, { current: 1, total: 1 }); + await closeSource(page, "session.ts"); + + // Inside of the same thunk, after several awaits + await jumpToReduxDispatch(page, "app/setRecordingTarget"); + await verifyJumpToCodeResults(page, "session.ts", 363, { current: 1, total: 1 }); + await closeSource(page, "session.ts"); + + // Inside of one of the bootstrapping functions that receives the store + // should be "debugger/src/client/index.ts" + await jumpToReduxDispatch(page, "sources/allSourcesReceived"); + await verifyJumpToCodeResults(page, "index.ts", 13, { current: 1, total: 1 }); + await closeSource(page, "index.ts"); + + // Inside of an RTK listener middleware effect + jumpToReduxDispatch(page, "tabs/tabsRestored"); + await verifyJumpToCodeResults(page, "newSources.ts", 43, { current: 1, total: 1 }); + await closeSource(page, "newSources.ts"); + + // Inside of a `useEffect` + jumpToReduxDispatch(page, "set_selected_primary_panel"); + await verifyJumpToCodeResults(page, "SidePanel.tsx", 57, { current: 1, total: 1 }); + await closeSource(page, "SidePanel.tsx"); + + // Inside of a `connect()`ed class component, with `this.props.setExpandedState()`. + // Note that this appears to be one or two execution ticks off, so the line hit won't + // line up perfectly, but it should still _display_ as "1/4" + jumpToReduxDispatch(page, "SET_EXPANDED_STATE"); + await verifyJumpToCodeResults(page, "SourcesTree.tsx", 196, { current: 1, total: 4 }); + await closeSource(page, "SourcesTree.tsx"); + + // Inside of an adapter that passes dispatch-wrapped actions to + // This is also one tick off, but should still _display_ as "1/3" + jumpToReduxDispatch(page, "quickOpen/setQuickOpenQuery"); + await verifyJumpToCodeResults(page, "QuickOpenModal.tsx", 551, { current: 1, total: 3 }); + await closeSource(page, "QuickOpenModal.tsx"); +}); diff --git a/packages/e2e-tests/tests/logpoints-12.test.ts b/packages/e2e-tests/tests/logpoints-12.test.ts new file mode 100644 index 00000000000..10c2909f3dd --- /dev/null +++ b/packages/e2e-tests/tests/logpoints-12.test.ts @@ -0,0 +1,59 @@ +import { openDevToolsTab, startTest } from "../helpers"; +import { verifyConsoleMessage } from "../helpers/console-panel"; +import { addLogpoint, editLogPoint, removeConditional } from "../helpers/source-panel"; +import test from "../testFixture"; + +const lineNumber = 20; +test.use({ exampleKey: "doc_rr_basic.html" }); + +test(`logpoints-12: should auto save when removing conditions`, async ({ + pageWithMeta: { page, recordingId, testScope }, + exampleKey, +}) => { + await startTest(page, recordingId, testScope); + await openDevToolsTab(page); + + await addLogpoint(page, { + content: '"initial"', + lineNumber, + saveAfterEdit: true, + url: exampleKey, + }); + await verifyConsoleMessage(page, "initial", "log-point", 10); + + // Add a condition that will hide console logs + await editLogPoint(page, { + condition: "false", + lineNumber, + saveAfterEdit: true, + url: exampleKey, + }); + await verifyConsoleMessage(page, "initial", "log-point", 0); + + // Remove condition and verify there are now console logs + await removeConditional(page, { lineNumber }); + await verifyConsoleMessage(page, "initial", "log-point", 10); + + // Re-add a condition that will hide console logs + await editLogPoint(page, { + condition: "false", + lineNumber, + saveAfterEdit: true, + url: exampleKey, + }); + + // Start a pending edit + await editLogPoint(page, { + content: '"updated"', + lineNumber, + saveAfterEdit: false, + url: exampleKey, + }); + + // Verify no console logs + await verifyConsoleMessage(page, "updated", "log-point", 0); + + // Remove condition and verify the pending edit was also saved + await removeConditional(page, { lineNumber }); + await verifyConsoleMessage(page, "updated", "log-point", 10); +}); diff --git a/packages/e2e-tests/tests/react_devtools-02-integrations.test.ts b/packages/e2e-tests/tests/react_devtools-02-integrations.test.ts index d16accb5790..4f2fac25636 100644 --- a/packages/e2e-tests/tests/react_devtools-02-integrations.test.ts +++ b/packages/e2e-tests/tests/react_devtools-02-integrations.test.ts @@ -32,10 +32,7 @@ test("react_devtools-02: RDT integrations (Chromium)", async ({ await openDevToolsTab(page); - await warpToMessage( - page, - "Waiting for breakpoint at doc_rr_basic_chromium.html:21 (waitForBreakpoint)" - ); + await warpToMessage(page, "Waiting for breakpoint at doc_rr_basic.html:21 (waitForBreakpoint)"); // If the "React" tab shows up, we know that the routine ran await openReactDevtoolsPanel(page); @@ -79,14 +76,14 @@ test("react_devtools-02: RDT integrations (Chromium)", async ({ "SystemProvider", "Head", "SideEffect", - "Auth0Provider", - "Auth0Provider", + // Tough transpiled variable, apparently: `export var ApolloProvider = function (_a) {` + "c", + "ApolloContext.Consumer", + "ApolloContext.Provider", + "ConfirmProvider", "Context.Provider", - "Context.Consumer", - "SSRRecordingPage", - "RecordingHead", - "Head", - "SideEffect", + "Routing", + "Provider", ]; debugPrint(page, "Checking list of rewritten component names"); @@ -143,7 +140,7 @@ test("react_devtools-02: RDT integrations (Chromium)", async ({ await searchComponents(page, "Anonymous"); // Search and select 1st result await verifySearchResults(page, { currentNumber: 1, - totalNumber: 15, + totalNumber: 16, }); await componentSearchInput.focus(); @@ -152,7 +149,7 @@ test("react_devtools-02: RDT integrations (Chromium)", async ({ await componentSearchInput.press("Enter"); await verifySearchResults(page, { currentNumber: 4, - totalNumber: 15, + totalNumber: 16, }); await viewSourceButton.click(); @@ -161,7 +158,7 @@ test("react_devtools-02: RDT integrations (Chromium)", async ({ await waitForSelectedSource(page, "SourcesTreeItem.tsx"); await waitFor(async () => { const lineNumber = await getSelectedLineNumber(page, false); - expect(lineNumber).toBe(133); + expect(lineNumber).toBe(132); }); list.evaluate(el => (el.scrollTop = 0)); diff --git a/packages/replay-next/components/AvatarImage.tsx b/packages/replay-next/components/AvatarImage.tsx index c79c02d8076..2fbbb5d64b2 100644 --- a/packages/replay-next/components/AvatarImage.tsx +++ b/packages/replay-next/components/AvatarImage.tsx @@ -47,7 +47,6 @@ export default function AvatarImage({ className, name, src, title, ...rest }: Pr (false); - const { lineHeight, pointPanelHeight, pointPanelWithConditionalHeight } = - useFontBasedListMeasurements(listRef); - const { executionPointLineHighlight, searchResultLineHighlight, viewSourceLineHighlight } = useLineHighlights(sourceId); @@ -139,6 +135,15 @@ export default function SourceList({ } }, [focusedSource, lineCount, markPendingFocusUpdateProcessed, pendingFocusUpdate, sourceId]); + const { data: streamingData, value: streamingValue } = useStreamingValue(streamingParser); + + const [getItemSize, lineHeight] = useGetItemSize({ + availableWidth: width - scrollbarWidth, + pointBehaviors, + pointsWithPendingEdits, + sourceId, + }); + useLayoutEffect(() => { // TODO // This is overly expensive; ideally we'd only reset this... @@ -148,48 +153,7 @@ export default function SourceList({ if (list) { list.resetAfterIndex(0); } - }, [pointBehaviors, pointsWithPendingEdits]); - - const { data: streamingData, value: streamingValue } = useStreamingValue(streamingParser); - - const getItemSize = useCallback( - (index: number) => { - const lineNumber = index + 1; - const point = findPointForLocation(pointsWithPendingEdits, sourceId, lineNumber); - if (!point) { - // If the Point has been removed by some external action, - // e.g. the Pause Information side panel, - // Then ignore any cached Point state. - return lineHeight; - } - - const pointBehavior = pointBehaviors[point.key]; - // This Point might have been restored by a previous session. - // In this case we should use its persisted values. - // Else by default, shared print statements should be shown. - // Points that have no content (breakpoints) should be hidden by default though. - const shouldLog = - pointBehavior?.shouldLog ?? - (point.content ? POINT_BEHAVIOR_ENABLED : POINT_BEHAVIOR_DISABLED); - if (shouldLog !== POINT_BEHAVIOR_DISABLED) { - if (point.condition !== null) { - return lineHeight + pointPanelWithConditionalHeight; - } else { - return lineHeight + pointPanelHeight; - } - } - - return lineHeight; - }, - [ - lineHeight, - pointBehaviors, - pointPanelHeight, - pointPanelWithConditionalHeight, - pointsWithPendingEdits, - sourceId, - ] - ); + }, [getItemSize]); const [minHitCount, maxHitCount] = getCachedMinMaxSourceHitCounts(sourceId, focusRange); @@ -234,9 +198,21 @@ export default function SourceList({ }; return ( - <> +
+ {SourceListRow} - +
); } diff --git a/packages/replay-next/components/sources/hooks/useFontBasedListMeasurements.ts b/packages/replay-next/components/sources/hooks/useFontBasedListMeasurements.ts deleted file mode 100644 index bd86db63611..00000000000 --- a/packages/replay-next/components/sources/hooks/useFontBasedListMeasurements.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { RefObject, useState } from "react"; -import { VariableSizeList as List } from "react-window"; - -import useClassListObserver from "../../../src/hooks/useClassListObserver"; - -type Measurements = { - lineHeight: number; - pointPanelHeight: number; - pointPanelWithConditionalHeight: number; -}; - -const REGULAR_SIZE: Measurements = { - lineHeight: 16, - pointPanelHeight: 72, - pointPanelWithConditionalHeight: 108, -}; -const LARGE_SIZE: Measurements = { - lineHeight: 18, - pointPanelHeight: 72, - pointPanelWithConditionalHeight: 108, -}; - -// HACK -// We could swap this out for something that lazily measures row height. -// There are only a small number of variations though, so this is more efficient. -export default function useFontBasedListMeasurements(listRef: RefObject): Measurements { - const [sizes, setSizes] = useState(REGULAR_SIZE); - - // Listen for font-size changes. - useClassListObserver(document.body.parentElement!, (classList: DOMTokenList) => { - const prefersLargeFontSize = classList.contains("prefers-large-font-size"); - if (prefersLargeFontSize) { - setSizes(LARGE_SIZE); - } else { - setSizes(REGULAR_SIZE); - } - - const list = listRef.current; - if (list) { - list.resetAfterIndex(0); - } - }); - - return sizes; -} diff --git a/packages/replay-next/components/sources/hooks/useGetItemSize.ts b/packages/replay-next/components/sources/hooks/useGetItemSize.ts new file mode 100644 index 00000000000..5320f60adc4 --- /dev/null +++ b/packages/replay-next/components/sources/hooks/useGetItemSize.ts @@ -0,0 +1,118 @@ +import assert from "assert"; +import { useCallback, useLayoutEffect, useState } from "react"; + +import { findPointForLocation } from "replay-next/components/sources/utils/points"; +import { PointBehaviorsObject } from "replay-next/src/contexts/points/types"; +import { POINT_BEHAVIOR_DISABLED, POINT_BEHAVIOR_ENABLED, Point } from "shared/client/types"; + +import useClassListObserver from "../../../src/hooks/useClassListObserver"; + +const LINE_HEIGHTS = { + large: 18, + regular: 16, +}; + +type GetItemSize = (index: number) => number; + +export default function useGetItemSize({ + availableWidth, + pointBehaviors, + pointsWithPendingEdits, + sourceId, +}: { + availableWidth: number; + pointBehaviors: PointBehaviorsObject; + pointsWithPendingEdits: Point[]; + sourceId: string; +}): [GetItemSize, lineHeight: number] { + const [lineHeight, setLineHeight] = useState(LINE_HEIGHTS.regular); + const [measurements, setMeasurements] = useState>(new Map()); + + // Listen for font-size changes. + useClassListObserver(document.body.parentElement!, (classList: DOMTokenList) => { + const prefersLargeFontSize = classList.contains("prefers-large-font-size"); + + // HACK + // We could swap this out for something that actually measures line height but it's probably not worth it. + // Text and icons are vertically centered within the available line height so an approximation is fine. + setLineHeight(prefersLargeFontSize ? LINE_HEIGHTS.large : LINE_HEIGHTS.regular); + }); + + useLayoutEffect(() => { + const newMap = new Map(); + pointsWithPendingEdits.forEach(point => { + // We could cache the most recent content+condition+width and only re-measure when they changed. + // It's probably not worth it though, given how infrequently print statements are typically used. + const height = measurePanelSize(sourceId, point.content, point.condition); + + newMap.set(point.location.line, height); + }); + + setMeasurements(newMap); + }, [availableWidth, lineHeight, pointsWithPendingEdits, sourceId]); + + const getItemSize = useCallback( + (index: number) => { + const lineNumber = index + 1; + const point = findPointForLocation(pointsWithPendingEdits, sourceId, lineNumber); + if (!point) { + // If the Point has been removed by some external action, + // e.g. the Pause Information side panel, + // Then ignore any cached Point state. + return lineHeight; + } + + const pointBehavior = pointBehaviors[point.key]; + // This Point might have been restored by a previous session. + // In this case we should use its persisted values. + // Else by default, shared print statements should be shown. + // Points that have no content (breakpoints) should be hidden by default though. + const shouldLog = + pointBehavior?.shouldLog ?? + (point.content ? POINT_BEHAVIOR_ENABLED : POINT_BEHAVIOR_DISABLED); + if (shouldLog !== POINT_BEHAVIOR_DISABLED) { + return lineHeight + (measurements.get(lineNumber) ?? 0); + } + + return lineHeight; + }, + [lineHeight, measurements, pointBehaviors, pointsWithPendingEdits, sourceId] + ); + + return [getItemSize, lineHeight]; +} + +function measurePanelSize(sourceId: string, content: string, condition: string | null) { + const root = document.querySelector( + `[data-test-id="PointPanel-DoubleBuffer-${sourceId}"]` + ) as HTMLElement; + assert(root, "Print statement panel double buffer not found"); + + const conditionalWrapperRow = root.querySelector( + '[data-test-name="PointPanel-ConditionalWrapperRow"]' + ) as HTMLElement; + assert(conditionalWrapperRow, "Print statement conditional wrapper row not found"); + conditionalWrapperRow.style.display = condition != null ? "" : "none"; + + const conditionElement = root.querySelector( + '[data-test-name="PointPanel-Condition"]' + ) as HTMLElement; + assert(conditionElement, "Syntax highlighted condition element not found"); + conditionElement.textContent = processTextForMeasuring(condition ?? ""); + + const contentElement = root.querySelector('[data-test-name="PointPanel-Content"]') as HTMLElement; + assert(contentElement, "Syntax highlighted content element not found"); + contentElement.textContent = processTextForMeasuring(content); + + return root.clientHeight; +} + +function processTextForMeasuring(text: string): string { + const lines = text.split(/[\r\n]/); + if (lines[lines.length - 1].length === 0) { + // If the last line is empty (e.g. Shift+Enter) then we don't always measure the size correctly + // Adding a space to the last line fixes this + return text + " "; + } + return text; +} diff --git a/packages/replay-next/components/sources/hooks/useSourceListCssVariables.ts b/packages/replay-next/components/sources/hooks/useSourceListCssVariables.ts index 772ee20d58b..e75cc60c0ef 100644 --- a/packages/replay-next/components/sources/hooks/useSourceListCssVariables.ts +++ b/packages/replay-next/components/sources/hooks/useSourceListCssVariables.ts @@ -1,4 +1,10 @@ -import { RefObject, useCallback, useLayoutEffect, useRef } from "react"; +import { MutableRefObject, RefObject, useCallback, useLayoutEffect, useRef } from "react"; + +type CSSVariables = { + "--longest-line-width": string; + "--source-hit-count-offset": string; + "--source-line-number-offset": string; +}; // In order to render the source list as efficiently as possible, it uses a flat DOM structure // This complicates positioning of items like the log point panel or inline search highlights @@ -16,37 +22,38 @@ export function useSourceListCssVariables({ maxLineIndexStringLength: number; }) { const longestLineWidthRef = useRef(0); - const cssVariablesRef = useRef<{ - "--longest-line-width": string; - "--source-hit-count-offset": string; - "--source-line-number-offset": string; - }>({ + const cssVariablesRef = useRef({ "--longest-line-width": "0px", "--source-hit-count-offset": "0px", "--source-line-number-offset": "0px", }); useLayoutEffect(() => { - const element = elementRef.current; - if (element) { - const hitCountElement = element.querySelector('[data-test-name="SourceLine-HitCount"]'); - if (hitCountElement) { - const value = `${ - (hitCountElement as HTMLElement).offsetWidth + - parseFloat(getComputedStyle(hitCountElement).marginRight) - }px`; + const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + if (mutation.type === "attributes" && mutation.attributeName === "class") { + const element = elementRef.current; + if (element) { + updateCssVariables(element, cssVariablesRef); + } + } + }); + }); - cssVariablesRef.current["--source-hit-count-offset"] = value; - element.style.setProperty("--source-hit-count-offset", value); - } + const root = document.body.parentElement; + if (root) { + observer.observe(root, { attributes: true }); + } - const lineNumberElement = element.querySelector('[data-test-name="SourceLine-LineNumber"]'); - if (lineNumberElement) { - const value = `${(lineNumberElement as HTMLElement).offsetWidth}px`; + return () => { + observer.disconnect(); + }; + }, [elementRef]); - cssVariablesRef.current["--source-line-number-offset"] = value; - element.style.setProperty("--source-line-number-offset", value); - } + useLayoutEffect(() => { + const element = elementRef.current; + if (element) { + updateCssVariables(element, cssVariablesRef); } }, [elementRef, maxHitCountStringLength, maxLineIndexStringLength]); @@ -80,3 +87,24 @@ export function useSourceListCssVariables({ itemsRenderedCallback, }; } + +function updateCssVariables(element: HTMLElement, cssVariablesRef: MutableRefObject) { + const hitCountElement = element.querySelector('[data-test-name="SourceLine-HitCount"]'); + if (hitCountElement) { + const value = `${ + (hitCountElement as HTMLElement).offsetWidth + + parseFloat(getComputedStyle(hitCountElement).marginRight) + }px`; + + cssVariablesRef.current["--source-hit-count-offset"] = value; + element.style.setProperty("--source-hit-count-offset", value); + } + + const lineNumberElement = element.querySelector('[data-test-name="SourceLine-LineNumber"]'); + if (lineNumberElement) { + const value = `${(lineNumberElement as HTMLElement).offsetWidth}px`; + + cssVariablesRef.current["--source-line-number-offset"] = value; + element.style.setProperty("--source-line-number-offset", value); + } +} diff --git a/packages/replay-next/components/sources/log-point-panel/Capsule.tsx b/packages/replay-next/components/sources/log-point-panel/Capsule.tsx index dd1e0651df3..09f02aa6297 100644 --- a/packages/replay-next/components/sources/log-point-panel/Capsule.tsx +++ b/packages/replay-next/components/sources/log-point-panel/Capsule.tsx @@ -104,20 +104,24 @@ export default function Capsule({ data-test-state={tooManyPointsToFind ? "too-many-points" : "valid"} onClick={onClickFocusContentEditable} > - + {hitPoints.length === 0 ? ( +
0
+ ) : ( + + )} {tooManyPointsToFind || /} diff --git a/packages/replay-next/components/sources/log-point-panel/LogPointPanel.module.css b/packages/replay-next/components/sources/log-point-panel/LogPointPanel.module.css index 762a0c630ac..f0f0abc11ae 100644 --- a/packages/replay-next/components/sources/log-point-panel/LogPointPanel.module.css +++ b/packages/replay-next/components/sources/log-point-panel/LogPointPanel.module.css @@ -3,7 +3,6 @@ .ErrorFallback { width: calc(var(--list-width) - var(--source-line-number-offset)); border-left: var(--hit-count-bar-size) solid var(--color-hit-counts-bar-0); - font-size: var(--font-size-small); user-select: none; display: flex; @@ -12,6 +11,9 @@ gap: 0.25rem; background-color: var(--point-panel-background-color); + font-size: var(--font-size-regular); + line-height: var(--line-height); + --badge-picker-button-size: 1.25rem; --badge-picker-icon-size: 1rem; } @@ -32,6 +34,7 @@ align-items: center; justify-content: center; color: var(--color-dimmer); + height: calc(100% - var(--line-height)); } .Loader { background-color: var(--point-panel-background-color); @@ -98,11 +101,6 @@ } .Content { - /* Allows height for horizontal scrollbar without shifting text */ - height: 1.5rem; - max-height: 1.5rem; - line-height: 1.5rem; - flex: 1 1 auto; white-space: pre; overflow-y: hidden; @@ -114,6 +112,7 @@ display: flex; flex-direction: row; align-items: center; + gap: 1ch; /* Firefox fixes */ scrollbar-width: thin; @@ -128,14 +127,14 @@ .Content:focus { border-color: var(--logpoint-border-color-focused); } +.Content [data-test-name="PointPanel-ConditionInput"], .Content [data-test-name="PointPanel-ContentInput"] { - white-space: pre !important; + padding: 0.25rem 0; } .EditButton, .RemoveConditionalButton, -.SaveButton, -.ToggleVisibilityButton { +.SaveButton { background: none; border: none; margin: 0; @@ -148,58 +147,35 @@ .EditButton:disabled, .RemoveConditionalButton[data-invalid], .RemoveConditionalButton:disabled, -.SaveButton[data-invalid], -.SaveButton:disabled .ToggleVisibilityButton:disabled { +.SaveButton[data-invalid] { cursor: default; } -.EditButton, -.ToggleVisibilityButton { +.ButtonWithIcon { flex-grow: 0; flex-shrink: 0; display: flex; align-items: center; justify-content: center; line-height: 1.5rem; + height: 1rem; + width: 1rem; + cursor: pointer; color: var(--point-panel-input-edit-button-color); } -.ContentWrapper[data-state-editable="true"]:hover .EditButton, -.EditButton:hover, -.ToggleVisibilityButton:hover { +.ButtonWithIcon:hover { color: var(--point-panel-input-edit-button-color-hover); } - -.RemoveConditionalButton { - color: var(--point-panel-input-cancel-button-color); -} -.RemoveConditionalButton:hover { - color: var(--point-panel-input-cancel-button-color-hover); -} -.RemoveConditionalButton[data-invalid] { - color: var(--point-panel-input-disabled-cancel-button-color); +.ButtonWithIcon[data-invalid] { + color: var(--badge-picker-invalid-background-color); } -.RemoveConditionalButton[data-invalid]:hover { +.ButtonWithIcon[data-invalid]:hover { color: var(--point-panel-input-disabled-cancel-button-color-hover); } -.RemoveConditionalButtonIcon { - width: 1rem; - height: 1rem; -} - -.EditButtonIcon, -.ToggleVisibilityButtonIcon { - height: 1rem; - width: 1rem; -} - .ContentInput, .ContentInputWithNag { width: 100%; - - /* Allows height for horizontal scrollbar without shifting text */ - height: 1.5rem; - line-height: 1.5rem; } .ContentInputWithNag::selection { background-color: var(--nag-background-color); @@ -235,32 +211,8 @@ flex: 0 0 1.5rem; } -.SaveButton { - flex: 0 0 1.5rem; - height: 1.5rem; - width: 1.5rem; - border-radius: 1.5rem; - display: flex; - align-items: center; - justify-content: center; - font-family: var(--font-family-default); - font-size: var(--font-size-regular); - background-color: var(--background-color-primary-button); - color: var(--color-primary-button); -} -.SaveButton[data-invalid] { - background-color: var(--background-color-primary-button-disabled); -} -.SaveButton:disabled { - background-color: var(--background-color-primary-button-disabled); -} - -.SaveButtonIcon { - height: 1rem; - width: 1rem; -} - .DisabledIconAndAvatar { + flex-shrink: 0; display: flex; align-items: center; gap: 0.5rem; @@ -272,3 +224,10 @@ width: 1rem; border-radius: 1rem; } + +.SyntaxHighlighter { + flex-grow: 1; + white-space: pre-wrap; + word-break: break-word; + padding: 0.25rem 0; +} diff --git a/packages/replay-next/components/sources/log-point-panel/LogPointPanel.tsx b/packages/replay-next/components/sources/log-point-panel/LogPointPanel.tsx index 3646ce72010..0f7c4cb62ce 100644 --- a/packages/replay-next/components/sources/log-point-panel/LogPointPanel.tsx +++ b/packages/replay-next/components/sources/log-point-panel/LogPointPanel.tsx @@ -1,6 +1,5 @@ import { TimeStampedPoint, TimeStampedPointRange } from "@replayio/protocol"; import { - MouseEvent, Suspense, unstable_useCacheRefresh as useCacheRefresh, useContext, @@ -72,34 +71,16 @@ export default function PointPanelWrapper(props: ExternalProps) { return null; } - const { className, pointForSuspense, pointWithPendingEdits } = props; + const { className, pointForSuspense } = props; - const loader = ( - - ); + const loader = ; if (pointForSuspense == null) { return loader; } const errorFallback = ( -
- Could not load hit points -
+
Could not load hit points
); return ( @@ -174,17 +155,19 @@ function PointPanel( ); } -function PointPanelWithHitPoints({ +export function PointPanelWithHitPoints({ className, enterFocusMode, hitPoints, hitPointStatus, pointWithPendingEdits, pointForSuspense, + readOnlyMode = false, setFocusToBeginning, setFocusToEnd, }: InternalProps & { pointForSuspense: Point; + readOnlyMode?: boolean; }) { const graphQLClient = useContext(GraphQLClientContext); const { showCommentsPanel } = useContext(InspectorContext); @@ -203,13 +186,13 @@ function PointPanelWithHitPoints({ // Only parts that may suspend should use lower priority values. const { condition, content, key, location, user } = pointWithPendingEdits; - const editable = user?.id === currentUserInfo?.id; + const editable = user?.id === currentUserInfo?.id && !readOnlyMode; const [showEditBreakpointNag, dismissEditBreakpointNag] = useNag(Nag.FIRST_BREAKPOINT_EDIT); const invalidateCache = useCacheRefresh(); - const [isEditing, setIsEditing] = useState(showEditBreakpointNag); + const [isEditing, setIsEditing] = useState(!readOnlyMode && showEditBreakpointNag); const [editReason, setEditReason] = useState(null); const [isPending, startTransition] = useTransition(); @@ -261,10 +244,13 @@ function PointPanelWithHitPoints({ } if (hasCondition) { - editPendingPointText(key, { condition: null }); - - // TODO [FE-1886] Also save the point; otherwise this change won't be applied on reload + // If we're removing a condition, we need to account for pending partial edits + // Ideally we would stash them, save the log point without a condition, and then reapply them + // But the save+render cycle is async so it's easiest to just save the pending edit along with the condition change + savePendingPointText(key, { condition: null, content }); + setIsEditing(false); } else { + // If we're adding a condition, just focus to the condition field if (!isEditing) { startEditing("condition"); } else { @@ -285,13 +271,6 @@ function PointPanelWithHitPoints({ ); }; - const onClickEyeIcon = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - toggleShouldLog(); - }; - let showTooManyPointsErrorMessage = false; let showUnknownErrorMessage = false; switch (hitPointStatus) { @@ -392,7 +371,10 @@ function PointPanelWithHitPoints({ > { hasCondition && ( -
+
@@ -441,7 +423,12 @@ function PointPanelWithHitPoints({ ) : ( <>
- +
- {isEditing ? saveButton : addCommentButton} + {addCommentButton}
) /* hasCondition */ } @@ -514,15 +501,14 @@ function PointPanelWithHitPoints({ point={pointWithPendingEdits} /> - {isEditing ? ( -
+
+ {isEditing ? (
+ ) : ( + + )} +
+ {isEditing ? ( + saveButton + ) : editable ? ( + + ) : null} +
- ) : ( - <> -
- -
-
- {shouldLog || ( - - )} - {editable && ( - - )} - -
- - )} +
)} - {hasCondition ? contentSpacer : isEditing ? saveButton : addCommentButton} + {hasCondition ? contentSpacer : addCommentButton}
@@ -599,12 +583,12 @@ function RemoveConditionalButton({ }) { return ( ); } @@ -620,13 +604,13 @@ function SaveButton({ }) { return ( ); } diff --git a/packages/replay-next/components/sources/log-point-panel/LogPointPanelDoubleBuffer.module.css b/packages/replay-next/components/sources/log-point-panel/LogPointPanelDoubleBuffer.module.css new file mode 100644 index 00000000000..a724fe3a09e --- /dev/null +++ b/packages/replay-next/components/sources/log-point-panel/LogPointPanelDoubleBuffer.module.css @@ -0,0 +1,5 @@ +.LogPointPanelDoubleBuffer { + visibility: hidden; + z-index: -1; + position: absolute; +} diff --git a/packages/replay-next/components/sources/log-point-panel/LogPointPanelDoubleBuffer.tsx b/packages/replay-next/components/sources/log-point-panel/LogPointPanelDoubleBuffer.tsx new file mode 100644 index 00000000000..a12c8bf59e4 --- /dev/null +++ b/packages/replay-next/components/sources/log-point-panel/LogPointPanelDoubleBuffer.tsx @@ -0,0 +1,47 @@ +import { TimeStampedPoint } from "@replayio/protocol"; + +import { PointPanelWithHitPoints } from "replay-next/components/sources/log-point-panel/LogPointPanel"; +import { Point } from "shared/client/types"; + +import styles from "./LogPointPanelDoubleBuffer.module.css"; + +export function LogPointPanelDoubleBuffer({ sourceId }: { sourceId: string }) { + return ( +
+ +
+ ); +} + +function noop() {} + +const EMPTY_HIT_POINTS = [] as TimeStampedPoint[]; + +const EMPTY_POINT: Point = { + badge: null, + condition: "fake", + content: "fake", + createdAt: new Date(), + key: "fake", + location: { + column: 0, + line: 0, + sourceId: "fake", + }, + recordingId: "fake", + user: null, +}; diff --git a/packages/replay-next/src/contexts/points/hooks/useSavePendingPointText.ts b/packages/replay-next/src/contexts/points/hooks/useSavePendingPointText.ts index 5ec859adf29..98d2db42dda 100644 --- a/packages/replay-next/src/contexts/points/hooks/useSavePendingPointText.ts +++ b/packages/replay-next/src/contexts/points/hooks/useSavePendingPointText.ts @@ -20,9 +20,9 @@ export default function useSavePendingPointText({ setPointBehaviors: SetLocalPointBehaviors; }) { return useCallback( - (key: PointKey) => { + (key: PointKey, partialPoint?: Partial>) => { const { pendingPointText } = committedValuesRef.current; - const pendingPoint = pendingPointText.get(key); + const pendingPoint = partialPoint ?? pendingPointText.get(key); if (pendingPoint) { saveLocalAndRemotePoints(key, pendingPoint); @@ -36,7 +36,7 @@ export default function useSavePendingPointText({ setPointBehaviors(prev => { const pointBehavior = prev[key]; - return prev[key].shouldLog === POINT_BEHAVIOR_ENABLED + return prev[key]?.shouldLog === POINT_BEHAVIOR_ENABLED ? prev : { ...prev, diff --git a/packages/replay-next/src/contexts/points/types.ts b/packages/replay-next/src/contexts/points/types.ts index aba3077d02f..e14b799bac6 100644 --- a/packages/replay-next/src/contexts/points/types.ts +++ b/packages/replay-next/src/contexts/points/types.ts @@ -31,7 +31,10 @@ export type EditPointBehavior = ( createdByCurrentUser: boolean ) => void; -export type SaveOrDiscardPendingText = (key: PointKey) => void; +export type SaveOrDiscardPendingText = ( + key: PointKey, + partialPoint?: Partial> +) => void; export type SaveLocalAndRemotePoints = ( key: PointKey, diff --git a/packages/replay-next/src/hooks/useNotification.ts b/packages/replay-next/src/hooks/useNotification.ts new file mode 100644 index 00000000000..a5ddea07216 --- /dev/null +++ b/packages/replay-next/src/hooks/useNotification.ts @@ -0,0 +1,56 @@ +import { useCallback, useState, useSyncExternalStore } from "react"; + +export type Permission = NotificationPermission | PermissionState; +export type RequestPermission = () => Promise; + +export function useNotification(): { + permission: Permission; + requested: boolean; + requestPermission: RequestPermission; + supported: boolean; +} { + const permission = useSyncExternalStore( + function subscribe(change: () => void) { + let permissionStatus: PermissionStatus; + + (async () => { + permissionStatus = await navigator.permissions.query({ name: "notifications" }); + permissionStatus.addEventListener("change", change); + })(); + + return () => { + if (permissionStatus) { + permissionStatus.removeEventListener("change", change); + } + }; + }, + () => Notification.permission, + () => Notification.permission + ); + + const [requested, setRequested] = useState(false); + + const requestPermission = useCallback(async () => { + if (!supported) { + return false; + } + + setRequested(true); + + let permission = Notification.permission; + if (permission !== "granted") { + permission = await Notification.requestPermission(); + } + + return permission === "granted"; + }, []); + + return { + permission, + requestPermission, + requested, + supported, + }; +} + +const supported = typeof window !== "undefined" && "Notification" in window; diff --git a/packages/replay-next/variables.css b/packages/replay-next/variables.css index b495de6f1ea..e30a0dc9894 100644 --- a/packages/replay-next/variables.css +++ b/packages/replay-next/variables.css @@ -198,6 +198,9 @@ --background-color-current-execution-point: #25364e; --background-color-current-execution-point-column: #2663c080; + --background-color-current-execution-point-test-code: var( + --background-color-current-execution-point-column + ); --background-color-current-search-result: #25364e; --background-color-default: var(--chrome); --background-color-disabled-button: #454950; @@ -311,8 +314,8 @@ --point-panel-background-color: #1f2b43; --point-panel-conditional-icon: var(--blue-40); --point-panel-input-background-color: var(--theme-base-100); - --point-panel-input-border-color: transparent; - --point-panel-input-border-color-hover: #273149; + --point-panel-input-border-color: #273149; + --point-panel-input-border-color-hover: #38425a; --point-panel-input-border-color-focus: var(--theme-selection-background); --point-panel-input-edit-button-color: #747476; --point-panel-input-edit-button-color-hover: var(--primary-accent); @@ -755,6 +758,9 @@ --background-color-current-execution-point: #eaf3ff; --background-color-current-execution-point-column: #b8d7ff; + --background-color-current-execution-point-test-code: var( + --background-color-current-execution-point-column + ); --background-color-current-search-result: #eaf3ff; --background-color-inputs: var(--theme-text-field-bgcolor); --background-color-resize-handle: #cfcfcf; @@ -861,9 +867,9 @@ --point-panel-background-color: #f0f1f4; --point-panel-conditional-icon: var(--blue-60); --point-panel-input-background-color: var(--theme-base-100); + --point-panel-input-border-color: #e9ebef; + --point-panel-input-border-color-hover: #d8dade; --point-panel-input-border-color-focus: var(--theme-selection-background); - --point-panel-input-border-color-hover: #e9ebef; - --point-panel-input-border-color: transparent; --point-panel-input-cancel-button-color-hover: #afafb5; --point-panel-input-cancel-button-color: #e2e2e2; --point-panel-input-disabled-background-color: #e4e4e4; diff --git a/packages/shared/test-suites/RecordingTestMetadata.ts b/packages/shared/test-suites/RecordingTestMetadata.ts index bf5fe8711f6..da139e0a520 100644 --- a/packages/shared/test-suites/RecordingTestMetadata.ts +++ b/packages/shared/test-suites/RecordingTestMetadata.ts @@ -1173,6 +1173,50 @@ export function isUserActionTestEvent( return value.type === "user-action"; } +export function isUserClickEvent(event: TestEvent) { + if (isUserActionTestEvent(event)) { + const { category, command, testRunnerName } = event.data; + if (category !== "command") { + return false; + } + + switch (testRunnerName) { + case "cypress": { + return ["click", "check", "uncheck"].includes(command.name); + } + case "playwright": { + return ["locator.click", "locator.check", "locator.uncheck"].some(name => + command.name.startsWith(name) + ); + } + } + } + + return false; +} + +export function isUserKeyboardEvent(event: TestEvent) { + if (isUserActionTestEvent(event)) { + const { category, command, testRunnerName } = event.data; + if (category !== "command") { + return false; + } + + switch (testRunnerName) { + case "cypress": { + return ["type"].includes(command.name); + } + case "playwright": { + return ["locator.type", "keyboard.down", "keyboard.press", "keyboard.type"].some(name => + command.name.startsWith(name) + ); + } + } + } + + return false; +} + export function compareTestEventExecutionPoints( a: RecordingTestMetadataV3.TestEvent, b: RecordingTestMetadataV3.TestEvent diff --git a/packages/shared/user-data/GraphQL/config.ts b/packages/shared/user-data/GraphQL/config.ts index 5b513dfcb2f..10ff6d6ab2b 100644 --- a/packages/shared/user-data/GraphQL/config.ts +++ b/packages/shared/user-data/GraphQL/config.ts @@ -138,11 +138,6 @@ export const config = { legacyKey: null, }, - global_disableLogRocket: { - defaultValue: Boolean(false), - label: "Disable LogRocket session replay", - legacyKey: "devtools.disableLogRocket", - }, global_enableLargeText: { defaultValue: Boolean(false), label: "Enable large text for Editor", diff --git a/pages/_document.tsx b/pages/_document.tsx index eb143345c19..6f052a2cfe9 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -26,7 +26,7 @@ const csp = (props: any) => { const authHost = getAuthHost(); return [ `default-src 'self'`, - `connect-src 'self' https://api.replay.io wss://api.replay.io wss://dispatch.replay.io ws://*.replay.prod http://*.replay.prod https://telemetry.replay.io https://${authHost} https://api-js.mixpanel.com https://*.sentry.io https://*.launchdarkly.com https://*.logrocket.io https://*.lr-ingest.io https://*.logrocket.com https://*.lr-in.com https://api.stripe.com https://vitals.vercel-insights.com ${ + `connect-src 'self' https://api.replay.io wss://api.replay.io wss://dispatch.replay.io ws://*.replay.prod http://*.replay.prod https://telemetry.replay.io https://${authHost} https://api-js.mixpanel.com https://*.sentry.io https://*.launchdarkly.com https://*.lr-ingest.io https://*.lr-in.com https://api.stripe.com https://vitals.vercel-insights.com ${ // Required to talk to local backend in development. Enabling // localhost:8000 for prod to support the ?dispatch parameter when running // the local backend @@ -34,22 +34,22 @@ const csp = (props: any) => { }`, `frame-src replay: https://js.stripe.com https://hooks.stripe.com https://${authHost} https://www.loom.com/`, // Required by some of our external services - `script-src 'self' 'unsafe-eval' https://cdn.logrocket.io https://cdn.lr-ingest.io https://cdn.lr-in.com https://js.stripe.com ${hash}`, + `script-src 'self' 'unsafe-eval' https://cdn.lr-ingest.io https://cdn.lr-in.com https://js.stripe.com ${hash}`, `form-action https://${authHost}`, // From vercel's CSP config and Google fonts `font-src 'self' data: https://fonts.gstatic.com`, // Google fonts `style-src-elem 'self' 'unsafe-inline' https://fonts.gstatic.com`, - // Required by LogRocket - `child-src 'self' blob:`, - `worker-src 'self' blob:`, // Required by some of our external services `style-src 'self' 'unsafe-inline'`, // Required to inline images from the database and from external avaters `img-src 'self' data: https:`, + + // Required for our logpoint analysis cache (which uses a Web worker) + `worker-src 'self' blob:`, ] .filter(Boolean) .join("; "); diff --git a/public/test/examples/doc_async_stack.html b/public/test/examples/doc_async_stack.html new file mode 100644 index 00000000000..6b3092bf7e1 --- /dev/null +++ b/public/test/examples/doc_async_stack.html @@ -0,0 +1,13 @@ + + +
Hello World!
+ + + diff --git a/src/base.css b/src/base.css index e3d2d785a9c..b5e3b2b4646 100644 --- a/src/base.css +++ b/src/base.css @@ -16,11 +16,6 @@ body { scrollbar-color: transparent; } -.showRedactions[data-private="true"] { - background: black !important; - border: 2px solid black !important; -} - :root { --dark-blue: #2d7eff; --dark-grey: #696969; diff --git a/src/devtools/client/debugger/src/components/Editor/EditorPane.tsx b/src/devtools/client/debugger/src/components/Editor/EditorPane.tsx index 7d0001b0b1a..eb32f94a102 100644 --- a/src/devtools/client/debugger/src/components/Editor/EditorPane.tsx +++ b/src/devtools/client/debugger/src/components/Editor/EditorPane.tsx @@ -1,9 +1,8 @@ import classNames from "classnames"; -import { useLayoutEffect, useRef } from "react"; +import { useRef } from "react"; import { IndeterminateProgressBar } from "replay-next/components/IndeterminateLoader"; import { userData } from "shared/user-data/GraphQL/UserData"; -import { Redacted } from "ui/components/Redacted"; import { getToolboxLayout } from "ui/reducers/layout"; import { getSelectedSourceId, getSourcesUserActionPending } from "ui/reducers/sources"; import { useAppSelector } from "ui/setup/hooks"; @@ -35,9 +34,9 @@ export const EditorPane = () => { {sourcesUserActionPending ? : null} {selectedSourceId ? ( - +
- +
) : ( )} diff --git a/src/devtools/client/debugger/src/components/Editor/Tab.tsx b/src/devtools/client/debugger/src/components/Editor/Tab.tsx index 30d35f54a66..5ce91e8a592 100644 --- a/src/devtools/client/debugger/src/components/Editor/Tab.tsx +++ b/src/devtools/client/debugger/src/components/Editor/Tab.tsx @@ -8,7 +8,6 @@ import useTabContextMenu from "devtools/client/debugger/src/components/Editor/us import useTooltip from "replay-next/src/hooks/useTooltip"; import { useSourcesById } from "replay-next/src/suspense/SourcesCache"; import { ReplayClientContext } from "shared/client/ReplayClientContext"; -import { Redacted } from "ui/components/Redacted"; import { SourceDetails, getSelectedSourceId } from "ui/reducers/sources"; import { useAppDispatch, useAppSelector } from "ui/setup/hooks"; import { trackEvent } from "ui/utils/telemetry"; @@ -87,7 +86,7 @@ export default function Tab({ return ( <> - e.button === 1 && closeTab(cx, source)} - refToForward={elementRef => setTabRef(sourceId, elementRef as HTMLDivElement | null)} + ref={elementRef => setTabRef(sourceId, elementRef as HTMLDivElement | null)} >
{getTruncatedFileName(source, query)}
-
+
{contextMenu} {tooltip} diff --git a/src/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.tsx b/src/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.tsx index 1d1c0e06fd5..7bdf9b6e01f 100644 --- a/src/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.tsx +++ b/src/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.tsx @@ -10,7 +10,6 @@ import { ContextMenuItem, useContextMenu } from "use-context-menu"; import Icon from "replay-next/components/Icon"; import { copyToClipboard } from "replay-next/components/sources/utils/clipboard"; -import { Redacted } from "ui/components/Redacted"; import type { SourceDetails } from "ui/reducers/sources"; import { getSourceQueryString } from "../../utils/source"; @@ -196,10 +195,10 @@ function SourceTreeItem2({ {itemArrow} - +
{getItemName(item)} {query} - +
{contextMenu} diff --git a/src/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.tsx b/src/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.tsx index e267ed4cfbe..ffeaa6ae295 100644 --- a/src/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.tsx +++ b/src/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.tsx @@ -4,7 +4,6 @@ import { ConnectedProps, connect } from "react-redux"; import type { Context } from "devtools/client/debugger/src/reducers/pause"; import { Point } from "shared/client/types"; -import { Redacted } from "ui/components/Redacted"; import { MiniSource, getSourceDetails } from "ui/reducers/sources"; import type { UIState } from "ui/state"; @@ -83,7 +82,7 @@ class BreakpointHeading extends PureComponent { onContextMenu={this.onContextMenu} onClick={this.onClick} > - {fileName} +
{fileName}
{allBreakpointsAreShared || ( - } {isSelectable &&
} -
+ {/*Keep the context menu separate to avoid `onMouseDown` bubbling up and causing unwanted frame selection behavior*/} diff --git a/src/devtools/client/debugger/src/components/SecondaryPanes/Frames/NewFrames.tsx b/src/devtools/client/debugger/src/components/SecondaryPanes/Frames/NewFrames.tsx index 5b23d524af4..6164a42a285 100644 --- a/src/devtools/client/debugger/src/components/SecondaryPanes/Frames/NewFrames.tsx +++ b/src/devtools/client/debugger/src/components/SecondaryPanes/Frames/NewFrames.tsx @@ -10,11 +10,9 @@ import { getSelectedFrameId, getThreadContext, } from "devtools/client/debugger/src/selectors"; -import { isFocusWindowApplied } from "devtools/client/debugger/src/utils/focus"; import { InlineErrorBoundary } from "replay-next/components/errors/InlineErrorBoundary"; import { copyToClipboard } from "replay-next/components/sources/utils/clipboard"; import { FocusContext } from "replay-next/src/contexts/FocusContext"; -import { SessionContext } from "replay-next/src/contexts/SessionContext"; import { useCurrentFocusWindow } from "replay-next/src/hooks/useCurrentFocusWindow"; import { useIsPointWithinFocusWindow } from "replay-next/src/hooks/useIsPointWithinFocusWindow"; import { getPointAndTimeForPauseId, pauseIdCache } from "replay-next/src/suspense/PauseCache"; @@ -46,13 +44,16 @@ function FramesRenderer({ const replayClient = useContext(ReplayClientContext); const sourcesState = useAppSelector(state => state.sources); const { rangeForSuspense: focusWindow } = useContext(FocusContext); - const { endpoint } = useContext(SessionContext); const dispatch = useAppDispatch(); + if (focusWindow === null) { + return null; + } + const asyncSeparator = asyncIndex > 0 ? (
- + async
@@ -64,30 +65,25 @@ function FramesRenderer({ asyncIndex, focusWindow ); - if (asyncParentPauseId === null) { + if (asyncParentPauseId === true) { return ( <> {asyncSeparator} -
- This part of the call stack is unavailable. - {isFocusWindowApplied(focusWindow, endpoint) && ( - <> - {" "} - Perhaps it is outside of{" "} - dispatch(enterFocusMode())}> - your debugging window - - . - - )} +
+ This part of the call stack is unavailable because it is outside{" "} + dispatch(enterFocusMode())}> + your debugging window + + .
); } - let frames = asyncParentPauseId - ? getPauseFramesSuspense(replayClient, asyncParentPauseId, sourcesState) - : undefined; + let frames = + typeof asyncParentPauseId === "string" + ? getPauseFramesSuspense(replayClient, asyncParentPauseId, sourcesState) + : undefined; if (asyncIndex > 0) { frames = frames?.slice(1); } @@ -108,7 +104,13 @@ function FramesRenderer({ name="NewFrames" fallback={
Error loading frames :(
} > - Loading async frames…
}> + + Loading async frames… + + } + > @@ -228,7 +230,13 @@ function Frames({ panel, point, time }: FramesProps) { name="Frames" fallback={
Error loading frames :((
} > - Loading...}> + + Loading... + + } + >
@@ -243,7 +251,9 @@ export default function NewFrames(props: FramesProps) { -
Loading...
+
+ Loading... +
} > diff --git a/src/devtools/client/debugger/src/components/SecondaryPanes/NewScopes.tsx b/src/devtools/client/debugger/src/components/SecondaryPanes/NewScopes.tsx index 15e1b9b4f38..ad0c5994dc8 100644 --- a/src/devtools/client/debugger/src/components/SecondaryPanes/NewScopes.tsx +++ b/src/devtools/client/debugger/src/components/SecondaryPanes/NewScopes.tsx @@ -12,7 +12,6 @@ import { sourcesByIdCache } from "replay-next/src/suspense/SourcesCache"; import { getPreferredLocation, getPreferredSourceId } from "replay-next/src/utils/sources"; import { ReplayClientContext } from "shared/client/ReplayClientContext"; import { enterFocusMode } from "ui/actions/timeline"; -import { Redacted } from "ui/components/Redacted"; import { getPreferredGeneratedSources } from "ui/reducers/sources"; import { useAppDispatch, useAppSelector } from "ui/setup/hooks"; import { pickScopes } from "ui/suspense/scopeCache"; @@ -150,7 +149,7 @@ export default function NewScopes() { return (
- +
- +
); } diff --git a/src/devtools/client/debugger/src/components/SourceOutline/SourceOutlineFunction.tsx b/src/devtools/client/debugger/src/components/SourceOutline/SourceOutlineFunction.tsx index 6b2af36631d..d462feb6722 100644 --- a/src/devtools/client/debugger/src/components/SourceOutline/SourceOutlineFunction.tsx +++ b/src/devtools/client/debugger/src/components/SourceOutline/SourceOutlineFunction.tsx @@ -2,7 +2,6 @@ import classnames from "classnames"; import React from "react"; import { FunctionOutlineWithHitCount } from "replay-next/src/suspense/OutlineHitCountsCache"; -import { Redacted } from "ui/components/Redacted"; import PreviewFunction from "../shared/PreviewFunction"; @@ -25,9 +24,9 @@ export const SourceOutlineFunction = React.memo(function OutlineFunction({ >
λ - +
- +
{func.hits !== undefined && (
{func.hits}
diff --git a/src/devtools/client/debugger/src/components/shared/PreviewFunction.js b/src/devtools/client/debugger/src/components/shared/PreviewFunction.js index 5f1d54a0ec7..6456327f7c6 100644 --- a/src/devtools/client/debugger/src/components/shared/PreviewFunction.js +++ b/src/devtools/client/debugger/src/components/shared/PreviewFunction.js @@ -5,9 +5,7 @@ import flatten from "lodash/flatten"; import times from "lodash/times"; import zip from "lodash/zip"; -import React, { Component } from "react"; - -import { RedactedSpan } from "ui/components/Redacted"; +import { Component } from "react"; export default class PreviewFunction extends Component { renderFunctionName(func) { @@ -39,12 +37,12 @@ export default class PreviewFunction extends Component { render() { const { func } = this.props; return ( - + {this.renderFunctionName(func)} ( {this.renderParams(func)} ) - + ); } } diff --git a/src/devtools/client/debugger/src/utils/focus.ts b/src/devtools/client/debugger/src/utils/focus.ts deleted file mode 100644 index bf3d94d12eb..00000000000 --- a/src/devtools/client/debugger/src/utils/focus.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TimeStampedPointRange } from "@replayio/protocol"; - -export function isFocusWindowApplied( - focusWindow: TimeStampedPointRange | null, - endpoint: string -): focusWindow is TimeStampedPointRange { - return ( - focusWindow !== null && (focusWindow.begin.point !== "0" || focusWindow.end.point !== endpoint) - ); -} diff --git a/src/ui/actions/eventListeners/jumpToCode.ts b/src/ui/actions/eventListeners/jumpToCode.ts index e70372b9bb3..b4793ac07c7 100644 --- a/src/ui/actions/eventListeners/jumpToCode.ts +++ b/src/ui/actions/eventListeners/jumpToCode.ts @@ -15,10 +15,8 @@ import { getThreadContext } from "devtools/client/debugger/src/reducers/pause"; import { RecordingTarget, recordingTargetCache } from "replay-next/src/suspense/BuildIdCache"; import { eventCountsCache, eventPointsCache } from "replay-next/src/suspense/EventsCache"; import { topFrameCache } from "replay-next/src/suspense/FrameCache"; -import { hitPointsForLocationCache } from "replay-next/src/suspense/HitPointsCache"; import { objectCache } from "replay-next/src/suspense/ObjectPreviews"; -import { pauseEvaluationsCache, pauseIdCache } from "replay-next/src/suspense/PauseCache"; -import { sourceOutlineCache } from "replay-next/src/suspense/SourceOutlineCache"; +import { pauseEvaluationsCache } from "replay-next/src/suspense/PauseCache"; import { sourcesCache } from "replay-next/src/suspense/SourcesCache"; import { isLocationBefore } from "replay-next/src/utils/source"; import { ReplayClientInterface } from "shared/client/types"; @@ -29,9 +27,6 @@ import { isFunctionPreview, shouldIgnoreEventFromSource, } from "ui/actions/eventListeners/eventListenerUtils"; -import { setViewMode } from "ui/actions/layout"; -import { JumpToCodeStatus } from "ui/components/shared/JumpToCodeButton"; -import { getViewMode } from "ui/reducers/layout"; import { SourcesState, getPreferredLocation, getSourceDetailsEntities } from "ui/reducers/sources"; import { UIState } from "ui/state"; import { ParsedJumpToCodeAnnotation } from "ui/suspense/annotationsCaches"; @@ -140,158 +135,6 @@ export const nextInteractionEventCache: Cache< }, }); -/* -Jump to the function location that ran for a given info sidebar event list item, -such as "Click" or "Key Press: L" - -This requires stringing together a series of assumptions and special cases: - -- The info sidebar "Click" events come from `Session.findMouseEvents`, and keyboard events - from `Session.findKeyboardEvents` - These events are recorded in our browser forks _before_ any actual JS code runs. -- However, we can assume that a _real_ "user interaction event" such as - `"click"` or `"keypress"` event occurs shortly thereafter. -- We can find that event based on a timeboxed search, with the initial sidebar event time - as the starting point. -- We can use the interaction event's stack frame to know the location of the JS event handler - that started running in response to the event. _However_, React attaches noop handlers, - and implements its own event listener lookups at the top level (delegation). -- Fortunately, React 16/17/18 add a secret expando property to DOM nodes, containing - the actual props that the user rendered for that DOM node, such as `onClick`. -- All interaction events will have a JS event object such as `MouseEvent`or `InputEvent` - as one of their arguments -- Once we find the event object, we can look at `event.target` to find the clicked node -- But, the _target_ may not have had the handler due to delegation - the user-provided - React handler prop may have been on an ancestor DOM node instead. -- So, we can walk up the parent node chain and find the first node that has a React - handler prop with a relevant name attached to it, if any, and return that -- In order to optimize the API calls and network traffic, that parent node traversal - is done via a single JS evaluation, which returns `{target, handlerProp?}`. -- If we found a React event handler prop in the chain, jump to that location. Otherwise, - jump to the location of the plain JS event handler in the stack frame. - -We _could_ do more analysis and find the nearest time where the first breakable location -inside that function is running, then seek to that point in time, but skipping for now. -*/ -export function jumpToClickEventFunctionLocation( - onSeek: (point: ExecutionPoint, time: number) => void, - event: PointWithEventType, - end?: TimeStampedPoint -): UIThunkAction> { - return async (dispatch, getState, { replayClient }) => { - const { point: executionPoint, time } = event; - const sourcesState = getState().sources; - - try { - // Actual browser click events get recorded a fraction later then the - // "mouse events" used by the sidebar. - // Look for the next click event within a short timeframe after the "mouse event". - // Yes, this is hacky, but it does seem pretty consistent. - if (!end) { - const arbitraryEndTime = time + 500; - const pointNearEndTime = await replayClient.getPointNearTime(arbitraryEndTime); - end = pointNearEndTime; - } - const actualEnd = end!; - - const focusWindow = replayClient.getCurrentFocusWindow(); - - // Safety check: don't ask for points if this time isn't loaded - const isEndTimeInLoadedRegion = - focusWindow != null && - focusWindow.begin.time <= actualEnd.time && - focusWindow.end.time >= actualEnd.time; - - if (!isEndTimeInLoadedRegion) { - return "not_focused"; - } - - // Go ahead and ensure that we're on DevTools mode right away, - // even before we know if there's a valid location to jump to - if (getViewMode(getState()) !== "dev") { - dispatch(setViewMode("dev")); - } - - // The sidebar event time/point is a fraction earlier than any - // actual JS that executed in response. Find the next click event - // within a small time window - const nextClickEvent = await nextInteractionEventCache.readAsync( - replayClient, - executionPoint, - actualEnd, - event.kind as InteractionEventKind, - sourcesState - ); - - if (!nextClickEvent) { - return "no_hits"; - } - - const pauseId = await pauseIdCache.readAsync( - replayClient, - nextClickEvent.point, - nextClickEvent.time - ); - - const functionSourceLocation = await eventListenerLocationCache.readAsync( - replayClient, - getState, - pauseId, - event.kind as InteractionEventKind - ); - - if (functionSourceLocation) { - const symbols = await sourceOutlineCache.readAsync( - replayClient, - functionSourceLocation.sourceId - ); - - const functionOutline = findFunctionOutlineForLocation(functionSourceLocation, symbols); - - const cx = getThreadContext(getState()); - - const nextBreakablePosition: Location | null = functionOutline?.breakpointLocation - ? { - ...functionOutline?.breakpointLocation, - sourceId: functionSourceLocation.sourceId, - } - : null; - const locationToOpen = nextBreakablePosition ?? functionSourceLocation; - - // Open the source file and jump to the found position. - // This is either the function definition itself, or the first position _inside_ the function. - dispatch(selectLocation(cx, locationToOpen)); - - if (nextBreakablePosition) { - // We think we know the first position _inside_ the function. - // Run analysis to find the next time this position got hit. - const [hitPoints] = await hitPointsForLocationCache.readAsync( - replayClient, - { begin: executionPoint, end: end.point }, - nextBreakablePosition, - null - ); - - const [firstHitPoint] = hitPoints; - if (firstHitPoint) { - // Assuming the position got hit, timewarp to that exact time. - // This should put the execution line+time inside the function, - // where the actual event listener logic is executing. - onSeek(firstHitPoint.point, firstHitPoint.time); - } - } - return "found"; - } else { - return "no_hits"; - } - } catch (err) { - // Let's just swallow this silently for now - } - - return "no_hits"; - }; -} - // TODO This cache looks unsafe because it's not idempotent; // it accepts a state getter function but does not reflect the state it reads as part of the cache key. export const eventListenerLocationCache: Cache< diff --git a/src/ui/actions/session.ts b/src/ui/actions/session.ts index 546f2811111..a6bfda80317 100644 --- a/src/ui/actions/session.ts +++ b/src/ui/actions/session.ts @@ -34,7 +34,6 @@ import { import { setFocusWindow } from "ui/reducers/timeline"; import { getMutableParamsFromURL } from "ui/setup/dynamic/url"; import type { ExpectedError, UnexpectedError } from "ui/state/app"; -import LogRocket from "ui/utils/logrocket"; import { endMixpanelSession } from "ui/utils/mixpanel"; import { registerRecording, trackEvent } from "ui/utils/telemetry"; import { subscriptionExpired } from "ui/utils/workspace"; @@ -176,8 +175,6 @@ export function createSocket(recordingId: string): UIThunkAction { dispatch(actions.setRecordingWorkspace(recording.workspace)); } - LogRocket.createSession({ recording, userInfo }); - registerRecording({ recording }); if ( diff --git a/src/ui/components/Avatar.tsx b/src/ui/components/Avatar.tsx index 0e40b255e83..389141d3f2a 100644 --- a/src/ui/components/Avatar.tsx +++ b/src/ui/components/Avatar.tsx @@ -16,7 +16,6 @@ type AvatarImageProps = Omit, "src"> & }; export const AvatarImage = (props: AvatarImageProps) => ( (e.currentTarget.src = "/recording/images/clear.png")} diff --git a/src/ui/components/DevToolsProcessingScreen.module.css b/src/ui/components/DevToolsProcessingScreen.module.css new file mode 100644 index 00000000000..90be0650009 --- /dev/null +++ b/src/ui/components/DevToolsProcessingScreen.module.css @@ -0,0 +1,19 @@ +.SecondaryMessage { + color: var(--color-dim); +} + +.NotifyMessage { + border: 1px solid var(--checkbox-border); + padding: 0.5rem 01rem; + border-radius: 2rem; + font-size: var(--font-size-regular); + cursor: pointer; +} +.NotifyMessage[data-disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +.Checkbox { + border-radius: 0.25rem; +} diff --git a/src/ui/components/DevToolsProcessingScreen.tsx b/src/ui/components/DevToolsProcessingScreen.tsx index f1625dab860..52a04f07fcd 100644 --- a/src/ui/components/DevToolsProcessingScreen.tsx +++ b/src/ui/components/DevToolsProcessingScreen.tsx @@ -1,17 +1,40 @@ -import { useEffect, useState } from "react"; +import { ChangeEvent, ReactNode, useEffect, useRef, useState } from "react"; +import { useNotification } from "replay-next/src/hooks/useNotification"; import LoadingScreen from "ui/components/shared/LoadingScreen"; import { useGetRecording, useGetRecordingId } from "ui/hooks/recordings"; import { getProcessingProgress } from "ui/reducers/app"; -import { useAppSelector } from "ui/setup/hooks"; +import { useAppSelector, useAppStore } from "ui/setup/hooks"; import { formatEstimatedProcessingDuration } from "ui/utils/formatEstimatedProcessingDuration"; +import styles from "./DevToolsProcessingScreen.module.css"; + const SHOW_STALLED_MESSAGE_AFTER_MS = 30_000; export function DevToolsProcessingScreen() { const recordingId = useGetRecordingId(); const { recording } = useGetRecording(recordingId); + const store = useAppStore(); + + const { + permission: notificationPermission, + requestPermission: requestNotificationPermission, + requested: requestedNotificationPermission, + supported: notificationSupported, + } = useNotification(); + + // Sync latest permission values to a ref so we they can be checked during an unmount + const notificationPermissionStateRef = useRef({ + permission: notificationPermission, + permissionRequested: requestedNotificationPermission, + }); + useEffect(() => { + const current = notificationPermissionStateRef.current; + current.permission = notificationPermission; + current.permissionRequested = requestedNotificationPermission; + }); + const processingProgress = useAppSelector(getProcessingProgress); const [showStalledMessage, setShowStalledMessage] = useState(false); @@ -30,8 +53,26 @@ export function DevToolsProcessingScreen() { } }, [processingProgress]); + useEffect(() => { + return () => { + // We are intentionally referencing this mutable object in a cleanup effect + // React warns that this may be a mistake (because the render and cleanup values may be different) + // but in our case, we want the latest imperative value (which may cause this component to be unmounted) + // eslint-disable-next-line react-hooks/exhaustive-deps + const { permission, permissionRequested } = notificationPermissionStateRef.current; + if (permissionRequested && permission === "granted" && recording) { + const progress = getProcessingProgress(store.getState()); + if (progress === 100 && !document.hasFocus()) { + new Notification(`"${recording.title}" has loaded`, { + tag: recording.id, + }); + } + } + }; + }, [recording, store]); + let message = "Processing..."; - let secondaryMessage = + let secondaryMessage: ReactNode = "This could take a while, depending on the complexity and length of the replay."; if (showStalledMessage) { @@ -45,5 +86,42 @@ export function DevToolsProcessingScreen() { message = `Processing... (${Math.round(processingProgress)}%)`; } - return ; + const onChange = async (event: ChangeEvent) => { + if (event.target.checked) { + const granted = await requestNotificationPermission(); + if (!granted) { + event.target.checked = false; + } + } + }; + + return ( + +
{message}
+
{secondaryMessage}
+ {notificationSupported && ( + + )} + + } + /> + ); } diff --git a/src/ui/components/ProtocolViewer/suspense/recordedProtocolMessagesCache.ts b/src/ui/components/ProtocolViewer/suspense/recordedProtocolMessagesCache.ts index 9b3846634dc..97727659225 100644 --- a/src/ui/components/ProtocolViewer/suspense/recordedProtocolMessagesCache.ts +++ b/src/ui/components/ProtocolViewer/suspense/recordedProtocolMessagesCache.ts @@ -86,10 +86,9 @@ export const recordedProtocolMessagesCache = createFocusIntervalCacheForExecutio return []; } - const preferredLocation = getPreferredLocation( - sourcesState, - pointDescriptions[0].frame ?? [] - ); + const preferredLocation = + pointDescriptions[0].frame && + getPreferredLocation(sourcesState, pointDescriptions[0].frame); if (!preferredLocation) { return []; diff --git a/src/ui/components/Redacted.tsx b/src/ui/components/Redacted.tsx deleted file mode 100644 index 71b637202be..00000000000 --- a/src/ui/components/Redacted.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import classnames from "classnames"; -import { HTMLProps, Ref } from "react"; - -import { useGraphQLUserData } from "shared/user-data/GraphQL/useGraphQLUserData"; - -type RefProp = { - refToForward?: Ref; -}; - -export function Redacted({ - className, - refToForward, - ...rest -}: HTMLProps & RefProp) { - const [showRedactions] = useGraphQLUserData("global_showRedactions"); - return ( -
} - /> - ); -} - -export function RedactedSpan({ - className, - refToForward, - ...rest -}: HTMLProps & RefProp) { - const [showRedactions] = useGraphQLUserData("global_showRedactions"); - return ( - - ); -} diff --git a/src/ui/components/SecondaryToolbox/SecondaryToolbox.module.css b/src/ui/components/SecondaryToolbox/SecondaryToolbox.module.css index e6eabbfe6d9..e22d9b92c53 100644 --- a/src/ui/components/SecondaryToolbox/SecondaryToolbox.module.css +++ b/src/ui/components/SecondaryToolbox/SecondaryToolbox.module.css @@ -7,3 +7,13 @@ border-radius: 0.25rem; font-size: var(--font-size-regular); } + +.CouldNotLoadMessage { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: 1rem; + text-align: center; + color: var(--color-dim); +} diff --git a/src/ui/components/SecondaryToolbox/index.tsx b/src/ui/components/SecondaryToolbox/index.tsx index 0c4c27196c1..0a65eb655ea 100644 --- a/src/ui/components/SecondaryToolbox/index.tsx +++ b/src/ui/components/SecondaryToolbox/index.tsx @@ -26,7 +26,6 @@ import { selectors } from "../../reducers"; import NetworkMonitor from "../NetworkMonitor"; import { NodePicker } from "../NodePicker"; import { LoadDependenciesButton } from "../LoadDependenciesButton"; -import { Redacted } from "../Redacted"; import ReplayLogo from "../shared/ReplayLogo"; import WaitForReduxSlice from "../WaitForReduxSlice"; import NewConsoleRoot from "./NewConsole"; @@ -245,7 +244,7 @@ export default function SecondaryToolbox() {
- +
}> {shouldShowNetworkTab && ( @@ -266,7 +265,7 @@ export default function SecondaryToolbox() { )} - +
); } diff --git a/src/ui/components/SecondaryToolbox/redux-devtools/ReduxDevToolsPanel.tsx b/src/ui/components/SecondaryToolbox/redux-devtools/ReduxDevToolsPanel.tsx index 7384ac34b5b..6e0175ff556 100644 --- a/src/ui/components/SecondaryToolbox/redux-devtools/ReduxDevToolsPanel.tsx +++ b/src/ui/components/SecondaryToolbox/redux-devtools/ReduxDevToolsPanel.tsx @@ -15,7 +15,7 @@ import { ReduxDevToolsContents } from "ui/components/SecondaryToolbox/redux-devt import { ReduxDevToolsList } from "ui/components/SecondaryToolbox/redux-devtools/ReduxDevToolsList"; import { useAppSelector } from "ui/setup/hooks"; import { reduxDevToolsAnnotationsCache } from "ui/suspense/annotationsCaches"; -import { applyMiddlewareDeclCache } from "ui/suspense/jumpToLocationCache"; +import { reduxStoreDetailsCache } from "ui/suspense/jumpToLocationCache"; import styles from "./ReduxDevToolsPanel.module.css"; @@ -84,7 +84,7 @@ export default function ReduxDevToolsPanel() { useEffect(() => { // Preload the cache for where `applyMiddleware` is defined // This will speed up the first click on a Redux "J2C" button - applyMiddlewareDeclCache.prefetch(client); + reduxStoreDetailsCache.prefetch(client); }, [client]); return ( diff --git a/src/ui/components/TestSuite/hooks/useJumpToSource.ts b/src/ui/components/TestSuite/hooks/useJumpToSource.ts index b44347a8b8d..91bb12dde39 100644 --- a/src/ui/components/TestSuite/hooks/useJumpToSource.ts +++ b/src/ui/components/TestSuite/hooks/useJumpToSource.ts @@ -19,11 +19,13 @@ import { AwaitTimeout, awaitWithTimeout } from "ui/utils/awaitWithTimeout"; export function useJumpToSource({ groupedTestCases, testEvent, + testEvents, testRecording, openSourceAutomatically = false, }: { groupedTestCases: GroupedTestCases; testEvent: TestEvent; + testEvents: TestEvent[]; testRecording: TestRecording; openSourceAutomatically: boolean; }) { @@ -57,7 +59,8 @@ export function useJumpToSource({ const locationPromise = TestStepSourceLocationCache.readAsync( replayClient, groupedTestCases, - testEvent + testEvent, + testEvents ); let location; diff --git a/src/ui/components/TestSuite/suspense/TestStepSourceLocationCache.ts b/src/ui/components/TestSuite/suspense/TestStepSourceLocationCache.ts index 4e0f7fa76ea..11c2bc34090 100644 --- a/src/ui/components/TestSuite/suspense/TestStepSourceLocationCache.ts +++ b/src/ui/components/TestSuite/suspense/TestStepSourceLocationCache.ts @@ -1,12 +1,18 @@ import assert from "assert"; import { Frame, Location } from "@replayio/protocol"; import { compare } from "compare-versions"; +import { Cache } from "suspense"; import { framesCache } from "replay-next/src/suspense/FrameCache"; import { pauseIdCache } from "replay-next/src/suspense/PauseCache"; import { createCacheWithTelemetry } from "replay-next/src/utils/suspense"; import { ReplayClientInterface } from "shared/client/types"; -import { GroupedTestCases, UserActionEvent } from "shared/test-suites/RecordingTestMetadata"; +import { + GroupedTestCases, + TestEvent, + UserActionEvent, + isUserActionTestEvent, +} from "shared/test-suites/RecordingTestMetadata"; function getCypressMarkerFrame(frames: Frame[]) { const markerFrameIndex = frames.findIndex( @@ -43,14 +49,19 @@ function getCypressMarkerFrame(frames: Frame[]) { return -1; } -export const TestStepSourceLocationCache = createCacheWithTelemetry< - [client: ReplayClientInterface, groupedTestCases: GroupedTestCases, testEvent: UserActionEvent], +export const TestStepSourceLocationCache: Cache< + [ + client: ReplayClientInterface, + groupedTestCases: GroupedTestCases, + testEvent: UserActionEvent, + testEvents: TestEvent[] + ], Location | undefined ->({ +> = createCacheWithTelemetry({ debugLabel: "TestStepSourceLocationCache", getKey: ([client, groupedTestCases, testEvent]) => `${groupedTestCases.source.title}:${testEvent.data.id}`, - load: async ([client, groupedTestCases, testEvent]) => { + load: async ([client, groupedTestCases, testEvent, testEvents]) => { const runner = groupedTestCases.environment.testRunner.name; const runnerVersion = groupedTestCases.environment.testRunner.version; @@ -64,6 +75,24 @@ export const TestStepSourceLocationCache = createCacheWithTelemetry< if (frames) { if (compare(runnerVersion, "8.0.0", ">=")) { const markerFrameIndex = getCypressMarkerFrame(frames); + if (markerFrameIndex < 0) { + if (testEvent.data.parentId) { + // If we couldn't find a marker frame, + // the parent event location would still be a meaningful place to jump to + const parentId = testEvent.data.parentId; + const parentTestEvent = testEvents.find( + testEvent => isUserActionTestEvent(testEvent) && testEvent.data.id === parentId + ); + if (parentTestEvent) { + return await TestStepSourceLocationCache.readAsync( + client, + groupedTestCases, + parentTestEvent as UserActionEvent, + testEvents + ); + } + } + } // and extract its sourceId const markerSourceId = frames[markerFrameIndex]?.functionLocation?.[0].sourceId; diff --git a/src/ui/components/TestSuite/views/TestRecording/TestEventStepDetails/TestEventDetails.module.css b/src/ui/components/TestSuite/views/TestRecording/TestEventStepDetails/TestEventDetails.module.css index db83007453e..8e569b39b51 100644 --- a/src/ui/components/TestSuite/views/TestRecording/TestEventStepDetails/TestEventDetails.module.css +++ b/src/ui/components/TestSuite/views/TestRecording/TestEventStepDetails/TestEventDetails.module.css @@ -99,7 +99,7 @@ padding: 0 0.5rem; } .SourceCodeLineSelected { - background-color: var(--background-color-current-execution-point); + background-color: var(--background-color-current-execution-point-test-code); } .TabsContainer { diff --git a/src/ui/components/TestSuite/views/TestRecording/TestRecordingEvents/UserActionEventRow.tsx b/src/ui/components/TestSuite/views/TestRecording/TestRecordingEvents/UserActionEventRow.tsx index 2abbdd43603..415e4995195 100644 --- a/src/ui/components/TestSuite/views/TestRecording/TestRecordingEvents/UserActionEventRow.tsx +++ b/src/ui/components/TestSuite/views/TestRecording/TestRecordingEvents/UserActionEventRow.tsx @@ -10,9 +10,12 @@ import { ReplayClientContext } from "shared/client/ReplayClientContext"; import { GroupedTestCases, RecordingTestMetadataV3, + TestEvent, TestSectionName, UserActionEvent, isUserActionTestEvent, + isUserClickEvent, + isUserKeyboardEvent, } from "shared/test-suites/RecordingTestMetadata"; import { jumpToKnownEventListenerHit } from "ui/actions/eventListeners/jumpToCode"; import { seek } from "ui/actions/timeline"; @@ -44,18 +47,20 @@ const cypressStepTypesToEventTypes = { export default memo(function UserActionEventRow({ groupedTestCases, isSelected, + testEvents, testSectionName, userActionEvent, }: { groupedTestCases: RecordingTestMetadataV3.GroupedTestCases; isSelected: boolean; + testEvents: TestEvent[]; testSectionName: TestSectionName; userActionEvent: UserActionEvent; }) { const { data } = userActionEvent; - const testRunnerName = groupedTestCases.environment.testRunner.name; - const { command, error, parentId } = data; - const resultPoint = userActionEvent.data.timeStampedPoints.result; + const { command, error, parentId, timeStampedPoints } = data; + + const resultPoint = timeStampedPoints.result; const replayClient = useContext(ReplayClientContext); @@ -82,6 +87,7 @@ export default memo(function UserActionEventRow({ const { disabled: jumpToTestSourceDisabled, onClick: onClickJumpToTestSource } = useJumpToSource({ groupedTestCases, testEvent: userActionEvent, + testEvents, testRecording, openSourceAutomatically: viewMode === "dev", }); @@ -90,7 +96,6 @@ export default memo(function UserActionEventRow({ annotationsStatus === STATUS_RESOLVED ? parsedAnnotations : NO_ANNOTATIONS; const [canShowJumpToCode, jumpToCodeAnnotation] = findJumpToCodeDetailsIfAvailable( - groupedTestCases, userActionEvent, jumpToCodeAnnotations ); @@ -211,65 +216,24 @@ function Badge({ } function findJumpToCodeDetailsIfAvailable( - groupedTestCases: GroupedTestCases, userActionEvent: UserActionEvent, jumpToCodeAnnotations: ParsedJumpToCodeAnnotation[] ) { let canShowJumpToCode = false; let jumpToCodeAnnotation: ParsedJumpToCodeAnnotation | undefined = undefined; - if (groupedTestCases.environment.testRunner.name === "cypress") { - const { data } = userActionEvent; - const { category, command, timeStampedPoints } = data; - const { name } = command; - - if (timeStampedPoints.beforeStep !== null && timeStampedPoints.afterStep !== null) { - // TODO This is very Cypress-specific. - // Playwright steps have a `name` like `locator.click("blah")`. - // We only care about click events and keyboard events. Keyboard events appear to be a "type" command, - // as in "type this text into the input". - canShowJumpToCode = - category === "command" && ["click", "type", "check", "uncheck"].includes(name); - - if (canShowJumpToCode) { - const eventKind = - cypressStepTypesToEventTypes[name as keyof typeof cypressStepTypesToEventTypes]; - - if (eventKind) { - jumpToCodeAnnotation = jumpToCodeAnnotations.find(a => - isExecutionPointsWithinRange( - a.point, - timeStampedPoints.beforeStep!.point, - timeStampedPoints.afterStep!.point - ) - ); - } - } - } - } else if (groupedTestCases.environment.testRunner.name === "playwright") { - const { data } = userActionEvent; - const { category, command, timeStampedPoints } = data; - const { name } = command; - - if (timeStampedPoints.beforeStep !== null && timeStampedPoints.afterStep !== null) { - canShowJumpToCode = - category === "command" && - (name.startsWith("locator.click") || - name.startsWith("locator.type") || - name.startsWith("keyboard.down") || - name.startsWith("keyboard.press") || - name.startsWith("keyboard.type")); - - if (canShowJumpToCode) { - jumpToCodeAnnotation = jumpToCodeAnnotations.find(a => - isExecutionPointsWithinRange( - a.point, - timeStampedPoints.beforeStep!.point, - timeStampedPoints.afterStep!.point - ) - ); - } - } + const { data } = userActionEvent; + const { timeStampedPoints } = data; + + if (timeStampedPoints.beforeStep !== null && timeStampedPoints.afterStep !== null) { + canShowJumpToCode = isUserClickEvent(userActionEvent) || isUserKeyboardEvent(userActionEvent); + jumpToCodeAnnotation = jumpToCodeAnnotations.find(a => + isExecutionPointsWithinRange( + a.point, + timeStampedPoints.beforeStep!.point, + timeStampedPoints.afterStep!.point + ) + ); } return [canShowJumpToCode, jumpToCodeAnnotation] as const; diff --git a/src/ui/components/TestSuite/views/TestRecording/TestSection.tsx b/src/ui/components/TestSuite/views/TestRecording/TestSection.tsx index f1a9e375186..4786069ca4c 100644 --- a/src/ui/components/TestSuite/views/TestRecording/TestSection.tsx +++ b/src/ui/components/TestSuite/views/TestRecording/TestSection.tsx @@ -31,6 +31,7 @@ export default function TestSection({ diff --git a/src/ui/components/TestSuite/views/TestRecording/TestSectionRow.tsx b/src/ui/components/TestSuite/views/TestRecording/TestSectionRow.tsx index b92dcc0df01..55c071b52a4 100644 --- a/src/ui/components/TestSuite/views/TestRecording/TestSectionRow.tsx +++ b/src/ui/components/TestSuite/views/TestRecording/TestSectionRow.tsx @@ -3,11 +3,14 @@ import { TimeStampedPoint, TimeStampedPointRange } from "@replayio/protocol"; import { ReactNode, useContext, useMemo } from "react"; import { highlightNodes, unhighlightNode } from "devtools/client/inspector/markup/actions/markup"; +import { RecordedMouseEventsCache } from "protocol/RecordedEventsCache"; import Icon from "replay-next/components/Icon"; -import { FocusContext } from "replay-next/src/contexts/FocusContext"; import { SessionContext } from "replay-next/src/contexts/SessionContext"; import { TimelineContext } from "replay-next/src/contexts/TimelineContext"; -import { isExecutionPointsGreaterThan } from "replay-next/src/utils/time"; +import { + isExecutionPointsGreaterThan, + isExecutionPointsWithinRange, +} from "replay-next/src/utils/time"; import { ReplayClientContext } from "shared/client/ReplayClientContext"; import { TestEvent, @@ -17,6 +20,7 @@ import { getTestEventTimeStampedPoint, getUserActionEventRange, isUserActionTestEvent, + isUserClickEvent, } from "shared/test-suites/RecordingTestMetadata"; import { extendFocusWindowIfNecessary, seek, setHoverTime } from "ui/actions/timeline"; import { TestSuiteCache } from "ui/components/TestSuite/suspense/TestSuiteCache"; @@ -33,10 +37,12 @@ import styles from "./TestSectionRow.module.css"; export function TestSectionRow({ testEvent, + testEvents, testRunnerName, testSectionName, }: { testEvent: TestEvent; + testEvents: TestEvent[]; testRunnerName: TestRunnerName | null; testSectionName: TestSectionName; }) { @@ -54,7 +60,7 @@ export function TestSectionRow({ const dispatch = useAppDispatch(); - const { contextMenu, onContextMenu } = useTestEventContextMenu(testEvent); + const { contextMenu, onContextMenu } = useTestEventContextMenu(testEvent, testEvents); const position = useMemo(() => { let position: Position = "after"; @@ -109,6 +115,7 @@ export function TestSectionRow({ @@ -146,7 +153,41 @@ export function TestSectionRow({ }; const onMouseEnter = async () => { - dispatch(setHoverTime(getTestEventTime(testEvent))); + let hoverTime: number | null = null; + if (isUserActionTestEvent(testEvent) && isUserClickEvent(testEvent)) { + // Find the actual mouse event that _should_ have occurred + // inside of this test step. We want to use that as the hover + // time, so that the `RecordedCursor` logic will show the mouse + // event correctly instead of finding an _earlier_ mouse event + // that happened _before_ this test step. + const { data } = testEvent; + const { timeStampedPoints } = data; + const mouseEvents = RecordedMouseEventsCache.getValueIfCached(); + + if (timeStampedPoints.beforeStep !== null && timeStampedPoints.afterStep !== null) { + const mouseEvent = mouseEvents?.find(e => { + return ( + e.kind === "mousedown" && + isExecutionPointsWithinRange( + e.point, + timeStampedPoints.beforeStep!.point, + timeStampedPoints.afterStep!.point + ) + ); + }); + + hoverTime = mouseEvent?.time ?? null; + } + } + + if (!hoverTime) { + // Otherwise, default the hover time to whatever is most + // appropriate for this test step based on its type. + hoverTime = getTestEventTime(testEvent); + } + + dispatch(setHoverTime(hoverTime)); + if (!isUserActionTestEvent(testEvent)) { return; } diff --git a/src/ui/components/TestSuite/views/TestRecording/useTestEventContextMenu.tsx b/src/ui/components/TestSuite/views/TestRecording/useTestEventContextMenu.tsx index 4b430700470..44f41599e93 100644 --- a/src/ui/components/TestSuite/views/TestRecording/useTestEventContextMenu.tsx +++ b/src/ui/components/TestSuite/views/TestRecording/useTestEventContextMenu.tsx @@ -19,10 +19,12 @@ import { TestSuiteCache } from "ui/components/TestSuite/suspense/TestSuiteCache" import { TestSuiteContext } from "ui/components/TestSuite/views/TestSuiteContext"; import { useAppDispatch } from "ui/setup/hooks"; -export function useTestEventContextMenu(testEvent: TestEvent) { +export function useTestEventContextMenu(testEvent: TestEvent, testEvents: TestEvent[]) { const { contextMenu, onContextMenu } = useContextMenu( <> - {isUserActionTestEvent(testEvent) && } + {isUserActionTestEvent(testEvent) && ( + + )} {isUserActionTestEvent(testEvent) && } @@ -33,7 +35,13 @@ export function useTestEventContextMenu(testEvent: TestEvent) { return { contextMenu, onContextMenu }; } -function JumpToSourceMenuItem({ userActionEvent }: { userActionEvent: UserActionEvent }) { +function JumpToSourceMenuItem({ + testEvents, + userActionEvent, +}: { + testEvents: TestEvent[]; + userActionEvent: UserActionEvent; +}) { const replayClient = useContext(ReplayClientContext); const { recordingId } = useContext(SessionContext); const { setTestEvent, testRecording } = useContext(TestSuiteContext); @@ -45,6 +53,7 @@ function JumpToSourceMenuItem({ userActionEvent }: { userActionEvent: UserAction const { disabled, onClick } = useJumpToSource({ groupedTestCases, testEvent: userActionEvent, + testEvents, testRecording, openSourceAutomatically: true, }); diff --git a/src/ui/components/TestSuite/views/TestSuiteContext.tsx b/src/ui/components/TestSuite/views/TestSuiteContext.tsx index 389a095c6e7..288557a2ab8 100644 --- a/src/ui/components/TestSuite/views/TestSuiteContext.tsx +++ b/src/ui/components/TestSuite/views/TestSuiteContext.tsx @@ -41,6 +41,7 @@ export function TestSuiteContextRoot({ children }: PropsWithChildren) { const setTestRecordingWrapper = useCallback( async (testRecording: TestRecording | null) => { setTestRecording(testRecording); + setTestEvent(null); if (testRecording != null) { const { timeStampedPointRange } = testRecording; diff --git a/src/ui/components/Timeline/Focuser.tsx b/src/ui/components/Timeline/Focuser.tsx index 6bb6688d88e..3b1d7494bc9 100644 --- a/src/ui/components/Timeline/Focuser.tsx +++ b/src/ui/components/Timeline/Focuser.tsx @@ -14,7 +14,7 @@ import { getPositionFromTime, getTimeFromPosition } from "ui/utils/timeline"; import { EditMode } from "./Timeline"; -function stopEvent(event: MouseEvent) { +function stopEvent(event: MouseEvent | React.MouseEvent) { event.preventDefault(); event.stopPropagation(); } @@ -206,7 +206,7 @@ function Focuser({ editMode, setEditMode }: Props) { const right = getPositionFromTime(focusWindow.end, zoomRegion); return ( -
+
(null); @@ -37,9 +39,20 @@ export function RecordedCursor() { element.style.top = `${mouseY}%`; element.style.transform = `scale(${cursorScale})`; element.style.setProperty("--click-display", shouldDrawClick ? "block" : "none"); + + // Set data attributes describing the cursor state for use in our E2E tests + element.dataset.cursorDisplay = "true"; + element.dataset.clickDisplay = shouldDrawClick ? "true" : "false"; + element.dataset.clientX = `${mouseEvent.clientX}`; + element.dataset.clientY = `${mouseEvent.clientY}`; } else { element.style.display = "none"; element.style.setProperty("--click-display", "none"); + + element.dataset.cursorDisplay = "false"; + element.dataset.clickDisplay = "false"; + element.dataset.clientX = "0"; + element.dataset.clientY = "0"; } } ); @@ -56,6 +69,11 @@ export function RecordedCursor() { display: "none", position: "absolute", }} + data-test-name="recorded-cursor" + data-cursor-display="false" + data-click-display="false" + data-client-x="0" + data-client-y="0" >
{ - const videoPanelRef = useRef(null); - - const [videoPanelCollapsed, setVideoPanelCollapsed] = useLocalStorageUserData( - "replayVideoPanelCollapsed" - ); - - const onVideoPanelCollapse = (collapsed: boolean) => { - setVideoPanelCollapsed(collapsed); - }; - - useLayoutEffect(() => { - const videoPanel = videoPanelRef.current; - if (videoPanel) { - if (videoPanelCollapsed) { - videoPanel.collapse(); - } else { - videoPanel.expand(); - } - } - }, [videoPanelCollapsed]); - +const Vertical = ({ + onVideoPanelCollapse, + toolboxLayout, + videoPanelCollapsed, + videoPanelRef, +}: { + onVideoPanelCollapse: (collapsed: boolean) => void; + toolboxLayout: ToolboxLayout; + videoPanelCollapsed: boolean; + videoPanelRef: RefObject; +}) => { return ( { ); }; -const Horizontal = ({ toolboxLayout }: { toolboxLayout: ToolboxLayout }) => { +const Horizontal = ({ + onVideoPanelCollapse, + videoPanelCollapsed, + videoPanelRef, +}: { + onVideoPanelCollapse: (collapsed: boolean) => void; + videoPanelCollapsed: boolean; + videoPanelRef: RefObject; +}) => { const replayClient = useContext(ReplayClientContext); const recordingCapabilities = recordingCapabilitiesCache.read(replayClient); - const videoPanelRef = useRef(null); - const [videoPanelCollapsed, setVideoPanelCollapsed] = useState(false); - return ( { : "Panel-SecondaryToolbox" } minSize={10} - onCollapse={() => setVideoPanelCollapsed(true)} - onExpand={() => setVideoPanelCollapsed(false)} + onCollapse={() => onVideoPanelCollapse(true)} + onExpand={() => onVideoPanelCollapse(false)} order={2} ref={videoPanelRef} > @@ -129,6 +123,27 @@ const Horizontal = ({ toolboxLayout }: { toolboxLayout: ToolboxLayout }) => { export default function Viewer() { const toolboxLayout = useAppSelector(getToolboxLayout); + const videoPanelRef = useRef(null); + + const [videoPanelCollapsed, setVideoPanelCollapsed] = useLocalStorageUserData( + "replayVideoPanelCollapsed" + ); + + const onVideoPanelCollapse = (collapsed: boolean) => { + setVideoPanelCollapsed(collapsed); + }; + + useLayoutEffect(() => { + const videoPanel = videoPanelRef.current; + if (videoPanel) { + if (videoPanelCollapsed) { + videoPanel.collapse(); + } else { + videoPanel.expand(); + } + } + }, [videoPanelCollapsed]); + return ( {toolboxLayout === "ide" && ( @@ -141,9 +156,18 @@ export default function Viewer() { )} {toolboxLayout === "left" ? ( - + ) : ( - + )} diff --git a/src/ui/components/shared/APIKeys.tsx b/src/ui/components/shared/APIKeys.tsx index b71c115df06..83c4aa329b9 100644 --- a/src/ui/components/shared/APIKeys.tsx +++ b/src/ui/components/shared/APIKeys.tsx @@ -76,7 +76,7 @@ function ApiKeyList({ apiKeys, onDelete }: { apiKeys: ApiKey[]; onDelete: (id: s : `(${apiKey.recordingCount} recordings)`; return (
- + {apiKey.label} {usage} diff --git a/src/ui/components/shared/LoadingScreen.module.css b/src/ui/components/shared/LoadingScreen.module.css index 7437605912a..7934bd0c924 100644 --- a/src/ui/components/shared/LoadingScreen.module.css +++ b/src/ui/components/shared/LoadingScreen.module.css @@ -1,55 +1,36 @@ -.loadingScreenWrapper { +.LoadingScreen { + width: 100%; + height: 100%; position: relative; display: flex; - width: 24rem; flex-direction: column; -} - -.hoverboardWrapper { - height: 8rem; - width: 8rem; - cursor: pointer; -} - -.messageWrapper { - font-size: 0.875rem; + align-items: center; text-align: center; + padding: 1rem; + gap: 1rem; + font-size: 0.875rem; } - -.messageWrapper a { +.LoadingScreen a { text-decoration: underline; } - -.messageWrapper a:hover { +.LoadingScreen a:hover { color: var(--primary-accent); } -.message { - margin-bottom: 1rem; -} - -.secondaryMessage { - color: var(--color-dim); - width: 18rem; -} - -.viewportWrapper { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - padding: 1rem; - margin: 0.5rem; - border-radius: 0.5rem; +.Hoverboard { + height: 8rem; + width: 8rem; } .HighRiskWarning { - width: 40ch; - padding: 1rem 2rem; - border-radius: 1rem; - margin: 3rem 0; + padding: 0.5rem; + border-radius: 0.5rem; text-align: center; font-size: var(--font-size-regular); background-color: var(--background-color-high-risk-setting); color: var(--color-high-risk-setting); } + +.Spacer { + flex-grow: 1; +} diff --git a/src/ui/components/shared/LoadingScreen.tsx b/src/ui/components/shared/LoadingScreen.tsx index b26b874622c..261fd716c6a 100644 --- a/src/ui/components/shared/LoadingScreen.tsx +++ b/src/ui/components/shared/LoadingScreen.tsx @@ -1,86 +1,65 @@ import dynamic from "next/dynamic"; -import { ReactNode, useCallback, useEffect, useState } from "react"; -import { ConnectedProps, connect } from "react-redux"; +import { ReactNode, useEffect, useState } from "react"; import { useHighRiskSettingCount } from "shared/user-data/GraphQL/useHighRiskSettingCount"; import { RecordingDocumentTitle } from "ui/components/RecordingDocumentTitle"; import { getAwaitingSourcemaps, getUploading } from "ui/reducers/app"; -import { UIState } from "ui/state"; +import { useAppSelector } from "ui/setup/hooks"; import { DefaultViewportWrapper } from "./Viewport"; import styles from "./LoadingScreen.module.css"; -const colorOptions: Array<"blue" | "green" | "red"> = ["blue", "green", "red"]; +export default function LoadingScreen({ message }: { message: ReactNode }) { + const isAwaitingSourceMaps = useAppSelector(getAwaitingSourcemaps); + const uploadingInfo = useAppSelector(getUploading); -const Hoverboard = dynamic(() => import("./Hoverboard"), { - ssr: false, - loading: () =>
, -}); - -export function LoadingScreenTemplate({ children }: { children?: ReactNode }) { - const [hoverboardColor, setHoverboardColor] = useState(colorOptions[2]); + const showHighRiskWarning = useHighRiskSettingCount() > 0; - const changeHoverboardColor = useCallback(() => { - const randomIndex = Math.floor(Math.random() * colorOptions.length); - setHoverboardColor(colorOptions[randomIndex]); - }, []); + const [colorIndex, setColorIndex] = useState(0); + const color = colorOptions[colorIndex % colorOptions.length]; useEffect(() => { - const timeoutId = setInterval(changeHoverboardColor, 5000); - return () => clearInterval(timeoutId); - }, [changeHoverboardColor]); + const timeoutId = setInterval(() => { + setColorIndex(prevIndex => prevIndex + 1); + }, 5_000); - return ( -
- -
-
-
- -
- {children} -
-
-
-
- ); -} + return () => clearInterval(timeoutId); + }, []); -function LoadingScreen({ - uploading, - awaitingSourcemaps, - message, - secondaryMessage, -}: PropsFromRedux & { message: string; secondaryMessage?: string }) { - const waitingForMessage = - awaitingSourcemaps || uploading ? ( - Uploading {Math.round(uploading?.amount ? Number(uploading.amount) : 0)}Mb - ) : ( - <> -
- {secondaryMessage &&
{secondaryMessage}
} - + let content: ReactNode; + if (isAwaitingSourceMaps || uploadingInfo) { + content = ( + + Uploading {Math.round(uploadingInfo?.amount ? Number(uploadingInfo.amount) : 0)}Mb + ); - - const showHighRiskWarning = useHighRiskSettingCount() > 0; + } else { + content = message; + } return ( - + <> -
{waitingForMessage}
- {showHighRiskWarning && ( -
- You have advanced settings enabled that may negatively affect performance + +
+
+
- )} - + {content} +
+ {showHighRiskWarning && ( +
+ You have advanced settings enabled that may negatively affect performance +
+ )} +
+ ); } -const connector = connect((state: UIState) => ({ - uploading: getUploading(state), - awaitingSourcemaps: getAwaitingSourcemaps(state), -})); -type PropsFromRedux = ConnectedProps; +const colorOptions = ["blue", "green", "red"] as const; -export default connector(LoadingScreen); +const Hoverboard = dynamic(() => import("./Hoverboard"), { + ssr: false, + loading: () =>
, +}); diff --git a/src/ui/components/shared/SharingModal/EmailForm.tsx b/src/ui/components/shared/SharingModal/EmailForm.tsx index fc62f785e12..ae0f8653032 100644 --- a/src/ui/components/shared/SharingModal/EmailForm.tsx +++ b/src/ui/components/shared/SharingModal/EmailForm.tsx @@ -112,7 +112,7 @@ export default function EmailForm({ recordingId }: { recordingId: RecordingId }) return (
- + {showAutocomplete ? (
{inputValue}
diff --git a/src/ui/components/shared/UserSettingsModal/components/BooleanPreference.tsx b/src/ui/components/shared/UserSettingsModal/components/BooleanPreference.tsx index 9ece3afd5b1..594558c874e 100644 --- a/src/ui/components/shared/UserSettingsModal/components/BooleanPreference.tsx +++ b/src/ui/components/shared/UserSettingsModal/components/BooleanPreference.tsx @@ -37,7 +37,6 @@ export function BooleanPreference({