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/reactis a thin reactive shell overcreateFormEngine(); 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 (
useFieldonuseSyncExternalStore); typing in one field re-renders that field, its REL dependents and a throttled progress bar — never the whole form. - Registry resolution: per-form
renderers→testers→ providerregistry→ built-in defaults →UnknownElementplaceholder for unregisteredx:*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 bysubmissionId; 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-parthooks,unstyledmode; 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'srenderForm().
1. Position in the stack
| Concern | Lives in | Notes |
|---|---|---|
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 components | Replaceable through the registry / components. |
| Persistence, blobs, datasets | @rasd/storage* via provider | Renderer only calls the StorageAdapter interface. |
| Capture (GPS, camera, barcode, signature, audio, files) | @rasd/media via provider | Attachment fields call adapters; never browser APIs directly. |
| Sync, license, theme JSON | @rasd/sync, @rasd/license, @rasd/themes | Surfaced 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
| Prop | Type | Default | Purpose |
|---|---|---|---|
storage | StorageAdapter | (() => Promise<StorageAdapter>) | MemoryStorage (dev warning) | Drafts, attachments, datasets, kv. Factory form defers open() to an effect (SSR-safe). |
license | LicenseHandle from createLicense() | none → evaluating on dev origins, else limited | Feeds useLicense(), watermark policy. |
theme | RasdTheme | string (theme id from @rasd/themes) | rasd-light | Compiled to --rasd-* on the provider root. |
locale | string | navigator.language in effect; settings.defaultLocale for SSR | Negotiated against settings.locales per 13 · i18n. |
messages | Record<locale, Record<key,string>> | built-ins | Chrome string overrides, deep-merged. |
registry | Registry from createRegistry() | built-in registry | Elements, component overrides, testers, custom validators, REL functions. |
sync | SyncEngine from createSyncEngine() | none | useSync(); renderer enqueues finalized submissions to storage.outbox regardless. |
media | MediaAdapters from @rasd/media | none → capture fields fall back to <input type="file"> where possible | Geolocation, camera, barcode, signature, audio, files. |
mode | { colorScheme?, contrast?, density?, reducedMotion? } | resolved from OS | Forces theme modes (data-color-scheme …). |
portalContainer | HTMLElement | provider root | Popovers/date pickers portal here (required inside Shadow DOM). |
cssNonce | string | — | Nonce for injected theme <style> under strict CSP. |
unstyled | boolean | false | Do not apply default CSS classes' styling contract (see §15). |
as, className, style | element props | div | The provider renders <div class="rasd-root" data-theme data-color-scheme data-contrast data-density dir>. |
onError | (e: RasdError) => void | console | Global 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,registryare 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, asubmissionId, an autosave scheduler and page state. Sync leadership is unaffected:createSyncEngineis created once at app level (Web Locks single leader) and passed by reference; the provider dedupes by identity. - Two providers with different
storage.namespacevalues isolate data completely (e.g., partner-agency mode).
3. FormRenderer
3.1 Props
| Prop | Type | Default | Notes |
|---|---|---|---|
definition | FormDefinition | { id: string; version?: string } | required | Reference form loads via storage.forms.get(id, version) (latest if omitted). |
submissionId | string | new UUID v7 | Resume if found; else create with this id. |
initialData | Partial<Data> | — | Applied only when creating; ignored on resume. |
locale | string | provider locale | Per-form override; changes dir, digits, calendar atomically. |
readOnly | boolean | false | All inputs aria-readonly, no autosave/finalize. |
mode | 'fill' | 'review' | 'summary' | 'fill' for drafts, 'review' otherwise | §9. |
validateOn | 'change' | 'blur' | 'page' | 'finalize' | 'change' | §7. |
page / onPageChange | number, (index, reason) => void | uncontrolled | Controlled page index (deep links). |
persist | boolean | true if storage present | false = in-memory only (previews, builder). |
engine | FormEngine | created internally | Advanced controlled usage. |
autosave | boolean | { debounceMs; maxWaitMs } | settings.autosaveMs | Min 250 ms. |
meta | Record<string, unknown> | {} | Host ${meta.custom.*} preload values. |
renderers, components, classNames, styles | see §5, §15 | — | Per-form overrides. |
reviewBeforeFinalize | boolean | false | Show summary screen before finalize. |
fallback | ReactNode | skeleton | While storage/definition/draft load. |
slots | { header?, footer?, nav?, watermark? } | defaults | Render-prop slots. |
3.2 Events
| Event | Payload | Fires |
|---|---|---|
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. |
onSave | Submission | After each autosave commit. |
onFinalize | Submission (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. |
onError | RasdError | Boundary 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.
| Hook | Subscribes to | Re-renders when |
|---|---|---|
useRasdForm() (no selector) | engine "structure" revision | page, status, dirty, errorCount, repeat count change — not on every value |
useRasdForm(sel) | selector result | selected slice changes (Object.is / custom) |
useField(path) | engine.subscribeField(path) | that path's value/relevance/errors/readonly change |
useSubmission(id) | storage change feed for id | autosave commit, sync status change |
useSync() | sync engine events | progress, error, conflict, state |
useLicense() | license.state$ | state transitions only |
useTheme() / useLocale() / useDirection() | provider contexts | provider 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 type | Default component (variants) | Chunk |
|---|---|---|
text | TextField (single, multiline, format email/phone/url, mask) | core |
number, range, rating | NumberField (inputmode, unit, separators), RangeSlider, Rating (star/number/smiley) | core |
date, time, datetime | DateField (native inputs; Hijri display via Intl calendar), TimeField, DateTimeField | core (Hijri picker lazy) |
select_one | SelectOne (radio default ≤ 7 choices, dropdown, chips, buttons, likert; auto SearchSelect when > 15 choices, search: true or dataset-backed) | core / search-select lazy |
select_multiple | SelectMultiple (checkbox, chips, multi-dropdown; exclusive) | core |
checkbox, consent, note, hidden, calculate | Checkbox, ConsentBlock, Note (sanitized markdown), none, none | core |
group, repeat | Group (section/card/collapsible/field-list), Repeat (list, windowed ≥ 50 rows) | core |
rank, matrix | RankList, MatrixGrid | lazy |
geopoint, geotrace, geoshape | GeoPointField, GeoPathField (map optional) | lazy (@rasd/media geolocation) |
image, audio, video, file, signature, barcode | ImageField, AudioField, VideoField, FileField, SignatureField, BarcodeField | lazy 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 withPageNav(Prev / Next / Finalize, ≥ 48 px targets, sticky bottom on narrow viewports withscroll-padding-bottomso 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
relevantis false are skipped in both directions and excluded from progress; a page with zero relevant elements is skipped too. next()runs required + constraint +validatorsfor the current page's relevant fields; blocked when anyerror-severity result exists unlessvalidateOn='finalize'.prev()never validates.goTo(pageOrId)from a jump menu validates the pages being skipped forward only whenvalidateOn !== '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: falsehides it. Triggers withcompleteactions jump to a completion screen (onComplete), which offers "Finalize" and "Review answers". - Controlled
pagelets hosts bind to URL state; the renderer still refuses to jump forward past a page with errors and callsonPageChange(index, 'error').
7. Validation UX
validateOn | Errors computed | Errors shown | Blocks Next | Blocks 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/finalize | yes | yes |
blur | on blur + page/finalize | after blur | yes | yes |
page | on next/finalize | on blocked next/finalize | yes | yes |
finalize | on finalize | on blocked finalize | no | yes |
- 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 tosubmission.auditasconstraint warningevents on finalize. - Blocked action →
ErrorSummary(role="alert") listing "N problems" with links; the first invalid field receives focus andscrollIntoView({ block: 'center' })(scroll-margin-topaccounts 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 fromrequiredMessage/constraintMessage/validatormessage(localized, Mini-Message interpolation with<bdi>isolates around values). - Custom validators (
{ type: 'custom', id }) resolve fromregistry.validators[id]: (ctx: { value, data, element, locale, signal }) => Result | Promise<Result>; async ones are debounced 300 ms, cancelled withAbortSignalon change, showaria-busy; results are cached per value. They must work offline; a check that needs the network must returnwarning, never block finalize. - Reopening a
rejectedsubmission maps server{ code, message, field }to field errors and shows a banner; re-finalize bumpsclientRev.
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 arestorage.submissions.patch(id, { data, audit, updatedAt })inside one transaction;clientRevbumps only on finalize. beforeunloadis registered only whileuseRasdBusy()is true (browsers ignore custom text). Because IndexedDB writes are async, the ≤ 2 s debounce pluspagehideflush bounds worst-case loss to the last keystrokes; the renderer never useslocalStoragefor data.- Save failure (
RASD_STORAGE_QUOTAetc.) → 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 outsubmission-updatedsouseSubmissionrefreshes. 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 theirformVersion+definitionHash; the renderer loads that exact version and never migrates silently (migrateSubmissionis host-invoked, see 04 · Form schema). - Crash safety: the form-level error boundary flushes pending autosave in
componentDidCatchbefore 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 witharia-readonly="true"(notdisabled, so screen readers can read values), attachments open in a viewer, navigation works, no autosave/finalize, nobeforeunload.mode='review'(default forfinalized|queued|sending|synced|rejected|conflict): fill layout with values, edit disabled, status chip and rejection/conflict reasons; "Edit" is available only forrejected(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 withuseLocale()(digits, calendar), choice labels resolved, attachments as thumbnails, print stylesheet (@media print), used byreviewBeforeFinalizeand 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.
| Metric | Budget |
|---|---|
| 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 keystroke | edited 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.
11. Dataset-backed selects and async search
- A
select_one/select_multiplewhose list hassource.type: 'dataset'(orsearch: true, or > 15 choices) resolves toSearchSelect: WAI-ARIA combobox (role="combobox",aria-expanded,aria-activedescendant, listbox with windowed options), keyboard navigation, popover inportalContainer. - 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 whenfilterKeysrestrict the set), the Arabic-aware normaliser (NFKC, tashkeel strip, alef/yaa/taa-marbuta folding, digit mapping) applied to both query and storedlabel_norm, andIntl.Collator(locale, { sensitivity: 'base', ignorePunctuation: true, numeric: true })ordering (research). - Cascades:
filterKeysequality is pushed to the store (gov_code = ${governorate}),choiceFilterREL 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 auditvalueevent (props.keepStaleValue: truekeeps 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) powerspulldata()displays. - Dataset updates (
datasetUpdatedsync 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.patch → onChange. 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
FieldWrapperemits<label for>(or<fieldset><legend>for choice groups withrole="radiogroup"/group),aria-describedby= hint + error,aria-invalid,aria-required,autocompleteon identity-like fields (formatemail/phone),inputmodeper 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.minTouchtoken; 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 todefault.expr/calculateprefills. - RTL:
diron.rasd-rootfromsettings.localeMeta[locale].diror the built-in RTL list (ar,ckb,fa,ps,ur,he,sd,ug); logical CSS only; free-text inputsdir="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 renderdir="auto";typography.fontFamilyRtlapplied; digits followsettings.numberingand are normalised to ASCII on save (research). - Content safety: label/note markdown is rendered through DOMPurify with the allow-list in 16 · Security;
dangerouslySetInnerHTMLis 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/corestays directive-free so validation/types run in Server Components. Client components are still server-rendered: nowindow,navigator, IndexedDB or license reads during render — storage opens in an effect (storagefactory form),useSyncExternalStoresuppliesgetServerSnapshot(empty draft,evaluatinglicense, forcedlocale). - Pass
localeexplicitly for deterministicdir; the first page renders server-side frominitialData, then hydrates and resumes the draft in an effect (fallbackshows only if the definition itself is loading). Import CSS once:import '@rasd/react/styles.css'inapp/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, notnext-pwa(research, 11 · PWA). - react-native-web hosts (Expo web, RNW 0.21.x):
@rasd/reactnever importsreact-native; it exposesbrowser/defaultconditions and noreact-nativecondition, so Metro resolves the DOM build. Use@rasd/reacton web for native form semantics;.rasd-rootre-establishesbox-sizing, focus outlines anddirthat RNW resets.@rasd/nativevia 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 bydata-color-scheme|contrast|densityattributes. - Theme JSON →
--rasd-<group>-<key>custom properties on the provider root, applied through a constructed stylesheet (adoptedStyleSheets) or a nonced<style>(cssNonce) — never inlinestyleattributes, so strict CSP works;@rasd/themestoCss(theme)lets hosts precompile at build time. - Every part exposes
class="rasd-<Component>__<part>"anddata-scope="rasd" data-part="<part>", state asdata-*(data-invalid,data-required,data-readonly,data-relevant);classNames,stylesandrenderper 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 setsportalContainer.- Fonts (
typography.fontFamily*) are theme assets: self-hosted WOFF2 withunicode-rangesplitting, 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
| Failure | Behaviour |
|---|---|
| Custom element throws | Element 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() fails | Falls back to MemoryStorage only when persist={false}; otherwise error card + onError (never silently lose data). |
| Quota exceeded | Banner, retry, finalize disabled until a save succeeds. |
| Media permission denied / unavailable | Field-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: type | Placeholder (§5). |
| Form-level render crash | Boundary 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
-
useFieldre-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 onpagehide,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.
-
validateOnmatrix (§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-XBat 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-…', nounsafe-inline/unsafe-eval) — theme applies and no console violations. -
unstyledmode 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-virtualpeer — decide after measuring the 200-row fixture on the reference device. - Should the renderer offer a
data/onChangefully-controlled mode without an engine handle, or isenginesufficient? - Where does the "Edit finalized submission" affordance live once records/cases ship — renderer
modeor a separateRecordRenderer? - Do we ship a first-party
<InstallHint>/<SyncStatus>UI kit inside@rasd/reactor in@rasd/pwa? - Confirm
content-visibilitybehaviour on Android WebView ≥ 100 low-end profiles before relying on it beyond progressive enhancement.
Related documents
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