BitCode adalah platform low-code/ERP. Form dan view-nya membutuhkan reaktivitas yang tidak ada di component library biasa (MUI/Mantine/shadcn), tapi standar di form engine & low-code (Filament, Formily, Ant Design Form, JsonForms, SurveyJS, Retool/Appsmith). Kasus nyata:
- Cascading data: provinsi -> kabupaten -> kecamatan -> desa. Pilih provinsi A, kabupaten re-query by A. Clear provinsi -> semua dependant ikut clear (rekursif).
- Conditional validation: field
tipe_identitas= NPWP ->nomorwajib 16 digit; KTP -> 16 digit; SIM -> pola lain. - Conditional UI: pilih nilai tertentu -> section/tombol/field tampil/sembunyi, aktif/nonaktif, wajib/opsional.
- Repeater: array field dinamis (baris invoice), add/remove/reorder, dengan
reaksi se-baris (
qty * harga = subtotal) dan agregasi ke induk (sum(subtotal)). - Cross-component (opt-in): filter bar -> DataTable re-query; pilih baris table -> form detail terisi; date range -> chart update; selection -> tombol aktif.
- Tight coupling: field tidak boleh saling kenal (BcSelect provinsi tak boleh tahu BcSelect kabupaten). Itu mimpi buruk maintenance.
- Global singleton state: merusak SSR (lihat temuan H2) + merusak standalone (dua form/scope di satu halaman saling ganggu).
- Platform lock-in: engine tak boleh React-specific (akan dipakai Vue, Svelte, Solid, Angular, RN, Lynx via Mitosis; Flutter via track terpisah).
eval(): reaksi bisa berasal dari JSON BitCode (semi-untrusted) -> haram.
core/types.ts->FetchParams.dependValues(temuan M2: dirancang untuk cascading, belum di-wire). Akan dipakai engine ini.packages/expr-js-> port TypeScript penuh dari expr-lang (v1.17.8), sedang dikerjakan. Menjadi default expression evaluator untuk JS targets.packages/go-json-> expr-lang di Go (backend). Frontend (expr-js) dan backend (go-json) berbagi bahasa ekspresi yang sama -> parity frontend<->backend.
- Satu primitif:
ReactiveStore(logika murni, framework-agnostic). Semua reaktivitas (form, repeater, cross-component) dibangun di atasnya. - Reaksi deklaratif dengan 4 jenis efek: DATA, VALIDATION, UI, VALUE.
- Evaluator interface dengan default impl = fungsi JS (zero-dep, unopinionated).
expr-jsadalah opsional plug-in untuk kasus preview/offline; bukan default. - BitCode JSON feed: ekspresi dievaluasi server-side oleh go-json (sumber kebenaran tunggal). Frontend terima hasil. Tidak ada evaluator duplikat.
- Scope resolution
$self/$parent/$rootuntuk repeater & nested. - Konsumen berlapis & opt-in: Level 0 controlled -> Level 1 BcForm -> Level 2 BcReactiveScope -> Level 3 BitCode JSON. Standalone di tiap level.
- Per-instance, bukan singleton (SSR-safe, multi-instance aman).
- Headless (pola TanStack): engine tak meng-render; komponen tetap dumb.
Keputusan rinci di bagian berikut.
Satu primitif logika murni. Bukan React hooks, bukan DOM. Modul TS biasa yang bisa dikonsumsi Mitosis (web/RN/Lynx) dan di-port ke Dart (Flutter).
- Menyimpan cells bernama (state addressable):
values,metaper field. - Mengelola reactions (graph dependency).
- Mengevaluasi expression via
Evaluatoryang di-inject. - Meng-emit perubahan ke subscriber (komponen mengikat lewat ini).
interface ReactiveStore {
get(path: string): unknown;
set(path: string, value: unknown): void; // memicu reaksi
getMeta(path: string): FieldMeta; // visible/disabled/required/options/rules/error
subscribe(listener: (change) => void): () => void;
unsubscribe(listener: (change) => void): void; // symmetric removal (BC-024)
evaluate(expr: string, scope?: Scope): unknown;
run(): void; // recompute reaksi terdampak
snapshot(): Record<string, unknown>;
dispose(): void; // teardown (no leak)
}
interface FieldMeta {
visible: boolean; disabled: boolean; required: boolean;
options?: unknown[]; rules?: ValidationRule[]; error?: string;
loading?: boolean;
}
- Per-instance: tiap BcForm/Scope = satu store. Tidak ada singleton global. (Pelajaran temuan H2: singleton tidak SSR-safe.)
- Headless: store tak meng-render apa pun (pola TanStack). Komponen dumb yang
membaca
get/getMetadan memanggilset. - Sinkron + async: efek UI/VALUE/VALIDATION sinkron; efek DATA (re-query)
async dengan state
loading. - Deterministic recompute: perubahan -> tandai reaksi terdampak -> evaluasi
topologis -> emit sekali per batch (hindari cascade berulang/
glitch).
Reaksi deklaratif. Field tidak saling kenal; store yang menghubungkan.
interface Reaction {
when: string | string[]; // field pemicu (path)
target: string; // field/elemen terdampak
effect: DataEffect | ValidationEffect | UiEffect | ValueEffect;
condition?: string; // expr opsional; reaksi jalan jika true
}
{ when:'provinsi', target:'kabupaten',
data:{ source:{ type:'model', name:'kabupaten',
filters:{ provinsi_id:'$provinsi' } },
clearOnChange:true } }
- Saat
provinsiberubah: re-resolvesourcekabupaten (pakaidependValues, temuan M2).clearOnChange-> kosongkan target + cascade ke dependant-nya.
{ when:'tipe_identitas', target:'nomor',
validation:{ map:{ NPWP:{length:16}, KTP:{length:16}, SIM:{pattern:'...'} } } }
{ when:'punya_npwp', target:'section_npwp',
ui:{ visible:'$punya_npwp == true' } }
- Efek UI:
visible,disabled,required(ekspresi boolean).
{ when:['qty','harga'], target:'subtotal',
value:{ expr:'$qty * $harga' } }
clearOnChange/clear pada source -> store menelusuri graph dependant secara rekursif dan mengosongkan semuanya (provinsi -> kabupaten -> kecamatan -> desa).- Deteksi siklus -> error saat build graph (bukan saat runtime).
Default cara menyatakan reaksi adalah fungsi JS biasa:
reactions: [
{ when: (s) => s.tipe_identitas, target: 'nomor',
validation: (s) => ({ length: s.tipe_identitas === 'NPWP' ? 16 : 16 }) },
{ when: (s) => s.punya_npwp, target: 'section_npwp',
ui: { visible: (s) => s.punya_npwp === true } },
{ when: (s) => [s.qty, s.harga], target: 'subtotal',
value: (s) => s.qty * s.harga },
]
- Zero-dep, unopinionated, semua developer paham.
@bitcode/uitidak perlu evaluator eksternal.- Komponen tetap standalone.
interface Evaluator {
evaluate(expr: string, scope: Record<string, unknown>): unknown;
compile?(expr: string): CompiledExpr; // opsional: cache AST
}
- Plug-in opsional, bukan default. Untuk kasus di mana reaksi harus berupa string (bukan fungsi): JSON-driven, persistable, parity lintas-runtime.
- Implementasi opsional:
expr-js(expr-lang subset), evaluator AST minimal kustom, atau bawaan sendiri. No-eval()tetap wajib bila pakai string.
- Ekspresi dari JSON BitCode dievaluasi server-side oleh
go-json(expr-lang authoritative di Go). - Frontend terima hasil; tidak perlu evaluator duplikat di UI.
- Keuntungan: satu evaluator (go-json) = tidak ada risiko drift frontend<->backend.
- expr-js dipakai di frontend hanya untuk:
- Live preview ekspresi di form builder (tanpa round-trip ke server).
- Validasi offline saat mode offline BitCode (Phase 3-5 ADR).
- Kedua kasus ini tidak urgent di Phase A/B; dapat ditunda.
- Reaksi (fungsi JS atau string) = pure (no side-effect di evaluator). Side-effect (re-query) ditangani store (efek DATA), bukan ekspresi.
- Evaluator (kalau string-based) tidak boleh akses
window/fs/process.
Ekspresi merujuk field via path. Dalam konteks bersarang (repeater), resolusi:
$field/$self.field-> field pada scope saat ini (mis. baris repeater).$parent.field-> scope induk (form pembungkus).$root.field-> scope teratas (form akar).$index-> indeks baris (dalam repeater).
Padanan Filament Repeater / Formily ArrayField / RHF useFieldArray.
- Tiap baris = nested ReactiveStore dengan scope sendiri + tautan ke induk.
- Operasi:
add,remove,move/reorder,duplicate. - Reaksi se-baris:
{ when:['qty','harga'], target:'subtotal', value:{ expr:'$qty * $harga' } }. - Agregasi ke induk: field induk
{ value:{ expr:'sum($self.lines, .subtotal)' } }. - Cascade clear & validation tetap berlaku per-scope.
<BcRepeater name="lines" reactions={[...]}>
{(row) => (<>
<BcNumber name="qty" />
<BcNumber name="harga" />
<BcNumber name="subtotal" readonly />
</>)}
</BcRepeater>
BcRepeatermembuat nested store per baris; field di dalamnya tetap dumb.
Reaktivitas opinionated, tapi opt-in & additive. Komponen tetap unopinionated. Berhenti di level mana pun -> tetap jalan.
Level 0 — Controlled (DEFAULT, paling unopinionated)
<BcInput value={v} onChange={..} /> pure React/Vue, tanpa engine.
Konsumen yang cuma mau "field bagus" berhenti di sini.
Level 1 — BcForm (opt-in form reactivity)
Engine di dalam BcForm: cascading, conditional validation, visibility,
cascade-clear. Dipakai cara React/Vue normal.
Level 2 — BcReactiveScope (opt-in cross-component)
Field <-> DataTable <-> Chart. Dibungkus eksplisit.
Tanpa scope -> komponen tetap controlled biasa.
Level 3 — BitCode JSON (platform feed)
Engine yang sama, di-drive definisi JSON BitCode.
- Standalone: Level 0 tak menyentuh engine/evaluator sama sekali. expr-js hanya ter-load bila Level 1+ dipakai (lazy/opt-in import).
- Unopinionated (komponen): komponen tak pernah mewajibkan store/Redux/Zustand. Engine hidup di container (BcForm/Scope), bukan di komponen.
- Easy + integrable: pola headless (TanStack-like). React/Vite/Vue/RN: import komponen, opsional bungkus BcForm/Scope. BitCode: feed JSON.
- Cross-platform: store + evaluator = logika murni -> ikut ter-port ke Flutter; rendering per-target.
<BcReactiveScope>
<BcInput name="q" /> // set scope.q
<BcDataTable source={{type:'model', name:'contact',
filters:{ name:'$q' }}} /> // re-query saat q berubah
</BcReactiveScope>
- Bahaya yang dihindari: TIDAK ada global store. Scope = ReactiveStore per-instance.
- ReactiveStore + reaksi DATA/VALIDATION/UI/VALUE (single scope) dengan fungsi JS sebagai evaluator default.
- BcForm konsumen: cascading (provinsi->...->desa), conditional validation (tipe_identitas), visibility/disabled/required.
- AC: demo alamat berjenjang + dokumen kondisional jalan; cascade-clear benar.
- BcRepeater + nested store;
$self/$parent/$root/$index. - Reaksi se-baris + agregasi ke induk.
- AC: invoice lines (qty*harga=subtotal, total=sum) jalan; add/remove/reorder mempertahankan reaksi.
- BcReactiveScope; field<->table<->chart.
- AC: filter->table re-query; selection->tombol enable; date-range->chart.
- Mapping definisi JSON BitCode -> reaksi engine.
- Ekspresi dievaluasi server-side oleh go-json; frontend terima hasil.
- AC: satu form ERP nyata ter-drive penuh dari JSON.
- Integrasi
expr-jssebagai evaluator plug-in untuk live preview di form builder dan validasi offline. - AC: form builder live-preview tanpa round-trip; mode offline dapat validasi client-side.
- ReactiveStore per-instance, headless, disposable, SSR-safe.
- 4 efek + cascade-clear + scope resolution teruji.
- Level 0 tetap standalone (tanpa evaluator eksternal).
- BitCode JSON dievaluasi go-json server-side (single source of truth).
- Jalan di target Tier A; contract Flutter (Tier C) terdefinisi.
- BitCode jadi platform low-code sungguhan (reaktif), bukan sekadar UI kit.
- Satu primitif untuk form/repeater/cross-component -> konsisten, mudah dirawat.
- Single source of truth untuk ekspresi = go-json. Tidak ada evaluator duplikat, tidak ada risiko drift frontend<->backend.
@bitcode/uibenar-benar unopinionated: reaksi = fungsi JS, zero-dep.
- Engine + scope resolution + graph recompute = infrastruktur baru.
- Repeater nested scope adalah bagian tersulit (path resolution).
- Round-trip ke server untuk ekspresi BitCode JSON -> butuh koneksi, tapi mitigasi: Phase E (expr-js offline) bila offline mode dibutuhkan.
- Latensi round-trip ekspresi BitCode JSON -> mitigasi: caching hasil, debounce, batch evaluation.
- Over-engineering (meniru Formily/MobX penuh) -> mitigasi: batasi ke dependency map + recompute + fungsi JS; bukan observable graph penuh.
- Cross-component (Level 2/3) beririsan dengan rombak engine BitCode -> jaga primitif tetap netral agar engine bisa men-drive via JSON nanti.
All acceptance criteria met as of 2026-06-21:
- ReactiveStore runtime tests pass: 25+ unit tests in
store.test.ts(subscribe/unsubscribe/dispose/cascade/clear), 32 runtime tests inreactive.rtl.test.tsx(mount, onChange firing via waitFor, unsubscribe-on-unmount no-leak, reactions, cascading, UI effects). - Cross-component reactive scenarios verified: 5 E2E scenario tests in Playwright (
bc-024-reactive-scope.spec.ts+phase-b1-reactivity.spec.ts): onChange firing, cleanup no-leak, cascade (qty→subtotal→tax), conditional visibility toggle, validation rules change. All pass in real Chromium. - Dual storage pattern documented: Section "Subscription Cleanup" below documents the useStore + useRef pattern for deterministic cleanup, applied to BcReactiveScope, BcForm, AppProvider.
- Symmetric unsubscribe method:
ReactiveStore.unsubscribe(listener)added alongsidesubscribe(listener) => disposer, mirroring DOM EventTarget add/removeEventListener pattern.
Mitosis useStore compiles to React useState. When a component stores the
disposer returned by store.subscribe() in useStore state and reads it in
onUnMount (which compiles to useEffect cleanup with deps: []), the
cleanup closure captures the initial value from the first render (null),
not the updated value after setScopeOff(off) in the mount effect. The
unsubscribe is never invoked, leaking the listener — especially dangerous
for externally-owned stores where dispose() is not called on unmount.
Components that subscribe to a ReactiveStore must use two storage slots:
useReffor the cleanup path —useRef.currentis a mutable container that is always current regardless of closure capture. The mount effect writes the listener (and store, if internally created) to refs; the unmount cleanup reads from refs.useStorefor the render path — state that the render function reads (e.g.state.reactiveStoreforhandleSetto callstore.set()) stays inuseStoreso React re-renders when it changes.
ReactiveStore now exposes unsubscribe(listener) alongside the existing
subscribe(listener) => () => void disposer API. This mirrors the DOM
EventTarget add/removeEventListener pattern and lets components that store
the listener function (stable reference) clean up deterministically,
without relying on the disposer closure being current.
Both APIs are supported. The disposer return-value pattern remains for
backward compatibility and one-shot subscriptions; the symmetric
unsubscribe(listener) is preferred for component lifecycle cleanup.
BcReactiveScope—changeListenerRef(useRef) +store.unsubscribe.BcForm—reactiveListenerRef+reactiveStoreRef(both useRef) +store.unsubscribe+store.dispose. (Before the fix,state.reactiveStorewas also stale in cleanup, sodispose()never ran — the listener genuinely leaked, not just theoretically.)AppProvider—disposersRef(useRef array) holds all disposers; cleanup iterates the ref.
Regular .ts files (stores, types, utilities) under src/ are mirrored to
each Mitosis output target's src/ directory by scripts/sync-regular-ts.mjs
(run as a build step). Without this, compiled components that import
ReactiveStore would resolve to stale artifacts in output/ and silently
use old source — the unsubscribe method added to store.ts was not visible
to BcForm.tsx until this sync step was added.
- ADR-002 (3-layer, SSR/H2), ADR-003 (BcForm P0, taksonomi).
packages/expr-js(port TS expr-lang, plug-in opsional),packages/go-json(expr-lang di Go, authoritative).- Prior art: Filament, Formily, Ant Design Form, JsonForms, SurveyJS, react-hook-form, Retool/Appsmith (reactive graph), TanStack (headless pattern).