Skip to main content

06 · React renderer (@rasd/react)

Purpose: Design and implementation contract for @rasd/react, the web renderer that turns a Rasd Form Definition (RFD) plus the headless engine in @rasd/core into an accessible, offline-first, themeable form UI. Audience: Engineers building @rasd/react; developers at UN/NGO organisations embedding it in React 19 / Next.js / Vite / Expo-web apps.

TL;DR

  • @rasd/react is a thin reactive shell over createFormEngine(); the engine owns state, REL evaluation, validation and repeats — the renderer owns DOM, focus, a11y, autosave scheduling and adapter wiring. Nothing in the renderer awaits the network (spine P1).
  • One <RasdProvider> supplies storage, license, theme, locale, registry, sync and media through separate React contexts; many <FormRenderer>s (and nested providers) can coexist on one page.
  • Field components subscribe per path (useField on useSyncExternalStore); typing in one field re-renders that field, its REL dependents and a throttled progress bar — never the whole form.
  • Registry resolution: per-form rendererstesters → provider registry → built-in defaults → UnknownElement placeholder for unregistered x:* types (value round-trips, never crashes).
  • Autosave: debounced settings.autosaveMs (default 2000 ms), flushed on page change, attachment write, visibilitychange, pagehide, finalize and unmount; drafts resume by submissionId; the library never reloads or touches the service worker (useRasdBusy() lets the host defer updates).
  • Validation policy validateOn: 'change' | 'blur' | 'page' | 'finalize' controls when errors are shown; page advance and finalize always run required + constraint; warnings never block; error summary + focus-first-invalid on every blocked action.
  • Ships one CSS file in @layer rasd, scoped --rasd-* variables on .rasd-root, data-part hooks, unstyled mode; heavy element types are lazy chunks; budget ≤ 90 kB min+gzip (target ≤ 60 kB) excluding React.
  • SSR-safe ('use client' entries, no storage access during render), works under react-native-web hosts, tested with @rasd/testing's renderForm().

1. Position in the stack

ConcernLives inNotes
Form state, REL evaluation, dependency graph, relevance, calculate, repeats, validation rules, toSubmission()@rasd/core (FormEngine)Framework-free; identical on native.
React bridge: contexts, hooks, useSyncExternalStore adapters, autosave scheduler, page state, focus/scroll management, error boundaries@rasd/react runtime~ half of the bundle budget.
Default web components (inputs, selects, repeat, nav, summary), CSS, a11y wiring@rasd/react componentsReplaceable through the registry / components.
Persistence, blobs, datasets@rasd/storage* via providerRenderer only calls the StorageAdapter interface.
Capture (GPS, camera, barcode, signature, audio, files)@rasd/media via providerAttachment fields call adapters; never browser APIs directly.
Sync, license, theme JSON@rasd/sync, @rasd/license, @rasd/themesSurfaced through hooks; renderer renders their state, does not drive them.
flowchart TD
Host[Host app] --> P[RasdProvider<br/>storage · license · theme · locale · registry · sync · media]
P --> F1[FormRenderer A]
P --> F2[FormRenderer B]
F1 --> E1[FormEngine A - core]
F2 --> E2[FormEngine B - core]
F1 -->|autosave · resume| S[(StorageAdapter)]
F2 -->|autosave · resume| S
F1 --> T[Element tree<br/>Page → FieldWrapper → registry component]
T -. useField per path .-> E1
Host -. useSync useLicense useSubmission .-> P

2. Provider and context architecture

2.1 RasdProvider props

PropTypeDefaultPurpose
storageStorageAdapter | (() => Promise<StorageAdapter>)MemoryStorage (dev warning)Drafts, attachments, datasets, kv. Factory form defers open() to an effect (SSR-safe).
licenseLicenseHandle from createLicense()none → evaluating on dev origins, else limitedFeeds useLicense(), watermark policy.
themeRasdTheme | string (theme id from @rasd/themes)rasd-lightCompiled to --rasd-* on the provider root.
localestringnavigator.language in effect; settings.defaultLocale for SSRNegotiated against settings.locales per 13 · i18n.
messagesRecord<locale, Record<key,string>>built-insChrome string overrides, deep-merged.
registryRegistry from createRegistry()built-in registryElements, component overrides, testers, custom validators, REL functions.
syncSyncEngine from createSyncEngine()noneuseSync(); renderer enqueues finalized submissions to storage.outbox regardless.
mediaMediaAdapters from @rasd/medianone → capture fields fall back to <input type="file"> where possibleGeolocation, camera, barcode, signature, audio, files.
mode{ colorScheme?, contrast?, density?, reducedMotion? }resolved from OSForces theme modes (data-color-scheme …).
portalContainerHTMLElementprovider rootPopovers/date pickers portal here (required inside Shadow DOM).
cssNoncestringNonce for injected theme <style> under strict CSP.
unstyledbooleanfalseDo not apply default CSS classes' styling contract (see §15).
as, className, styleelement propsdivThe provider renders <div class="rasd-root" data-theme data-color-scheme data-contrast data-density dir>.
onError(e: RasdError) => voidconsoleGlobal error sink (adapters, boundaries).

2.2 Context split, nesting, multiple forms

  • Each concern is its own context (StorageContext, LicenseContext, ThemeContext, LocaleContext, RegistryContext, SyncContext, MediaContext, PortalContext). A theme change never re-renders field components; a license transition re-renders only the watermark and finalize button.
  • Nested <RasdProvider> inherits every prop it does not set (theme, locale, mode, registry are the common overrides). A nested provider renders its own .rasd-root, so variables re-scope for that subtree — two agency themes on one page work.
  • Multiple <FormRenderer>s share one provider; each owns an engine, a submissionId, an autosave scheduler and page state. Sync leadership is unaffected: createSyncEngine is created once at app level (Web Locks single leader) and passed by reference; the provider dedupes by identity.
  • Two providers with different storage.namespace values isolate data completely (e.g., partner-agency mode).

3. FormRenderer

3.1 Props

PropTypeDefaultNotes
definitionFormDefinition | { id: string; version?: string }requiredReference form loads via storage.forms.get(id, version) (latest if omitted).
submissionIdstringnew UUID v7Resume if found; else create with this id.
initialDataPartial<Data>Applied only when creating; ignored on resume.
localestringprovider localePer-form override; changes dir, digits, calendar atomically.
readOnlybooleanfalseAll inputs aria-readonly, no autosave/finalize.
mode'fill' | 'review' | 'summary''fill' for drafts, 'review' otherwise§9.
validateOn'change' | 'blur' | 'page' | 'finalize''change'§7.
page / onPageChangenumber, (index, reason) => voiduncontrolledControlled page index (deep links).
persistbooleantrue if storage presentfalse = in-memory only (previews, builder).
engineFormEnginecreated internallyAdvanced controlled usage.
autosaveboolean | { debounceMs; maxWaitMs }settings.autosaveMsMin 250 ms.
metaRecord<string, unknown>{}Host ${meta.custom.*} preload values.
renderers, components, classNames, stylessee §5, §15Per-form overrides.
reviewBeforeFinalizebooleanfalseShow summary screen before finalize.
fallbackReactNodeskeletonWhile storage/definition/draft load.
slots{ header?, footer?, nav?, watermark? }defaultsRender-prop slots.

3.2 Events

EventPayloadFires
onChange{ type: 'value' | 'repeat_add' | 'repeat_remove' | 'repeat_move'; path; value; previous; submissionId; data }After the engine applies a change (batched per microtask). data is a lazy getter.
onSaveSubmissionAfter each autosave commit.
onFinalizeSubmission (status finalized)After validation passes and the submission is written; may return false or throw RasdError to veto (then status stays draft).
onInvalid{ errors: FieldError[]; page: number }When next/finalize is blocked.
onPageChange(index, reason: 'next' | 'prev' | 'jump' | 'error' | 'trigger')After focus moves.
onComplete{ trigger: TriggerId; message }A complete trigger fired.
onErrorRasdErrorBoundary catches, storage failures.

3.3 Controlled vs uncontrolled

Uncontrolled (default) is the field-monitor path: renderer owns the engine, autosaves to storage, resumes by submissionId, writes to storage.outbox on finalize. Controlled usage exists for previews and embedded editors: pass persist={false} plus initialData (re-key to reset), or create the engine yourself with createFormEngine(def) and pass engine — you then call engine.setValue()/engine.subscribe() and the renderer is a pure view. Do not mix engine with submissionId.

stateDiagram-v2
[*] --> loading
loading --> draft: create / resume
draft --> draft: change → autosave ≤ 2 s
draft --> validating: finalize()
validating --> draft: errors → onInvalid, focus first
validating --> finalized: onFinalize ok
finalized --> queued: storage.outbox.enqueue
queued --> review: renderer reopens read-only
draft --> [*]: unmount → flush

4. Hooks

useRasdForm<T = FormApi>(selector?: (api: FormApi) => T, isEqual?: (a: T, b: T) => boolean): T;
useField<V = unknown>(path: string): FieldApi<V>;
useSubmission(id: string): { submission?: Submission; status?: SubmissionStatus; attachments: Attachment[]; patch; remove; loading };
useSync(): { state: 'idle'|'syncing'|'paused'|'offline'|'error'; pending: { submissions: number; attachments: number }; lastSyncAt?: string; lastError?: RasdError; progress?: { done; total }; conflicts: Conflict[]; syncNow(); pause(); resume() };
useLicense(): { state: 'evaluating'|'trial'|'active'|'grace'|'limited'|'invalid'; plan?; features: string[]; expiresAt?; graceUntil?; can(feature: string): boolean; refresh() };
useTheme(): { theme: RasdTheme; resolved: { colorScheme; contrast; density; reducedMotion }; setMode(partial) };
useLocale(): { locale; dir; t; tf; formatNumber; formatDate; formatList; collator; setLocale };
useDirection(): 'ltr' | 'rtl';
useRasdBusy(): boolean; // dirty || save in flight || finalize in flight

FormApi = { engine, definition, submissionId, status, page, pages, goTo, next, prev, canNext, validate, finalize, save, dirty, errorCount, locale, setLocale }. FieldApi = { value, setValue, blur, touched, errors, warnings, relevant, required, readonly, calculated, element, path, ids: { input, label, hint, error }, inputProps } where inputProps carries id, aria-labelledby, aria-describedby, aria-invalid, aria-required, dir, inputMode, autoComplete, onBlur.

HookSubscribes toRe-renders when
useRasdForm() (no selector)engine "structure" revisionpage, status, dirty, errorCount, repeat count change — not on every value
useRasdForm(sel)selector resultselected slice changes (Object.is / custom)
useField(path)engine.subscribeField(path)that path's value/relevance/errors/readonly change
useSubmission(id)storage change feed for idautosave commit, sync status change
useSync()sync engine eventsprogress, error, conflict, state
useLicense()license.state$state transitions only
useTheme() / useLocale() / useDirection()provider contextsprovider prop change or OS signal

The engine batches dependent updates in a microtask, so one keystroke produces one render pass for the edited field plus its dependents.

5. Field registry

defineElement<V>({ type: 'x:beneficiary-lookup', component: BeneficiaryLookup, valueSchema?: ZodType<V>,
builder?: { icon, label, inspector }, chunk?: () => Promise<{ default: ElementComponent<V> }> });
createRegistry({ elements?: ElementDefinition[], components?: Partial<Components>, renderers?: Partial<Record<ElementType, ElementComponent>>,
testers?: { rank: number; test: (el, ctx) => boolean; component }[], validators?: Record<string, CustomValidator>,
functions?: Record<string, RelFunction>, unknownElement?: ElementComponent });

Element components receive { field: FieldApi, element, path, variant, mode, readOnly, locale, dir } and must render through FieldWrapper (or replicate its accessible-props contract: label id, aria-describedby = hint + error, error state).

Resolution order for element type T: (1) FormRenderer.renderers[T] → (2) registry testers sorted by rank (highest wins, first match ≥ 1) → (3) registry renderers[T] → (4) defineElement entries (host x:* types, or overrides of built-ins) → (5) built-in default → (6) unknownElement. appearance.variant selects a variant inside the resolved component; unsupported variants fall back to the component default with a dev warning.

Unknown x:*: UnknownElement renders a muted card ("This question needs an app update" + type in dev), preserves and round-trips the stored value, skips validators; if required and empty it blocks finalize with that message (host may set unknownElement to change this).

Element typeDefault component (variants)Chunk
textTextField (single, multiline, format email/phone/url, mask)core
number, range, ratingNumberField (inputmode, unit, separators), RangeSlider, Rating (star/number/smiley)core
date, time, datetimeDateField (native inputs; Hijri display via Intl calendar), TimeField, DateTimeFieldcore (Hijri picker lazy)
select_oneSelectOne (radio default ≤ 7 choices, dropdown, chips, buttons, likert; auto SearchSelect when > 15 choices, search: true or dataset-backed)core / search-select lazy
select_multipleSelectMultiple (checkbox, chips, multi-dropdown; exclusive)core
checkbox, consent, note, hidden, calculateCheckbox, ConsentBlock, Note (sanitized markdown), none, nonecore
group, repeatGroup (section/card/collapsible/field-list), Repeat (list, windowed ≥ 50 rows)core
rank, matrixRankList, MatrixGridlazy
geopoint, geotrace, geoshapeGeoPointField, GeoPathField (map optional)lazy (@rasd/media geolocation)
image, audio, video, file, signature, barcodeImageField, AudioField, VideoField, FileField, SignatureField, BarcodeFieldlazy per capability

Layout components overridable through components: FieldWrapper, Page, PageNav, ProgressBar, ErrorSummary, RepeatItem, Button, Watermark, SummaryRow.

6. Page navigation and progress

  • settings.navigation: 'paged' renders one page per screen with PageNav (Prev / Next / Finalize, ≥ 48 px targets, sticky bottom on narrow viewports with scroll-padding-bottom so the focused input is never obscured — WCAG 2.2 SC 2.4.11). 'scroll' renders all pages as sections with a single Finalize.
  • Pages whose relevant is false are skipped in both directions and excluded from progress; a page with zero relevant elements is skipped too.
  • next() runs required + constraint + validators for the current page's relevant fields; blocked when any error-severity result exists unless validateOn='finalize'. prev() never validates. goTo(pageOrId) from a jump menu validates the pages being skipped forward only when validateOn !== 'finalize'.
  • Page change runs inside startTransition; on commit focus moves to the page heading (<h2 tabindex="-1">), the page scrolls to top (instant under reduced motion) and a polite live region announces "Page 3 of 7 — Household".
  • Progress = relevant pages by default (aria-valuenow, text alternative "3 of 7"); settings.showProgress: false hides it. Triggers with complete actions jump to a completion screen (onComplete), which offers "Finalize" and "Review answers".
  • Controlled page lets hosts bind to URL state; the renderer still refuses to jump forward past a page with errors and calls onPageChange(index, 'error').

7. Validation UX

validateOnErrors computedErrors shownBlocks NextBlocks Finalize
change (default)every change (microtask)constraint/regex/range while typing once the field is non-empty; required only after blur or an attempted next/finalizeyesyes
bluron blur + page/finalizeafter bluryesyes
pageon next/finalizeon blocked next/finalizeyesyes
finalizeon finalizeon blocked finalizenoyes
  • Constraint ignores empty values (ODK semantics, research); required is enforced only when relevant; severity: 'warning' | 'info' results render (amber/blue) but never block; warnings are appended to submission.audit as constraint warning events on finalize.
  • Blocked action → ErrorSummary (role="alert") listing "N problems" with links; the first invalid field receives focus and scrollIntoView({ block: 'center' }) (scroll-margin-top accounts for sticky headers; behavior: 'auto' under reduced motion). On multi-page finalize the renderer jumps to the first page with errors first.
  • Field errors bind via aria-describedby; text + icon, never colour alone; message from requiredMessage/constraintMessage/validator message (localized, Mini-Message interpolation with <bdi> isolates around values).
  • Custom validators ({ type: 'custom', id }) resolve from registry.validators[id]: (ctx: { value, data, element, locale, signal }) => Result | Promise<Result>; async ones are debounced 300 ms, cancelled with AbortSignal on change, show aria-busy; results are cached per value. They must work offline; a check that needs the network must return warning, never block finalize.
  • Reopening a rejected submission maps server { code, message, field } to field errors and shows a banner; re-finalize bumps clientRev.

8. Autosave, drafts, resume, crash safety

sequenceDiagram
participant U as User
participant E as FormEngine
participant A as Autosave scheduler
participant S as StorageAdapter
U->>E: setValue(path, v)
E-->>A: change event (batched)
A->>A: debounce autosaveMs (max wait 10 s)
A->>S: transaction: submissions.patch + audit append
S-->>A: committed
A-->>U: onSave · live region "Saved 10:42"
Note over A,S: pagehide / visibilitychange hidden / page change / attachment write ⇒ flush now
  • Trailing debounce of settings.autosaveMs (default 2000, min 250) with a 10 s max wait; immediate flush on page change, repeat add/remove, attachment write, visibilitychange === 'hidden', pagehide, freeze, before finalize and on unmount. Writes are storage.submissions.patch(id, { data, audit, updatedAt }) inside one transaction; clientRev bumps only on finalize.
  • beforeunload is registered only while useRasdBusy() is true (browsers ignore custom text). Because IndexedDB writes are async, the ≤ 2 s debounce plus pagehide flush bounds worst-case loss to the last keystrokes; the renderer never uses localStorage for data.
  • Save failure (RASD_STORAGE_QUOTA etc.) → sticky banner + onError, exponential retry (1 s → 30 s), editing continues; the finalize button is disabled while a save is failing.
  • Multi-tab: navigator.locks.request('rasd-sub-' + id, { ifAvailable: true }) guards a draft; a second tab opens it read-only with "Being edited in another tab"; BroadcastChannel('rasd:' + namespace) fans out submission-updated so useSubmission refreshes. Without Web Locks the last write wins and a warning is logged.
  • Resume: draft data, current page and scroll anchor (submission.ext['dev.rasd.ui']) are restored; a polite announcement says "Draft restored from 15 Aug, 10:42". Drafts pin their formVersion + definitionHash; the renderer loads that exact version and never migrates silently (migrateSubmission is host-invoked, see 04 · Form schema).
  • Crash safety: the form-level error boundary flushes pending autosave in componentDidCatch before showing the recovery card, so data at rest is at most one debounce window old.

9. Read-only, review and summary modes

  • readOnly: inputs stay focusable with aria-readonly="true" (not disabled, so screen readers can read values), attachments open in a viewer, navigation works, no autosave/finalize, no beforeunload.
  • mode='review' (default for finalized|queued|sending|synced|rejected|conflict): fill layout with values, edit disabled, status chip and rejection/conflict reasons; "Edit" is available only for rejected (creates a new draft revision) or when the form is a record/case (10 · Sync).
  • mode='summary': compact label → value list of relevant answered questions grouped by page and repeat row (itemLabel), values formatted with useLocale() (digits, calendar), choice labels resolved, attachments as thumbnails, print stylesheet (@media print), used by reviewBeforeFinalize and as an export preview.

10. Performance

Reference device: Android 7, 1 GB RAM, Chrome/WebView ≥ 100, 4× CPU throttle in CI (Playwright); fixtures: 500-question form and a 200-row repeat.

MetricBudget
Keystroke → paint (single field, 500-question form)≤ 16 ms mid-range, ≤ 50 ms reference device
Page switch (30 elements)≤ 100 ms to focus on heading
Add row 200 in a repeat≤ 50 ms
First render of a 500-question paged form (definition cached)≤ 1 s to interactive
Re-renders per keystrokeedited field + REL dependents + progress (throttled 250 ms)

Techniques: (1) per-path subscriptions and microtask batching in the engine; (2) ElementHost is React.memo keyed by path with stable callbacks; the engine instance in context is stable so context churn is zero; (3) @rasd/react is precompiled with babel-plugin-react-compiler (target: '19'), so manual useMemo is limited to measured hot paths and opt-outs use "use no memo" (research); (4) startTransition for page switch, repeat add and dataset search results, useDeferredValue for the search query; (5) content-visibility: auto; contain-intrinsic-size: auto 96px on element roots in scroll mode (progressive; no effect where unsupported) plus JS windowing when a page exceeds 300 elements; (6) no scrollIntoView per keystroke, IntersectionObserver-based "current section" tracking.

200-row repeats: rows beyond virtualizeAfter (default 50) are windowed (overscan 5); collapsed rows render only the itemLabel computed by the engine and expand on tap/Enter (accordion appearance, one row's fields mounted); adding a row focuses its first field; delete honours confirmDelete; reorder via ⋯ menu (Move up/down/to…) with drag as an optional lazy dnd-adapter (WCAG 2.5.7); count-driven shrink hides rows, never deletes (ODK); aggregates (sum(${hh[].age})) are recomputed incrementally by the engine, not by rendered rows.

  • A select_one/select_multiple whose list has source.type: 'dataset' (or search: true, or > 15 choices) resolves to SearchSelect: WAI-ARIA combobox (role="combobox", aria-expanded, aria-activedescendant, listbox with windowed options), keyboard navigation, popover in portalContainer.
  • Lists ≤ 200 rows are loaded once and filtered in memory; larger lists query storage.datasets.query(name, { filter, search, limit: 50 }) with 150 ms debounce, minimum 2 characters (0 when filterKeys restrict the set), the Arabic-aware normaliser (NFKC, tashkeel strip, alef/yaa/taa-marbuta folding, digit mapping) applied to both query and stored label_norm, and Intl.Collator(locale, { sensitivity: 'base', ignorePunctuation: true, numeric: true }) ordering (research).
  • Cascades: filterKeys equality is pushed to the store (gov_code = ${governorate}), choiceFilter REL runs lazily per row; when a parent changes and the child value is no longer in the filtered set, the child is cleared with an audit value event (props.keepStaleValue: true keeps it and flags a constraint error instead).
  • Selected labels resolve offline via the dataset row; a tombstoned row shows "code (label unavailable)". useDatasetRow(name, key) (internal hook) powers pulldata() displays.
  • Dataset updates (datasetUpdated sync event) refresh open lists without resetting the value.

12. Attachment fields

Value type is attachmentRef { attachmentId, mime, bytes, name?, sha256 }; the blob lives in storage.attachments, the reference in data, and the submission.attachments[] entry (status pending) is added in the same transaction. Flow: capture via @rasd/media adapter → compress in a Web Worker (photos: long edge 1280 px, JPEG q ≈ 0.7, EXIF stripped, geotag sidecar when geotag: true) → storage.attachments.put(id, blob, meta) + submissions.patchonChange. Thumbnails use storage.attachments.getUri(id) (object URLs revoked on unmount). Retake replaces the reference and marks the old blob for GC (deleted after successful sync or draft discard). Limits: props.maxBytes per field, 10 MB total per submission by default (warning at 80 %, RASD_STORAGE_QUOTA surfaced inline on quota errors). Web fallbacks: <input type="file" accept="image/*" capture="environment"> when no camera adapter; barcode uses BarcodeDetector when present, else the barcode-detector ponyfill with self-hosted WASM; signature uses a canvas pad exporting a trimmed PNG; audio uses MediaRecorder with isTypeSupported negotiation (research). Upload state (uploading, failed, uploaded) is displayed from useSubmission(); the field itself never uploads. See 14 · Media.

13. Keyboard, screen reader and RTL behaviour

  • FieldWrapper emits <label for> (or <fieldset><legend> for choice groups with role="radiogroup"/group), aria-describedby = hint + error, aria-invalid, aria-required, autocomplete on identity-like fields (format email/phone), inputmode per number kind. Enter in single-line inputs never submits; Space/Enter toggles choice buttons; arrow keys move within radio groups and rank lists (with "Move up/down" buttons as the non-drag path).
  • Live regions: polite for autosave/sync/page announcements, assertive for the error summary; status text is not the only signal (icons + text).
  • Focus ring 2 px, 3:1 contrast, never removed; targets ≥ 48 px (control.minTouch token; hard floor 24 px per WCAG 2.2 SC 2.5.8); focus never obscured by sticky nav (SC 2.4.11); no re-entry across pages (SC 3.3.7) thanks to default.expr/calculate prefills.
  • RTL: dir on .rasd-root from settings.localeMeta[locale].dir or the built-in RTL list (ar, ckb, fa, ps, ur, he, sd, ug); logical CSS only; free-text inputs dir="auto"; interpolated values and dataset labels wrapped in <bdi>; LTR islands (dir="ltr", text-align: end) for number, phone, ID, barcode, geo and date inputs; directional icons flipped via [dir=rtl] [data-part=icon-directional] { transform: scaleX(-1) }, checkmarks not; progress and sliders fill from inline-start; fallback strings render dir="auto"; typography.fontFamilyRtl applied; digits follow settings.numbering and are normalised to ASCII on save (research).
  • Content safety: label/note markdown is rendered through DOMPurify with the allow-list in 16 · Security; dangerouslySetInnerHTML is banned outside that path.

14. SSR, Next.js App Router, react-native-web

  • Every component and hook entry carries 'use client' in built output; @rasd/core stays directive-free so validation/types run in Server Components. Client components are still server-rendered: no window, navigator, IndexedDB or license reads during render — storage opens in an effect (storage factory form), useSyncExternalStore supplies getServerSnapshot (empty draft, evaluating license, forced locale).
  • Pass locale explicitly for deterministic dir; the first page renders server-side from initialData, then hydrates and resumes the draft in an effect (fallback shows only if the definition itself is loading). Import CSS once: import '@rasd/react/styles.css' in app/layout.tsx.
  • Heavy or DOM-only surfaces load with next/dynamic(() => import('@rasd/builder').then(m => m.FormBuilder), { ssr: false }); Next 16 defaults to Turbopack and PWA integration goes through Serwist, not next-pwa (research, 11 · PWA).
  • react-native-web hosts (Expo web, RNW 0.21.x): @rasd/react never imports react-native; it exposes browser/default conditions and no react-native condition, so Metro resolves the DOM build. Use @rasd/react on web for native form semantics; .rasd-root re-establishes box-sizing, focus outlines and dir that RNW resets. @rasd/native via RNW is supported but a11y is weaker (see 07 · Native renderer).

15. CSS delivery, theming hooks, unstyled mode

  • One file @rasd/react/styles.css (budget ≤ 12 kB min+gzip): @layer rasd.reset, rasd.base, rasd.components; so unlayered host CSS always wins; default token values on .rasd-root; @media (prefers-reduced-motion: reduce), (forced-colors: active) and (prefers-color-scheme) only as defaults, overridden by data-color-scheme|contrast|density attributes.
  • Theme JSON → --rasd-<group>-<key> custom properties on the provider root, applied through a constructed stylesheet (adoptedStyleSheets) or a nonced <style> (cssNonce) — never inline style attributes, so strict CSP works; @rasd/themes toCss(theme) lets hosts precompile at build time.
  • Every part exposes class="rasd-<Component>__<part>" and data-scope="rasd" data-part="<part>", state as data-* (data-invalid, data-required, data-readonly, data-relevant); classNames, styles and render per part accept values or (state) => value.
  • unstyled (provider or form): the CSS file is not required, no default classes' visuals are assumed, but semantic classes/data-parts/ARIA remain — hosts bring Tailwind or their design system. Shadow DOM (@rasd/element) injects the same CSS inside the shadow root and sets portalContainer.
  • Fonts (typography.fontFamily*) are theme assets: self-hosted WOFF2 with unicode-range splitting, precached by the host SW; never fetched from a CDN at runtime.

16. Bundle and code-splitting

Entry points: @rasd/react (runtime + core components), @rasd/react/elements/{matrix,rank,geo,media,barcode,signature,search-select}, @rasd/react/locales/<lc>, @rasd/react/styles.css. The built-in registry maps heavy types to lazy(() => import(...)), so bundlers split them and the host's precache glob (Workbox) picks the chunks up; preloadElements(['image','geopoint']) warms them for offline first-run. Budgets (min+gzip, excluding React): @rasd/react ≤ 90 kB (target ≤ 60 kB); form-runner (core + react + storage-dexie + sync) ≤ 120 kB; each chunk < 2 MB uncompressed. Rules: ESM-only, sideEffects: false except CSS; peer react ^19 (tested against 18.3); validateFormDefinition (zod, ~60 kB gzip) runs only in dev or when validateDefinition is set — production render paths never import zod; the markdown sanitizer, Hijri picker and locale catalogs are lazy. Enforced with size-limit in CI.

17. Error boundaries and failure modes

FailureBehaviour
Custom element throwsElement boundary → inline error card, RASD_ELEMENT_RENDER to onError; rest of form usable; value preserved.
Definition invalid (RASD_SCHEMA_INVALID, RASD_EXPR_PARSE, cycle)Renderer shows a non-recoverable card with error list; no engine created.
Storage open() failsFalls back to MemoryStorage only when persist={false}; otherwise error card + onError (never silently lose data).
Quota exceededBanner, retry, finalize disabled until a save succeeds.
Media permission denied / unavailableField-level message with manual fallback (allowManual, file input).
License limited (soft)Watermark slot rendered, console warning; hard: new submissions blocked with message, drafts finish, export works.
Unknown x: typePlaceholder (§5).
Form-level render crashBoundary flushes autosave, shows recovery card ("your answers are saved") with Retry and Export.

18. Testing with @rasd/testing

renderForm(definition, { initialData?, locale?, storage?: fakeStorage(), clock?: fakeClock(), network?: faultyNetwork(), registry?, license? }) renders <RasdProvider><FormRenderer/></RasdProvider> with @testing-library/react + user-event and returns { ...rtl, engine, storage, clock, field(name), fill(name, value), next(), prev(), finalize(), expectError(name, matcher), submission() }. Conventions: Vitest 4 + RTL + vitest-axe (0 violations per story), fake timers to assert autosave at autosaveMs, fake-indexeddb for Dexie tests, Storybook 10 matrix en · ar · en-XB · xx-LS × light · dark · highContrast × font scale 1.0 · 1.3 · 2.0, Playwright projects chromium, chromium-ar-rtl, offline (context.setOffline(true)), plus a 4×-throttled perf project for §10 budgets. See 18 · Engineering practices.

19. Examples

Minimal

'use client';
import { RasdProvider, FormRenderer } from '@rasd/react';
import '@rasd/react/styles.css';
import def from './pdm-gfd-2026.form.json';

export function Pdm() {
return (
<RasdProvider locale="ar" theme="rasd-field">
<FormRenderer definition={def} onFinalize={(s) => console.log(s.id, s.status)} />
</RasdProvider>
);
}

Custom element (x:beneficiary-lookup)

import { defineElement, createRegistry, FieldWrapper, type ElementComponent } from '@rasd/react';

const BeneficiaryLookup: ElementComponent<string> = ({ field, element }) => (
<FieldWrapper field={field}>
<input {...field.inputProps} value={field.value ?? ''} inputMode="numeric" dir="ltr"
onChange={(e) => field.setValue(e.target.value.replace(/\D/g, ''))}
placeholder={element.props?.placeholder as string} />
</FieldWrapper>
);
export const registry = createRegistry({
elements: [defineElement({ type: 'x:beneficiary-lookup', component: BeneficiaryLookup,
builder: { icon: 'id-card', label: { en: 'Beneficiary lookup', ar: 'بحث عن مستفيد' }, inspector: [] } })],
});

Custom validator (referenced by { "type": "custom", "id": "hhIdChecksum" })

// luhn() = host-provided checksum helper; validators must be synchronous or offline-capable (§7)
const registry = createRegistry({
validators: {
hhIdChecksum: ({ value }) => (typeof value === 'string' && luhn(value)
? { ok: true } : { ok: false, severity: 'error', message: { en: 'Invalid household ID', ar: 'رقم الأسرة غير صحيح' } }),
},
});

Offline + sync + license wired together

'use client';
import { RasdProvider, FormRenderer, useSync, useLicense } from '@rasd/react';
import { createDexieStorage } from '@rasd/storage-dexie';
import { createSyncEngine } from '@rasd/sync';
import { createLicense } from '@rasd/license';
import { web as media } from '@rasd/media';
import type { FormDefinition } from '@rasd/core';

const storage = createDexieStorage({ namespace: 'wfp-jo' });
const sync = createSyncEngine({ storage, baseUrl: 'https://forms.example.org', getAuthToken: () => auth.token() });
const license = createLicense({ tokenEndpoint: '/api/rasd-license', storage });

function SyncBar() {
const { state, pending, lastSyncAt, syncNow } = useSync();
const lic = useLicense();
return <div role="status">{state} · {pending.submissions} pending · last {lastSyncAt ?? '—'} · {lic.state}
<button onClick={syncNow}>Sync now</button></div>;
}
export function App({ def, draftId }: { def: FormDefinition; draftId?: string }) {
return (
<RasdProvider storage={storage} sync={sync} license={license} media={media} theme="rasd-field" locale="ar">
<SyncBar />
<FormRenderer definition={def} submissionId={draftId} onFinalize={() => sync.syncNow()} />
</RasdProvider>
);
}

20. Acceptance criteria

  • useField re-renders only the edited field and its REL dependents (verified with React Profiler test on the 500-question fixture).
  • Two FormRenderers and a nested themed provider coexist on one page without cross-talk in state, variables or autosave.
  • Autosave writes within autosaveMs (fake timers) and flushes on pagehide, visibilitychange, page change and unmount; killing the tab loses ≤ one debounce window.
  • Draft resume restores data, page and announces restoration; version-pinned definition is loaded.
  • validateOn matrix (§7) behaves as tabulated; warnings never block; error summary focuses the first invalid field.
  • Unknown x: type renders the placeholder, round-trips its value, never throws.
  • Every element type has an axe-clean story in en, ar, en-XB at font scale 2.0; RTL checklist (§13) passes visual snapshots.
  • Keyboard-only completion of the full PDM example form, including repeat add/reorder/delete and search-select.
  • Performance budgets (§10) pass under 4× CPU throttle in CI for the 500-question and 200-row fixtures.
  • Dataset select with 50,000 rows returns results ≤ 150 ms after debounce and matches Arabic queries with diacritics/hamza variants.
  • Attachment capture stores blob + reference + attachments[] entry atomically; quota errors are surfaced without data loss.
  • Renders under Next.js App Router with SSR (no hydration warnings), and inside an Expo-web (react-native-web) host.
  • Strict CSP (style-src 'self' 'nonce-…', no unsafe-inline/unsafe-eval) — theme applies and no console violations.
  • unstyled mode renders semantic markup with data-parts and no visual regressions when the host supplies CSS.
  • size-limit: @rasd/react ≤ 90 kB gz, form-runner ≤ 120 kB gz, no zod in production chunk.
  • Error boundary test: throwing custom element leaves the rest of the form editable and preserves data.
  • License states limited(soft) shows watermark and keeps forms working; limited(hard) blocks only new submissions.

Open questions

  • Should validateOn: 'change' remain the spine default given NN/g evidence favouring on-blur? The touched-gating in §7 mitigates it; a survey of pilot enumerators should decide.
  • Windowing implementation: in-house hook (~2 kB) vs optional @tanstack/react-virtual peer — decide after measuring the 200-row fixture on the reference device.
  • Should the renderer offer a data/onChange fully-controlled mode without an engine handle, or is engine sufficient?
  • Where does the "Edit finalized submission" affordance live once records/cases ship — renderer mode or a separate RecordRenderer?
  • Do we ship a first-party <InstallHint>/<SyncStatus> UI kit inside @rasd/react or in @rasd/pwa?
  • Confirm content-visibility behaviour on Android WebView ≥ 100 low-end profiles before relying on it beyond progressive enhancement.

00 · Decisions & conventions · 03 · Architecture · 04 · Form schema spec · 05 · Logic & expressions · 07 · Renderer native · 08 · Builder · 09 · Offline storage · 10 · Sync protocol · 11 · PWA & embedding · 12 · Theming · 13 · i18n, RTL & accessibility · 14 · Media & field capture · 15 · Licensing & billing · 16 · Security & data protection · 17 · API reference · 18 · Engineering practices · 21 · Getting started