From a52f3873ccc5d4cba0acc2c9de0b8b112a28099d Mon Sep 17 00:00:00 2001 From: cpadm <57954026+cpAdm@users.noreply.github.com> Date: Sun, 1 Feb 2026 14:22:31 +0100 Subject: [PATCH 1/4] fix: Keep network entry selected during test run --- packages/trace-viewer/src/ui/networkTab.tsx | 12 ++++---- .../ui-mode-test-network-tab.spec.ts | 28 +++++++++++++++++-- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index e014401cb0220..767304e8ff618 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -72,11 +72,9 @@ export const NetworkTab: React.FunctionComponent<{ sdkLanguage: Language, }> = ({ boundaries, networkModel, onResourceHovered, sdkLanguage }) => { const [sorting, setSorting] = React.useState(undefined); - const [selectedEntry, setSelectedEntry] = React.useState(undefined); + const [selectedEntryKey, setSelectedEntryKey] = React.useState(undefined); const [filterState, setFilterState] = React.useState(defaultFilterState); - const visibleSelectedEntry = React.useMemo(() => (selectedEntry && networkModel.resources.includes(selectedEntry.resource)) ? selectedEntry : undefined, [selectedEntry, networkModel.resources]); - const { renderedEntries } = React.useMemo(() => { const renderedEntries = networkModel.resources.map((entry, i) => renderEntry(entry, boundaries, networkModel.contextIdMap, i)).filter(filterEntry(filterState)); if (sorting) @@ -84,13 +82,15 @@ export const NetworkTab: React.FunctionComponent<{ return { renderedEntries }; }, [networkModel.resources, networkModel.contextIdMap, filterState, sorting, boundaries]); + const visibleSelectedEntry = React.useMemo(() => (selectedEntryKey ? renderedEntries.find(entry => JSON.stringify(entry) === selectedEntryKey) : undefined), [selectedEntryKey, renderedEntries]); + const [columnWidths, setColumnWidths] = React.useState>(() => { return new Map(allColumns().map(column => [column, columnWidth(column)])); }); const onFilterStateChange = React.useCallback((newFilterState: FilterState) => { setFilterState(newFilterState); - setSelectedEntry(undefined); + setSelectedEntryKey(undefined); }, []); if (!networkModel.resources.length) @@ -101,7 +101,7 @@ export const NetworkTab: React.FunctionComponent<{ ariaLabel='Network requests' items={renderedEntries} selectedItem={visibleSelectedEntry} - onSelected={item => setSelectedEntry(item)} + onSelected={item => setSelectedEntryKey(JSON.stringify(item))} onHighlighted={item => onResourceHovered?.(item?.ordinal)} columns={visibleColumns(!!visibleSelectedEntry, renderedEntries)} columnTitle={columnTitle} @@ -122,7 +122,7 @@ export const NetworkTab: React.FunctionComponent<{ sidebarIsFirst={true} orientation='horizontal' settingName='networkResourceDetails' - main={ setSelectedEntry(undefined)} />} + main={ setSelectedEntryKey(undefined)} />} sidebar={grid} />} ; diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts index 79fa8387e5757..1ef1899ba770e 100644 --- a/tests/playwright-test/ui-mode-test-network-tab.spec.ts +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -374,7 +374,6 @@ test('should not preserve selection across test runs', async ({ runUITest, serve import { test, expect } from '@playwright/test'; test('network tab test', async ({ page }) => { await page.goto('${server.PREFIX}/network-tab/network.html'); - await page.evaluate(() => (window as any).donePromise); }); `, }); @@ -383,11 +382,34 @@ test('should not preserve selection across test runs', async ({ runUITest, serve await expect(page.getByTestId('workbench-run-status')).toContainText('Passed'); await page.getByRole('tab', { name: 'Network' }).click(); - const networkItem = page.getByRole('listitem').filter({ hasText: 'network.html' }); - await networkItem.click(); + await page.getByRole('listitem').filter({ hasText: 'network.html' }).click(); const headersPanel = page.getByRole('tabpanel', { name: 'Headers' }); await expect(headersPanel).toBeVisible(); await page.getByRole('treeitem', { name: 'network tab test' }).dblclick(); + await expect(page.getByTestId('workbench-run-status')).toContainText('Passed'); await expect(headersPanel).toBeHidden(); }); + +test('should preserve selection during test run', async ({ runUITest, server }, testInfo) => { + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test, expect } from '@playwright/test'; + test('network tab test', async ({ page }) => { + await page.goto('${server.PREFIX}/network-tab/network.html'); + // Keep test running to make sure that selected network entry stay open + await page.waitForTimeout(${testInfo.timeout}); + }); + `, + }); + + await page.getByRole('treeitem', { name: 'network tab test' }).dblclick(); + await page.getByRole('tab', { name: 'Network' }).click(); + await page.getByRole('listitem').filter({ hasText: 'network.html' }).click(); + const headersPanel = page.getByRole('tabpanel', { name: 'Headers' }); + await expect(headersPanel).toBeVisible(); + + // Wait to ensure that trace polling (every 500ms) does not close the selected entry + await page.waitForTimeout(1000); + await expect(headersPanel).toBeVisible(); +}); From d48385996ec1cb03b98a11aa22ea7eb6a538666e Mon Sep 17 00:00:00 2001 From: cpadm <57954026+cpAdm@users.noreply.github.com> Date: Sun, 1 Feb 2026 17:03:48 +0100 Subject: [PATCH 2/4] fix: Calculate key based on resource --- packages/trace-viewer/src/ui/networkTab.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 767304e8ff618..7f4f28d0e640f 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -72,7 +72,7 @@ export const NetworkTab: React.FunctionComponent<{ sdkLanguage: Language, }> = ({ boundaries, networkModel, onResourceHovered, sdkLanguage }) => { const [sorting, setSorting] = React.useState(undefined); - const [selectedEntryKey, setSelectedEntryKey] = React.useState(undefined); + const [selectedResourceKey, setSelectedResourceKey] = React.useState(undefined); const [filterState, setFilterState] = React.useState(defaultFilterState); const { renderedEntries } = React.useMemo(() => { @@ -82,7 +82,7 @@ export const NetworkTab: React.FunctionComponent<{ return { renderedEntries }; }, [networkModel.resources, networkModel.contextIdMap, filterState, sorting, boundaries]); - const visibleSelectedEntry = React.useMemo(() => (selectedEntryKey ? renderedEntries.find(entry => JSON.stringify(entry) === selectedEntryKey) : undefined), [selectedEntryKey, renderedEntries]); + const visibleSelectedEntry = React.useMemo(() => (selectedResourceKey ? renderedEntries.find(entry => JSON.stringify(entry.resource) === selectedResourceKey) : undefined), [selectedResourceKey, renderedEntries]); const [columnWidths, setColumnWidths] = React.useState>(() => { return new Map(allColumns().map(column => [column, columnWidth(column)])); @@ -90,7 +90,7 @@ export const NetworkTab: React.FunctionComponent<{ const onFilterStateChange = React.useCallback((newFilterState: FilterState) => { setFilterState(newFilterState); - setSelectedEntryKey(undefined); + setSelectedResourceKey(undefined); }, []); if (!networkModel.resources.length) @@ -101,7 +101,7 @@ export const NetworkTab: React.FunctionComponent<{ ariaLabel='Network requests' items={renderedEntries} selectedItem={visibleSelectedEntry} - onSelected={item => setSelectedEntryKey(JSON.stringify(item))} + onSelected={item => setSelectedResourceKey(JSON.stringify(item.resource))} onHighlighted={item => onResourceHovered?.(item?.ordinal)} columns={visibleColumns(!!visibleSelectedEntry, renderedEntries)} columnTitle={columnTitle} @@ -122,7 +122,7 @@ export const NetworkTab: React.FunctionComponent<{ sidebarIsFirst={true} orientation='horizontal' settingName='networkResourceDetails' - main={ setSelectedEntryKey(undefined)} />} + main={ setSelectedResourceKey(undefined)} />} sidebar={grid} />} ; From 4f7d413802a31cea513be9ff2d2e4d9a13ade87b Mon Sep 17 00:00:00 2001 From: cpadm <57954026+cpAdm@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:31:42 +0100 Subject: [PATCH 3/4] fix: Add id on Entry to avoid expensive JSON.stringify --- packages/trace-viewer/src/ui/networkTab.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 7f4f28d0e640f..71763ae34f7bf 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -29,8 +29,10 @@ import type { ContextEntry } from '@isomorphic/trace/entries'; import { NetworkFilters, defaultFilterState, type FilterState, type ResourceType } from './networkFilters'; import type { Language } from '@isomorphic/locatorGenerators'; +type EntryWithId = Entry & { id: string }; + type NetworkTabModel = { - resources: Entry[], + resources: EntryWithId[], contextIdMap: ContextIdMap, }; @@ -44,7 +46,7 @@ type RenderedEntry = { size: number, start: number, route: string, - resource: Entry, + resource: EntryWithId, contextId: string, }; type ColumnName = keyof RenderedEntry; @@ -53,13 +55,12 @@ const NetworkGridView = GridView; export function useNetworkTabModel(model: TraceModel | undefined, selectedTime: Boundaries | undefined): NetworkTabModel { const resources = React.useMemo(() => { - const resources = model?.resources || []; - const filtered = resources.filter(resource => { + const resourcesWithIds = (model?.resources || []).map((resource, i) => ({ ...resource, id: `${context(resource).contextId}-${i}`, })); + return resourcesWithIds.filter(resource => { if (!selectedTime) return true; return !!resource._monotonicTime && (resource._monotonicTime >= selectedTime.minimum && resource._monotonicTime <= selectedTime.maximum); }); - return filtered; }, [model, selectedTime]); const contextIdMap = React.useMemo(() => new ContextIdMap(model), [model]); return { resources, contextIdMap }; @@ -82,7 +83,7 @@ export const NetworkTab: React.FunctionComponent<{ return { renderedEntries }; }, [networkModel.resources, networkModel.contextIdMap, filterState, sorting, boundaries]); - const visibleSelectedEntry = React.useMemo(() => (selectedResourceKey ? renderedEntries.find(entry => JSON.stringify(entry.resource) === selectedResourceKey) : undefined), [selectedResourceKey, renderedEntries]); + const visibleSelectedEntry = React.useMemo(() => (selectedResourceKey ? renderedEntries.find(entry => entry.resource.id === selectedResourceKey) : undefined), [selectedResourceKey, renderedEntries]); const [columnWidths, setColumnWidths] = React.useState>(() => { return new Map(allColumns().map(column => [column, columnWidth(column)])); @@ -101,7 +102,7 @@ export const NetworkTab: React.FunctionComponent<{ ariaLabel='Network requests' items={renderedEntries} selectedItem={visibleSelectedEntry} - onSelected={item => setSelectedResourceKey(JSON.stringify(item.resource))} + onSelected={item => setSelectedResourceKey(item.resource.id)} onHighlighted={item => onResourceHovered?.(item?.ordinal)} columns={visibleColumns(!!visibleSelectedEntry, renderedEntries)} columnTitle={columnTitle} @@ -265,7 +266,7 @@ function hasMultipleContexts(renderedEntries: RenderedEntry[]): boolean { return false; } -const renderEntry = (resource: Entry, boundaries: Boundaries, contextIdGenerator: ContextIdMap, ordinal: number): RenderedEntry => { +const renderEntry = (resource: EntryWithId, boundaries: Boundaries, contextIdGenerator: ContextIdMap, ordinal: number): RenderedEntry => { const routeStatus = formatRouteStatus(resource); let resourceName: string; try { From a0002311e07ae0ec4003dbb488e25aa68d883cfa Mon Sep 17 00:00:00 2001 From: cpadm <57954026+cpAdm@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:50:05 +0100 Subject: [PATCH 4/4] chore: Move resource id calculation to traceModel & use newly suggested id --- .../src/utils/isomorphic/trace/traceModel.ts | 6 +++-- packages/trace-viewer/src/ui/networkTab.tsx | 22 +++++++++---------- .../ui-mode-test-network-tab.spec.ts | 1 + 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts index afa2e7d63e6e6..91cfeb14bff52 100644 --- a/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts +++ b/packages/playwright-core/src/utils/isomorphic/trace/traceModel.ts @@ -42,6 +42,8 @@ export type SourceModel = { content: string | undefined; }; +export type ResourceEntry = ResourceSnapshot & { id: string }; + export type ActionTraceEventInContext = ActionEntry & { context: ContextEntry; }; @@ -84,7 +86,7 @@ export class TraceModel { readonly sdkLanguage: Language | undefined; readonly testIdAttributeName: string | undefined; readonly sources: Map; - resources: ResourceSnapshot[]; + resources: ResourceEntry[]; readonly actionCounters: Map; readonly traceUri: string; @@ -113,7 +115,7 @@ export class TraceModel { this.errors = ([] as trace.ErrorTraceEvent[]).concat(...contexts.map(c => c.errors)); this.hasSource = contexts.some(c => c.hasSource); this.hasStepData = contexts.some(context => context.origin === 'testRunner'); - this.resources = [...contexts.map(c => c.resources)].flat(); + this.resources = [...contexts.map(c => c.resources)].flat().map(entry => ({ ...entry, id: `${entry.pageref}-${entry.time}-${entry.request.url}` })); this.attachments = this.actions.flatMap(action => action.attachments?.map(attachment => ({ ...attachment, callId: action.callId, traceUri })) ?? []); this.visibleAttachments = this.attachments.filter(attachment => !attachment.name.startsWith('_')); diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx index 71763ae34f7bf..c182b46736054 100644 --- a/packages/trace-viewer/src/ui/networkTab.tsx +++ b/packages/trace-viewer/src/ui/networkTab.tsx @@ -14,14 +14,13 @@ * limitations under the License. */ -import type { Entry } from '@trace/har'; import * as React from 'react'; import type { Boundaries } from './geometry'; import './networkTab.css'; import { NetworkResourceDetails } from './networkResourceDetails'; import { bytesToString, msToString } from '@web/uiUtils'; import { PlaceholderPanel } from './placeholderPanel'; -import { context } from '@isomorphic/trace/traceModel'; +import { context, type ResourceEntry } from '@isomorphic/trace/traceModel'; import type { TraceModel } from '@isomorphic/trace/traceModel'; import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { SplitView } from '@web/components/splitView'; @@ -29,10 +28,8 @@ import type { ContextEntry } from '@isomorphic/trace/entries'; import { NetworkFilters, defaultFilterState, type FilterState, type ResourceType } from './networkFilters'; import type { Language } from '@isomorphic/locatorGenerators'; -type EntryWithId = Entry & { id: string }; - type NetworkTabModel = { - resources: EntryWithId[], + resources: ResourceEntry[], contextIdMap: ContextIdMap, }; @@ -46,7 +43,7 @@ type RenderedEntry = { size: number, start: number, route: string, - resource: EntryWithId, + resource: ResourceEntry, contextId: string, }; type ColumnName = keyof RenderedEntry; @@ -55,12 +52,13 @@ const NetworkGridView = GridView; export function useNetworkTabModel(model: TraceModel | undefined, selectedTime: Boundaries | undefined): NetworkTabModel { const resources = React.useMemo(() => { - const resourcesWithIds = (model?.resources || []).map((resource, i) => ({ ...resource, id: `${context(resource).contextId}-${i}`, })); - return resourcesWithIds.filter(resource => { + const resources = model?.resources || []; + const filtered = resources.filter(resource => { if (!selectedTime) return true; return !!resource._monotonicTime && (resource._monotonicTime >= selectedTime.minimum && resource._monotonicTime <= selectedTime.maximum); }); + return filtered; }, [model, selectedTime]); const contextIdMap = React.useMemo(() => new ContextIdMap(model), [model]); return { resources, contextIdMap }; @@ -224,7 +222,7 @@ class ContextIdMap { constructor(model: TraceModel | undefined) {} - contextId(resource: Entry): string { + contextId(resource: ResourceEntry): string { if (resource.pageref) return this._pageId(resource.pageref); else if (resource._apiRequest) @@ -242,7 +240,7 @@ class ContextIdMap { return shortId; } - private _apiRequestContextId(resource: Entry): string { + private _apiRequestContextId(resource: ResourceEntry): string { const contextEntry = context(resource); if (!contextEntry) return ''; @@ -266,7 +264,7 @@ function hasMultipleContexts(renderedEntries: RenderedEntry[]): boolean { return false; } -const renderEntry = (resource: EntryWithId, boundaries: Boundaries, contextIdGenerator: ContextIdMap, ordinal: number): RenderedEntry => { +const renderEntry = (resource: ResourceEntry, boundaries: Boundaries, contextIdGenerator: ContextIdMap, ordinal: number): RenderedEntry => { const routeStatus = formatRouteStatus(resource); let resourceName: string; try { @@ -299,7 +297,7 @@ const renderEntry = (resource: EntryWithId, boundaries: Boundaries, contextIdGen }; }; -function formatRouteStatus(request: Entry): string { +function formatRouteStatus(request: ResourceEntry): string { if (request._wasAborted) return 'aborted'; if (request._wasContinued) diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts index 1ef1899ba770e..968a91e5c0036 100644 --- a/tests/playwright-test/ui-mode-test-network-tab.spec.ts +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -387,6 +387,7 @@ test('should not preserve selection across test runs', async ({ runUITest, serve await expect(headersPanel).toBeVisible(); await page.getByRole('treeitem', { name: 'network tab test' }).dblclick(); + await expect(headersPanel).toBeHidden(); await expect(page.getByTestId('workbench-run-status')).toContainText('Passed'); await expect(headersPanel).toBeHidden(); });