diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bffbfeb..d140cd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- feat(ui): add `ios-widgets` skill for MetaMask Mobile home screen widgets and Live Activities, replacing the repo-local `.cursor/rules/widget-development.mdc` ([#110](https://github.com/MetaMask/skills/pull/110)). - Add opt-in stale project skill pruning via `--prune-stale` and `SKILLS_PRUNE_STALE=1`. - docs(testing): document CV stale-press flakiness plus high-leverage assert patterns (migration parity, filter both-sides example, loading/skeleton honesty, RefreshControl + flag overrides) in `mobile-testing` component-view and placement refs. diff --git a/domains/ui/skills/ios-widgets/references/adding-a-widget.md b/domains/ui/skills/ios-widgets/references/adding-a-widget.md new file mode 100644 index 00000000..6463e95e --- /dev/null +++ b/domains/ui/skills/ios-widgets/references/adding-a-widget.md @@ -0,0 +1,159 @@ +# Adding a home screen widget + +A widget needs both a JS half (layout + registration + data) and a native half +(a Swift file in the WidgetKit extension target). Both halves are keyed on one +string: the widget's name. + +Work through these in order. Steps 5 to 8 are the ones a JS-only change will +forget, and their absence shows up as "the widget never appears in the +gallery". + +## 1. Design the props + +A flat, JSON-serializable interface. Pre-formatted, pre-translated, pre-masked +— see **Designing props** in the skill body. + +## 2. Layout and registration — `app/core/Widgets/widgets/MyWidget.ios.tsx` + +```tsx +import { Text, VStack } from '@expo/ui/swift-ui'; +import { font, foregroundStyle, padding } from '@expo/ui/swift-ui/modifiers'; +import type { WidgetEnvironment } from 'expo-widgets'; + +import { createMetaMaskWidget } from '../createMetaMaskWidget.ios'; +import type { WithWidgetTheme } from '../types'; + +export interface MyWidgetProps { + /** Already formatted and privacy-mode aware. Computed by WidgetUpdaterService. */ + valueDisplay: string; + label: string; +} + +function MyWidgetLayout( + props: MyWidgetProps & WithWidgetTheme, + environment: WidgetEnvironment, +) { + 'widget'; + + const { valueDisplay, label, theme } = props; + const activeTheme = + environment.colorScheme === 'dark' ? theme.dark : theme.light; + + return ( + + + {label} + + + {valueDisplay} + + + ); +} + +export const MY_WIDGET_NAME = 'MyWidget'; + +export const MyWidget = createMetaMaskWidget( + MY_WIDGET_NAME, + MyWidgetLayout, +); +``` + +Keep the layout dumb: destructure, arrange, return. The `'widget'` directive +must be the first statement in the function body. + +## 3. Fallback — `app/core/Widgets/widgets/MyWidget.tsx` + +Duplicate `MyWidgetProps`, re-export the same name constant, and register a +no-op layout through the **extensionless** wrapper: + +```tsx +import { createMetaMaskWidget } from '../createMetaMaskWidget'; + +export interface MyWidgetProps { + valueDisplay: string; + label: string; +} + +export const MY_WIDGET_NAME = 'MyWidget'; + +export const MyWidget = createMetaMaskWidget( + MY_WIDGET_NAME, + () => undefined, +); +``` + +`.tsx`, not `.ts`, even with no JSX in it. See **Platform split** in the skill +body — a `.ts` fallback silently shadows the real widget on iOS. + +## 4. Data — `app/core/Widgets/WidgetUpdaterService.ts` + +Add one `private computeMyWidgetProps()` and one +`private pushMyWidgetUpdate()`, and call the push method from +`pushUpdates()`. Every selector read, formatter call, privacy-mode mask, and +`strings()` lookup belongs here — never in the widget file. + +The service already debounces store changes (2s) and skips the native write +when the newly computed props are `JSON.stringify`-identical to the last push. +Do not push from anywhere else to work around that. + +## 5. Swift file — `ios/ExpoWidgetsTarget/MyWidget.swift` + +Copy `BalanceWidget.swift` and replace `name` (must exactly equal +`MY_WIDGET_NAME`), `configurationDisplayName`, `description`, and +`supportedFamilies`. + +## 6. Bundle entry — `ios/ExpoWidgetsTarget/index.swift` + +Add `MyWidget()` to the `WidgetBundle` body. WidgetKit caps one bundle at +**4 widgets**; a fifth needs a chained nested bundle (see the comments in that +file). + +## 7. Declare it in `app.config.js` + +Add an entry to the `expo-widgets` plugin's `widgets` array mirroring the +Swift file's `name`, `displayName`, `description`, `supportedFamilies`, and +`contentMarginsDisabled`. + +This entry is **never evaluated** — the repo has a checked-in `ios/` directory, +so `expo prebuild` never runs and the config plugin never executes. It exists +as the canonical human-readable declaration, the same way `expo-font`'s +`fonts` array is declared beside a hand-committed `UIAppFonts` list. Keeping +it in sync matters because a future `expo prebuild` would regenerate +`ios/ExpoWidgetsTarget/` from it alone. + +## 8. Xcode target membership + +The new `.swift` file must be a **member of the `ExpoWidgetsTarget` target**, +not merely present on disk (Xcode → File Inspector → Target Membership). +`scripts/ios/setup-expo-widgets-target.rb` does not help: it early-returns +once the target exists. + +## 9. Tests + +See [`testing.md`](testing.md). + +## 10. Verify in a simulator + +Full rebuild (widget code lives in a separate native binary, so a Metro reload +is not enough), then long-press the home screen → **+** → search the widget's +display name. Check light and dark mode, and that it updates after the +underlying data changes in-app. + +## No analytics step + +Adoption is tracked automatically, keyed on the WidgetKit `kind` — which is +already `MY_WIDGET_NAME`. A new widget is measured the moment its Swift file +exists and a user places it. There is nothing to add. diff --git a/domains/ui/skills/ios-widgets/references/live-activities.md b/domains/ui/skills/ios-widgets/references/live-activities.md new file mode 100644 index 00000000..e887bd0b --- /dev/null +++ b/domains/ui/skills/ios-widgets/references/live-activities.md @@ -0,0 +1,152 @@ +# Adding a Live Activity + +A Live Activity is the Lock Screen and Dynamic Island counterpart of a home +screen widget. Every rule in the skill body applies unchanged: the `'widget'` +directive and its no-closures consequence, the `.ios.tsx` + `.tsx` pair, both +theme variants, pre-formatted props. + +Three things differ, and they are the whole delta. + +## 1. No native work at all + +No `.swift` file, no `index.swift` entry, no Xcode target membership, no +`app.config.js` entry. `expo-widgets`' generic `WidgetLiveActivity()` renderer +is already in the bundle and `NSSupportsLiveActivities` is already set. +`createLiveActivity(name, layout)` writes the stringified layout into the +shared App Group container at **import time**, and the extension reads it back +by `name` at render time. + +A new Live Activity is a pure-JS change and ships over Metro/OTA like any +other JS. No rebuild is needed to see it in a simulator that already has the +extension installed — reloading JS is enough, because registration happens at +import. + +## 2. The layout returns an object, not a JSX tree + +A widget layout returns one view. A Live Activity layout returns a +`LiveActivityLayout` whose keys are the presentation regions iOS asks for: + +| Key | Where it appears | +| --- | --- | +| `banner` | Lock Screen and Notification Center | +| `bannerSmall` | CarPlay / watchOS (falls back to `banner`) | +| `compactLeading` | Collapsed Dynamic Island, left of the camera | +| `compactTrailing` | Collapsed Dynamic Island, right of the camera | +| `minimal` | Dynamic Island when another app shares it | +| `expandedLeading` / `expandedTrailing` / `expandedCenter` / `expandedBottom` | Long-pressed Dynamic Island | + +The second parameter is a `LiveActivityEnvironment`, not a +`WidgetEnvironment` — same `colorScheme`, no `widgetFamily`. `expo-widgets` +does not re-export the type from its package root, so import it from +`../createMetaMaskLiveActivity.ios`, which derives and re-exports it. + +```tsx +import { Text } from '@expo/ui/swift-ui'; +import { foregroundStyle } from '@expo/ui/swift-ui/modifiers'; + +import { + createMetaMaskLiveActivity, + type LiveActivityEnvironment, +} from '../createMetaMaskLiveActivity.ios'; +import type { WithWidgetTheme } from '../types'; + +export interface MyActivityProps { + valueDisplay: string; + /** Semantic, not a resolved color — the layout still has to pick light/dark. */ + isProfit: boolean; +} + +function MyActivityLayout( + props: MyActivityProps & WithWidgetTheme, + environment: LiveActivityEnvironment, +) { + 'widget'; + + const { valueDisplay, isProfit, theme } = props; + const activeTheme = + environment.colorScheme === 'dark' ? theme.dark : theme.light; + const valueColor = isProfit + ? activeTheme.colors.success + : activeTheme.colors.error; + + const value = ( + {valueDisplay} + ); + + return { + banner: value, + compactLeading: value, + compactTrailing: value, + minimal: value, + }; +} + +export const MY_ACTIVITY_NAME = 'MyActivity'; + +export const MyActivity = createMetaMaskLiveActivity( + MY_ACTIVITY_NAME, + MyActivityLayout, +); +``` + +The plain `MyActivity.tsx` fallback mirrors this through +`../createMetaMaskLiveActivity` with a `() => undefined` layout, same as a +widget's fallback. + +## 3. The lifecycle is feature-owned + +`WidgetUpdaterService` exists to fan one debounced Redux snapshot out to +widgets. A Live Activity is a state machine — start on open, update while +open, end on close — and its data is often not in Redux at all. So the owning +feature drives it, from its own service. + +That service must: + +- Gate on `Platform.OS === 'ios'` and `process.env.MM_WIDGETS_ENABLED === 'true'`. +- Call `endLiveActivitiesFromPreviousLaunch()` once when it starts (see + [Orphaned activities](#orphaned-activities) below). +- **Throttle, then dedupe.** ActivityKit budgets update frequency, and a price + feed ticks far faster than a Lock Screen card is worth redrawing. Throttle + the source subscriptions, then skip the write entirely when the newly + computed props are `JSON.stringify`-identical to the last push — the same + pattern `WidgetUpdaterService` uses. +- End with `.end('immediate')`. + +A Live Activity can only be **started** while the app is foregrounded. + +## Orphaned activities + +`expo-widgets` renders every Live Activity through a single shared +`ActivityAttributes` type, discriminating kinds only by a `name` string inside +the content state. Two consequences: + +- **`getInstances()` on any factory returns every live instance app-wide**, + regardless of which factory started it, and `update()` rewrites that `name` + from the factory the handle came from. Never adopt `getInstances()[0]` — you + may silently repurpose another feature's activity. +- iOS keeps a Live Activity alive after its host app is terminated, but the JS + handle needed to end it does not survive. `reconcileLiveActivities.ts` + therefore ends **everything**, once per process, at launch — before any + feature has started an activity of its own. It is guarded so a second caller + cannot end the first caller's freshly started activity. + +## Privacy mode + +Suppress the activity outright rather than masking the numbers. A Lock Screen +is readable without unlocking the device. This is a deliberate difference from +a home screen widget, which masks instead. + +## Checklist + +1. Design the props — flat, pre-formatted, semantic flags over resolved + colors. +2. `app/core/Widgets/liveActivities/MyActivity.ios.tsx` — props, `'widget'` + layout returning a `LiveActivityLayout`, `createMetaMaskLiveActivity(...)`. +3. `app/core/Widgets/liveActivities/MyActivity.tsx` — no-op fallback, `.tsx` + extension. +4. A feature-owned lifecycle service: `.start()` / `.update()` / + `.end('immediate')`, throttled and deduped, gated on platform and flag, + calling `endLiveActivitiesFromPreviousLaunch()` once. +5. Tests — see [`testing.md`](testing.md). +6. Verify in a simulator. No rebuild needed if the extension is already + installed. diff --git a/domains/ui/skills/ios-widgets/references/testing.md b/domains/ui/skills/ios-widgets/references/testing.md new file mode 100644 index 00000000..a745d1a7 --- /dev/null +++ b/domains/ui/skills/ios-widgets/references/testing.md @@ -0,0 +1,72 @@ +# Testing widgets and Live Activities + +A `'widget'`-directive function becomes a **string literal** at build time, +and `babel.config.tests.js` uses the same preset — so the transform runs under +Jest too. Importing the module and calling the layout does not execute your +JSX; it returns a string. React Testing Library cannot help here. + +Test each layer at the boundary where it is still real code. + +## What to test where + +| Layer | What is assertable | +| --- | --- | +| `WidgetTheme.ts` | Pure functions: color and typography mapping, spacing scale, font-weight fallback, singleton exports | +| `createMetaMaskWidget.ios.ts` / `createMetaMaskLiveActivity.ios.ts` | Delegation to `expo-widgets`' `createWidget` / `createLiveActivity` with the right `(name, layout)` | +| The plain `.ts` fallbacks | Every method is a safe no-op and never throws | +| A widget's own `*.ios.tsx` | Registration only: the name matches the Swift file, and the layout argument is now a `string` | +| `WidgetUpdaterService.ts` | All of the real logic: formatting, privacy masking, debounce, redundant-push skipping, cleanup | +| A Live Activity's lifecycle service | All of the real logic: start/update/end transitions, throttling, dedupe, flag and platform gating | + +## The registration test + +Small, but it is the regression check that the Babel transform actually ran — +if someone breaks the preset config, the layout stays a function and the app +ships a widget that cannot render: + +```ts +expect(createWidget).toHaveBeenCalledWith('MyWidget', expect.any(String)); +``` + +Assert the name matches the Swift file's `kind`, and that the returned object +exposes `updateSnapshot` / `reload`. Nothing about rendered UI is assertable +from this file. + +## The logic test + +This is where the coverage actually belongs. Mock `ReduxService.store`, the +selectors, and the widget module (`jest.mock('./widgets/MyWidget', ...)`), +then drive the captured `store.subscribe` listener with `jest.useFakeTimers()` +to assert debounce, skip, and cleanup behavior. `WidgetUpdaterService.test.ts` +is the full worked pattern. + +For a Live Activity, the equivalent is the feature's lifecycle service: drive +its data source, then assert `.start()` / `.update()` / `.end()` calls and +that a duplicate payload produces no second write. + +## Jest infrastructure + +Native-module mocks are registered explicitly in `jest.config.js`'s +`moduleNameMapper` — this project does not rely on the auto-discovered root +`__mocks__/` convention: + +- `app/__mocks__/expo-widgets.ts` +- `app/__mocks__/@expo/ui/swift-ui.ts` +- `app/__mocks__/@expo/ui/swift-ui-modifiers.ts` + +They exist so the **import statements** at the top of `.ios.tsx` files resolve +without the real module's throwing `requireNativeModule`. The stubbed +components are never invoked, since the JSX never executes under Jest. + +Importing an `@expo/ui` submodule that has no stub yet throws at require time. +Add the `moduleNameMapper` entry next to the existing ones and create the +matching stub in `app/__mocks__/@expo/ui/`. + +## Testing the feature flag + +`jest.config.js` defaults `MM_WIDGETS_ENABLED` to `'true'` so the enabled path +is the default under test. Because the value is inlined by +`transform-inline-environment-variables` at transform time, a test can only +toggle it at runtime if **both the module under test and its test file** are +in `babel.config.tests.js`'s inline-env `exclude` list. Add both when a new +service reads the flag and you want to cover the disabled no-op path. diff --git a/domains/ui/skills/ios-widgets/references/troubleshooting.md b/domains/ui/skills/ios-widgets/references/troubleshooting.md new file mode 100644 index 00000000..9acd92ec --- /dev/null +++ b/domains/ui/skills/ios-widgets/references/troubleshooting.md @@ -0,0 +1,81 @@ +# Troubleshooting + +Start from the symptom. Most of these are the two-process model asserting +itself, not a defect in the foundation. + +## `ReferenceError` for some variable on device, but the editor was happy + +You referenced something outside the layout function's own parameters — a +closure, an import, a module-scope constant. TypeScript resolves it against +the module; the JavaScriptCore sandbox never receives the module. Move the +value into props and compute it in the producer. + +## The app crashes on Android after a change under `app/core/Widgets/` + +You imported `expo-widgets` or `@expo/ui` from a file without an `.ios.` +extension, or used an explicit `.ios` suffix on a **value** import from a file +that is not itself iOS-only. Both put a throwing `requireNativeModule` call in +the Android bundle. See **Platform split** in the skill body. + +## `createWidget` throws `The 2nd argument cannot be cast to type String` at startup + +A split pair whose two halves have different extensions — a `.ios.tsx` +implementation with a `.ts` fallback. Metro resolves the `.ts` no-op first on +iOS and hands its `() => undefined` layout to the real native `createWidget`. +Rename the fallback to match the implementation's extension. Jest will not +catch this. + +## The widget never appears in the simulator's widget gallery + +Check, in order: the `.swift` file is a **member of the `ExpoWidgetsTarget` +target** (not just on disk), `index.swift` lists it in the `WidgetBundle`, and +you did a **full rebuild** — widget code lives in a separate native binary, so +a Metro reload changes nothing. + +## The widget shows stale or blank data + +- Confirm `WidgetUpdaterService.initialize()` actually ran. It is called once + from `app/store/index.ts` after persisted state rehydrates, and it is a + no-op unless `MM_WIDGETS_ENABLED === 'true'`. +- Confirm the App Group identifier matches **exactly** across + `ios/MetaMask/Info.plist`'s `ExpoWidgetsAppGroupIdentifier`, + `MetaMask.entitlements`, `MetaMaskDebug.entitlements`, and the extension's + `Info.plist` + entitlements. A mismatch fails silently rather than erroring. +- Remember the ~2s debounce, plus WidgetKit's own daily refresh budget, which + throttles renders regardless of how often the app pushes. + +## Jest throws trying to `require` an `.ios.tsx` widget file + +A `moduleNameMapper` entry is missing for an `@expo/ui` submodule you newly +imported. Add it beside the existing `@expo/ui/swift-ui` entries in +`jest.config.js` and create the stub in `app/__mocks__/@expo/ui/`. + +## Two entries for the same widget in the gallery + +A stale WidgetKit registration from before the extension target's identity +last changed — not a code defect. Verify with +`xcrun simctl spawn pluginkit -m -p com.apple.widgetkit-extension`, +which should show one entry for the extension's bundle id. Erase the simulator +(`xcrun simctl erase `), wipe DerivedData, and reinstall. + +## `pod install` fails: `Unable to find a target named 'ExpoWidgetsTarget'` + +The Xcode target is missing, most likely from a `project.pbxproj` merge or +rebase conflict resolved by taking one side wholesale. Run +`ruby scripts/ios/setup-expo-widgets-target.rb` (idempotent — safe even if you +are unsure), then rerun `pod install`. + +## A Live Activity is frozen on the Lock Screen after a force-quit + +Expected without cleanup: iOS keeps the activity alive but the JS handle to +end it does not survive the process. The owning service must call +`endLiveActivitiesFromPreviousLaunch()` once at start. If it already does and +the card persists, check that the service is reached at all under the current +platform and feature-flag gating. + +## A Live Activity shows another feature's data + +Something adopted `getInstances()[0]`. That array is app-wide across every +kind, and `update()` rewrites the instance's `name` from whichever factory the +handle came from. Each feature must own the handle returned by its own +`.start()`. diff --git a/domains/ui/skills/ios-widgets/repos/metamask-mobile.md b/domains/ui/skills/ios-widgets/repos/metamask-mobile.md new file mode 100644 index 00000000..6b8560ad --- /dev/null +++ b/domains/ui/skills/ios-widgets/repos/metamask-mobile.md @@ -0,0 +1,66 @@ +--- +repo: metamask-mobile +parent: ios-widgets +--- + +# MetaMask Mobile + +This skill installs only for **metamask-mobile**. `expo-widgets` is iOS-only, +so nothing here has an Android counterpart. + +## Canonical source of truth + +`docs/widgets/README.md` is the long-form human guide — architecture, +rationale, possibilities, limitations. This skill is the condensed, actionable +version. When the two disagree, the repo wins; read the live code before +proposing a change. + +## Where things live + +| Path | What | +| --- | --- | +| `app/core/Widgets/` | All JS/TS foundation: theme bridge, `createMetaMask*` wrappers, `WidgetUpdaterService`, `reconcileLiveActivities` | +| `app/core/Widgets/widgets/BalanceWidget.ios.tsx` + `.tsx` | Reference widget — copy its shape | +| `app/core/Widgets/liveActivities/PerpsPnlLiveActivity.ios.tsx` + `.tsx` | Reference Live Activity | +| `app/components/UI/Perps/services/PerpsLiveActivityService.ts` | Reference lifecycle driver — the more instructive half; throttle-then-dedupe and privacy suppression both live here | +| `ios/ExpoWidgetsTarget/` | The WidgetKit app extension target (Swift) | +| `ios/MetaMask/NativeModules/RCTWidgetInfo/` | `WidgetCenter.getCurrentConfigurations` bridge, used for adoption analytics | +| `scripts/ios/setup-expo-widgets-target.rb` | Recreates the Xcode target; only needed after a `project.pbxproj` conflict | + +## Feature flag + +`MM_WIDGETS_ENABLED` gates `WidgetUpdaterService.initialize()` and every Live +Activity service. It defaults to `'false'` in `builds.yml`'s `_public_envs` +while the feature is in development, so every shipped build has widgets +receiving no data. + +To work on widgets locally, set `MM_WIDGETS_ENABLED="true"` in `.js.env` and +**restart Metro** — the value is inlined at transform time by +`transform-inline-environment-variables`, not read at runtime. + +## Adoption analytics are automatic + +`initialize()` fire-and-forgets `trackWidgetAdoption()` once per launch, +reporting which widgets are actually **installed** rather than tapped — a +passive glanceable widget is looked at, and `expo-widgets` exposes no tap +signal for a non-interactive widget anyway. It is keyed on the WidgetKit +`kind`, which already equals each widget's exported name constant, so a new +widget is covered the moment its Swift file exists. **There is no analytics +step to add.** + +## Repo-specific limitations + +- **`MetaMask-Flask` ships without widgets.** `ExpoWidgetsTarget` is embedded + only in the `MetaMask` scheme. Shipping on Flask needs a second parallel + extension target with its own bundle id, entitlements, and provisioning — + not a config change. Open an RFC first. +- **Device and IPA builds need Apple Developer portal work that does not + exist yet** (App Group capability on the existing profiles, two new profiles + for the extension's bundle id, `provisioningProfiles` entries in the export + options plists, manual signing). See the provisioning section of + `docs/widgets/README.md`. Simulator builds, `yarn start:ios`, and E2E are + unaffected. +- **`expo prebuild` never runs here.** The repo has a checked-in `ios/` + directory and `app.config.js` nests everything under `expo:`, which + `@expo/config` reduces away before any plugin is seen. Every native change + is hand-applied to `ios/`. diff --git a/domains/ui/skills/ios-widgets/skill.md b/domains/ui/skills/ios-widgets/skill.md new file mode 100644 index 00000000..c22af832 --- /dev/null +++ b/domains/ui/skills/ios-widgets/skill.md @@ -0,0 +1,152 @@ +--- +name: ios-widgets +description: >- + Build and review iOS home screen widgets and Live Activities in MetaMask + Mobile, on the expo-widgets + @expo/ui foundation in app/core/Widgets/ and + ios/ExpoWidgetsTarget/. Use when adding, changing, or reviewing a widget, a + Live Activity, a Dynamic Island or Lock Screen surface, WidgetKit/App Group + wiring, WidgetUpdaterService data pushes, or widget theming; when a file + carries a 'widget' directive or is one half of an .ios.tsx/.tsx + platform-split pair; or when debugging a widget that renders stale or blank + data, throws ReferenceError only on device, crashes the Android bundle at + import time, or never appears in the simulator's widget gallery. +maturity: stable +--- + +# iOS widgets and Live Activities + +A widget does not run in your app. It runs in a **separate iOS app extension +process** (`ExpoWidgetsTarget`), inside an embedded **JavaScriptCore** VM that +shares no memory, no imports, and no module state with the React Native +runtime. The only channel between the two is a serialized `props` object +written to a shared **App Group** container. + +Every rule below follows from that one fact. Almost every widget bug is a +violation of it that TypeScript, ESLint, and Jest all accept. + +## When to use + +- Adding or changing a home screen widget, a Live Activity, a Lock Screen + card, or a Dynamic Island presentation. +- Reviewing a diff that touches `app/core/Widgets/`, `ios/ExpoWidgetsTarget/`, + a `'widget'`-directive function, or an `.ios.tsx`/`.tsx` pair. +- Debugging a widget that shows stale data, renders blank, throws + `ReferenceError` on device only, or takes the Android app down at startup. + +**Out of scope:** Android home screen widgets (no equivalent foundation +exists — `expo-widgets` is iOS-only), push-driven remote updates, and +interactive widget buttons. + +## Hard rules + +1. **No closures inside a `'widget'`-directive function.** `babel-preset-expo` + replaces the whole function with a **string literal of its own source** at + build time. Anything it references that is not one of its own parameters — + an import, a module constant, a selector, `strings()`, `Logger`, + `console.log` — is `undefined` in the sandbox. Only `@expo/ui/swift-ui` + (and `/modifiers`) and plain JS are injected there. +2. **Every value the layout needs arrives as a prop**, already fetched, + formatted, translated, and masked by the caller. +3. **Platform-split every file that imports `expo-widgets` or `@expo/ui`.** + Their JS entry points call a *throwing* `requireNativeModule` / + `requireNativeView` at import time, so merely importing one from the + Android bundle crashes the app at startup. +4. **Both halves of a split pair must share the same file extension.** A + `.ios.tsx` implementation needs a `.tsx` fallback, even when that fallback + contains no JSX (see [Platform split](#platform-split) for why a `.ts` + fallback silently wins on iOS). +5. **Pass both theme variants and resolve inside the layout** via + `environment.colorScheme`. Resolving "the current theme" outside means the + widget only tracks an OS appearance change on the *next* app-triggered + push. +6. **Never adopt `getInstances()[0]`** for a Live Activity — that array is + app-wide across every kind. See + [`references/live-activities.md`](references/live-activities.md). + +## Platform split + +| File | Runs on | Contains | +| --- | --- | --- | +| `MyThing.ios.tsx` | iOS | Real implementation; may import `expo-widgets` / `@expo/ui` | +| `MyThing.tsx` | Everywhere else | No-op fallback with the same exported names and types | + +- Import through the **extensionless** path (`from './MyThing'`). Metro picks + the `.ios` variant on iOS and the fallback everywhere else; `tsc` resolves + the fallback, and each platform file is still type-checked when `tsc` walks + it directly. +- Duplicate small prop interfaces into the fallback rather than importing + them from the `.ios` file. +- An explicit `./MyThing.ios` **value** import bypasses Metro's platform + exclusion and ships the module to Android. It is only safe from a file that + is itself `.ios`-only. `import type` is erased by Babel and is safe + anywhere, but prefer the extensionless path for consistency. + +Extension parity (rule 4) is what keeps Metro and Jest in agreement. Metro +loops `sourceExts` on the outside and platform on the inside, so for a +`.ios.tsx` + `.ts` pair it finds `MyThing.ts` **before** it ever tries +`MyThing.ios.tsx` — the no-op shadows the real implementation on iOS, and +`createWidget` then throws `The 2nd argument cannot be cast to type String` at +import time. Jest's resolver tries all platform variants first and does not +reproduce this, so the suite stays green while the app is broken at startup. + +## Designing props + +The props object is the entire API between the two processes. Make it flat, +JSON-serializable, and inert: + +- **Pre-format and pre-translate.** Currency strings, percentages, dates, and + labels arrive rendered — there is no `Intl` config, no formatter, and no + i18n catalogue in the sandbox. +- **Pre-mask.** Privacy mode is applied by the producer. For a Live Activity, + prefer suppressing the activity entirely over masking, because the Lock + Screen is readable without unlocking the device. +- **Pass semantic flags, not resolved colors** (`isProfit: boolean`, not + `color: '#1c8234'`), so the layout can still pick the right light/dark + variant when the OS appearance changes without an app push. + +## Theming + +Widgets cannot use `useTailwind()`, +`@metamask/design-system-react-native`, or any NativeWind runtime — none of it +exists inside the sandbox. Use the serializable `WidgetTheme` snapshot +(colors, typography `{ size, weight }`, a 4px spacing scale) instead of +hand-rolling values. + +Only **solid** design tokens belong in it: `@expo/ui` reads an 8-digit hex as +`#AARRGGBB` (SwiftUI's order) while `@metamask/design-tokens`' alpha tokens are +`#RRGGBBAA`, so an alpha token silently renders the wrong color. Prefer +`border.default` over `border.muted`, or convert explicitly. + +Widgets always follow the **system** appearance, never MetaMask's in-app theme +override — `colorScheme` comes from WidgetKit and cannot see app state. + +## Workflow + +| Task | Open | +| --- | --- | +| Add or change a home screen widget | [`references/adding-a-widget.md`](references/adding-a-widget.md) | +| Add or change a Live Activity / Dynamic Island surface | [`references/live-activities.md`](references/live-activities.md) | +| Write or fix tests for either | [`references/testing.md`](references/testing.md) | +| A widget renders wrong, stale, or not at all | [`references/troubleshooting.md`](references/troubleshooting.md) | + +Read the reference for the task at hand, not all four. A widget and a Live +Activity share every rule above and differ only in registration, layout shape, +and who drives the lifecycle. + +## Review checklist + +Reject a change that does any of these: + +- References anything inside a `'widget'`-directive function other than its + own parameters and `@expo/ui/swift-ui`(`/modifiers`). +- Imports `expo-widgets` or `@expo/ui/*` from a file without an `.ios.` + extension. +- Uses an explicit `.ios`-suffixed **value** import from a file that is not + itself `.ios`-only. +- Adds an `.ios.ts(x)` file without a plain fallback counterpart **using the + same extension**. +- Passes one resolved theme instead of both variants, or resolves + `colorScheme` outside the layout. +- Formats, translates, or masks data inside the layout instead of in the + producer. +- Adopts an existing Live Activity instance from `getInstances()`.