Skip to main content

07 · Renderer — React Native (@rasd/native)

Purpose: Design and implementation guide for @rasd/native, the React Native / Expo renderer of Rasd Forms: what it shares with @rasd/react, how every element type maps to RN primitives, and how keyboard, performance, storage, media, sync, theming, RTL and accessibility are handled on phones in the field. Audience: Engineers building @rasd/native; developers at UN/NGO organisations embedding it into an existing Expo or bare React Native app.

TL;DR

  • @rasd/native exposes the same public API as @rasd/react (RasdProvider, FormRenderer, hooks, defineElement, registry) over @rasd/core; only the component implementations, the theme runtime (StyleSheet instead of CSS variables) and the platform adapters differ.
  • Targets React Native ≥ 0.81, New Architecture only, Expo SDK ≥ 54, Hermes, Android 7+ (API 24) and the iOS floor of the RN release in use (15.1 on RN 0.81); tested against RN 0.81 / 0.85 / 0.87 and Expo SDK 54 / 56 (research/08).
  • Every element type renders to plain RN primitives (TextInput, Pressable, Switch, Modal, FlatList/FlashList); heavy pickers and capture widgets are optional peers or live in @rasd/media; unknown x:* types render a placeholder, never crash.
  • Storage is @rasd/storage-sqlite (expo-sqlite default, op-sqlite optional, SQLCipher when available, key in expo-secure-store); attachments are files under the app documents directory, referenced by relative path.
  • Sync is foreground-driven (AppState, NetInfo, finalize, manual); expo-background-task is an opportunistic accelerator with a 15-minute floor and no guarantees (research/04).
  • RTL is per form via Yoga direction on the form root (no I18nManager restart); font scaling is honoured up to typography.maxFontScale (2.0); every control is ≥ 48 dp and carries accessibilityRole/Label/State.
  • Ships an Expo config plugin (app.plugin.js) that writes backup-exclusion rules, permission strings and background-task identifiers, plus a ~100-line Expo Module for iOS backup exclusion and a security self-report.
  • No HTML is ever rendered on native: labels/notes use a safe markdown subset rendered to Text (research/11).

1. Scope and support matrix

ItemDecision
Package@rasd/native (ESM-only, built with react-native-builder-bob, exports with react-native condition first; source-available FSL license, see 15)
Peers (required)react ^19, react-native >=0.81 <1 (New Architecture only)
Peers (optional)expo >=54, expo-sqlite, expo-file-system, expo-secure-store, expo-background-task + expo-task-manager, @react-native-community/netinfo, react-native-gesture-handler ^3, react-native-reanimated ^4, @react-native-community/datetimepicker, @react-native-community/slider, @shopify/flash-list, expo-image, expo-haptics, @op-engineering/op-sqlite, react-native-keyboard-controller (capture peers such as expo-camera, expo-location, react-native-svg belong to @rasd/media)
Not a targetWeb via react-native-web (use @rasd/react); Legacy Architecture; Expo Go for production (see §20)
Test matrixRN 0.81 (last legacy-capable release, New Arch enabled) / 0.85 (SDK 56) / 0.87 (Strict TS API — no react-native/Libraries/* deep imports); Android API 24, 28, 34; iOS 15.1, 17, 18

The floor is RN ≥ 0.81 / SDK ≥ 54 (spine §12): RN 0.79+ Metro honours exports by default and SDK 54 is the last SDK that tolerates the Legacy Architecture, which @rasd/native does not support.

2. API parity with @rasd/react

Everything named in the spine §11 exists in @rasd/native with the same signature. The table lists what is identical and what necessarily differs.

SurfaceIdenticalNative difference
<RasdProvider storage license theme locale registry sync>Props, context shape, license/theme/locale resolutionstorage defaults to nothing (host passes createSqliteStorage(...)); accepts a security policy object (defined in 16); wraps children in the Yoga-direction root View
<FormRenderer definition submissionId? initialData? onChange onFinalize onSave locale? readOnly? validateOn?>All props and callbacks; validateOn: 'change' | 'blur' | 'page' | 'finalize'Extra optional prop keyboardVerticalOffset?: number (default 0; pass the host header height); renders a KeyboardAvoidingView + scroll container instead of a <form>
Hooks useRasdForm, useField, useSubmission, useSync, useLicense, useTheme, useLocale, useDirection, useRasdBusySame return typesuseTheme() returns resolved tokens and a styles(factory) memoised StyleSheet helper; useDirection() returns 'ltr' | 'rtl' for Yoga, not a DOM dir
defineElement({ type: 'x:foo', component, builder?, valueSchema? })Samecomponent receives RN-flavoured field props (ref is a TextInput-compatible focus target)
Registry: components ({ TextField, … }) and renderers ({ 'select_one': MySelect })Same keys, same accessible-props contractExtra layout slots: FormScroll, PageTransition, Sheet (bottom-sheet host), DatePicker, Slider
Per-part customisationrender per partstyles per part (StyleProp<ViewStyle | TextStyle> or (state) => StyleProp); no classNames, no unstyled CSS switch (an unstyled boolean still disables default StyleSheets)
Engine semantics, REL, validation, i18n, versioning, license statesIdentical (all in @rasd/core)

Anything an app writes against @rasd/react hooks (useField, useRasdForm) ports 1:1; only JSX for custom components changes.

3. Architecture and render pipeline

flowchart TD
Host["Host app screen"] --> P["RasdProvider<br/>storage · license · theme · locale · registry · sync · security"]
P --> D["Direction root View<br/>style.direction from useDirection"]
D --> FR["FormRenderer"]
FR --> E[("FormEngine<br/>@rasd/core")]
FR --> KAV["KeyboardAvoidingView"]
KAV --> SC["FormScroll<br/>ScrollView or FlashList"]
SC --> PG["Page"]
PG --> EL["Element components<br/>via registry"]
EL -- "useField(path)" --> E
E -- "autosave 2000 ms" --> ST[("StorageAdapter<br/>@rasd/storage-sqlite")]
EL -- "capture" --> MD["@rasd/media adapters"]
MD --> ST
  • One engine per FormRenderer. createFormEngine(def) is created once (keyed by definitionHash + submissionId) and lives in a ref; React state is not the source of truth. Elements subscribe with useField(path), which uses useSyncExternalStore against a per-path selector, so a keystroke re-renders exactly one field plus any dependents the engine marks dirty.
  • Navigation. settings.navigation: "paged" (default) mounts one page at a time inside FormScroll; page transitions are a horizontal slide via Reanimated 4 when present, otherwise an instant swap; PageTransition is a registry slot. "scroll" renders the flattened element list in one scroll container (FlashList when installed, see §6).
  • Autosave. Every engine change schedules a submissions.patch(id, partial, { bumpRev: true }) after settings.autosaveMs (2000 ms default, trailing debounce); an AppState change to background/inactive flushes immediately (iOS gives roughly 5 s). onSave fires after the write resolves; a RASD_STORAGE_QUOTA or write error surfaces through onError and a persistent banner ("Not saved – 3 changes pending"), never a silent drop.
  • Busy state. useRasdBusy() reflects engine recomputation, pending autosave and media processing so the host can gate "Finalize".

4. Element → React Native component mapping

All components use theme tokens, expose styles/render per part and satisfy the accessible-props contract (§14). "Sheet" = the Sheet registry slot; default is a Modal (presentationStyle="pageSheet" iOS / full-height on Android) with a drag handle, search box and FlashList/FlatList body.

Element typeDefault RN implementationNotes / props honoured
textTextInput (multilinemultiline, textAlignVertical="top", numberOfLines 3, grows to 8)format: emailkeyboardType="email-address", autoCapitalize="none"; phonephone-pad; urlurl. mask applied as a controlled formatter. maxLength forwarded. bind.sensitiveimportantForAutofill="no", autoComplete="off", autoCorrect={false}, textContentType="none", contextMenuHidden
numberTextInput keyboardType="number-pad" (integer) / "decimal-pad" (decimal), inputMode mirroredAccepts , and ٫ as decimal separators and Arabic-Indic/Extended digits; normalises to ASCII on save (research/13); unit rendered as an end adornment; thousandsSeparator display-only; iOS InputAccessoryView with Next/Done because number pads have no return key
date / time / datetimeDatePicker slot. Default = built-in JS calendar grid + hour/minute wheel in a Sheet (works in Expo Go, supports min/max, Hijri display). @rasd/native/pickers/community wraps @react-native-community/datetimepicker (native pickers) for hosts that install itValue stays ISO-8601; calendar: "hijri" affects display only via @umalqura/core-style table because Hermes calendar support is unverified
select_one≤ 7 choices or appearance.variant: "radio"Pressable rows in an accessibilityRole="radiogroup" View; "buttons"/"chips" → wrapped Pressables with accessibilityState.selected; "dropdown", search: true or > 7 choices → trigger button + Sheet with search; "likert" → horizontal equal-width buttonschoiceFilter evaluated by the engine; other adds an inline TextInput; randomize seeded per submission; dataset-backed lists query datasets.query() with debounce 200 ms and ≥ 2 chars
select_multipleSame as above with accessibilityRole="checkbox" rows; Sheet keeps a selected-count footerminSelected/maxSelected enforced with live count; exclusive values clear the rest
rankOrdered list with drag handle (RNGH 3 + Reanimated 4, see §7) and always-visible ▲/▼ buttonsValue string[]; drag disabled when peers absent or reduced motion is on
ratingRow of Pressable icons (star/smiley) or numbered buttons; container accessibilityRole="adjustable" with accessibilityActions increment/decrementmax ≤ 10 inline, otherwise falls back to number
rangeSlider slot: @react-native-community/slider when installed; default = stepper (−/+ buttons, 48 dp) + numeric readoutshowValue, step; slider track mirrored manually in RTL
checkboxSwitch (accessibilityRole="switch") or, with appearance.variant: "buttons", a Pressable checkbox rowBoolean value
consentScrollable statement Text (safe markdown), method control: tap = "I agree" Pressable; signature = @rasd/media signature pad; verbal = enumerator attestation switch; withdraw button when allowWithdrawStores { granted, at, textVersion, locale, method }; bind.sensitive treatment always on
matrixHorizontal ScrollView grid (sticky first column) on ≥ 600 dp; below that each row becomes a card with the column controls stackedrows[] × columns of select_one/number/text
geopoint@rasd/media geolocation adapter (expo-location): live accuracy readout, "Capture" button, auto-accept at accuracyThreshold, optional map (MapLibre RN, optional)Shows mocked warning on Android; allowManual opens lat/lng inputs
geotrace / geoshapeSame adapter; list of captured points with remove; auto mode uses watchPositionAsync at intervalSeconds while the screen is focusedForeground only by default (store policy)
image@rasd/media camera/picker adapter (expo-camera / expo-image-picker), thumbnail Image (≤ 240 dp, resizeMode="cover"), retake/removeResize to maxPixels (default 1280 long edge), JPEG quality (0.7), EXIF stripped, sidecar geotag; multiple/maxCount grid
audio / video / fileexpo-audio recorder (mono ~32 kbps default), expo-image-picker video, expo-document-picker filemaxDurationSeconds, maxBytes, accept enforced before storing
barcodeSheet with expo-camera CameraView (barcodeScannerSettings.barcodeTypes from formats), torch toggle, allowManual TextInputHaptic on scan (expo-haptics optional)
signature@rasd/media signature pad: RNGH pan gesture drawing into react-native-svg, exported to PNG (see 14); no WebViewpenColor; clear/undo; landscape hint on narrow screens
noteText tree from the safe markdown subset; style colours; collapsiblePressable header with accessibilityState.expandedLinks only via host onOpenLink; images only from the media allow-list
hidden / calculateNothing renderedValues flow through the engine
groupView; variant section (heading), card (surface + radius), collapsible (header toggle, children unmounted when collapsed), field-list (compact rows)relevant hides the whole subtree
repeatList of instance cards (§6, §7) with add/remove/reorder; confirmDelete uses Alert.alertmin/max/count, itemLabel (REL), addLabel, removeLabel, keyField
x:<name>From registry; unregistered → placeholder card "Unsupported element x:name" and one console.warnPlaceholder is not focusable; blocks finalize only if the element is required

5. Keyboard handling and focus flow

  • Container. FormRenderer renders KeyboardAvoidingView (behavior="padding" on iOS, undefined on Android) around FormScroll, a ScrollView with keyboardShouldPersistTaps="handled", keyboardDismissMode="on-drag" (iOS "interactive") and automaticallyAdjustKeyboardInsets on iOS. Android relies on windowSoftInputMode="adjustResize" (Expo android.softwareKeyboardLayoutMode: "resize", the default). Hosts with a translucent header pass keyboardVerticalOffset.
  • Scroll-to-focused. On focus each field measures itself against the scroll container (measureLayout) and calls scrollTo so the input sits above the keyboard with a 96 dp margin plus the sticky "Next" bar height (WCAG 2.4.11 — the focused input is never obscured); in FlashList mode scrollToIndex({ viewPosition: 0.3 }) runs first, then the fine adjustment.
  • Return-key flow. A FocusManager in FormRenderer keeps the ordered list of focusable refs on the current page. Text/number inputs get returnKeyType="next" (last one "done"), submitBehavior="submit" (do not blur) and onSubmitEditingfocusManager.next(); when the next element is not a text input (select, photo) focus moves to its trigger via setAccessibilityFocus and, for sighted users, scrolls it into view. multiline fields use returnKeyType="default".
  • Optional keyboard-controller adapter. Hosts that already ship react-native-keyboard-controller register FormScroll: KeyboardAwareFormScroll from @rasd/native/keyboard-controller; it replaces KeyboardAvoidingView with KeyboardAwareScrollView for interactive Android keyboard handling. Nothing else changes.
  • Edge cases. Blur commits the value and, under validateOn: 'blur', runs validators; opening a Sheet calls Keyboard.dismiss() first so the sheet is not clipped; a pending scroll is cancelled when the keyboard hides during a page transition.

6. Long forms, repeats and performance

ConcernGuidance
Rendering strategypaged keeps only the current page mounted (typical page ≤ 30 elements → plain ScrollView). scroll mode flattens elements into rows and virtualises: FlashList (@shopify/flash-list, optional peer, New-Architecture build) when installed, else FlatList with initialNumToRender=12, windowSize=7, maxToRenderPerBatch=8, removeClippedSubviews (Android) and getItemLayout for fixed-height rows (notes, single-line inputs)
Repeats≤ 20 instances render inline; above that the repeat body becomes its own virtualised list with an "Instances 21–40" pager; each instance is React.memo'd on (instancePath, rev); adding an instance scrolls to it and focuses its first field
Re-render budgetKeystroke → paint ≤ 16 ms on a 2019 low-end Android (Snapdragon 4xx class); page transition ≤ 100 ms; form open ≤ 1.5 s for a 300-element / 5-page definition; heap ≤ 150 MB with 30 photo thumbnails
MemoisationComponents are precompiled with the React Compiler (target: '19', research/08); no inline style objects (all through theme.styles(...)); choice rows are memo'd on (value, selected, label); useField selectors return stable references
Engine work off the render pathRecompute is batched per microtask inside @rasd/core; large count()/sum() over repeats recompute incrementally; a recompute > 8 ms triggers useRasdBusy()
DatasetsChoice lists over 200 rows are never held in React state; the Sheet queries datasets.query(name, { search, filter, limit: 50 }) page by page (label_norm LIKE / FTS5 on SQLite)
ImagesThumbnails are generated once (≤ 240 dp) and cached under the attachment id; the full-size photo is loaded only in the viewer; expo-image is used when installed (recyclingKey), otherwise Image

7. Reordering repeats (gestures)

repeat.props.allowReorder and rank share one internal reorder-adapter (mirrors the builder's dnd-adapter boundary):

  • Default implementation: RNGH 3 Gesture.Pan() activated by a long-press (250 ms) on the drag handle, item offset in a Reanimated 4 useSharedValue, neighbours shift with withTiming(120 ms); auto-scroll near list edges; light haptic on pick-up/drop when expo-haptics is present. Reanimated 4 requires RN ≥ 0.83 (research/03); on 0.81–0.82 or when the peers are absent the drag handle is not rendered.
  • Always-available equivalent (WCAG 2.2 SC 2.5.7): every instance card has a ⋯ menu (Pressable, accessibilityRole="button") with Move up, Move down, Move to top/bottom, Delete; the same actions are exposed as accessibilityActions so TalkBack/VoiceOver users act without dragging.
  • Reduced motion: when AccessibilityInfo.isReduceMotionEnabled() is true, drag stays enabled but neighbour animations are instant.
  • Engine contract: reordering calls engine.moveRepeat(path, from, to) (a @rasd/core operation added for this purpose); instances carry stable ids, so useField subscriptions survive the move; the audit trail records { event: 'reorder', field, old: from, new: to }.
  • react-native-sortables is a candidate replacement behind the same adapter if the in-house gesture code becomes a maintenance burden.

8. Storage wiring (SQLite)

import { createSqliteStorage } from '@rasd/storage-sqlite';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';

const KEY_OPTS = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
const NS = 'pdm';
const KEY_ID = `rasd.dbkey.${NS}`; // namespaced: one device may hold two agencies' datasets

async function openStorage() {
let key = await SecureStore.getItemAsync(KEY_ID, KEY_OPTS);
if (!key) {
key = toHex(await Crypto.getRandomBytesAsync(32)); // 256-bit random key, hex-encoded (64 chars < 2 KB)
await SecureStore.setItemAsync(KEY_ID, key, KEY_OPTS);
}
const storage = createSqliteStorage({ driver: 'expo', namespace: NS, encryptionKey: key });
await storage.open({ namespace: NS }); // WAL, PRAGMA key, migrations
return storage;
}
  • The renderer never touches SQL; it only calls the StorageAdapter (submissions.patch, attachments.put, datasets.query). Driver details are in 09.
  • driver: 'expo' needs the expo-sqlite config plugin useSQLCipher: true for encryption (not available in Expo Go → plaintext DB, onSecureStoreUnavailable/loud warning, PRAGMA secure_delete=ON); driver: 'op' uses op-sqlite's SQLCipher build. Keys live in expo-secure-store under the namespaced id rasd.dbkey.<namespace> (WHEN_UNLOCKED_THIS_DEVICE_ONLY, ≤ 2 KB, 00 §7) — never MMKV/AsyncStorage. SecureStore items survive reinstall on iOS, so storage.wipe() also deletes the key (crypto-shredding is the wipe primitive, research/11).
  • Missing key at open (device restored from backup, Keystore reset) ⇒ RasdError RASD_STORAGE_KEY_UNAVAILABLE; the provider surfaces it through its onError prop and renders an error fallback rather than an unencrypted database (never re-key, never open plaintext — 09).
  • Autosave writes go through withTransactionAsync; the sync engine and renderer share one adapter instance (single JS engine, no leader election needed on RN).

9. Attachments and the file system

  • Root: Paths.document (expo-file-system SDK 54+ API; legacy documentDirectory via expo-file-system/legacy for older hosts) → rasd/<namespace>/attachments/<attachmentId>.<ext> and …/thumbs/<attachmentId>.jpg. Never the cache directory (OS may purge), never MediaStore/Photos unless the host opts in.
  • Store relative paths. The iOS container path changes between installs/updates; attachments[].localUri is persisted relative to the namespace root and resolved on read.
  • Backup exclusion: the config plugin writes Android dataExtractionRules (cloud-backup + device-transfer, merged with expo-secure-store rules) excluding databases/ and the Rasd directory; on iOS the bundled Expo Module sets isExcludedFromBackup on the namespace directory at first open(). securityReport() reports whether both are active.
  • Writes are atomic (write to <id>.tmp, then rename); SHA-256 is computed on write and stored in attachments[].sha256; attachments.sizeOf() powers the storage estimate banner (storage.estimate()).
  • Purge-after-sync default deletes the file after the server ACK and keeps only metadata; deletion of the file plus key destruction is the "secure delete" story on iOS/Android.

10. Media capture and the permissions flow

@rasd/media (native files) wraps expo-camera, expo-image-picker, expo-location, expo-audio, expo-document-picker; @rasd/native only calls the adapter interface. Rules:

stateDiagram-v2
[*] --> Idle
Idle --> Rationale: user taps Capture
Rationale --> Requesting: user continues
Requesting --> Granted: granted
Requesting --> DeniedAskable: denied and canAskAgain
Requesting --> DeniedBlocked: denied and cannot ask again
DeniedAskable --> Idle: inline hint plus allowManual fallback
DeniedBlocked --> Idle: Open Settings via Linking.openSettings plus manual fallback
Granted --> Capturing
Capturing --> Idle: value stored or cancelled
  • Request on first use of the question, never at app start; show a one-sentence rationale in the form locale first (Play/App Store guidance).
  • Denied and blocked ⇒ the question stays answerable through allowManual (geopoint, barcode) or gallery pick (image, when source allows); a RasdError RASD_MEDIA_PERMISSION (details.reason: 'denied' | 'blocked') is emitted to the host's onError once per question and session (14), and the audit trail records permission_denied.
  • Foreground only by default: no ACCESS_BACKGROUND_LOCATION, no background audio, no READ_MEDIA_IMAGES (system photo picker instead) — see research/11 §11 for the store-policy consequences.
  • Bare RN hosts must add the manifest permissions and Info.plist usage strings themselves (§17); the config plugin does it for Expo.

11. Connectivity and background sync

  • Signals. @rasd/native exports createNetInfoConnectivity(syncEngine) (wraps @react-native-community/netinfo: isConnected, isInternetReachable, type, details.isConnectionExpensive): it subscribes to NetInfo and AppState and drives the engine — syncNow('online') on reconnect, syncNow('foreground') when the app becomes active, finalize → syncNow('finalize') — and feeds the metered signal into policy.attachments.onMetered (10 §4). The engine still runs its own reachability probe because captive portals report "connected".
  • Foreground first. The engine is single-flight and priority-ordered (outbox → attachments → forms → datasets → records); the UI always renders last sync and pending counts from useSync().
  • Background top-up.
// src/backgroundTask.ts — imported from the app entry, top level
import * as TaskManager from 'expo-task-manager';
import * as BackgroundTask from 'expo-background-task';
TaskManager.defineTask('dev.rasd.sync', async () => {
const { syncEngine } = await getRasd(); // reopen storage if needed
const report = await syncEngine.syncNow('background'); // policy.background caps the run: ~25 s budget, attachments ≤ 5 MiB
return report.error ? BackgroundTask.BackgroundTaskResult.Failed : BackgroundTask.BackgroundTaskResult.Success;
});
export async function registerRasdBackgroundSync() {
await BackgroundTask.registerTaskAsync('dev.rasd.sync', { minimumInterval: 15 });
}

Limits (research/04): 15-minute floor, OS decides when, skipped on low battery/no network, never on the iOS simulator, stops after force-quit; iOS needs UIBackgroundModes: ["processing"] and BGTaskSchedulerPermittedIdentifiers (the plugin adds dev.rasd.sync). Because tus uploads are resumable, a task killed mid-upload loses at most one chunk (adaptive 256 KiB–8 MiB, 10 §4).

  • Android foreground service. Not shipped by Rasd. Hosts needing multi-minute uploads with the screen off implement a dataSync foreground service (Android 14+: FOREGROUND_SERVICE_DATA_SYNC type plus Play declaration) and drive syncEngine.syncNow() from it; progress events feed a notification that shows counts only, never answers.
  • iOS. Suspended apps cannot keep sockets; sync resumes on next foreground. Files stay in the default Data Protection class C so background tasks can read them.

12. Theming with StyleSheet

useTheme() resolves the theme JSON (tokens.color/typography/spacing/radius/elevation/motion/control, components overrides, mode) into a frozen object and a memoised StyleSheet factory keyed by (themeId, mode, contrast, density, fontScale, direction):

const { tokens, styles, mode } = useTheme();
const s = styles((t) => StyleSheet.create({
input: {
minHeight: t.control.height, // 48
borderWidth: t.control.borderWidth,
borderColor: t.color.outline,
borderRadius: t.radius.md,
paddingHorizontal: t.spacing.unit * 3,
color: t.color.onSurface,
fontFamily: t.typography.fontFamily, // fontFamilyRtl when direction === 'rtl'
fontSize: t.typography.baseSize,
},
}));
  • Elevation tokens map to boxShadow (RN ≥ 0.76 New Architecture) with an elevation fallback on Android; motion durationMs becomes 0 under reduced motion.
  • Modes: useColorScheme() for light/dark, AccessibilityInfo.isHighTextContrastEnabled (Android) / isDarkerSystemColorsEnabled (iOS) for high contrast, isReduceMotionEnabled for motion, isBoldTextEnabled bumps weights.regular to medium; the host may force any mode through the theme prop.
  • Fonts are theme assets: static TTF/OTF only (variable fonts are not cross-platform on Android, research/07), embedded with the expo-font config plugin (production) or useFonts (Expo Go); the default OFL Arabic family ships in @rasd/themes. Per-script family: fontFamilyRtl is used when the resolved direction is RTL, and per-run when a label's first strong character is Arabic-script.
  • Component overrides: theme.components.SelectOne.variant, Button.radius etc. are read by the default components; registry.components replaces whole components; per-part styles wins over both.

13. Font scaling, RTL and i18n on Hermes

  • Font scaling. allowFontScaling stays true; every Text/TextInput sets maxFontSizeMultiplier={tokens.typography.maxFontScale ?? 2}; layouts use minHeight and wrap, never fixed heights; the snapshot matrix runs at font scale 1.0 / 1.3 / 2.0.
  • Direction without restart. useDirection() resolves theme.direction ?? settings.localeMeta[locale].dir ?? scriptOf(locale); RasdProvider renders <View style={{ direction, flex: 1 }}> so start/end, marginStart/End, paddingStart/End, insetInlineStart/End and flexDirection: 'row' flip per subtree (research/13 §5). All Rasd styles use logical properties; directional icons use transform: [{ scaleX: -1 }]; clocks/checkmarks are not mirrored. Verify non-root direction on Android/Fabric per RN release (flagged unverified in research) — the CI device matrix includes an Arabic form in an LTR host app.
  • I18nManager caveat. I18nManager.forceRTL() is global and needs an app reload (Updates.reloadAsync()); Rasd never calls it. Arabic-only host apps may set allowRTL(true) + forceRTL(true) once at first launch; both configurations are supported.
  • Text inside inputs. writingDirection is iOS-only and buggy in TextInput (RN issue #54399); Rasd instead sets textAlign from the first strong character of the current value; numeric, phone, ID, barcode and coordinate fields are LTR islands (textAlign: 'left', direction: 'ltr'); interpolated values in messages are wrapped in U+2066…U+2069 isolates.
  • Hermes Intl. Only Intl.NumberFormat, DateTimeFormat, Collator are used; plurals come from @rasd/core's compiled CLDR functions (Intl.PluralRules is absent on Hermes; the FormatJS polyfill path costs ~150 KB gz and is not used); numberingSystem on iOS DateTimeFormat is ignored, so digit rendering of dates goes through the same settings.numbering normaliser as numbers.

14. Accessibility (TalkBack / VoiceOver)

RequirementImplementation
Accessible nameEvery control: accessibilityLabel = localized label (+ ", required" when required, + hint); accessibilityLabelledBy on Android points at the label Text nativeID; the label is also visible text (never placeholder-only)
Rolesradio inside radiogroup, checkbox, switch, button, adjustable (rating/range with accessibilityActions increment/decrement), header on page/group titles, summary for the progress text
State/valueaccessibilityState={{ checked, selected, disabled, expanded, busy }}, accessibilityValue={{ min, max, now, text }} on rating/range/progress
ErrorsError Text has accessibilityLiveRegion="polite" (Android) and is announced with AccessibilityInfo.announceForAccessibility() on both platforms; on finalize, focus moves to the error summary (setAccessibilityFocus) whose items are buttons that jump to the field
Page changeAnnounce "Page 3 of 7, {title}" and move focus to the page heading; hidden pages/collapsed groups are unmounted (not display: none), so nothing focusable is left behind
Touch targetsMin 48 × 48 dp (control.minTouch), spacing ≥ 8 dp; hitSlop extends small icons
Contrast / high contrastDefault themes pass 4.5:1 text / 3:1 UI; rasd-high-contrast selected automatically from OS signals unless forced
MotionReduced motion disables slide transitions and reorder animations
Lint/testseslint-plugin-react-native-a11y; RNTL role/name queries by default; manual TalkBack (Android 9, 13) and VoiceOver (iOS 17) passes per release

15. Low-end Android guidance

  • Hermes bytecode is precompiled by the host build; ship no import.meta, no top-level await, no console.log in production (the redacting logger strips debug).
  • Keep the JS thread free while typing: engine recompute is microtask-batched, autosave debounced 2000 ms, dataset search debounced 200 ms, thumbnails generated once at capture (never re-encoded on render).
  • Prefer Pressable over TouchableOpacity; avoid Modal stacking (one Sheet at a time); avoid LayoutAnimation; use startTransition for page changes so keystrokes win.
  • Images: 1280 px long edge / JPEG 0.7 (~150–350 KB) at capture, thumbnails ≤ 240 dp, never the full-size photo in a list (see §6).
  • CI runs the example app on an Android emulator profile with 1 GB RAM, API 24 and measures form open, keystroke latency and heap (research/08); regressions > 15 % fail the build.
  • Battery: GPS watchPositionAsync runs only while a geo question is focused/visible; sync backs off (1 s → 5 min full jitter) and respects isConnectionExpensive for attachments when the policy says so.

16. Expo config plugin and native module

@rasd/native ships app.plugin.js (Expo config plugin) and a tiny Expo Module (RasdSecurity, Swift + Kotlin, ~100 lines) that is autolinked in development builds and bare RN and feature-detected at runtime (absent in Expo Go). Decided: the module ships inside @rasd/native — there is no separate @rasd/native-security package and no 18th package (00 §3); hosts that cannot link it (Expo Go, bare RN without install-expo-modules) keep working, and securityReport() says which controls are therefore inactive.

// app.json
{ "expo": { "plugins": [
["@rasd/native", {
"backupExclusion": true, // Android dataExtractionRules + iOS isExcludedFromBackup
"backgroundSync": true, // UIBackgroundModes: processing + BGTaskSchedulerPermittedIdentifiers: dev.rasd.sync
"permissions": { "camera": true, "location": "whenInUse", "microphone": true, "photos": false },
"usageDescriptions": { "camera": "Take photos of distribution sites for monitoring reports." }
}],
["expo-sqlite", { "useSQLCipher": true }],
["expo-font", { "fonts": ["./assets/fonts/NotoSansArabic-Regular.ttf"] }]
]}}

The plugin: writes/merges res/xml/rasd_data_extraction_rules.xml and sets android:dataExtractionRules/android:fullBackupContent (merging with expo-secure-store's rules, never overwriting); adds NSCameraUsageDescription, NSLocationWhenInUseUsageDescription, NSMicrophoneUsageDescription (localised via CFBundleLocalizations when strings are given per locale); adds background modes/identifiers; sets android.softwareKeyboardLayoutMode to resize if unset; never requests background location or media-read permissions. The module exposes setExcludedFromBackup(path), securityReport() (SQLCipher on?, rules present?, exclusion applied?, https-only?) and preventScreenCapture(bool) passthrough to expo-screen-capture when installed.

17. Installation

Expo (development build; Expo Go only for prototyping):

npx expo install expo-sqlite expo-file-system expo-secure-store expo-crypto expo-background-task expo-task-manager \
@react-native-community/netinfo react-native-gesture-handler react-native-reanimated
pnpm add @rasd/core @rasd/native @rasd/storage @rasd/storage-sqlite @rasd/sync @rasd/media @rasd/license @rasd/themes
npx expo prebuild && npx expo run:android

Add the plugin (§16), the Reanimated Babel plugin (react-native-worklets/plugin in Reanimated 4), and wrap the root in GestureHandlerRootView.

Bare React Native (≥ 0.81, New Architecture): run npx install-expo-modules (recommended; the Expo packages above then work unchanged) or use @op-engineering/op-sqlite (driver: 'op') with the @rasd/storage-sqlite fileSystem adapter option and react-native-keychain through the @rasd/storage key-provider interface. Then: pod install; manifest permissions (CAMERA, ACCESS_FINE_LOCATION, RECORD_AUDIO as needed); Info.plist usage strings; dataExtractionRules XML (template in @rasd/native/templates/); BGTaskSchedulerPermittedIdentifiers; Hermes on (default); Metro package exports (default since RN 0.79). Without the Expo Module the host performs iOS backup exclusion itself and securityReport() says so.

18. Testing

LayerToolingWhat Rasd provides
Unit / componentJest + @react-native/jest-preset (RN 0.85+) or jest-expo; RNTL 14 (async render, host-only queries)@rasd/testing renderForm(def, { platform: 'native', storage: fakeStorage(), media: fakeMedia() }), fake clock, network fault injection, form fixtures; mocks for expo-* modules
Stories / snapshots@storybook/react-native v10 on-device + @storybook/react-native-web-vite for Chromatic; matrix en/ar/en-XB × light/dark/high-contrast × font scale 1.0/1.3/2.0Story decorators for theme, direction, font scale
E2EMaestro flows on the Expo example (EAS Workflows or CI emulators); Detox is community-only for ExpoFlows: open form offline (adb shell svc wifi disable), fill 5 pages, capture photo (mock camera), finalize, re-launch, verify draft restored, reconnect and assert synced
PerformanceEmulator 1 GB RAM API 24 profile; PerformanceObserver/performance.now() marks around open/keystroke/pagePerf fixture: 300-element / 5-page / 3-repeat form
Accessibilityeslint-plugin-react-native-a11y; RNTL role queries; manual TalkBack/VoiceOver checklist per releaseChecklist in apps/example-expo/a11y.md

Example RNTL test:

const user = userEvent.setup();
const { engine } = await renderForm(pdmForm, { platform: 'native' });
await user.press(screen.getByRole('radio', { name: 'Yes' }));
await user.type(screen.getByLabelText('Household size, required'), '٥');
expect(engine.getState().data.hh_size).toBe(5); // Arabic-Indic digit normalised

19. Example Expo app structure (apps/example-expo)

apps/example-expo/
app.json # plugins from §16, android.allowBackup=false
app/_layout.tsx # GestureHandlerRootView > RasdProvider (storage, sync, license, theme, locale)
app/index.tsx # assigned forms + pending counts (useSync)
app/form/[id].tsx # <FormRenderer definition submissionId onFinalize/>
app/drafts.tsx # useSubmission list, resume/delete
app/settings.tsx # locale, theme mode, sync now, storage estimate, securityReport()
src/rasd.ts # openStorage(), createSyncEngine(), createLicense(), createNetInfoConnectivity()
src/backgroundTask.ts # TaskManager.defineTask('dev.rasd.sync') — imported first in _layout
assets/fonts/ # static TTFs (Inter, Noto Sans Arabic)
maestro/ # offline-fill.yaml, rtl.yaml, resume-draft.yaml

_layout.tsx sketch:

export default function Layout() {
const rasd = useRasdBoot(); // opens storage, license, sync once
if (!rasd) return <Splash />;
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<RasdProvider storage={rasd.storage} sync={rasd.sync} license={rasd.license} theme={rasdField} locale="ar">
<Stack />
</RasdProvider>
</GestureHandlerRootView>
);
}

20. Known limitations

  • Expo Go: no SQLCipher, no config-plugin effects, no RasdSecurity module → plaintext database with a warning banner; license state is evaluating; prototyping only.
  • Background sync is best-effort (15-minute floor, OS-scheduled, not on simulators, dies on force-quit); iOS gives no long-running upload without a foreground app.
  • Hermes Intl gaps (§13) and RTL caveats (§13): no Intl.PluralRules/ListFormat/DisplayNames; Hijri calendar via tables; I18nManager restart avoided; non-root Yoga direction re-verified per RN release; caret quirks with numbers in RTL on Android.
  • Reorder gestures need RNGH 3 + Reanimated 4 (RN ≥ 0.83); otherwise menu-only.
  • No HTML in labels/notes; unsupported markdown falls back to plain text.
  • iOS SecureStore values persist across reinstall and are capped ~2 KB; on some Android 12+ devices Auto Backup may still transfer data device-to-device despite allowBackup=false — the exclusion rules are the real control.
  • Native date pickers vary by OS version and ignore calendar: "hijri"; the built-in JS picker is the consistent path.
  • Not supported: Legacy Architecture, react-native-web through @rasd/native, Modal-in-Modal sheets, WebView-based widgets.

21. Acceptance criteria

  • @rasd/native type-checks against RN 0.81, 0.85 and 0.87 (Strict TS API) with react ^19; no react-native/Libraries/* imports; publint and attw --pack pass.
  • Every element type in spine §4.3 renders from the default registry; unknown x:* shows a placeholder and never throws.
  • useField, useRasdForm, useSubmission, useSync, useLicense, useTheme, useLocale, useDirection, useRasdBusy behave identically to @rasd/react in the shared conformance suite (@rasd/testing).
  • Keystroke → paint ≤ 16 ms, page transition ≤ 100 ms, form open ≤ 1.5 s (300-element fixture) on the 1 GB API 24 emulator profile; heap ≤ 150 MB with 30 thumbnails.
  • Focused input is never obscured by keyboard or sticky bar on iOS 15+/Android 7+; return key advances through all text/number fields on a page; iOS number pads show Next/Done.
  • Repeat with 200 instances scrolls at 60 fps in FlashList and FlatList modes; reorder works by drag (when peers present) and by ⋯ menu; audit records the move.
  • Autosave persists within 2 s of a change and on background; killing the app and relaunching restores the draft with all values, repeat instances and attachments.
  • With useSQLCipher: true, PRAGMA cipher_version is non-empty, DB key lives only in SecureStore, storage.wipe() removes DB, files and key; without SQLCipher the warning path and secure_delete are active.
  • Attachments are stored under the namespace directory with relative paths, survive an iOS app update, are excluded from Android backup and iOS iCloud backup (bmgr backupnow restore test and simctl attribute dump in CI).
  • Permission denial (askable and blocked) leaves every capture question answerable through its fallback and emits RASD_MEDIA_PERMISSION with details.reason; no background-location, background-audio or media-read permissions appear in the merged manifest.
  • Background task registered with minimumInterval: 15 runs syncNow('background') on a physical device and never runs when unregistered; NetInfo offline → online triggers sync within 5 s.
  • Arabic form inside an English host app renders fully RTL (layout, icons, text alignment) without app restart; numeric/ID fields stay LTR; snapshots at font scale 2.0 show no clipping.
  • TalkBack and VoiceOver: every control has role, name and state; errors are announced; page changes move focus to the heading; all targets ≥ 48 dp.
  • Config plugin output verified by MASTG-style checks (rules present, usage strings present, background identifiers present); securityReport() returns the expected flags on the example app.
  • Maestro offline-fill, resume-draft and RTL flows pass on CI emulator and on one physical low-end Android device per release.

Open questions

  • Should the built-in JS date picker be the permanent default, or should @react-native-community/datetimepicker become a required peer once its Hijri/min/max behaviour is verified across OS versions?
  • FlashList v2 (New-Architecture rewrite) API stability and the exact minimum RN version — confirm before making it the documented recommendation over FlatList.
  • Is a first-party Android dataSync foreground-service recipe worth maintaining, given Play declaration overhead, or do we keep it as documentation only?
  • Verify Yoga non-root direction on Android/Fabric for RN 0.85–0.87 and on Android 7–9 devices before promising per-form RTL without caveats.
  • Should engine.moveRepeat() be added to the @rasd/core public API (needed for reorder)? 17 · API reference already lists it on FormEngine, but the spine §11 list omits it — the spine should be amended.