diff --git a/development/scripts/win-tab-switch-perf.mjs b/development/scripts/win-tab-switch-perf.mjs new file mode 100644 index 000000000000..2c2214e8c663 --- /dev/null +++ b/development/scripts/win-tab-switch-perf.mjs @@ -0,0 +1,552 @@ +/* eslint-disable no-console */ +/* cspell:words recalc */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { chromium } from 'playwright-core'; + +const CDP_URL = process.env.CDP_URL || 'http://127.0.0.1:9222'; +const DEFAULT_OUT_DIR = '.tmp/win-tab-switch-perf'; +const POST_READY_MS = 1200; +const READY_TIMEOUT_MS = 15_000; + +const TABS = [ + { key: 'market', readyTestIds: ['market-page'] }, + { key: 'swap', readyTestIds: ['swap-content-container'] }, + { + key: 'perp', + readyTestIds: [ + 'perp-margin-mode-selector', + 'perp-long-button', + 'perp-short-button', + ], + readySelectors: ['webview[src*="tradingview.onekey.so"]'], + }, + { + key: 'earn', + readyTestIds: [ + 'earn-page', + 'earn-portfolio-overview', + 'earn-market-selector', + ], + }, + { + key: 'referfriends', + readySelectors: ['[data-testid^="refer-friends-"]'], + }, + { + key: 'discovery', + readyTestIds: [ + 'discovery-dashboard-page', + 'sidebar-browser-section', + 'explore-index-search', + 'discovery-trending-section', + ], + }, + { key: 'home', readyTestIds: ['home-page'] }, +]; + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token.startsWith('--')) { + const key = token.slice(2); + const value = argv[index + 1]; + if (value === undefined || value.startsWith('--')) { + args[key] = true; + } else { + args[key] = value; + index += 1; + } + } + } + return args; +} + +function ensureDirectory(directory) { + fs.mkdirSync(path.resolve(directory), { recursive: true }); +} + +function metricMap(metrics) { + return Object.fromEntries( + metrics.metrics.map(({ name, value }) => [name, value]), + ); +} + +function metricDelta(before, after, name) { + return (after[name] || 0) - (before[name] || 0); +} + +function summarizeCpuProfile(profile) { + const nodesById = new Map(profile.nodes.map((node) => [node.id, node])); + const selfTimeByFrame = new Map(); + for (let index = 0; index < (profile.samples?.length || 0); index += 1) { + const node = nodesById.get(profile.samples[index]); + if (node) { + const frame = node.callFrame || {}; + let source = '(native)'; + if (frame.url) { + try { + const parsed = new URL(frame.url); + source = parsed.pathname.split('/').slice(-2).join('/'); + } catch { + source = frame.url.split('/').slice(-2).join('/'); + } + } + const key = `${frame.functionName || '(anonymous)'} @ ${source}:${frame.lineNumber ?? '?'}`; + selfTimeByFrame.set( + key, + (selfTimeByFrame.get(key) || 0) + (profile.timeDeltas?.[index] || 0), + ); + } + } + const totalUs = [...selfTimeByFrame.values()].reduce( + (total, value) => total + value, + 0, + ); + return [...selfTimeByFrame.entries()] + .toSorted((left, right) => right[1] - left[1]) + .slice(0, 30) + .map(([frame, selfUs]) => ({ + frame, + selfMs: selfUs / 1000, + share: totalUs ? selfUs / totalUs : 0, + })); +} + +async function readTraceStream(session, stream) { + const chunks = []; + while (true) { + const result = await session.send('IO.read', { handle: stream }); + chunks.push(result.data); + if (result.eof) { + break; + } + } + await session.send('IO.close', { handle: stream }); + return chunks.join(''); +} + +async function startTrace(session) { + await session.send('Tracing.start', { + categories: [ + 'blink.user_timing', + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.frame', + 'loading', + 'toplevel', + 'v8', + 'v8.execute', + ].join(','), + options: 'sampling-frequency=1000', + transferMode: 'ReturnAsStream', + }); +} + +async function stopTrace(session) { + const tracingComplete = new Promise((resolve) => { + session.once('Tracing.tracingComplete', resolve); + }); + await session.send('Tracing.end'); + const { stream } = await tracingComplete; + return readTraceStream(session, stream); +} + +async function installObservers(page) { + await page.evaluate(() => { + globalThis.__onekeyTabPerf = { + longTasks: [], + mutation: null, + }; + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + globalThis.__onekeyTabPerf.longTasks.push({ + startTime: entry.startTime, + duration: entry.duration, + }); + } + }); + observer.observe({ entryTypes: ['longtask'] }); + globalThis.__onekeyTabPerf.longTaskObserver = observer; + } catch { + // PerformanceObserver long-task entries are unavailable in some builds. + } + }); +} + +async function prepareSwitch(page) { + return page.evaluate(() => { + const now = performance.now(); + const sidebar = document.querySelector( + '[data-testid="Desktop-AppSideBar-Container"]', + ); + const root = document.querySelector('#root') || document.body; + globalThis.__onekeyTabPerf.mutation?.observer?.disconnect(); + const mutation = { + clickStart: now, + firstContentMutation: null, + lastContentMutation: null, + count: 0, + }; + const observer = new MutationObserver((records) => { + const hasContentMutation = records.some((record) => { + const target = + record.target.nodeType === Node.ELEMENT_NODE + ? record.target + : record.target.parentElement; + return target && !sidebar?.contains(target); + }); + if (!hasContentMutation) { + return; + } + const mutationTime = performance.now(); + mutation.firstContentMutation ??= mutationTime; + mutation.lastContentMutation = mutationTime; + mutation.count += 1; + }); + observer.observe(root, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + mutation.observer = observer; + globalThis.__onekeyTabPerf.mutation = mutation; + return now; + }); +} + +function isElementVisible(element) { + if (!element) { + return false; + } + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + const ancestorsVisible = element.checkVisibility + ? element.checkVisibility({ + checkOpacity: true, + checkVisibilityCSS: true, + }) + : true; + return ( + ancestorsVisible && + style.display !== 'none' && + style.visibility !== 'hidden' && + Number(style.opacity || 1) > 0 && + rect.width > 0 && + rect.height > 0 + ); +} + +async function waitForTabReady(page, tab) { + const activeHandle = await page.waitForFunction( + ({ key }) => { + const tabElement = document.querySelector(`[data-testid="${key}"]`); + return tabElement?.querySelector( + '[data-testid^="tab-modal-active-item-"]', + ) + ? performance.now() + : null; + }, + tab, + { timeout: READY_TIMEOUT_MS }, + ); + const activeAt = await activeHandle.jsonValue(); + const readyHandle = await page.waitForFunction( + ({ readySelectors, readyTestIds }) => { + const visible = (element) => { + if (!element) { + return false; + } + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + const ancestorsVisible = element.checkVisibility + ? element.checkVisibility({ + checkOpacity: true, + checkVisibilityCSS: true, + }) + : true; + return ( + ancestorsVisible && + style.display !== 'none' && + style.visibility !== 'hidden' && + Number(style.opacity || 1) > 0 && + rect.width > 0 && + rect.height > 0 && + rect.right > 72 && + rect.left < window.innerWidth && + rect.bottom > 48 && + rect.top < window.innerHeight + ); + }; + const selectors = [ + ...(readyTestIds || []).map((testId) => `[data-testid="${testId}"]`), + ...(readySelectors || []), + ]; + const readySelector = selectors.find((selector) => + [...document.querySelectorAll(selector)].some(visible), + ); + if (!readySelector) { + return null; + } + return { + readyAt: performance.now(), + readySelector, + }; + }, + tab, + { timeout: READY_TIMEOUT_MS }, + ); + return { activeAt, ...(await readyHandle.jsonValue()) }; +} + +async function collectSwitchResult({ networkEvents, page, session, tab }) { + const nav = page.locator( + `[data-testid="${tab.key}"] > [data-testid^="tab-modal-"]`, + ); + if ((await nav.count()) !== 1) { + throw new Error(`Expected exactly one navigation item for ${tab.key}`); + } + const navElement = await nav.elementHandle(); + if (!navElement || !(await navElement.evaluate(isElementVisible))) { + throw new Error(`Navigation item ${tab.key} is not visible`); + } + + const beforeMetrics = metricMap(await session.send('Performance.getMetrics')); + const networkStartIndex = networkEvents.length; + const clickStart = await prepareSwitch(page); + await nav.click(); + const ready = await waitForTabReady(page, tab); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }), + ); + const paintedAt = await page.evaluate(() => performance.now()); + await page.waitForTimeout(POST_READY_MS); + const afterMetrics = metricMap(await session.send('Performance.getMetrics')); + const details = await page.evaluate((startTime) => { + const mutation = globalThis.__onekeyTabPerf.mutation; + mutation?.observer?.disconnect(); + const longTasks = (globalThis.__onekeyTabPerf.longTasks || []).filter( + (entry) => entry.startTime >= startTime, + ); + return { + firstContentMutationAt: mutation?.firstContentMutation, + lastContentMutationAt: mutation?.lastContentMutation, + mutationCount: mutation?.count || 0, + longTasks, + }; + }, clickStart); + const longTaskTotalMs = details.longTasks.reduce( + (total, entry) => total + entry.duration, + 0, + ); + const longTaskMaxMs = details.longTasks.reduce( + (maximum, entry) => Math.max(maximum, entry.duration), + 0, + ); + + const switchNetworkEvents = networkEvents.slice(networkStartIndex); + const requestCount = switchNetworkEvents.filter( + (event) => event.kind === 'request', + ).length; + const failedRequestCount = switchNetworkEvents.filter( + (event) => event.kind === 'failed', + ).length; + + return { + tab: tab.key, + activeMs: ready.activeAt - clickStart, + readyMs: ready.readyAt - clickStart, + paintedMs: paintedAt - clickStart, + firstContentMutationMs: + details.firstContentMutationAt === null + ? null + : details.firstContentMutationAt - clickStart, + lastContentMutationMs: + details.lastContentMutationAt === null + ? null + : details.lastContentMutationAt - clickStart, + mutationCount: details.mutationCount, + longTaskCount: details.longTasks.length, + longTaskTotalMs, + longTaskMaxMs, + readySelector: ready.readySelector, + requestCount, + failedRequestCount, + metrics: { + taskDurationMs: + metricDelta(beforeMetrics, afterMetrics, 'TaskDuration') * 1000, + scriptDurationMs: + metricDelta(beforeMetrics, afterMetrics, 'ScriptDuration') * 1000, + layoutDurationMs: + metricDelta(beforeMetrics, afterMetrics, 'LayoutDuration') * 1000, + recalcStyleDurationMs: + metricDelta(beforeMetrics, afterMetrics, 'RecalcStyleDuration') * 1000, + layoutCount: metricDelta(beforeMetrics, afterMetrics, 'LayoutCount'), + recalcStyleCount: metricDelta( + beforeMetrics, + afterMetrics, + 'RecalcStyleCount', + ), + jsHeapUsedDeltaMb: + metricDelta(beforeMetrics, afterMetrics, 'JSHeapUsedSize') / 1_048_576, + nodeDelta: metricDelta(beforeMetrics, afterMetrics, 'Nodes'), + }, + }; +} + +async function collectPass({ + name, + networkEvents, + outDirectory, + page, + session, +}) { + console.error(`Starting ${name} pass`); + await session.send('Profiler.enable'); + await session.send('Profiler.setSamplingInterval', { interval: 1000 }); + await session.send('Profiler.start'); + await startTrace(session); + + const switches = []; + for (const tab of TABS) { + const result = await collectSwitchResult({ + networkEvents, + page, + session, + tab, + }); + switches.push(result); + console.error( + `${name.padEnd(5)} ${tab.key.padEnd(12)} ready=${result.readyMs.toFixed(1)}ms ` + + `paint=${result.paintedMs.toFixed(1)}ms maxLongTask=${result.longTaskMaxMs.toFixed(1)}ms`, + ); + } + + const trace = await stopTrace(session); + const { profile } = await session.send('Profiler.stop'); + const tracePath = path.resolve(outDirectory, `${name}.trace.json`); + const profilePath = path.resolve(outDirectory, `${name}.cpuprofile`); + fs.writeFileSync(tracePath, trace); + fs.writeFileSync(profilePath, JSON.stringify(profile)); + return { + name, + switches, + tracePath, + profilePath, + cpuTopSelfTime: summarizeCpuProfile(profile), + }; +} + +async function fetchTargets() { + const response = await fetch(`${CDP_URL}/json`); + if (!response.ok) { + throw new Error(`GET ${CDP_URL}/json failed: ${response.status}`); + } + const targets = await response.json(); + return targets.map((target) => { + let origin = ''; + try { + origin = new URL(target.url).origin; + } catch { + // Keep non-URL targets empty so no page content is written to artifacts. + } + return { + type: target.type, + title: + target.type === 'page' && target.url === 'file:///' ? 'OneKey' : '', + origin, + }; + }); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const outDirectory = path.resolve(args.out || DEFAULT_OUT_DIR); + const settleSeconds = Number(args.settle || 20); + ensureDirectory(outDirectory); + + const browser = await chromium.connectOverCDP(CDP_URL); + const pages = browser.contexts().flatMap((context) => context.pages()); + const page = + pages.find((candidate) => candidate.url().startsWith('file:')) || pages[0]; + if (!page) { + throw new Error('No Electron renderer page is exposed over CDP'); + } + await page.locator('[data-testid="home-page"]').waitFor({ + state: 'visible', + timeout: READY_TIMEOUT_MS, + }); + await page.locator('[data-testid="home-total-balance"]').waitFor({ + state: 'visible', + timeout: READY_TIMEOUT_MS, + }); + await page.waitForFunction( + () => + document.querySelectorAll( + '[data-testid="Desktop-AppSideBar-Container"] [data-testid^="tab-modal-"]', + ).length >= 7, + undefined, + { timeout: READY_TIMEOUT_MS }, + ); + + const session = await page.context().newCDPSession(page); + await session.send('Performance.enable'); + await installObservers(page); + + const networkEvents = []; + page.on('request', (request) => { + networkEvents.push({ + kind: 'request', + resourceType: request.resourceType(), + }); + }); + page.on('requestfailed', (request) => { + networkEvents.push({ + kind: 'failed', + resourceType: request.resourceType(), + }); + }); + + const startedAt = new Date().toISOString(); + const cold = await collectPass({ + name: 'cold', + networkEvents, + outDirectory, + page, + session, + }); + console.error(`Waiting ${settleSeconds}s before warm pass`); + await page.waitForTimeout(settleSeconds * 1000); + const warm = await collectPass({ + name: 'warm', + networkEvents, + outDirectory, + page, + session, + }); + + const result = { + startedAt, + finishedAt: new Date().toISOString(), + cdpUrl: CDP_URL, + settleSeconds, + targets: await fetchTargets(), + passes: { cold, warm }, + }; + const resultPath = path.resolve(outDirectory, 'results.json'); + fs.writeFileSync(resultPath, JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result, null, 2)); + console.error(`Results written to ${resultPath}`); + await session.detach().catch(() => {}); + await browser.close(); +} + +await main(); diff --git a/packages/components/src/actions/Popover/index.tsx b/packages/components/src/actions/Popover/index.tsx index 01902fe06f4e..db66e25d594c 100644 --- a/packages/components/src/actions/Popover/index.tsx +++ b/packages/components/src/actions/Popover/index.tsx @@ -282,7 +282,13 @@ function RawPopover({ }: IPopoverProps) { const { bottom } = useSafeAreaInsets(); const triggerRef = useRef(null); - const placement = getPlacement(placementProp, triggerRef); + // Closed Windows popovers are present throughout every tab tree. Measuring + // each trigger during a focus render forces repeated synchronous layouts; + // the geometry is only needed once the popover is actually opened. + const shouldSkipClosedMeasurement = platformEnv.isDesktopWin && !isOpen; + const placement = shouldSkipClosedMeasurement + ? placementProp || 'bottom-end' + : getPlacement(placementProp, triggerRef); const transformOrigin = useMemo(() => { switch (placement) { case 'top': @@ -398,7 +404,9 @@ function RawPopover({ const isShowNativeKeepChildrenMountedBackdrop = platformEnv.isNative && props.keepChildrenMounted; - const maxScrollViewHeight = getMaxScrollViewHeight(); + const maxScrollViewHeight = shouldSkipClosedMeasurement + ? undefined + : getMaxScrollViewHeight(); const transformOriginStyle = useMemo( () => ({ transformOrigin }), [transformOrigin], diff --git a/packages/kit/src/hooks/useListenTabFocusState.test.tsx b/packages/kit/src/hooks/useListenTabFocusState.test.tsx new file mode 100644 index 000000000000..0950aa3560f9 --- /dev/null +++ b/packages/kit/src/hooks/useListenTabFocusState.test.tsx @@ -0,0 +1,94 @@ +/** + * @jest-environment jsdom + */ + +import { act, renderHook } from '@testing-library/react-native'; + +import { ERootRoutes, ETabRoutes } from '@onekeyhq/shared/src/routes'; + +import useListenTabFocusState from './useListenTabFocusState'; + +import type { NavigationState } from '@react-navigation/native'; + +let mockRouterChangeListener: + | ((state: NavigationState | undefined) => void) + | undefined; + +jest.mock('@onekeyhq/components', () => ({ + useOnRouterChange: ( + callback: (state: NavigationState | undefined) => void, + ) => { + mockRouterChangeListener = callback; + }, +})); + +function buildState( + currentTab: ETabRoutes, + { withModal = false }: { withModal?: boolean } = {}, +) { + const tabRoutes = [ + { key: 'home', name: ETabRoutes.Home }, + { key: 'market', name: ETabRoutes.Market }, + { key: 'swap', name: ETabRoutes.Swap }, + ]; + const routes: any[] = [ + { + key: 'main', + name: ERootRoutes.Main, + state: { + index: tabRoutes.findIndex((route) => route.name === currentTab), + routeNames: tabRoutes.map((route) => route.name), + routes: tabRoutes, + }, + }, + ]; + if (withModal) { + routes.push({ key: 'modal', name: ERootRoutes.Modal }); + } + return { index: routes.length - 1, routes } as NavigationState; +} + +describe('useListenTabFocusState', () => { + beforeEach(() => { + mockRouterChangeListener = undefined; + }); + + it('only notifies when focus or modal visibility changes', () => { + const callback = jest.fn(); + renderHook(() => useListenTabFocusState(ETabRoutes.Home, callback)); + + act(() => mockRouterChangeListener?.(buildState(ETabRoutes.Home))); + expect(callback).toHaveBeenLastCalledWith(true, false); + + act(() => mockRouterChangeListener?.(buildState(ETabRoutes.Home))); + expect(callback).toHaveBeenCalledTimes(1); + + act(() => mockRouterChangeListener?.(buildState(ETabRoutes.Market))); + expect(callback).toHaveBeenLastCalledWith(false, false); + + act(() => mockRouterChangeListener?.(buildState(ETabRoutes.Market))); + expect(callback).toHaveBeenCalledTimes(2); + + act(() => + mockRouterChangeListener?.( + buildState(ETabRoutes.Market, { withModal: true }), + ), + ); + expect(callback).toHaveBeenLastCalledWith(false, true); + + act(() => mockRouterChangeListener?.(buildState(ETabRoutes.Market))); + expect(callback).toHaveBeenLastCalledWith(false, false); + expect(callback).toHaveBeenCalledTimes(4); + }); + + it('preserves the extension fallback state', () => { + const callback = jest.fn(); + renderHook(() => useListenTabFocusState(ETabRoutes.Home, callback)); + + act(() => mockRouterChangeListener?.(undefined)); + act(() => mockRouterChangeListener?.(undefined)); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(true, false); + }); +}); diff --git a/packages/kit/src/hooks/useListenTabFocusState.ts b/packages/kit/src/hooks/useListenTabFocusState.ts index 4baf375bdc9d..407ad3160e90 100644 --- a/packages/kit/src/hooks/useListenTabFocusState.ts +++ b/packages/kit/src/hooks/useListenTabFocusState.ts @@ -8,10 +8,24 @@ export default function useListenTabFocusState( callback: (isFocus: boolean, isHideByModal: boolean) => void, // do NOT useCallback to wrap the callback ) { const tabNames = Array.isArray(tabName) ? tabName : [tabName]; + const previousStateRef = useRef< + | { + isFocus: boolean; + isHideByModal: boolean; + } + | undefined + >(undefined); useOnRouterChange((state) => { // the state may be undefined when initializing the interface on the Ext. if (!state) { - callback(tabName === ETabRoutes.Home, false); + const isFocus = tabName === ETabRoutes.Home; + if ( + previousStateRef.current?.isFocus !== isFocus || + previousStateRef.current?.isHideByModal !== false + ) { + previousStateRef.current = { isFocus, isHideByModal: false }; + callback(isFocus, false); + } return; } const rootState = state?.routes.find( @@ -29,10 +43,18 @@ export default function useListenTabFocusState( const currentTabName = rootState?.routeNames ? (rootState?.routeNames?.[rootState?.index || 0] as ETabRoutes) : (rootState?.routes[0].name as ETabRoutes); - callback( - tabNames.includes(currentTabName), - !!(modalRoutes || fullModalRoutes || fullScreenPushRoutes), - ); + const nextState = { + isFocus: tabNames.includes(currentTabName), + isHideByModal: !!(modalRoutes || fullModalRoutes || fullScreenPushRoutes), + }; + if ( + previousStateRef.current?.isFocus === nextState.isFocus && + previousStateRef.current?.isHideByModal === nextState.isHideByModal + ) { + return; + } + previousStateRef.current = nextState; + callback(nextState.isFocus, nextState.isHideByModal); }); } diff --git a/packages/kit/src/hooks/usePromiseResult.test.tsx b/packages/kit/src/hooks/usePromiseResult.test.tsx index 56d1d32aefdc..6c136f443733 100644 --- a/packages/kit/src/hooks/usePromiseResult.test.tsx +++ b/packages/kit/src/hooks/usePromiseResult.test.tsx @@ -41,16 +41,19 @@ jest.mock('@onekeyhq/kit/src/hooks/useRouteIsFocused', () => { const ReactModule = require('react') as typeof import('react'); let currentFocus = true; const listeners: Array<(v: boolean) => void> = []; + const refListeners: Array<(v: boolean) => void> = []; const __setFocus = (v: boolean) => { if (currentFocus === v) return; currentFocus = v; listeners.slice().forEach((l) => l(v)); + refListeners.slice().forEach((l) => l(v)); }; const __resetFocus = () => { currentFocus = true; listeners.slice().forEach((l) => l(true)); + refListeners.slice().forEach((l) => l(true)); }; const useRouteIsFocused = () => { @@ -67,8 +70,54 @@ jest.mock('@onekeyhq/kit/src/hooks/useRouteIsFocused', () => { return v; }; + const useRouteIsFocusedRef = ({ + onChangeRef, + overrideIsFocused, + }: { + onChangeRef: React.MutableRefObject< + ((isFocused: boolean) => void) | undefined + >; + overrideIsFocused?: (isFocused: boolean) => boolean; + }) => { + const overrideRef = ReactModule.useRef(overrideIsFocused); + overrideRef.current = overrideIsFocused; + const applyOverride = (isFocused: boolean) => + overrideRef.current?.(isFocused) ?? isFocused; + const renderValue = applyOverride(currentFocus); + const focusedRef = ReactModule.useRef(renderValue); + focusedRef.current = renderValue; + const notifiedRef = ReactModule.useRef(undefined); + + ReactModule.useEffect(() => { + const listener = (isFocused: boolean) => { + const nextValue = applyOverride(isFocused); + focusedRef.current = nextValue; + if (notifiedRef.current !== nextValue) { + notifiedRef.current = nextValue; + onChangeRef.current?.(nextValue); + } + }; + refListeners.push(listener); + listener(currentFocus); + return () => { + const idx = refListeners.indexOf(listener); + if (idx >= 0) refListeners.splice(idx, 1); + }; + }, [onChangeRef]); + + ReactModule.useEffect(() => { + if (notifiedRef.current !== renderValue) { + notifiedRef.current = renderValue; + onChangeRef.current?.(renderValue); + } + }, [onChangeRef, renderValue]); + + return focusedRef; + }; + return { useRouteIsFocused, + useRouteIsFocusedRef, __setFocus, __resetFocus, }; @@ -709,6 +758,27 @@ describe('usePromiseResult', () => { expect(method).toHaveBeenCalledTimes(1); }); + it('does not rerender the consumer solely because route focus changes', async () => { + const method = jest.fn(() => new Promise(() => {})); + const { result } = renderHook(() => + usePromiseResultWithRenderCount(method), + ); + + await waitFor(() => { + expect(method).toHaveBeenCalledTimes(1); + }); + const initialRenderCount = result.current.renderCount; + + act(() => { + focusControl.__setFocus(false); + }); + act(() => { + focusControl.__setFocus(true); + }); + + expect(result.current.renderCount).toBe(initialRenderCount); + }); + it('does not start fetch when not focused at mount (default checkIsFocused: true)', async () => { act(() => { focusControl.__setFocus(false); diff --git a/packages/kit/src/hooks/usePromiseResult.ts b/packages/kit/src/hooks/usePromiseResult.ts index 11e410e8ac07..ee8bbec2b7de 100644 --- a/packages/kit/src/hooks/usePromiseResult.ts +++ b/packages/kit/src/hooks/usePromiseResult.ts @@ -8,7 +8,7 @@ import { useDeferredPromise, useNetInfo, } from '@onekeyhq/components'; -import { useRouteIsFocused as useIsFocused } from '@onekeyhq/kit/src/hooks/useRouteIsFocused'; +import { useRouteIsFocusedRef } from '@onekeyhq/kit/src/hooks/useRouteIsFocused'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { swrCacheUtils } from '@onekeyhq/shared/src/utils/swrCacheUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; @@ -157,13 +157,6 @@ export function usePromiseResult( } const [isLoading, setIsLoading] = useState(); const isMountedRef = useIsMounted(); - const _isFocused = useIsFocused({ testID: options.testID }); - const isFocusedRef = useRef(_isFocused); - const pollingNonceRef = useRef(0); - isFocusedRef.current = _isFocused; - if (options?.overrideIsFocused !== undefined) { - isFocusedRef.current = options?.overrideIsFocused?.(_isFocused); - } const methodRef = useRef(method); methodRef.current = method; const optionsRef = useRef(options); @@ -175,6 +168,15 @@ export function usePromiseResult( alwaysSetState: false, ...options, }; + const focusChangeCallbackRef = useRef< + ((isFocused: boolean) => void) | undefined + >(undefined); + const isFocusedRef = useRouteIsFocusedRef({ + onChangeRef: focusChangeCallbackRef, + overrideIsFocused: options.overrideIsFocused, + testID: options.testID, + }); + const pollingNonceRef = useRef(0); const isDepsChangedOnBlur = useRef(false); const nonceRef = useRef(0); @@ -477,9 +479,9 @@ export function usePromiseResult( // eslint-disable-next-line react-hooks/exhaustive-deps }, runnerDeps); - const isFocusedRefValue = isFocusedRef.current; - const prevFocusedRef = useRef(isFocusedRefValue); + const prevFocusedRef = useRef(undefined); const isLoadingRef = useRef(isLoading); + isLoadingRef.current = isLoading; const runWithPollingNonce = useCallback(() => { isDepsChangedOnBlur.current = false; void runRef.current({ pollingNonce: pollingNonceRef.current }); @@ -501,58 +503,71 @@ export function usePromiseResult( } }, [isInternetReachable, runWithPollingNonce]); - useEffect(() => { - if (optionsRef.current.checkIsFocused) { - if (isFocusedRefValue) { - resolveDefer(); - } else { - resetDefer(); + const focusIdleHandlesRef = useRef< + Set> + >(new Set()); + const applyFocusedState = useCallback( + (isFocused: boolean) => { + isFocusedRef.current = isFocused; + if (prevFocusedRef.current === isFocused) { + return; } - - // On native, defer focus-recovery re-execution until the JS thread is - // idle so the first render frame after tab switch can paint without - // being blocked by data fetching across 40+ hooks. - const idleHandles: ReturnType[] = []; - const scheduleRun = () => { - if (platformEnv.isNative) { - idleHandles.push(requestIdleCallback(runWithPollingNonce)); + const wasFocused = prevFocusedRef.current; + prevFocusedRef.current = isFocused; + if (optionsRef.current.checkIsFocused) { + if (isFocused) { + resolveDefer(); } else { - runWithPollingNonce(); + resetDefer(); } - }; - // By employing a hack to simulate the recovery from a network disconnection and subsequently make a new network request. - if ( - platformEnv.isNative && - !isLoadingRef.current && - isEmptyResultRef.current && - optionsRef.current.revalidateOnReconnect - ) { - scheduleRun(); - } + // On native, defer focus-recovery re-execution until the JS thread is + // idle so the first render frame after tab switch can paint without + // being blocked by data fetching across 40+ hooks. + const scheduleRun = () => { + if (platformEnv.isNative) { + const idleHandle = requestIdleCallback(() => { + focusIdleHandlesRef.current.delete(idleHandle); + runWithPollingNonce(); + }); + focusIdleHandlesRef.current.add(idleHandle); + } else { + runWithPollingNonce(); + } + }; - if ( - prevFocusedRef.current === false && - isFocusedRefValue && - optionsRef.current.revalidateOnFocus - ) { - scheduleRun(); - } else if (isFocusedRefValue && isDepsChangedOnBlur.current) { - scheduleRun(); - } - prevFocusedRef.current = isFocusedRefValue; + // By employing a hack to simulate the recovery from a network disconnection and subsequently make a new network request. + if ( + platformEnv.isNative && + !isLoadingRef.current && + isEmptyResultRef.current && + optionsRef.current.revalidateOnReconnect + ) { + scheduleRun(); + } - return () => { - if (platformEnv.isNative) { - idleHandles.forEach(cancelIdleCallback); + if ( + wasFocused === false && + isFocused && + optionsRef.current.revalidateOnFocus + ) { + scheduleRun(); + } else if (isFocused && isDepsChangedOnBlur.current) { + scheduleRun(); } - }; - } - }, [isFocusedRefValue, resetDefer, resolveDefer, runWithPollingNonce]); + } + }, + [isFocusedRef, resetDefer, resolveDefer, runWithPollingNonce], + ); + + focusChangeCallbackRef.current = applyFocusedState; useEffect(() => { + const focusIdleHandles = focusIdleHandlesRef.current; return () => { isEffectValid.current = false; + focusIdleHandles.forEach(cancelIdleCallback); + focusIdleHandles.clear(); }; }, []); diff --git a/packages/kit/src/hooks/useRouteIsFocused.ts b/packages/kit/src/hooks/useRouteIsFocused.ts index fa4bcec27031..71f475572be6 100644 --- a/packages/kit/src/hooks/useRouteIsFocused.ts +++ b/packages/kit/src/hooks/useRouteIsFocused.ts @@ -1,8 +1,9 @@ -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { MutableRefObject } from 'react'; -import { useIsFocused } from '@react-navigation/core'; +import { useIsFocused, useNavigation } from '@react-navigation/core'; -import { rootNavigationRef } from '@onekeyhq/components'; +import { rootNavigationRef, useOnRouterChange } from '@onekeyhq/components'; import { useAppIsLockedAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms/passwordLock'; export const getRootRoutersLength = () => @@ -28,3 +29,61 @@ export const useRouteIsFocused = ({ rootRoutersLength >= getRootRoutersLength() ); }; + +export const useRouteIsFocusedRef = ({ + disableLockScreenCheck = false, + onChangeRef, + overrideIsFocused, + testID: _testID, +}: { + disableLockScreenCheck?: boolean; + onChangeRef: MutableRefObject<((isFocused: boolean) => void) | undefined>; + overrideIsFocused?: (isFocused: boolean) => boolean; + testID?: string; +}) => { + const navigation = useNavigation(); + const [isLocked] = useAppIsLockedAtom(); + const isLockedRef = useRef(isLocked); + isLockedRef.current = isLocked; + const overrideIsFocusedRef = useRef(overrideIsFocused); + overrideIsFocusedRef.current = overrideIsFocused; + const rootRoutersLength = useMemo(getRootRoutersLength, []); + + const getIsFocused = useCallback(() => { + const isFocused = + (disableLockScreenCheck || !isLockedRef.current) && + navigation.isFocused() && + rootRoutersLength >= getRootRoutersLength(); + return overrideIsFocusedRef.current?.(isFocused) ?? isFocused; + }, [disableLockScreenCheck, navigation, rootRoutersLength]); + + const renderFocusedValue = getIsFocused(); + const isFocusedRef = useRef(renderFocusedValue); + isFocusedRef.current = renderFocusedValue; + const notifiedFocusedRef = useRef(undefined); + + const notifyFocusChange = useCallback( + (isFocused: boolean) => { + isFocusedRef.current = isFocused; + if (notifiedFocusedRef.current === isFocused) { + return; + } + notifiedFocusedRef.current = isFocused; + onChangeRef.current?.(isFocused); + }, + [onChangeRef], + ); + + // Navigation focus is a gate for async work, not rendered output. Updating a + // ref from the router event avoids forcing every data hook consumer to + // re-render together during a tab switch. + useOnRouterChange(() => notifyFocusChange(getIsFocused())); + + // Re-evaluate lock state and caller overrides when their owning component + // renders for a reason unrelated to navigation. + useEffect(() => { + notifyFocusChange(renderFocusedValue); + }, [notifyFocusChange, renderFocusedValue]); + + return isFocusedRef; +}; diff --git a/packages/kit/src/states/jotai/contexts/earn/actions.ts b/packages/kit/src/states/jotai/contexts/earn/actions.ts index 4033c4f980be..7d23790d19ec 100644 --- a/packages/kit/src/states/jotai/contexts/earn/actions.ts +++ b/packages/kit/src/states/jotai/contexts/earn/actions.ts @@ -1,5 +1,7 @@ import { useCallback, useRef } from 'react'; +import { isEqual } from 'lodash'; + import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; import { ContextJotaiActionsBase } from '@onekeyhq/kit/src/states/jotai/utils/ContextJotaiActionsBase'; import { memoFn } from '@onekeyhq/shared/src/utils/cacheUtils'; @@ -62,6 +64,9 @@ class ContextJotaiActionsEarn extends ContextJotaiActionsBase { availableAssets: IAvailableAsset[], ) => { const earnData = get(earnAtom()); + if (isEqual(earnData.availableAssetsByType?.[type], availableAssets)) { + return; + } this.syncToDb.call(set, { availableAssetsByType: { ...earnData.availableAssetsByType, diff --git a/packages/kit/src/states/jotai/contexts/marketV2/actions.ts b/packages/kit/src/states/jotai/contexts/marketV2/actions.ts index c368410daf61..574cf59b2b32 100644 --- a/packages/kit/src/states/jotai/contexts/marketV2/actions.ts +++ b/packages/kit/src/states/jotai/contexts/marketV2/actions.ts @@ -1,6 +1,6 @@ import { useRef } from 'react'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; import { ContextJotaiActionsBase } from '@onekeyhq/kit/src/states/jotai/utils/ContextJotaiActionsBase'; @@ -452,9 +452,14 @@ class ContextJotaiActionsMarketV2 extends ContextJotaiActionsBase { // Existing WatchList Actions flushWatchListV2Atom = contextAtomMethod( - (_, set, payload: IMarketWatchListItemV2[]) => { + (get, set, payload: IMarketWatchListItemV2[]) => { + const previous = get(marketWatchListV2Atom()); + if (isEqual(previous.data, payload)) { + return previous; + } const result = { data: payload }; set(marketWatchListV2Atom(), result); + return result; }, ); diff --git a/packages/kit/src/views/Earn/EarnHome.tsx b/packages/kit/src/views/Earn/EarnHome.tsx index 106a45f65464..ff11d5ce964e 100644 --- a/packages/kit/src/views/Earn/EarnHome.tsx +++ b/packages/kit/src/views/Earn/EarnHome.tsx @@ -87,8 +87,13 @@ function BasicEarnHome({ useBlockRegion(); const { faqList, isFaqLoading, refetchFAQ } = useFAQListInfo(); - const [isEarnTabFocused, setIsEarnTabFocused] = useState(false); - const [isEarnDataActive, setIsEarnDataActive] = useState(false); + const isEarnTabFocusedRef = useRef(false); + // Desktop data hooks already gate work through their non-rendering route + // focus ref. Keep their inner-tab active state stable so a top-level tab + // switch doesn't re-render the entire Earn tree. + const [isEarnDataActive, setIsEarnDataActive] = useState( + platformEnv.isDesktop, + ); const [isManualRefreshing, setIsManualRefreshing] = useState(false); const wasFocusedRef = useRef(false); const wasHiddenByModalRef = useRef(false); @@ -337,8 +342,8 @@ function BasicEarnHome({ [navigation, route.params?.tab], ); - useEffect(() => { - if (!showContent || !isEarnTabFocused) { + const logEarnModeSwitch = useCallback(() => { + if (!showContent || !isEarnTabFocusedRef.current) { return; } @@ -357,7 +362,11 @@ function BasicEarnHome({ switchType, }); earnModeSwitchTypeRef.current = 'default'; - }, [defaultMode, isEarnTabFocused, showContent]); + }, [defaultMode, showContent]); + + useEffect(() => { + logEarnModeSwitch(); + }, [defaultMode, logEarnModeSwitch]); useEffect(() => { const handleSwitchEarnMode = ({ @@ -400,8 +409,10 @@ function BasicEarnHome({ if (isVisibleFocus && !wasFocused && !wasHiddenByModal) { shouldLogEnterEarnRef.current = true; } - setIsEarnTabFocused(isVisibleFocus); - setIsEarnDataActive(isDataActive); + isEarnTabFocusedRef.current = isVisibleFocus; + if (!platformEnv.isDesktop) { + setIsEarnDataActive(isDataActive); + } if ( !isVisibleFocus || showContent === false || @@ -410,6 +421,8 @@ function BasicEarnHome({ return; } + logEarnModeSwitch(); + void refetchFAQ(); if (!isAssetsTabActiveRef.current) { @@ -435,7 +448,13 @@ function BasicEarnHome({ actions.current.triggerRefresh(); } }, - [actions, prefetchEarnAvailableAssets, refetchFAQ, showContent], + [ + actions, + logEarnModeSwitch, + prefetchEarnAvailableAssets, + refetchFAQ, + showContent, + ], ); useListenTabFocusState(earnFocusTabRoutes, handleListenTabFocusState); diff --git a/packages/kit/src/views/Earn/components/EarnMainTabs.tsx b/packages/kit/src/views/Earn/components/EarnMainTabs.tsx index 21de869a73d0..7e5f097216f5 100644 --- a/packages/kit/src/views/Earn/components/EarnMainTabs.tsx +++ b/packages/kit/src/views/Earn/components/EarnMainTabs.tsx @@ -27,7 +27,7 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { ListItem } from '../../../components/ListItem'; import { useIsFirstFocused } from '../../../hooks/useIsFirstFocused'; -import { useRouteIsFocused } from '../../../hooks/useRouteIsFocused'; +import { useRouteIsFocusedRef } from '../../../hooks/useRouteIsFocused'; import { useTabContainerWidth } from '../../../hooks/useTabContainerWidth'; import { useEarnHideSmallAssets } from '../hooks/useEarnHideSmallAssets'; @@ -234,17 +234,20 @@ const EarnMainTabsComponent = ({ useDesktopPageScrollTabs, ]); - const isFocused = useRouteIsFocused(); - const isFocusedRef = useRef(isFocused); - - useEffect(() => { - if (platformEnv.isNativeIOS) { + const routeFocusChangeRef = useRef< + ((isFocused: boolean) => void) | undefined + >(undefined); + useRouteIsFocusedRef({ onChangeRef: routeFocusChangeRef }); + const previousRouteFocusedRef = useRef(undefined); + routeFocusChangeRef.current = (isFocused: boolean) => { + if (previousRouteFocusedRef.current === undefined) { + previousRouteFocusedRef.current = isFocused; return; } - if (isFocused === isFocusedRef.current) { + previousRouteFocusedRef.current = isFocused; + if (platformEnv.isNativeIOS) { return; } - isFocusedRef.current = isFocused; if (defaultTab) { const targetTabName = initialTabName; if (!useDesktopPageScrollTabs) { @@ -256,15 +259,7 @@ const EarnMainTabsComponent = ({ syncDesktopTabName(targetTabName); } } - }, [ - defaultTab, - desktopTabName, - initialTabName, - isFocused, - syncDesktopTabName, - tabsRef, - useDesktopPageScrollTabs, - ]); + }; useEffect(() => { const callback = ({ tab }: { tab: 'assets' | 'portfolio' | 'faqs' }) => { @@ -468,6 +463,6 @@ function ForwardedEarnMainTabs( ) { const isFocused = useIsFocused(); const isFirstFocused = useIsFirstFocused(isFocused); - return isFirstFocused ? : null; + return isFirstFocused ? : null; } export const EarnMainTabs = memo(ForwardedEarnMainTabs); diff --git a/packages/kit/src/views/Earn/hooks/useFAQListInfo.ts b/packages/kit/src/views/Earn/hooks/useFAQListInfo.ts index 51ef0aa09bbc..85962db851cc 100644 --- a/packages/kit/src/views/Earn/hooks/useFAQListInfo.ts +++ b/packages/kit/src/views/Earn/hooks/useFAQListInfo.ts @@ -1,3 +1,7 @@ +import { useCallback } from 'react'; + +import { isEqual } from 'lodash'; + import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; import { usePromiseResult } from '../../../hooks/usePromiseResult'; @@ -6,7 +10,7 @@ export const useFAQListInfo = () => { const { result: faqList, isLoading: isFaqLoading = true, - run: refetchFAQ, + setResult: setFaqList, } = usePromiseResult( async () => { const result = @@ -20,5 +24,13 @@ export const useFAQListInfo = () => { }, ); + const refetchFAQ = useCallback(async () => { + const nextFaqList = + await backgroundApiProxy.serviceStaking.getFAQListForHome(); + setFaqList((previousFaqList) => + isEqual(previousFaqList, nextFaqList) ? previousFaqList : nextFaqList, + ); + }, [setFaqList]); + return { faqList, isFaqLoading, refetchFAQ }; };