MetaMask / MetaMask/metamask-mobile
Memoize SnapInterfaceContext value and stabilize its handler functions
- Dominant language
- TypeScript
- Stars
- 3k
- Forks
- 1.7k
- Avg merge
- 1d 14h
- Merged PRs (30d)
- 669
Description
> **Performance audit finding** · Severity: **High** · Effort: Easy · Fix risk: Simple · Test safety net: Partial
> Owner: `@MetaMask/core-platform`
> File: `app/components/Snaps/SnapInterfaceContext.tsx:172`
### What is this about?
`SnapInterfaceContextProvider` builds an inline object literal for `SnapInterfaceContext.Provider value={{ ... }}`, and every function inside it (`handleEvent`, `getValue`, `handleInputChange`, `setCurrentFocusedInput`) is re-created on every render because they are declared inline in the component body with no `useCallback`. Both the object and the function refs change every render.
**Why it matters**
`useSnapInterfaceContext` is consumed in ~18 files that render Snap UI components (inputs, dropdowns, buttons, forms). Snap interfaces re-render on every interface-state update; each update creates fresh handler refs and a fresh context object, forcing every Snap UI element in the tree to re-render even when its own value is unchanged. For form-heavy Snap dialogs this multiplies re-renders across the whole rendered interface.
### Scenario
N/A — see Technical Details.
### Design
N/A — internal performance change; no UI/design impact.
### Technical Details
**Evidence**
`app/components/Snaps/SnapInterfaceContext.tsx:172`
```tsx
return (
```
The handlers are plain functions defined at `:118` (`handleEvent`), `:139` (`handleInputChange`), `:155` (`getValue`), `:167` (`setCurrentFocusedInput`) — none memoized.
**Fix**
Wrap each handler in `useCallback` (deps: `snapId`, `interfaceId`, `controllerMessenger`; the others read refs) and wrap the provider value in `useMemo`:
```tsx
const handleEvent = useCallback(..., [snapId, interfaceId]);
const handleInputChange = useCallback(..., [snapId, interfaceId]);
const getValue = useCallback(..., [initialState]);
const setCurrentFocusedInput = useCallback(..., []);
const value = useMemo(
() => ({ handleEvent, getValue, handleInputChange, setCurrentFocusedInput, focusedInput: focusedInput.current, snapId }),
[handleEvent, getValue, handleInputChange, setCurrentFocusedInput, snapId],
);
```
Note `focusedInput.current` is a ref read — it will only reflect updates when the provider re-renders, which is the existing behavior, so this is no regression.
### Threat Modeling Framework
N/A — performance-only change; behavior is preserved, no new data flow / trust boundary / attack surface.
### Acceptance Criteria
- - Run `yarn jest app/components/Snaps/SnapInterfaceContext.test.tsx`.
- Open a Snap dialog with multiple inputs, type in one input, and confirm with the Profiler that sibling inputs no longer re-render on each keystroke.
### References
- File: `app/components/Snaps/SnapInterfaceContext.tsx:172`
- Source: MetaMask Mobile performance audit — finding `context-snap-interface-inline-value`
- Owner (CODEOWNERS / best-effort): @MetaMask/core-platform
- Status: **UNVALIDATED**
Contributor guide
Research direction
Start in app/components/Snaps/SnapInterfaceContext.tsx at the handler definitions and provider value around line 172, then read app/components/Snaps/SnapInterfaceContext.test.tsx. Run the named Jest test, apply the memoization change described in the issue, and verify with the Profiler that sibling inputs do not re-render on each keystroke in a multi-input Snap dialog.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react-native, typescript
- Domain
- mobile, performance
- Issue type
- Refactor
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 74/100