--- name: add-sheet description: Use when adding, registering, or debugging a bottom sheet (action sheet) in the mobile app, including when a sheet's cancel or dismissal reaches the caller as the wrong result. argument-hint: [sheet-name] --- # Add Sheet Registry, show call and mount lifecycle: CLAUDE.md "Sheets (Bottom Sheets)". This file adds the procedure plus the result convention new sheets here should follow. ## Resolve with an object, gate on a named field `react-native-actions-sheet` 0.9.7, `node_modules/react-native-actions-sheet/dist/src/index.js:408`: ```js actionSheetEventManager.publish("onclose_".concat(sheetId), data || payloadRef.current || data, currentContext); ``` `data` is what you passed to `SheetManager.hide(id, { payload: data })`. `payloadRef.current` is NOT the show payload: it tracks ``'s own `payload` prop (`payload = _a.payload` at index.js:53, `useRef(payload)` at 87, `payloadRef.current = payload` at 139). The `SheetManager.show` payload goes somewhere else entirely, to the registered component as a prop from the provider (``, `dist/src/provider.js:160`). Sheets in `src/` do not forward that prop down into ``: `grep -rn "payload=" src/` returns zero hits. So `payloadRef.current` is `undefined` for the sheets here. `data || payloadRef.current || data` collapses to `data`, so a falsy return does reach the caller intact today. Same expression on `onBeforeClose` (line 385) and `onClose` (line 401). Still resolve with an object and gate on a named field. Two reasons: - A backdrop tap, swipe down or hardware back closes with `data === undefined`, so `show()` resolves `undefined`. Truthiness cannot separate that dismissal from a sheet that deliberately answered `false`, `0` or `''`. A named field can. - The substitution is one prop away from going live. Adding `payload={payload}` to an `` would silently turn every falsy cancel in that sheet into the truthy show payload, with no type error and no crash. Copy `modNotesSheet`, `communityRoleEditSheet`, `walletHistoryFiltersSheet` or `newsletterDigestSheet`. All four resolve `{ cancelled: true }` on cancel. `searchFiltersSheet` is apply-only, with no cancel control, so it is not a model here. `src/components/authUpgradeSheet/authUpgradeSheet.tsx` is one counter-example: it cancels with `_close(false)` while `src/providers/sdk/mobilePlatformAdapter.ts:317` gates on `if (!result) return false;`. That reads correctly right now, since cancel and dismissal are both falsy there and both mean the same thing, but it is one of the sheets that breaks first if anyone gives it a `payload` prop. Comments across `src/` (in `sheets.tsx`, in several sheet components, in several screens) justify this convention by claiming a dismissal resolves the payload object. The convention is right; that reason is not. ## Step 1: Component `src/components//.tsx` is the usual path for a component typed with `SheetProps<'...'>`. Other sheets use the enum form `SheetProps`, which is equally accepted. Trimmed from `src/components/modNotesSheet/modNotesSheet.tsx`: ```typescript import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import ActionSheet, { SheetManager, SheetProps } from 'react-native-actions-sheet'; import EStyleSheet from 'react-native-extended-stylesheet'; import { MainButton } from '../mainButton'; // Matches the SheetNames value. Sheets keep this so `hide` has an id even when the // sheet is rendered outside the registry. const FALLBACK_SHEET_ID = 'my_sheet'; /** `{ value }` on confirm, `{ cancelled: true }` on cancel. A backdrop, swipe or back * dismissal resolves `undefined`, so callers gate on a string `value`, never on * truthiness. */ export interface MySheetResult { value?: string; cancelled?: boolean; } const MySheet: React.FC> = ({ sheetId, payload }) => { const intl = useIntl(); const [value, setValue] = useState(''); const closedRef = useRef(false); const _reset = useCallback(() => { closedRef.current = false; setValue(''); }, []); // onBeforeShow fires the same reset on every fresh presentation, which a registered // sheet also gets from its fresh mount. This effect covers the one case both miss, a // payload swap while the sheet is already open, because use-sheet-manager.js drops the // re-show with `if (visible) return;` before onBeforeShow can run. Do not delete it as // redundant. useEffect(() => { _reset(); }, [payload, _reset]); // closedRef stops a double tap firing two hides, which would resolve twice. const _close = (result: MySheetResult) => { if (closedRef.current) return; closedRef.current = true; SheetManager.hide(sheetId || FALLBACK_SHEET_ID, { payload: result }); }; return ( _close({ value: value.trim() })} /> _close({ cancelled: true })} /> ); }; const styles = EStyleSheet.create({ sheetContainer: { paddingHorizontal: 0, backgroundColor: '$primaryBackgroundColor' }, }); export default MySheet; ``` Colors come from EStyleSheet theme variables in `src/themes/` (`$primaryBackgroundColor`, `$primaryBlack`, `$primaryDarkGray`, `$iconColor`) rather than a hex literal. For a color a prop needs as a plain string rather than a style, resolve it with `EStyleSheet.value('$primaryDarkGray')`, as `modNotesSheet` does for `placeholderTextColor`. ## Step 2: Folder index `src/components//index.ts`. Sheet folders re-export their sheet with the first line; those that also publish a result type add the second: ```typescript export { default as MySheet } from './'; export type { MySheetResult } from './'; ``` A sheet that `sheets.tsx` imports by path can skip this file entirely. `walletHistoryFiltersSheet` has no `index.ts`. ## Step 3: Components barrel `src/components/index.tsx` is an import list plus one `export { ... }` block at line 164. Add `import { MySheet } from './';` plus a `MySheet,` entry inside that block. There is no `export ... from` line to add. A sheet nothing else imports can skip this step and be imported in `sheets.tsx` by path, as some registrations are. ## Step 4: Register in `src/navigation/sheets.tsx` Add the `SheetNames` member (`MY_SHEET = 'my_sheet',`), the `registerSheet(SheetNames.MY_SHEET, MySheet);` call, then extend `Sheets`. **The key must be a string literal, not `[SheetNames.MY_SHEET]`**: string enum member types are nominal, so with a computed key `keyof Sheets` carries the enum member rather than the string literal and `SheetProps<'my_sheet'>` stops resolving. ```typescript declare module 'react-native-actions-sheet' { interface Sheets { my_sheet: SheetDefinition<{ payload: { someParam: string }; // `{ value }` on confirm, `{ cancelled: true }` on cancel, `undefined` on a // backdrop, swipe or back dismissal. Gate on `value`, not on truthiness. returnValue: MySheetResult | undefined; }>; } } ``` Skipping this fails `yarn typecheck`: the `everySheetHasDefinition` assertion at the bottom of the file names any `SheetNames` member missing from the augmentation. ## Step 5: Show it and read the result ```typescript const result = await SheetManager.show(SheetNames.MY_SHEET, { payload: { someParam: 'x' } }); // Only a confirmation carries a string `value`. Cancel yields { cancelled: true }; a // backdrop, swipe or back dismissal yields undefined. Both are rejected here. const confirmed = typeof result?.value === 'string' ? result.value : ''; if (!confirmed) return; ``` Real callers: `src/components/postOptionsModal/container/postOptionsModal.tsx:742` (`typeof result?.notes === 'string'`) and `src/screens/assetDetails/screen/assetDetailsScreen.tsx:265` (`!Array.isArray(result?.operations)`). ## Step 6: i18n strings Edit `src/config/locales/en-US.json` only; Crowdin owns the other locales. The file is nested objects, not dotted keys, while `formatMessage` ids stay dotted: ```json "my_sheet": { "title": "Sheet Title", "confirm": "Confirm", "cancel": "Cancel" }, ``` ## Lifecycle - **Sheets mount on a fresh show and unmount on hide**: the library renders the registered component only while that sheet is visible. `useState` initials are fresh on that open, so nothing stale needs clearing on mount; unmount cleanups run on close. The exception is a `show()` against a sheet that is already open: the stored payload is replaced with no remount, so reset on the `payload` prop as well, as Step 1 does. The CLAUDE.md bullet states the mount rule without that exception. - **Sheets render outside the ErrorBoundary**: `SheetProvider` returns `<>{children}{sheets}`, so sheets are siblings of `` while the boundary sits inside it (`src/screens/application/index.tsx:17`). A throw in a sheet render or effect cleanup is fatal. Be careful with native or Expo shared objects in cleanups. - **The payload is captured at show time**: it is stored in state at that show, so a callback passed inside a payload keeps the closure it had then. Route anything that changes through a ref, as `src/components/quickPostModal/quickPostModalContent.tsx:565-575` does.