perf: Memoize ShieldSubscriptionProvider context value and callback#39309
perf: Memoize ShieldSubscriptionProvider context value and callback#39309
ShieldSubscriptionProvider context value and callback#39309Conversation
|
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. |
✨ Files requiring CODEOWNER review ✨🔐 @MetaMask/web3auth (2 files, +467 -136)
|
f6fdccb to
4f235d9
Compare
4f235d9 to
e6244fc
Compare
e6244fc to
aea7b5c
Compare
aea7b5c to
a7e31e8
Compare
Builds ready [a7e31e8]
UI Startup Metrics (1358 ± 89 ms)
📊 Page Load Benchmark ResultsCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs
|
a7e31e8 to
00f6e52
Compare
Builds ready [00f6e52]
UI Startup Metrics (1341 ± 109 ms)
Sum of mean exceeds: 0ms | Sum of p95 exceeds: 386ms Sum of all benchmark exceeds: 386ms 📊 Page Load Benchmark ResultsCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
ShieldSubscriptionProvider context value and callback
| /** | ||
| * Memoize the context value to prevent creating a new object reference | ||
| * on every render, which would cause unnecessary re-renders of consuming components. | ||
| */ | ||
| const contextValue = useMemo( | ||
| () => ({ evaluateCohortEligibility }), | ||
| [evaluateCohortEligibility], | ||
| ); | ||
|
|
||
| return ( | ||
| <ShieldSubscriptionContext.Provider | ||
| value={{ | ||
| evaluateCohortEligibility, | ||
| }} | ||
| > | ||
| <ShieldSubscriptionContext.Provider value={contextValue}> |
There was a problem hiding this comment.
Motivation for this PR: Memoize the context value
| const evaluateCohortEligibility = useCallback( | ||
| async (entrypointCohort: string): Promise<void> => { | ||
| await evaluateCohortEligibilityRef.current?.(entrypointCohort); | ||
| }, | ||
| [ | ||
| dispatch, | ||
| isMetaMaskShieldFeatureEnabled, | ||
| isBasicFunctionalityEnabled, | ||
| isShieldSubscriptionActive, | ||
| isSignedIn, | ||
| isUnlocked, | ||
| hasShieldEntryModalShownOnce, | ||
| getShieldSubscriptionEligibility, | ||
| assignToCohort, | ||
| captureShieldEligibilityCohortEvent, | ||
| ], | ||
| [], |
There was a problem hiding this comment.
- Makes
contextValuedeps array stable. - Not a breaking change since callback body only contains ref.
- The flowchart logic in https://github.com/MetaMask/metamask-extension/pull/39309/files#diff-c60e97713e2a9c3dce3c5fb9f3ae1326b2505d30287bfada5bac15c015d2cb21L127-L136 was and is handled upstream by
getShieldSubscriptionEligibility,useSubscriptionEligibility, not here in theevaluateCohortEligibilitydeps array. - Moved the flowchart jsdoc to
ShieldSubscriptionProviderhere
| * Ref to hold the latest implementation of evaluateCohortEligibility. | ||
| * Assigned synchronously during render (not in useEffect) so the ref | ||
| * is populated before commit-phase callbacks (componentDidUpdate) fire. | ||
| */ | ||
| const evaluateCohortEligibility = useCallback( | ||
| async (entrypointCohort: string): Promise<void> => { | ||
| try { | ||
| if (!isMetaMaskShieldFeatureEnabled || !isBasicFunctionalityEnabled) { | ||
| return; | ||
| } | ||
| const evaluateCohortEligibilityRef = | ||
| useRef<(entrypointCohort: string) => Promise<void>>(null!); |
There was a problem hiding this comment.
Assigned synchronously at render time due to this edge case:
evaluateCohortEligibilitynow delegates to evaluateCohortEligibilityRef.current. If it's only assigned inside auseEffect, callers that invoke the callback during the commit phase (e.g., Home’scomponentDidUpdate) can hit an uninitialized ref, making the call a silent no-op and potentially skipping Shield cohort evaluation permanently.
-- #39309 (comment)
Builds ready [825e4ae]
⚡ Performance Benchmarks
🌐 Dapp Page Load BenchmarksCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
Builds ready [3c72f0f]
⚡ Performance Benchmarks
🌐 Dapp Page Load BenchmarksCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
…tiating with noop function
Builds ready [ca53602]
⚡ Performance Benchmarks
🌐 Dapp Page Load BenchmarksCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
|
Builds ready [50a2fed]
⚡ Performance Benchmarks
🌐 Dapp Page Load BenchmarksCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|



Description
ShieldSubscriptionProviderwas creating a new context value object on every render and recreatingevaluateCohortEligibilitywhenever any of its 10 dependencies changed, causing unnecessary re-renders of the Home component subtree.Two fixes applied:
Stabilized callback with ref pattern -- Moved the
evaluateCohortEligibilityimplementation into a ref updated viauseEffect. The public callback delegates to the ref, keeping a stable reference (useCallbackwith empty deps) while still accessing current closure values.Memoized context value -- Wrapped the provider value object in
useMemokeyed on the now-stable callback, preventing a new object reference on every render.This is fix 6 of 6 in the P0 Global Cascade Re-render Fixes epic (RCA, Profiling Report). Together, the 6 memoization fixes break the cascade chain originating in Routes/Home that propagated through the entire component tree on every navigation and state update.
Profiling Results (all 6 fixes combined -- Full Report)
We ran 7 rounds of controlled A/B benchmarks over 41 hours. Results confirm the cascade was the dominant source of interactive UI latency. WDYR audit: 0 cascade re-renders on treatment (RCA measured 125-200 per action on baseline).
Per-interaction improvements
Cascade compounding under sustained interaction
Route cycling benchmark (3 round-trips: Send -> Assets -> Swap):
The 7x is a compounding effect across successive transitions, not a per-transition effect. Isolated transitions show only 10-15% improvement. Each navigation accumulates subscription/listener state on baseline, escalating re-render overhead -- consistent with the RCA's cascade chain model (Routes -> Home -> all children).
This compounding behavior is itself a key finding: it means any remaining or future cascade leak will degrade non-linearly with session length, even if it looks trivial in a single benchmark.
Production estimate: StrictMode roughly doubles render work. The 7x measured in dev builds is expected to land at ~2-7x in production depending on wallet size, tree depth, and GC timing.
Metrics to watch after merge
metamask-performance):assetClickToPriceChartshould show a step-change (83->37ms dev).cascadeFixProfilingpreset can be added for ongoing regression tracking.Changelog
CHANGELOG entry: Fix memoization issue in top-level context provider that was causing cascading re-renders.
Related issues
Fixes: MetaMask/MetaMask-planning#6638
Part of epic: MetaMask-planning#6669 -- P0 Global Cascade Re-render Fixes
Sibling PRs:
withRouterHooksparams/location (#6671)getPendingApprovalsselector (#6673)pendingConfirmationsSortedSelector(#6674)Manual testing steps
Screenshots/Recordings
Pre-merge author checklist
Pre-merge reviewer checklist
Note
Medium Risk
Changes how
evaluateCohortEligibilityis wired (ref-backed stable callback) and how the context value is memoized, which could alter when/with-what state eligibility checks run if the ref is not kept in sync. Added tests reduce risk but this touches subscription/cohort gating that affects modal display behavior.Overview
Reduces cascading re-renders from
ShieldSubscriptionProviderby making the exportedevaluateCohortEligibilityfunction reference-stable and memoizing the contextvalueobject.Refactors
evaluateCohortEligibilityto delegate through auseRef-stored implementation (updated during render so it’s available in commit-phase callbacks) while keeping the public callbackuseCallback([]). Adds a new Jest test suite validating child rendering, stable callback/context identity across re-renders, and that the stable callback still observes updated selector values.Written by Cursor Bugbot for commit 50a2fed. This will update automatically on new commits. Configure here.