Skip to content

[DRAFT] PoC: TanStack Query Option B — UIMessenger-enforced background ownership + UI-side staleTime support#41102

Closed
MajorLift wants to merge 18 commits intomainfrom
jongsun/poc/tq/ui-default-bg-sync
Closed

[DRAFT] PoC: TanStack Query Option B — UIMessenger-enforced background ownership + UI-side staleTime support#41102
MajorLift wants to merge 18 commits intomainfrom
jongsun/poc/tq/ui-default-bg-sync

Conversation

@MajorLift
Copy link
Copy Markdown
Contributor

@MajorLift MajorLift commented Mar 20, 2026

PoC: TanStack Query Option B

Summary

  • UIMessenger capability gating - Background ownership enforcement without axis coupling with UIMessenger gating (decisions#126) and queryFn scope lint rule enforce the same boundary as OmitKeyof, without stripping staleTime from the UI type signature.
  • Call-site cache policy — per-component staleTime (5s swaps, 30s portfolio) on the same hook and queryKey — a type error under Option A's OmitKeyof.
  • Axis independence — fetch ownership (Axis 2) and cache policy location (Axis 1) enforced as independent constraints, not coupled via type erasure.

Option A vs Option B

Option A (fb/rq-poc) Option B (this PoC)
Ownership enforcement OmitKeyof strips queryFn + staleTime UIMessenger capability gating + lint rule
UI staleTime Stripped from type signature Call-site API, per-component freshness
UI queryFn Proxy to background via messenger Own queryFn from get*QueryOptions
Axis coupling Coupled. Background ownership implies service-layer cache policy Decoupled. Independent enforcement per axis
New API lead time Data service fetchQuery + proxy routing per endpoint get*QueryOptions direct, zero lead time
Background sync createUIQueryClient -> hydrate() BaseDataService -> cacheUpdate -> useSyncExternalStore
BaseDataService Required for all queries (proxy through background) Background-owned queries only. UI-direct coexists
core-backend usage get*QueryOptions wrapped in proxy; staleTime stripped get*QueryOptions consumed directly. staleTime/select/retry preserved

Ownership boundary enforcement

The central question between Option A and Option B: how is the fetch ownership boundary enforced?

Option A: OmitKeyof

Background owns all fetches. UI receives a QueryClient with staleTime: 0 and OmitKeyof stripping staleTime/queryFn from the type signature. This enforces ownership — but as a blunt instrument. Stripping staleTime is a side effect of stripping queryFn, not an independent constraint.

Option B: UIMessenger capability gating + lint rule

Two independent, composable mechanisms — each enforcing one axis:

1. UIMessenger capability gating (Axis 2 — fetch ownership)

Per-controller messenger configs declare which events are exposed to the UI. Routes declare which events they can subscribe to. Components use useMessenger() with compile-time and runtime validation against the route's declared capabilities.

// Route declares its sync event capabilities via layout route
{
  element: (
    <RouteWithMessenger events={['CurrencyRateDataService:cacheUpdate']}>
      <Outlet />
    </RouteWithMessenger>
  ),
  children: [{ path: DEFAULT_ROUTE, element: <Home /> }],
}

// useControllerState validates route capabilities internally via useMessenger().
// Subscribing to an undeclared event is a compile-time type error and a runtime throw.
const data = useControllerState(currencyRatesStore, 'CurrencyRateDataService:cacheUpdate');

The enforcement hierarchy:

  • Per-controller config (shared/messenger-config/*.ts) — team-owned declaration of UI-exposed events using DataServiceEvents types from BaseDataService. Adding/removing an event here controls access at compile time and runtime.
  • Route declaration (layout route with RouteWithMessenger) — scopes event access per route. RouteWithMessenger creates a child messenger that only delegates declared events.
  • Component access (useMessenger({ events })) — validates requested events against the route's delegated capabilities. Undeclared event = type error + runtime throw.
  • Hook-level enforcementuseControllerState calls useMessenger() internally, so every controller-state subscription is validated against the route's declared capabilities. The underlying createControllerStore subscribes at module scope for efficiency, but the React hook enforces the messenger hierarchy at render time.

This follows the three-tier messenger hierarchy from decisions#126: RootMessengerUIMessengerRouteMessenger.

2. queryFn scope lint rule (Axis 2 — fetch authorship)

no-restricted-imports in ui/hooks/** requires queryFn via get*QueryOptions from @metamask/core-backend. Blocks unauthorized direct-API calls in UI hooks — without restricting staleTime or other cache policy.

Together: UIMessenger gates event subscriptions (controller access boundary). Lint rule gates queryFn authorship (fetch ownership boundary). These are the same constraints OmitKeyof enforces — but as independent mechanisms that don't couple Axis 1 to Axis 2.

Per-screen vs per-client cache policy

Per-client divergence (extension vs mobile) is solvable under both options via data service constructor config. The constraint unique to Option A is per-screen divergence within the same client: the data service instance is shared across all screens, so a single staleTime governs every consumer.

Examples: gas estimates (activity list 30s vs confirmation 3s), token balances (dashboard 30s vs send confirmation 0), select for re-render scope (home: all tokens, swap: only source/dest), refetchOnMount: 'always' for confirmation screens. staleTime is type-stripped by OmitKeyof; other options remain available but staleTime: 0 breaks the conditional interaction system (e.g., refetchOnMount: true always fires because data is always stale).

What background proxy uniquely provides

After accounting for core-backend (shared query definitions), constructor config (per-client divergence), and persistQueryClient (cache persistence):

  1. Bypass resistanceOmitKeyof is compile-time; lint rules can be eslint-disabled
  2. Single-process observability — all fetches in one process
  3. Process-persistent cache — extension only; independently solvable via persistQueryClient

These are operational concerns. The ADR question: do they justify staleTime: 0 breaking the conditional refetch system, core-backend pass-through freshness undermined, and split-process staleness indirection?

Staleness indirection

The ADR identifies a structural problem with background-owned fetches: when the QueryClient that detects staleness is not the one that owns the queryFn, two staleTime values interact opaquely.

UI-direct (useSpotPricesQuery) Controller-driven (useCurrencyRatesQuery)
queryFn owner UI QueryClient directly Background (CurrencyRateDataService)
Staleness indirection Absent. Single staleTime, single process Absent. No proxy cycle. UI is a passive receiver
Call-site cache policy Yes, staleTimeOverride per component No. Background cadence is authoritative

Option A's proxy pattern produces the indirection for all queries. Option B eliminates it for UI-direct queries (structurally impossible, single owner) and replaces it with transparent push semantics for controller-driven queries (isFetching: false, dataUpdatedAt reflects background timing via DehydratedState).

Architecture

flowchart TB
    subgraph UIDirectFetch["UI-direct: server state (useQuery)"]
        direction TB
        A1["useSpotPricesQuery(staleTimeOverride?)"]
        A2["useQuery(apiClient.prices.getV3SpotPricesQueryOptions(...))"]
        A3["price.api.cx.metamask.io"]
        A1 --> A2 --> A3
    end

    subgraph BackgroundSync["Controller state (useSyncExternalStore)"]
        direction TB
        B1["CurrencyRateController:stateChange"]
        B2["CurrencyRateDataService.fetchQuery()"]
        B3["@tanstack/query-core (background-internal)"]
        B4["cacheUpdate messenger event"]
        B5["useSyncExternalStore → component"]
        B1 --> B2 --> B3 --> B4 --> B5
    end

    style B3 fill:#f5f5f5,stroke:#ccc,stroke-dasharray: 5 5
Loading

Two tools for two problems. TQ on the background side is an implementation detail — its only UI-visible output is the messenger event.

TQ usage by layer

Layer TQ involvement What it does
Background (BaseDataService) @tanstack/query-core — internal fetchQuery for caching, deduplication, gc. staleTime/gcTime/retry are background-internal. Publishes cacheUpdate messenger events.
Transport None Messenger events carry data across the process boundary. Payload format (currently DehydratedState) is an implementation detail of BaseDataService.
UI, server state @tanstack/react-query — full API useQuery/useInfiniteQuery with get*QueryOptions from @metamask/core-backend. Call-site staleTime, refetchOnFocus, select, retry.
UI, controller state None useSyncExternalStore over messenger events. No queryFn, no staleTime, no TQ cache policy. Background owns cadence. useControllerState validates route-level messenger capabilities via useMessenger().

Background-side TQ guidelines

Data services use @tanstack/query-core as internal infrastructure. The UI does not consume background TQ artifacts directly.

  • queryKey is internal. The messenger event name (e.g., CurrencyRateDataService:cacheUpdate) is the contract between background and UI — not the queryKey.
  • staleTime/gcTime/retry are background-internal cache policy. They govern how the background caches and refreshes data. They do not leak to the UI.
  • fetchQuery vs prefetchQuery: Use fetchQuery when the caller needs the result (e.g., controller consuming the data). Use prefetchQuery when the goal is just to populate the cache for later cacheUpdate events.
  • One data service per controller concern, not per UI view. The data service models the background's data domain; the UI subscribes to the events it needs.
  • Do not export queryOptions from data services. If the UI needs to fetch the same data directly (display-only context), it uses get*QueryOptions from @metamask/core-backend — a separate code path with its own queryFn.

Per-controller vs multi-endpoint data services

The ADR identifies a packaging choice for background data services: per-controller (TokenRatesDataService) vs multi-endpoint (@metamask/core-backend). Under Option A, where all data routes through the background, these are competing organizational models for the same concern.

This PoC resolves the tension: they serve different data categories.

Per-controller data service Multi-endpoint (core-backend)
Data category Controller state Server state
Purpose Bridge controller state to UI via messenger Library of query definitions for direct UI use
UI consumption useControllerState (useSyncExternalStore) useQuery(get*QueryOptions(...))
TQ involvement (UI) None Full API
TQ involvement (bg) @tanstack/query-core — internal N/A (no runtime, just config)
Cross-client reuse Shared via core data service package Shared via core-backend package

Per the ADR: "A per-controller data service can consume core-backend's exported get*QueryOptions while owning its own QueryClient and lifecycle — using core-backend as a query definition library rather than a runtime service." This PoC makes that concrete: core-backend is the library, per-controller data services are the runtime — and they don't overlap.

core-backend alignment

@metamask/core-backend exports get*QueryOptions for 40+ endpoints with { queryKey, queryFn, staleTime, gcTime }. Its FetchOptions pass-through preserves staleTime, select, retry at the call site. Option B consumes this directly:

const queryOptions = apiClient.prices.getV3SpotPricesQueryOptions(assetIds, { currency });
return useQuery({
  ...queryOptions,
  ...(staleTimeOverride !== undefined && { staleTime: staleTimeOverride }),
});

Option A wraps the same get*QueryOptions in a proxy queryFn and strips staleTime via OmitKeyof. The pass-through that core-backend preserves is removed at the consumption layer.

Existing usage on main and mobile confirms the full API surface is needed:

  • activity-v2 uses useInfiniteQuery, select, keepPreviousData, refetchOnMount, prefetchInfiniteQuery. select and refetchOnMount are available under Option A (not stripped by OmitKeyof), but staleTime: 0 breaks conditional behavior — refetchOnMount: true always fires.
  • metamask-mobile test: header integration test for contract interaction #25981 uses staleTime: 10_000, full status API, controller-boundary queryFn. Call-site staleTime, stripped under Option A.

Option B is a superset: full TQ API at call site, plus controller state via useSyncExternalStore, plus per-component staleTimeOverride.

Model selection: UI-direct vs controller-driven

When adding a new data hook, use this decision tree:

Does a background controller already consume this data?
├── YES → Does the data need to persist when UI is unmounted?
│   ├── YES → Controller state (useSyncExternalStore over messenger)
│   └── NO  → Is the fetch cadence externally driven (WebSocket, polling)?
│       ├── YES → Controller state
│       └── NO  → Server state (useQuery — component declares freshness)
└── NO  → Server state (useQuery with get*QueryOptions from core-backend)

Rule of thumb: default to UI-direct (useQuery). Cross-client reuse is automatic — @metamask/core-backend is the shared query options package, consumed directly by both extension and mobile. Upgrade to controller state only when an independent background ownership criterion is met (controller dependency, process persistence, externally driven cadence).

Shared queryKey staleTime behavior

When multiple useQuery observers share a queryKey with different staleTime values, TQ uses the shortest staleTime for refetch decisions. Per-component staleTimeOverride (e.g., 5s swap vs 30s portfolio) achieves differentiated freshness only when the components are on separate routes (mutually exclusive mounts). When co-mounted, the shorter staleTime governs both.

Open in GitHub Codespaces

Changelog

CHANGELOG entry:

Related issues

Fixes:

Manual testing steps

  1. Go to this page...

Screenshots/Recordings

Before

After

Pre-merge author checklist

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

`fetchQuery` with `staleTime: 0` is intentional for controller-driven
push data: each state change executes the `queryFn` (which returns the
state directly), storing it in the background `QueryClient` cache.
`BaseDataService` then auto-publishes `cacheUpdate` with `DehydratedState`.
This is the fetch ownership boundary — the controller decides when to
update, not the UI.
…ery`

Uses `hydrate()` instead of `setQueryData` so the `DehydratedState` from
`BaseDataService:cacheUpdate` carries cache metadata (`dataUpdatedAt`,
`staleTime`) from the background — UI cache reflects background timing
semantics without re-fetching. `useCurrencyRatesQuery` is a passive
cache reader (`queryFn: () => undefined`); `isFetching` stays false.
… enforcement

Per-route gating instead of global subscription prevents components from
silently depending on events their route hasn't declared. Undeclared
event subscriptions are a compile-time type error (via `useMessenger`
generics) and a runtime throw (via delegated capabilities check).
@metamaskbot metamaskbot added the team-extension-platform Extension Platform team label Mar 20, 2026
@github-actions
Copy link
Copy Markdown
Contributor

CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.

@socket-security
Copy link
Copy Markdown

socket-security bot commented Mar 20, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​metamask-previews/​base-data-service@​0.0.0-preview-00245ea731007397100

View full report

@metamaskbotv2
Copy link
Copy Markdown
Contributor

metamaskbotv2 bot commented Mar 20, 2026

✨ Files requiring CODEOWNER review ✨

👨‍🔧 @MetaMask/core-extension-ux (1 files, +264 -0)
  • 📁 ui/
    • 📁 components/
      • 📁 multichain/
        • 📁 spot-prices-query/
          • 📄 SpotPricesQueryPanel.tsx +264 -0

📜 @MetaMask/policy-reviewers (12 files, +84 -0)
  • 📁 lavamoat/
    • 📁 browserify/
      • 📁 beta/
        • 📄 policy.json +9 -0
      • 📁 experimental/
        • 📄 policy.json +9 -0
      • 📁 flask/
        • 📄 policy.json +9 -0
      • 📁 main/
        • 📄 policy.json +9 -0
    • 📁 webpack/
      • 📁 mv2/
        • 📁 beta/
          • 📄 policy.json +7 -0
        • 📁 experimental/
          • 📄 policy.json +7 -0
        • 📁 flask/
          • 📄 policy.json +7 -0
        • 📁 main/
          • 📄 policy.json +7 -0
      • 📁 mv3/
        • 📁 beta/
          • 📄 policy.json +5 -0
        • 📁 experimental/
          • 📄 policy.json +5 -0
        • 📁 flask/
          • 📄 policy.json +5 -0
        • 📁 main/
          • 📄 policy.json +5 -0

Tip

Follow the policy review process outlined in the LavaMoat Policy Review Process doc before expecting an approval from Policy Reviewers.

@MajorLift MajorLift added the DO-NOT-MERGE Pull requests that should not be merged label Mar 20, 2026
@MajorLift
Copy link
Copy Markdown
Contributor Author

@metamaskbot update-policies

@metamaskbot
Copy link
Copy Markdown
Collaborator

Policies updated.
👀 Please review the diff for suspicious new powers.

🧠 Learn how: https://lavamoat.github.io/guides/policy-diff/#what-to-look-for-when-reviewing-a-policy-diff

👀 lavamoat/browserify/beta/policy.json changes differ from main/policy.json policy changes
👀 lavamoat/browserify/experimental/policy.json changes differ from main/policy.json policy changes
👀 lavamoat/browserify/flask/policy.json changes differ from main/policy.json policy changes
👀 lavamoat/webpack/mv2/beta/policy.json changes differ from mv2/main/policy.json policy changes
👀 lavamoat/webpack/mv2/experimental/policy.json changes differ from mv2/main/policy.json policy changes
👀 lavamoat/webpack/mv2/flask/policy.json changes differ from mv2/main/policy.json policy changes
👀 lavamoat/webpack/mv3/beta/policy.json changes differ from mv3/main/policy.json policy changes
👀 lavamoat/webpack/mv3/experimental/policy.json changes differ from mv3/main/policy.json policy changes
👀 lavamoat/webpack/mv3/flask/policy.json changes differ from mv3/main/policy.json policy changes

@metamaskbotv2
Copy link
Copy Markdown
Contributor

metamaskbotv2 bot commented Mar 20, 2026

Builds ready [bd5fb82]
⚡ Performance Benchmarks
👆 Interaction Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Load New Accountload_new_account373263557128501557
total373263557128501557
Confirm Txconfirm_tx6005598160392260206039
total6005598160392260206039
Bridge User Actionsbridge_load_page21518125224232252
bridge_load_asset_picker22918525325244253
bridge_search_token7597517666762766
total1178114012022411931202
🔌 Startup Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Standard HomeuiStartup14281172169610814801625
load117096214059212231338
domContentLoaded116495913939112171329
domInteractive2916101202483
firstPaint187631287185206336
backgroundConnect21019226114213242
firstReactRender19135252129
initialActions106114
loadScripts97176612009010241127
setupStore1375171424
numNetworkReqs403290163279
Power User HomeuiStartup5638246213767222566379214
load13321105189913713821647
domContentLoaded13121088186013113701625
domInteractive44202704736131
firstPaint19689499103275350
backgroundConnect174831410449181925454999
firstReactRender25175262538
initialActions1010114
loadScripts1077869159412411141373
setupStore187298291732
numNetworkReqs23712745062269344
🧭 User Journey Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Onboarding Import WalletimportWalletToSocialScreen2182172191218219
srpButtonToSrpForm92919319393
confirmSrpToPwForm21212202122
pwFormToMetricsScreen15151501515
metricsToWalletReadyScreen15151601616
doneButtonToHomeScreen61152373593712735
openAccountMenuToAccountListLoaded2920289529662729142966
total3931379039917839873991
Onboarding New WalletcreateWalletToSocialScreen2192192190219219
srpButtonToPwForm1101091121111112
createPwToRecoveryScreen989099
skipBackupToMetricsScreen40384224242
agreeButtonToOnboardingSuccess16161601616
doneButtonToAssetList51647155329543553
total91086694829937948
Asset DetailsassetClickToPriceChart49485114951
total49485114951
Solana Asset DetailsassetClickToPriceChart1168318741137187
total1168318741137187
Import Srp HomeloginToHomeScreen2394229225158624282515
openAccountMenuAfterLogin67656926969
homeAfterImportWithNewWallet1224323243996923762439
total382026835595121749815595
Send TransactionsopenSendPageFromHome492166186466
selectTokenToSendFormLoaded35284773347
reviewTransactionToConfirmationPage9808951082679741082
total107399311646110701164
SwapopenSwapPageFromHome1056313727125137
fetchAndDisplaySwapQuotes268926852693326922693
total2794275228212628172821
🌐 Dapp Page Load Benchmarks

Current Commit: bd5fb82 | Date: 3/20/2026

📄 Localhost MetaMask Test Dapp

Samples: 100

Summary

  • pageLoadTime-> current mean value: 1.01s (±45ms) 🟡 | historical mean value: 1.04s ⬇️ (historical data)
  • domContentLoaded-> current mean value: 708ms (±41ms) 🟢 | historical mean value: 732ms ⬇️ (historical data)
  • firstContentfulPaint-> current mean value: 84ms (±13ms) 🟢 | historical mean value: 86ms ⬇️ (historical data)

📈 Detailed Results

Metric Mean Std Dev Min Max P95 P99
pageLoadTime 1.01s 45ms 968ms 1.29s 1.07s 1.29s
domContentLoaded 708ms 41ms 677ms 974ms 762ms 974ms
firstPaint 84ms 13ms 68ms 184ms 104ms 184ms
firstContentfulPaint 84ms 13ms 68ms 184ms 104ms 184ms
largestContentfulPaint 0ms 0ms 0ms 0ms 0ms 0ms
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 1.68 MiB (32.48%)
  • ui: -69.4 KiB (-0.81%)
  • common: 397.23 KiB (3.52%)

@MajorLift MajorLift force-pushed the jongsun/poc/tq/ui-default-bg-sync branch from bf23bc6 to 150cc56 Compare March 22, 2026 20:45
@metamaskbotv2
Copy link
Copy Markdown
Contributor

metamaskbotv2 bot commented Mar 22, 2026

Builds ready [150cc56]
⚡ Performance Benchmarks
👆 Interaction Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Load New Accountload_new_account2812722897285289
total2812722897285289
Confirm Txconfirm_tx6059604360811460626081
total6059604360811460626081
Bridge User Actionsbridge_load_page31921740367371403
bridge_load_asset_picker17813722730193227
bridge_search_token72170173211732732
total1246121612712212621271
🔌 Startup Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Standard HomeuiStartup15101288203711715941683
load12421033173711113091408
domContentLoaded12361030172811013011398
domInteractive3018152232688
firstPaint1396837665182229
backgroundConnect22520427614231255
firstReactRender20146462129
initialActions109113
loadScripts1027824151310710891174
setupStore1473751621
numNetworkReqs403283154178
Power User HomeuiStartup5604228013528214464667965
load13141142312322313291612
domContentLoaded12941136310821713021572
domInteractive39201863631146
firstPaint225861374191283414
backgroundConnect22773089102190632085179
firstReactRender25166162733
initialActions104113
loadScripts1061927286421110701324
setupStore1776091834
numNetworkReqs1448530141148244
🧭 User Journey Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Onboarding Import WalletimportWalletToSocialScreen2192172222221222
srpButtonToSrpForm92919209292
confirmSrpToPwForm21212202222
pwFormToMetricsScreen15151601516
metricsToWalletReadyScreen16151711617
doneButtonToHomeScreen66450979096729790
openAccountMenuToAccountListLoaded29922717332820630933328
total3992396040484040484048
Onboarding New WalletcreateWalletToSocialScreen2192192190219219
srpButtonToPwForm1101091101110110
createPwToRecoveryScreen989099
skipBackupToMetricsScreen41404104141
agreeButtonToOnboardingSuccess16161701617
doneButtonToAssetList61850873998711739
total1020902114310511311143
Asset DetailsassetClickToPriceChart48455234852
total48455234852
Solana Asset DetailsassetClickToPriceChart80431192782119
total80431192782119
Import Srp HomeloginToHomeScreen2436238724743224462474
openAccountMenuAfterLogin59311103073110
homeAfterImportWithNewWallet1614377255295523512552
total41392887503090848295030
Send TransactionsopenSendPageFromHome32303423434
selectTokenToSendFormLoaded35304254142
reviewTransactionToConfirmationPage1127850145524012721455
total1195921151823613401518
SwapopenSwapPageFromHome90761011099101
fetchAndDisplaySwapQuotes268726842690226872690
total2789276328202228092820
🌐 Dapp Page Load Benchmarks

Current Commit: 150cc56 | Date: 3/22/2026

📄 Localhost MetaMask Test Dapp

Samples: 100

Summary

  • pageLoadTime-> current mean value: 1.05s (±66ms) 🟡 | historical mean value: 1.04s ⬆️ (historical data)
  • domContentLoaded-> current mean value: 742ms (±79ms) 🟢 | historical mean value: 732ms ⬆️ (historical data)
  • firstContentfulPaint-> current mean value: 91ms (±128ms) 🟢 | historical mean value: 86ms ⬆️ (historical data)

📈 Detailed Results

Metric Mean Std Dev Min Max P95 P99
pageLoadTime 1.05s 66ms 1.01s 1.37s 1.27s 1.37s
domContentLoaded 742ms 79ms 703ms 1.30s 955ms 1.30s
firstPaint 91ms 128ms 64ms 1.37s 92ms 1.37s
firstContentfulPaint 91ms 128ms 64ms 1.37s 92ms 1.37s
largestContentfulPaint 0ms 0ms 0ms 0ms 0ms 0ms
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 1.68 MiB (32.48%)
  • ui: -70.92 KiB (-0.83%)
  • common: 397.23 KiB (3.52%)

…tate subscriptions

`useSyncExternalStore` adapter for background messenger events. Controller state
is not server state — the background owns cadence, the UI renders the latest value.
No TQ involvement on the UI side.
Currency rates are controller state pushed from background — not server state
the UI fetches. Replace no-op `queryFn` + `staleTime` with direct messenger
event subscription via `useSyncExternalStore`.
…drate()` path

No longer needed — controller state flows through `useControllerState` over
messenger events. Simplify `QueryClient` to standard TQ defaults; per-query
cache policy via `get*QueryOptions`.
…outes

`createRouteWithLayout` is a temporary v5-to-v6 bridge being deprecated.
Route-level messenger scoping now uses `RouteWithMessenger` as a layout
route element with `Outlet` — idiomatic React Router v6.
@MajorLift MajorLift force-pushed the jongsun/poc/tq/ui-default-bg-sync branch from 150cc56 to 61d47e9 Compare March 23, 2026 12:45
@metamaskbotv2
Copy link
Copy Markdown
Contributor

metamaskbotv2 bot commented Mar 23, 2026

Builds ready [61d47e9]
⚡ Performance Benchmarks
👆 Interaction Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Load New Accountload_new_account29727231818317318
total29727231818317318
Confirm Txconfirm_tx605660536063460586063
total605660536063460586063
Bridge User Actionsbridge_load_page25824428115260281
bridge_load_asset_picker25423627314269273
bridge_search_token7597537697769769
total1264120213033812981303
🔌 Startup Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Standard HomeuiStartup14081190172410714601612
load115693813909812251329
domContentLoaded114893413469512181309
domInteractive281698192583
firstPaint172621355172199346
backgroundConnect20518925413207237
firstReactRender17123231823
initialActions107113
loadScripts96075111589310231118
setupStore1274451520
numNetworkReqs403287163282
Power User HomeuiStartup60902346141292196666610710
load13661183257220513861665
domContentLoaded13401172256419513561601
domInteractive42212834134138
firstPaint254831603273274471
backgroundConnect207031210963220333117526
firstReactRender25186472636
initialActions105112
loadScripts1104953234817911161363
setupStore1575061726
numNetworkReqs20511736750223306
🧭 User Journey Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Onboarding Import WalletimportWalletToSocialScreen2242212262224226
srpButtonToSrpForm1091071091109109
confirmSrpToPwForm27252812828
pwFormToMetricsScreen16151811618
metricsToWalletReadyScreen19162322023
doneButtonToHomeScreen61055073269643732
openAccountMenuToAccountListLoaded297929752985429852985
total3969389141007939494100
Onboarding New WalletcreateWalletToSocialScreen2192172211220221
srpButtonToPwForm1091061112110111
createPwToRecoveryScreen989099
skipBackupToMetricsScreen38373903839
agreeButtonToOnboardingSuccess16151611616
doneButtonToAssetList4934904973497497
total8828778916891891
Asset DetailsassetClickToPriceChart46434724747
total46434724747
Solana Asset DetailsassetClickToPriceChart824014540111145
total824014540111145
Import Srp HomeloginToHomeScreen23542210256213224442562
openAccountMenuAfterLogin683989198789
homeAfterImportWithNewWallet2321222723926323652392
total4673465746911446914691
Send TransactionsopenSendPageFromHome30263433334
selectTokenToSendFormLoaded532499307899
reviewTransactionToConfirmationPage1302123613373913221337
total1387136114101813981410
SwapopenSwapPageFromHome883014646140146
fetchAndDisplaySwapQuotes268526832689226842689
total2778271228485428352848
🌐 Dapp Page Load Benchmarks

Current Commit: 61d47e9 | Date: 3/23/2026

📄 Localhost MetaMask Test Dapp

Samples: 100

Summary

  • pageLoadTime-> current mean value: 984ms (±70ms) 🟢 | historical mean value: 1.06s ⬇️ (historical data)
  • domContentLoaded-> current mean value: 695ms (±67ms) 🟢 | historical mean value: 746ms ⬇️ (historical data)
  • firstContentfulPaint-> current mean value: 76ms (±10ms) 🟢 | historical mean value: 91ms ⬇️ (historical data)

📈 Detailed Results

Metric Mean Std Dev Min Max P95 P99
pageLoadTime 984ms 70ms 939ms 1.25s 1.21s 1.25s
domContentLoaded 695ms 67ms 655ms 953ms 912ms 953ms
firstPaint 76ms 10ms 64ms 160ms 88ms 160ms
firstContentfulPaint 76ms 10ms 64ms 160ms 88ms 160ms
largestContentfulPaint 0ms 0ms 0ms 0ms 0ms 0ms
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 1.68 MiB (32.5%)
  • ui: -63.11 KiB (-0.74%)
  • common: 398.21 KiB (3.53%)

`createControllerStore` subscribes at module scope, bypassing the
UIMessenger hierarchy. The hook now accepts an `event` parameter and
calls `useMessenger()` to validate that the current route has declared
the event in its `RouteWithMessenger` capabilities — enforcing the
three-tier messenger boundary at the React layer.
@metamaskbotv2
Copy link
Copy Markdown
Contributor

metamaskbotv2 bot commented Mar 23, 2026

Builds ready [9aa181c]
⚡ Performance Benchmarks
👆 Interaction Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Load New Accountload_new_account31127435234350352
total31127435234350352
Confirm Txconfirm_tx606660556073860736073
total606660556073860736073
Bridge User Actionsbridge_load_page30826236435316364
bridge_load_asset_picker21117225127225251
bridge_search_token73472575110740751
total1253120013435112721343
🔌 Startup Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Standard HomeuiStartup14871257179210915421680
load1223101115279912791393
domContentLoaded1216100815189812721386
domInteractive3018104202689
firstPaint152721097115202259
backgroundConnect21820226810222235
firstReactRender21136372228
initialActions106124
loadScripts101281313009610681168
setupStore1473661724
numNetworkReqs403288154177
Power User HomeuiStartup62562179178272846664311258
load13141160171112913691632
domContentLoaded12921143168012013321534
domInteractive3620183273190
firstPaint215891503191263334
backgroundConnect226134014468289530229044
firstReactRender24184342632
initialActions104112
loadScripts1059940143911010941282
setupStore1475071728
numNetworkReqs18310031748206281
🧭 User Journey Benchmarks
BenchmarkMetricMean (ms)Min (ms)Max (ms)Std Dev (ms)P75 (ms)P95 (ms)
Onboarding Import WalletimportWalletToSocialScreen2192182190219219
srpButtonToSrpForm94929619596
confirmSrpToPwForm22222302223
pwFormToMetricsScreen15151601616
metricsToWalletReadyScreen16151711617
doneButtonToHomeScreen620515751101730751
openAccountMenuToAccountListLoaded29842893311310231033113
total3994398440081040084008
Onboarding New WalletcreateWalletToSocialScreen2192172222219222
srpButtonToPwForm1071051081108108
createPwToRecoveryScreen888088
skipBackupToMetricsScreen36353713737
agreeButtonToOnboardingSuccess15151501515
doneButtonToAssetList50748853919504539
total9448741103879731103
Asset DetailsassetClickToPriceChart52475735457
total52475735457
Solana Asset DetailsassetClickToPriceChart533967116567
total533967116567
Import Srp HomeloginToHomeScreen232023152327523272327
openAccountMenuAfterLogin59526656366
homeAfterImportWithNewWallet1196333248997322732489
total36642714483689946584836
Send TransactionsopenSendPageFromHome522576217476
selectTokenToSendFormLoaded37353913739
reviewTransactionToConfirmationPage1101848138321813401383
total1188938149421713981494
SwapopenSwapPageFromHome11410113011115130
fetchAndDisplaySwapQuotes268326822684126832684
total280227912812828032812

Dapp page load benchmarks: data not available.

Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 1.68 MiB (32.5%)
  • ui: -61.59 KiB (-0.72%)
  • common: 399.89 KiB (3.55%)

@MajorLift MajorLift closed this Apr 8, 2026
@github-actions github-actions bot locked and limited conversation to collaborators Apr 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

DO-NOT-MERGE Pull requests that should not be merged size-XL team-extension-platform Extension Platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants