Multi-framework architecture
What is framework-agnostic today, what is deliberately Angular, and the concrete recipe each adapter follows.
The split as it stands
Section titled “The split as it stands”@modyra/core — zero dependencies, runs in plain Node:
- Form engine: fields, sync/async/cross-field validation, submit + server
errors, drafts, undo/redo,
mutate()(coalesced history entries),getChanges()(MdyFormEngine). - Typed layer:
createForm(),field(),group(),array(), handle tree. - Validators (
required,min, …,crossField). - The reactive contract (
MdyReactivity:signal/computed/effect/untracked, plus optionalcapabilities/createScope/MdyReactiveScope, typed errors and structured diagnostics — see Writing a reactivity adapter) plusvanillaReactivity(), the reference implementation (realbatch()/flush()/observe()too). @modyra/core/testing—runReactivityContractTests, the conformance suite every adapter below is checked against.- Headless widget logic: calendar math and localized date parsing
(
date-utils), time parsing/formatting and clock geometry (time-utils), overlay positioning geometry (overlay-position), option filtering (options-utils), value serialization (serialize), dynamic-form config domain + runtime validation (dynamic-config, Contract v1 and v2 — see AI-generated forms), i18n message catalogs en/it/de/fr/es (i18n).
@modyra/widgets — headless widget controllers, framework-free:
Field/boolean/option field controllers and a select controller (listbox
navigation, active descendant, search-to-select) — state machines that
used to live only inside the Angular components, now shared by every
adapter that binds @modyra/widgets (React, Vue, Solid, Preact, Svelte
all do; see headless recipes for shadcn/Radix/
Kobalte prop-mappers over them).
@modyra/angular — the Angular adapter (this repo’s packages/angular):
angularReactivity()— binds the contract to native Angular signals; hardened (realcapabilities, typed error instead of a silent no-op effect without anInjector,onErrorrespected) — see the reactivity adapter guide.- Thin typed wrappers (
MdyDeclarativeAdapter,mdyForm()). - DI plumbing: tokens (
MDY_I18N_MESSAGES,MDY_DATE_LOCALE, …),provideModyraLocale(). - The renderer catalog, wizard, devtools, dynamic-form component and all directives. These are intentionally Angular: templates, content projection, signal inputs, host bindings. A renderer is Layer 3 — it is supposed to be framework-native.
- Datepicker and timepicker interaction state machines (grid focus movement, segment editing) — still Angular-only; see “not extracted yet” below.
So: yes, the renderer catalog is Angular-centric — by design. Field-level
interaction logic (select, boolean, text) is not: it moved to
@modyra/widgets once the other adapters needed it too.
What each adapter implements
Section titled “What each adapter implements”An adapter provides two things:
- A reactivity binding (or uses
vanillaReactivity()): theMdyReactivitycontract, nothing framework-shaped about its shape — real implementations exist for Angular, Vue and Solid signals today. - Bindings/hooks that connect DOM inputs to field handles
(
value(),set(),markAsTouched(),errors()), reusing@modyra/widgets’ headless controllers for composite widgets (select, checkbox/switch) instead of reimplementing their state machines per framework.
React (@modyra/react — shipped)
Section titled “React (@modyra/react — shipped)”No native signals: the engine runs on vanillaReactivity() and
components subscribe through useSyncExternalStore. createFieldStore()
resolves the reactivity that actually created a field handle via
getFieldHandleOwner() instead of building an unrelated one — building a
fresh vanillaReactivity() just to observe someone else’s signals is the
cross-runtime bug the reactivity adapter guide
warns against (found and fixed in this package during the
reactivity-adapter-api plan):
// packages/react/src/index.ts, simplifiedexport function createFieldStore(handle: MdyFieldHandle<unknown>) { return createStore( [handle.value, handle.errors, handle.touched, handle.dirty, handle.valid, handle.pending, handle.disabled], getFieldHandleOwner(handle), // the handle's REAL owner, not a fresh instance );}useMdyForm(schema, options) constructs with autoActivate: false and
activates/deactivates from its own useEffect — safe under React Strict
Mode’s dev-only double-invoke and during SSR (see
Typed forms).
Headless field/select controllers come from @modyra/widgets.
Preact (@modyra/preact — shipped)
Section titled “Preact (@modyra/preact — shipped)”A thin variant of the React adapter: same vanillaReactivity() +
getFieldHandleOwner() + autoActivate: false pattern, useSyncExternalStore
via preact/compat (no getServerSnapshot third argument — Preact’s
signature differs from React’s here, the one real API gap between them).
Vue (@modyra/vue — shipped, full capabilities)
Section titled “Vue (@modyra/vue — shipped, full capabilities)”Vue’s reactivity maps 1:1 onto the contract — the binding wraps
shallowRef/computed, and (since Phase P2, 2026-07-23) a real
scheduler and Vue’s own effectScope():
export function vueReactivity(): MdyReactivity & /* Batching/Flush/Observe */ { return { kind: "vue", capabilities: { effects: true, effectOwnership: true, signalEquality: true, batching: true, deterministicFlush: true, directObservation: true, /* … */ }, signal: (v, opts) => { const r = shallowRef(v); /* opts.equal gates the write, not just Vue's own same-value check */ }, computed: (fn) => { const c = computed(fn); return () => c.value; }, effect: (fn) => { const runner = vueEffect(() => fn(...), { scheduler: () => scheduleEffect(runner) }); /* scheduler queues into a shared microtask-drained pending set — same design as vanillaReactivity()'s own Milestone 3 */ }, createScope: (opts) => /* wraps effectScope() — native nesting/cascade-on-dispose */, batch, flush, observe, // all real, built on the same scheduler untracked: (fn) => { pauseTracking(); try { return fn(); } finally { resetTracking(); } }, };}Components use createVueForm(schema) (or useVueForm inside an active
effect scope, for automatic disposal) and bind with v-model-style
wrappers over the handles. Headless field/select controllers come from
@modyra/widgets. One real behavior change worth knowing: adding a
scheduler means effect() (and anything built on it, like the widget
layer’s internal state-sync) is now microtask-batched instead of always
synchronous — bringing Vue in line with vanilla/React/Preact/Svelte/Lit’s
own timing model, not a regression (see any test awaiting a tick after a
dispatch()/set() for why). computedEquality stays honestly false:
@vue/reactivity’s own computed() has no public custom-comparator hook
the way Angular’s native one does, and reimplementing computed by hand
around a raw effect would stop being native Vue reactivity.
Solid (@modyra/solid — shipped, full capabilities)
Section titled “Solid (@modyra/solid — shipped, full capabilities)”Solid’s primitives map almost 1:1: createSignal → signal, createMemo
→ computed, untrack → untracked. The one gap is a manually-destroyable
effect handle — the engine calls effect(...).destroy() imperatively
outside any component (async validators/drafts/history), so each
effect() wraps createEffect in its own createRoot. Testing/SSR note:
solid-js’s plain Node import condition resolves to a non-reactive SSR
stub, so any Node consumer (node --test, ts-node, a server-rendered
handler, and now scripts/reactivity-capability-matrix.mjs itself) needs
--conditions=browser or every signal silently goes inert. Headless
field/select controllers come from @modyra/widgets.
Since Phase P3 (2026-07-23), this adapter declares full capabilities —
uniquely among all seven, computedEquality: true (verified directly:
createMemo’s own equals comparator stops staleness from propagating
to downstream consumers, a stronger guarantee than vanilla’s or Vue’s).
batch() is a thin wrap over Solid’s own batch() — no custom scheduler
needed, since a batch of writes (including a chained cascade where one
effect’s write triggers another) settles synchronously by the time
Solid’s batch() returns; this was verified empirically before writing
any code, not assumed. flush() is Promise.resolve().then(() => {}) —
Solid’s own scheduler settles even a multi-hop chain within one microtask
(also verified directly). createScope() is an explicit parent/child
tree over Solid’s own disposal roots (createRoot/runWithOwner): unlike
Vue’s effectScope(), createRoot() has no “nest under the currently
active scope” primitive, so cascade-on-destroy is managed the same way
vanillaReactivity()’s own scope is — bookkeeping, not a Solid feature.
Svelte (@modyra/svelte — shipped)
Section titled “Svelte (@modyra/svelte — shipped)”Svelte 5 runes ($state/$derived) are compiler macros meant to be
transformed by the consumer’s bundler, not something a library’s own
build step can resolve — confirmed by trying: the compiled output still
contained literal $state(...) calls. So this adapter runs the engine on
vanillaReactivity(), same shape as React, and toStore() bridges it to
a real Svelte Readable store (svelte/store’s writable/derived/get
run as plain, uncompiled JavaScript — no compiler needed). One caveat:
toStore()-backed state is microtask-batched, unlike Svelte’s own
synchronous writable() — await a tick after a write before asserting.
examples/svelte compiles through esbuild-svelte — the same “keep the
example pipeline on esbuild” call as Solid’s esbuild-plugin-solid, no
separate Vite toolchain needed.
Lit (@modyra/lit — shipped)
Section titled “Lit (@modyra/lit — shipped)”Run on vanillaReactivity(); a ReactiveController subscribes an element
to the handles it renders:
class FieldController implements ReactiveController { hostConnected() { this.ref = rx.effect(() => { this.handle.errors(); this.host.requestUpdate(); }); } hostDisconnected() { this.ref.destroy(); }}Because Lit ships web components, this adapter is also the path to using
Modyra controls inside any framework — including Angular-free pages.
Ships its own themable custom elements (<mdy-*-field>), not just a
headless binding.
Astro (recipe, not a new binding)
Section titled “Astro (recipe, not a new binding)”Astro has no client reactivity of its own: the engine already works
server-side (no DOM, storage inert), so createForm() can render initial
markup in .astro frontmatter; interactive islands then use whichever
adapter matches the island’s framework (React/Vue/Lit/Preact/Solid/Svelte
above).
What is NOT extracted yet (honest list)
Section titled “What is NOT extracted yet (honest list)”- Datepicker and timepicker interaction state machines (grid focus
movement, segment editing) still live inside the Angular components —
unlike select/boolean/text, which moved to
@modyra/widgets. Same “the math is already in core” situation@modyra/core/keyboard/date-utils/time-utilsdescribe; the remaining work is the stateful navigation logic itself. - Overlay orchestration: positioning geometry is core; the listener/lifecycle management is per-framework by nature.
- A11y announcer, devtools UI, Angular’s renderer catalog: framework-native forever, by design (Layer 3).
Reactivity contract migration— done, all 7 adapters, 2026-07-23 (ROADMAP.md’s Phase P): React/Preact/Svelte/Lit via a named export forwarding vanilla’s own, already-active capabilities; Vue and Solid with genuine native ones (real schedulers/native batching,createScope()viaeffectScope()/an explicit disposal-root tree respectively). Only Angular’screateScope()and per-adapter migration to child scopes for array rows remain, tracked in “Later / watchlist”. Full detail: the generated capability matrix.
Package policy
Section titled “Package policy”@modyra/vue, @modyra/react, @modyra/solid, @modyra/preact,
@modyra/svelte and @modyra/lit are shipped, tested engine bindings
published on npm at 0.4.0 — reactivity adapter + typed form factory
(+ hooks/controller/composable),
plus headless field/select controllers via @modyra/widgets. They do not
ship a themed UI component library the way @modyra/angular’s renderer
catalog does (Lit ships its own custom elements, which is closer to a UI
kit than the others); see headless recipes for
pairing them with shadcn/Radix/Kobalte/your own design system.