- Melact renders on a server without a DOM:
melact-ppx -serverwithmelact.server(Melange, in Node) ormelact.server.native(native OCaml) writes byte for byte what React 18'srenderToStringandrenderToStaticMarkupwrite,useIdand Suspense included, and Melact's client hydrates it with the same ids. A 1,000-row page takes 0.26 ms natively and 0.15 ms in Node, React 1.1 ms.
The first release.
- melhole: views compiled at build time to DOM templates with holes, each hole guarded by
===on the values it reads; keyed loops that re-render only the rows whose item changed; server rendering in React's HTML format, natively or in JavaScript, and hydration. - Melact (
melact,melact-ppx): reason-react 0.17's API without React: components and every hook, context,ReactDOMandReactDOMServer,ReasonReactRouter,ReasonReactErrorBoundary,React.Suspense, React 19's experimental hooks,ReactDOMTestUtils, and bindings to npm'sreactrewritten to Melact's own functions. An app moves over by changing two lines ofdune. - Meltea (
meltea,meltea-ppx): the Elm architecture in mlx, with a compiler that readsupdateandviewto re-render only what a message changed, commands, subscriptions and transitions.
How the five packages got here, step by step.
- Milestone 1:
melact.view(templates with holes, hole patcher, keyed reconcile, delegated events),melact.programsandbox, hand-written counter example, 26 vitest + jsdom tests. - Milestone 2: component instances and hooks (
Hookkeys,Component,Context, microtask flush, effects),React,ReactDOM.Client; the same test suite passes against reason-react with React 18 and against melact. - Milestone 3:
melact-ppxfor Reason, OCaml[@JSX]and mlx: one template per JSX tree, hook keys per call site,[@react.component]and signatures; expect tests intest/ppx. - Runtime trimming: no
Printf,HashtblorMapin the bundle; keyed lookup by sorted keys. The counter app went from 23.4 kB to 10.2 kB gzip. - A runtime made of parts: every kind of child carries its own mount and patch code, every hole its own
put_*, and the component scheduler is installed by the first component. Unused parts drop out of the bundle: the counter app is 5.7 kB gzip with Meltea and 7.5 kB with Melact. - Compiled walks: the compiler writes out the
firstChild/nextSiblingwalk to every hole and picks HTML or SVG parsing, so the runtime keeps no paths and no tag table. The counter app is 4.2 kB gzip with Meltea and 5.9 kB with Melact. - Direct writes: the compiled walk makes typed holes once per clone and the refresh writes them with
set_text,set_attrand friends; no binding table, no indices. The counter app is 4.0 kB gzip with Meltea and 5.7 kB with Melact. - TodoMVC in Meltea (
examples/todomvc): routing, editing,localStoragesaved through jsonkit's[@@deriving json], checked by a model-based law over real DOM events. Meltea gainsSub.hash,Cmd.focusandCmd.port; subscriptions are typed by their source. Controlled inputs:valueandcheckedare put back after input when the state did not take it, as in React, for both Meltea and Melact. - Benchmark and the first half of P0:
bench/measures the keyed js-framework-benchmark operations against vanilla JS and React 18. The runtime calls its own closures directly ([@u]), keeps nolazyand no bounds checks, usesJs.nullpositions and a closed type of states;sandboxno longer pulls inSubandCmd; handlers are guarded by the values they read; swapped ends of a keyed list are found without sorting, and a list that fills its parent is cleared with onetextContent = "". The counter is 3.0 kB gzip with Meltea and 5.6 kB with Melact. - Compiled keyed loops:
Html.list (List.map row xs)compiles toView.each, which reuses a row whose item is physically the same and whose outer variables (found by expanding local definitions) did not change, without callingrow. Meltea is 1.09× vanilla JS on the keyed benchmark, Melact 1.10×, React 18 1.42×.React.elementandHtml.tare abstract, with%identityconversions for generated code. - Server rendering and hydration:
Served.renderwrites HTML with markers around dynamic parts,Served.hydratetakes it over (blocks adopt their nodes, lists from the last item back, the compiled walk skips marked ranges) and falls back to a render on a mismatch.Program.render/~start:Program.hydratein Meltea,ReactDOMServer.renderToString/ReactDOM.Client.hydrateRootin Melact. The browser path only pays a smallView.mediumrecord: Meltea's counter grows by 65 bytes. - Less memory per row: a handler hole is one record with its property key computed once per event type, delegated handlers register no cleanup, and the keyed loop no longer keeps row descriptions (it marks rows as kept or fresh while patching);
Reconcile.planfinds old keys in a small open-addressing table instead of sorting them. 1,000 rows take 1.67 MB of heap in Meltea (was 2.04) and 1.74 MB in Melact (was 2.20). - Laws for the Vapor-like parts: the keyed loop under random edits (patch equals mount; with the order kept, exactly the rows whose item or projection changed are rendered; an outer change no projection sees writes nothing), pure components (renders and remounts exactly when inputs or identity change), and the reconciler on edit-shaped inputs. Nine mutants of these mechanisms are all caught.
- An optional typed pass in
meltea-ppx(staged_pps … -- -typed): it types the rewritten file with compiler-libs and warns where a view reads a model field that can change in place; the JavaScript of TodoMVC is the same with and without it. - The same typed pass for
melact-ppx, shared by both inmelhole.typed: the untyped rewrite leaves questions ([@melhole.ask n]) and sites ([@melhole.site n]) with the functions that decide them; after every rewriter the file is typed and the sites decided, and a file that does not type keeps the untyped output exactly. Queries:Mutability(Immediate,Immutable_boxed,Mutable_inside,Unknown), now also behind Meltea's alert, andTyped.component(Compiled,Of_props). First use: a binding to npm'sFragmentwhose type expands to a component of children becomes Melact's fragment. The dual suite and the reported apps compile to the same JavaScript with and without it; a probe types every variable argument of the dual suite and checks theImmediateones at run time; native laws check both queries on generated declarations; 33 mutants are caught. - Getting more out of the ppx: the field analysis reads
updatethrough helperlets and through a wrapper, and finds the masks of another module (open!over a fallback, withanalysed == M.updatechecked once), so TodoMVC is tracked; the Rules of Hooks are checked by the type checker through ahookalert on every React hook, re-enabled bymelact-ppxin branches, loops and callbacks. - Keyed loops patch in place when the keys keep their order (no plan, a position only for rows that changed), and
Reconcile.planreturns at once when one side is empty: clearing 1,000 rows went from 1.34× to 1.16× vanilla JS. - Meltea's compiler reads
updateandviewfor the model fields each message changes and the view reads; a message that changes nothing the view reads does not call the view. - Components without hooks compile to
View.pure, with no instance: creating 1,000 rows in Melact went from 1.45× to 1.04× vanilla JS. Keyed benchmark: Meltea 1.16×, Melact 1.17×, React 18 1.64×. - melhole knows no components: instances, hook slots, context and the scheduler moved to Melact's
melact.runtime, built on melhole's open child protocol. Melact'sReact.array (Array.map …)compiles to the same keyed loop and selector as Meltea's lists. - Compiled selectors, after Vue Vapor: the compiler splits a row into the pieces that read outer variables and re-renders only the rows whose pieces changed; selecting a row re-renders two rows.
React.Event, as reason-react 0.14+ declares it, replaces the oldReactEvent: every family, read straight from the native event. Delegated handlers see the element they were set on ascurrentTarget, as in React. One law sends random mouse and keyboard events that handlers prevent or stop, and React 18 and Melact must read the same values.React.createElement Foo.make (Foo.makeProps ~label ()): melact-ppx writesmakePropsnext to every[@react.component](and itsval), as a call tomakewith a key.ReasonReactRouterwith reason-react's API. Laws for both run against React 18 and Melact.- reason-seoul's TodoMVC (reason-react with hooks,
useReducer, a hand-written context provider,ReasonReactRouter) runs on Melact with only itsdunefile changed:compat/run.shwalks it randomly five times, 605 steps of clicks, typing, submits and route changes, and React 18 and Melact show the same DOM after every step. Controlledvaluekeeps thevalueattribute in step, as React does. - Transitions, as Vue's
<Transition>and<TransitionGroup>:Html.transition ~nameandHtml.transition_group ~namein Meltea, overMelhole.Transition. Enter and leave classes, the leaving element kept in place until its transition ends, moves slid with FLIP in a group, the time from~durationor the computed style. A hosted child, so apps that do not animate carry none of it (the counter stays 3873 bytes). Seven laws in jsdom with fake timers, and fifteen mutants, all caught. - Compiled rows: a keyed row that is one lowercase element with a
keycompiles toView.rows, wh 9FFC ose rows are one record each (Mounted_row: root, scope, holes and aType.Idwitness) with the refresh called directly; no child description,with_keyor mounted-child closures per row. Benchmark (Meltea, 1,000 rows): heap 1.68 → 1.40 MB, allocations while creating −34%; replace, partial update, select, clear and append are faster; creating from an empty list now meets a garbage collection inside its window, and its style and layout slow down with the marking, which is what the next steps on memory target.bench/run.mjs --allocsamples allocations per function. - Flat holes: compiled text, attribute and handler holes are the bare node, element and event key; the last written value lives in a slot of the scope's
deps(put_text,put_attr,put_handler,listen). A benchmark row loses its five hole records: 1,000 rows take 1.31 MB in Meltea (1.68 before compiled rows). Two new laws: a hole is written exactly when its value changes and an attribute that becomesNonegoes; an element's own listener for a non-bubbling event goes with it. - Linked handlers in Meltea, after Inferno's
linkEvent: an event prop compiles to one handler per site, hoisted to module level (View.link), and the element carries only the locals the message reads (View.put_linked); the handler reads them fromcurrentTargetand builds the message at the click. A benchmark row no longer allocates two closures. Creating 1,000 rows allocates 481 kB (939 before compiled rows), 1,000 rows take 1.23 MB, replace went from ×1.30 to ×1.14 vanilla and the geometric mean from 1.17 to 1.14. A law clicks inside buttons of a list that changes under it and throughHtml.map, and checks every message. - No closures per item in the loop patch, and no
Curryin the runtime's hot loops:compare_inputs, the browser'sbuilt, the projections at mount and the in-place path are hand-written loops instead ofArray.for_all2,Array.init,Array.mapandArray.mapi, whose callbacks Melange calls throughCurry. In profiles of select and partial updateCurry._2had been the largest single cost. - Fixed: a keyed loop's fallback and a content hole's plain compilation were built eagerly (
Option.value ~default:), so every list row was compiled twice and the unused templates and handler sites shipped in the bundle; a hoisted handler site that nothing used could also leave a weak type. Found by a new law: a compiled list holds exactly one listener of its own per row, and none once it stops. Melhole.WitnessreplacesType.Idfor templates: the stdlib module broughtObj.extension_constructorinto every Meltea bundle. The counter is 3,865 bytes again. Clearing a list skips rows with nothing to release.- A dynamic text that is the only child of its element is written through the element (ivi's trick):
textContentwhen the element has no text node yet, thenfirstChild.data. NocreateTextNode, noinsertBefore, no anchor in the walk, and no markers in server HTML: the element bounds the text. Creating 1,000 benchmark rows allocates 438 kB. The Markup laws model the new hole as a slot inserted as the element's only child. - A compiled list patched by a plan walks its rows back to front itself and makes a position only for a row it creates; a row that stays costs no allocation (swapping two of 1,000 rows used to make 1,000 positions).
- Delegated mouse events skip the handlers of a disabled
button,input,selectortextareaand go on bubbling, as React does: a click on text inside a disabled button no longer runs the button's handler, in Melact and in Meltea. A dual law clicks and presses inside a button that is disabled or not, under React 18 and Melact. - Transitions: without a
~durationa phase ends when the element's running animations finish (getAnimations(), everyfinished), which counts iterations and animations the computed style does not show; the computed style is the fallback where the Web Animations API is missing. An element that leaves getsinert, so it can no longer be clicked or focused. Laws: a row leaves exactly when the last of its stubbed animations finishes, at once with none, and is inert while it leaves. - Server HTML in React's format, and hydration in document order.
Served.renderwrites what React'srenderToStringwrites: no markers,<!-- -->only between two texts. Hydration runs code the compiler generates per template under the new-hydratableflag (as Svelte'shydratable), after the template's own refresh has computed the values on an off-page clone; hand-written templates hydrate through their hole paths. React's own HTML, and so server-reason-react's, hydrates in Melact: a dual law renders random pages with npm React'srenderToString, hydrates them and patches them through a series of changes. Adjacent static texts are now separate nodes, as in React. The walk no longer looks for markers, so the counter is 3,848 bytes; mismatches say what was expected and found. - Events before the code arrives:
Melhole_server.captureis a script for the page's<head>that records clicks, typing, key presses and submits on the server's HTML, with each target'svalueandcheckedat that moment; the first hydration delivers the events inside its root, in order, through the live event paths, with each target showing its value of the time. A TodoMVC law acts on the server's page before hydration and compares it with a mounted page that got the same actions. - Fixed: after hydration, a dynamic part followed by a neighbour remembered the neighbour's first node as its end instead of the anchor put back between them, so replacing the neighbour and then the part failed. Found by the dual law on React's HTML; the hydration law now also patches with edits of the first tree, which keeps its structure, and finds it at once.
- A server renderer without a DOM: under
meltea-ppx -servera view compiles to code that writes its HTML throughmelhole.server, plain OCaml that builds natively and under Melange, in the format of React'srenderToString.meltea.serverhas Meltea'sHtml,Cmd,SubandProgram, so one module builds for the browser and for the server.examples/servedis a native server and a client that hydrates its page. Laws: the server's HTML parses into the nodes the DOM renderer writes, hydrates with the server's own elements and patches like a mounted page; a native corpus matches the Melange build byte for byte; floats are written as JavaScript writes them. - Fixed: a static element inside a dynamic part of a Meltea view (
match ... with true -> <b>on</b> | ...) built its template again on every evaluation, so it was replaced on every patch instead of kept; its template is now hoisted once. - Fixed: hydration refused a
<textarea>with a value, whose server HTML holds the value as text. - A checkbox gets the
checkedattribute it had at mount, as React's does (defaultCheckedon the first write). A dual law toggles and remounts checkboxes under React 18 and Melact and compares the property and the attribute. - Fixed: a child with a
keyoutside a list kept its state when the key changed; it is now mounted again, as in React, which is how React code resets a component. - The model in the page:
Melhole_server.data ~id jsonwrites JSON into a<script type="application/json">with every<escaped, andServed.data ~idreads it back, so a hydrating program starts from the server's model without runninginitagain. A law sends text full of</script>and<!--through a full HTML parse. - Fanout alerts in Meltea:
meltea-ppxfollows each frequent event (onInput, keys, pointer moves, scroll, drag) through the message it sends and the fields that message's branch ofupdatewrites to the lists ofviewthat read them, through record patterns,model.fieldandlets; where they meet, an alert namedfanoutat the handler says that every such event checks all of the list's rows. Literal key branches ("Enter" -> Add) are single actions and do not count. The alert compiles to nothing. A native law checks the alerts against an independent model on random modules, with mutants for each step of the path. - Fixed: TodoMVC's
initdecoded the saved todos twice. - Pacing in Meltea: messages from pointer moves, scroll, wheel and drag are drawn once per animation frame, as in the Elm runtime; clicks and keys still draw at the next microtask and move a pending frame draw forward. A model-based law over microtask and frame boundaries counts view calls and checks the page.
useIdstays a client counter: Melact's hydration writes every attribute from the client render, so ids inid,htmlForandaria-*agree on the page whatever format the server used.- Remembered values in Meltea views: a
letat the top ofviewthat calls functions on model fields (let shown = List.filter … model.items) compiles toView.remember, keyed by the fields and locals it reads, so it returns the same object while they are the same and the holes that read it are skipped. A law counts its computations against the draws where its fields changed. - Keyed moves use
moveBeforewhere the browser has it, so focus, media, iframes and animations inside a moved row survive;insertBeforeotherwise. A law over random edits of both compiled loops checks that no row is inserted again whilemoveBeforeexists. - Hot reload in Meltea:
~start:Program.hottakes the model of the program running on the root when the shape of the module's types (a digestmeltea-ppxwrites next toupdate) is the same, and starts frominitotherwise; with Vite's HMR, TodoMVC shows an edit as the new view of the old state, checked in Chrome. A stopped program drops the messages that still reach it. A law covers both shapes and the old program's silence. Cmd.view_transition: an update asks for its draw to run insidedocument.startViewTransition, so the browser animates the page; a plain draw where the API is missing. A law with a stubbed API counts the transitions.- Subscriptions read by field:
meltea-ppxreadssubscriptionsas it readsview, and a program asks for them again only after a draw that changed a field they read; TodoMVC's are asked once. A law counts the calls. View.rowstakes the template, the slot count, one refresh that takes the item and the key function once per loop, so no row builds a description of itself; V8 had already been removing those short-lived records, so allocations stay at about 430 kB for 1,000 rows. Quiet benchmark: Meltea ×1.15, Melact ×1.17, React 18 ×1.70 against vanilla JS.- Figures redrawn as one system: every figure opens with a kicker, a headline that states its point and one line of context; a new benchmark figure shows time (the work and the overhead apart), bundle size, memory and the swap against vanilla JS and React 18; stale captions fixed (markers,
Served.capture, row parts). - A new Melact logo: frames that turn into a funnel around one live hole, in both themes; the frames sway and the hole glows.
- Bindings to npm's
reactandreact-dom(external useReducer : … = "useReducer" [@@mel.module "react"], also inside a module and used qualified): melact-ppx drops them and rewrites every use to Melact's function, with hook keys as forReact.useReducer;useEffect,useMemo,useCallbackand friends with a dependency array become their…1forms. Bindings to anything else stay as they are. reason-react's demo now runs whole, the component that binds npm'suseReducerincluded: 37 steps, identical DOM. A dual law runs such a component under React 18 and Melact. - DOM elements built at run time:
ReactDOM.domProps(every DOM prop as an optional label with reason-react's types, generated fromDom_props),ReactDOM.createElement tag ~props children,createDOMElementVariadic,jsx/jsxs,stringToComponentandReact.createElementVariadic. A patch with the same tag keeps the element and writes only the changed props. A dual law checks the page and the element identity against a model under React 18 and Melact; apps that do not use them do not carry the prop table. - An element that has had a style keeps
style=""when the style goes, as in React (found by the new dual law). ReasonReactErrorBoundary: errors thrown while rendering, in a state update or in an effect show the nearest boundary's fallback. The recovery follows React 18 pass by pass, because reason-react's boundary only hascomponentDidCatch: errors caught while rendering keep the one thrown last in the tree and the boundary's children go at commit; an error from an effect drops the children on the next pass, so a fallback that throws there wins over it; passive effects are flushed before a fallback renders; a fallback that throws goes to the boundary above. A dual law sows random fields of boundaries and bombs (render, layout and passive effects, own clicks, parent ticks, rows that mount later, fallbacks that throw directly or from a component) and checks React 18 and Melact against one model at every step; nine of eleven mutants are caught, the other two are unreachable branches. The boundary code is installed by the first boundary, so apps without one carry only thetryaround renders and effects: the benchmark bundle grows from 9.9 to 10.1 kB gzip and the timings do not move.- Bindings to npm's
react, two fixes from a report: dependencies typed as an n-tuple (reason-react's ownuseEffect3 : (…, ('a, 'b, 'c)) => unit) pickReact.useEffect3, an array picksuseEffect1,unitpicksuseEffect0, for every hook with dependencies; and a binding no longer disappears: it becomes a definition that calls Melact's hook with a key of its own (Hook.unchecked, so a polymorphic binding stays polymorphic), so aHooksmodule of bindings works from other files,useEffect8with an array of eight included. Uses in the same file still become direct calls with a key per call site. The npm law now uses a tuple binding and a bindings module in another file (polymorphicuseState,useEffect8), and an expect test pins the rewrite. - Dependency tuples longer than seven (
useEffect8 : (…, ('a, …, 'h)) => unit) pick…1and pass the tuple as the JavaScript array it already is (Hook.listed); uses in the same file call the binding's definition. The npm law adds auseMemo8over a tuple of eight from the bindings module. The error-boundary law found a rare case: two fallbacks throwing into one boundary in the same pass kept the one processed last instead of the one later in the tree, because a boundary with its children dropped has no nodes to compare; a boundary is now placed by its start anchor. React.component('props)andcomponentLike, as in reason-react: a[@react.component]ismake : props -> React.elementwith an abstract, unboxedpropsthatmakePropsbuilds (the element itself, with its key), andmakeis%identity, so JSX stays one call with no props object. Code typed againstReact.componentnow compiles and runs: wrappers (let bordered (inner : 'props React.component) = …),React.memo,React.component, and modules written by hand in reason-react's style ([@mel.obj] external makeProps,props##text).Fragment,StrictMode,Suspense,Context.providerandReasonReactErrorBoundarytake props objects as in reason-react;React.jsx,jsxs,jsxKeyed,jsxsKeyedandReactDOM.jsxKeyed/jsxsKeyedare added;ReactDOM.domPropsis the props object andstringToComponentgives adomProps React.component. A dual law drives a wrapper, a hand-written module, a memo andReact.componentunder React 18 and Melact, and keyed rows built withReact.jsxKeyedmust match JSX. The benchmark and the bundle do not change. One break: amakewritten by hand with labels and no[@react.component]is no longer a component in JSX (it never was in reason-react); mark it[@react.component].- Hooks that reason-react 0.17 declares and React 18 has:
useInsertionEffect4to7,useSyncExternalStoreWithServer(the client reads its own snapshot and hydration patches what differs),useDeferredValuewith~initialValue(Melact renders without interruption, so it is the value) anduseTransitionAsync; npm bindings touseDeferredValueand to a three-argumentuseSyncExternalStoreare rewritten too. A dual law checks them under React 18 and Melact; two mutants are caught. reason-react'suseTransitionAsynccannot start a transition under React 18 itself: React's start function haslength2, so Melange'sCurry._1applies it partially. ReactDOMTestUtils, as reason-react 0.17 has it:act,actAsync,Simulate(each sends the native event Melact's handler for the prop listens to:changeisinput,focusisfocusin,blurisfocusout;...WithEventlends the event its fields, and itstarget.valueortarget.checkedis written to the element),DOM.valueand thefindBy…helpers,prepareContainer,cleanupContainer,getContainer,isElement,isDOMComponentandisCompositeComponent(always false: no class components).isElementOfTyperaises, since an element keeps no component type. A dual law drives a page through them in random order under React 18 and Melact; two mutants are caught.- Bindings to npm React's element functions, from a user report:
createElementfrom"react"(children as arguments or a[@mel.variadic]array),jsx,jsxsandjsxDEVfrom"react/jsx-runtime"(with or without a key) andFragmentno longer reach npm's React when they returnReact.element: they becomeMelact_npm.created, where a string tag builds a DOM element from the plain props object and a component is called with its props and children, as React does. A binding that returns a type of its own is left alone, as a deliberate use of npm React. A dual law renders a page through every shape under React 18 and Melact; an expect test pins the report's binding.React.actalso puts controlled inputs back at once, as React does, instead of two microtasks later (found by theReactDOMTestUtilslaw). - Eight findings from a user, each now a test (
test/regress, built as the report builds: Reason,(pps melange.ppx melact-ppx …)): hooks are found underopen ReactandReact.( … ), through a module of the file that includes React, and through-hook-modulesfor one in another file; a custom hook polymorphic in its value compiles, because a call site's key is hoisted asunit Hook.keyand used throughHook.unchecked(the order of hooks is still checked); a[@react.component] externalto a JavaScript React library's component is a clear compile error instead of a confusing one;dangerouslySetInnerHTMLcompiles whatever order the ppx run in, because the compiler reads__htmlthroughMelhole.View.html_ofinstead of writing##; the generated props type ismake_props, so a file's owntype propsno longer clashes;ReactDOM.Ref.t,currentDomRefandcallbackDomRefexist; melact-ppx's npm rewrite is an instrumentation run before the other rewriters, not a whole-file preprocessor, so it sits next to another ppx that has one (server-reason-react.ppx); and a binding to npm'sFragmentwith a type of its own is left alone again. ReactDOM.Experimental's resource hints as React 19 does them on the client:preconnect,prefetchDNS,preload(nothing withoutas),preloadModule,preinit(a script, or a stylesheet placed after the last one of its precedence) andpreinitModuleadd their tag to the head once per resource. A law checks random sequences against a model of React 19's head; a mutant that ignores precedence groups is caught. Everyasvalue carries[@mel.as]: when onlyobjectdid, as in reason-react's declaration, Melange turned every other value intoundefined.- A file that starts with
include React;, the usual shape of a module of hooks in reason-react code, has its hooks found as underopen React, in components and in custom hooks alike (from the same user; a regression test builds such a file). - React 19's
React.Experimental:useTransitionAsync(pending while its async action runs),useOptimistic(an action's updates on top of the state) anduseContext; async actions are entangled as React 19 entangles them, so every action stays pending and every optimistic update stays until all running actions have settled (found with a probe of React 19).useActionStateis minimal: reason-react 0.17's binding types its action as returning nothing and gives aformActionno form accepts.usePromiseandpromisewait for Suspense. A new dual suite runs on React 19 (test/dual19, its own React intest/dual19/env, mapped byvite.config.js), and the rest stays on React 18; its law clicks and releases concurrent actions against a model, and a mutant without entanglement is caught. npm bindings touseOptimistic,useActionStateanduseof a context are rewritten too. React.Suspense, with React 19'suseof a promise (React.Experimental.usePromise) andReact.Experimental.promise: a component reading a pending promise suspends; the nearest boundary shows its fallback; content that has never been shown is dropped and built again, and content already shown stays in the DOM, hidden (display: none !important, emptied text) with its state, as React hides it; an update to a waiting boundary renders its children again, and the boundary waits on the newest promise. The boundary is installed by its first use, next to error boundaries, so apps without one do not carry it (the benchmark bundle grows by 0.2 kB raw, the timings do not move). A dual law on React 19 resolves, replaces and pre-resolves promises and clicks a counter inside the boundary; five mutants are caught.- Two fixes found on the way, for error boundaries too. An exception thrown inside JSX (the compiler evaluates hole expressions while the template is built or updated) is now caught like one thrown before it: the
trycovers a component's build and update as well as its render. And a render that throws halfway through its holes makes its template forget the values it last read (View.forget), so the next render evaluates every hole again instead of skipping the one whose guard had already recorded the new value. The error-boundary law gains bombs that throw from inside their JSX. - From the same user, each with a test in
test/shadowed(a build with-open Shadow, whoseOption,Array,List,String,Printf,Fun,IntandFloatare empty): a typed optional prop with a default (?(tone : Tone.t = A), Reason's~tone: Tone.t=A) compiles, because the alias melact-ppx adds goes inside the type constraint, so the default is typed by it; the code both ppx generate namesStdlib.Option,Stdlib.ArrayandStdlib.List, so a project's own modules under those names no longer break it, andStdlib.Array.mapandStdlib.List.mapcompile to keyed loops asArray.mapandList.mapdo;onLoadis in the DOM prop table (<img onLoad>, aReact.Event.Image), the one prop of reason-react'sdomPropsthat was missing. - A build that contains reason-react next to Melact (a library in the emit's closure that requires reason-react) used to compile against one
Reactand could import the other, leaving hooksundefinedwith only a bundler warning. Every file melact-ppx compiles now callsReact.reason_react_is_also_in_this_build_remove_it (), which only Melact'sReacthas: such a build fails to compile with that name, or, if only the JavaScript import went to reason-react's file, fails as the module loads with the same name. The README says how to fix it. - Three more from the same user. Custom CSS properties (
--gapthroughReactDOM.Style.unsafeAddProp) are written withsetPropertyand removed withremoveProperty, as React does, instead of astyle[name]assignment that ignores them; the server writes their names as they are, without turning capitals into dashes.children == React.null(structural equality) works:React.nullcarries aTAGof its own, so Melange'sequalandcomparetell it from any other element before they reach the functions an element holds. A dual law sets, changes and removes a custom property beside an ordinary one under React 18 and Melact, and another compares children withReact.nullboth ways round; a mutant of each is caught. Readingpropsback from an element cannot be supported without giving every element a props object; the README now says so and what to do instead. - From a user's real app: a controlled
<input>lost what was typed on a real keyboard, in Melact and Meltea alike. The value was put back by a listener on the element itself, two microtasks later; a trusted event runs a microtask checkpoint after every listener, so the restore ran before the delegated handler readevent.target.value. jsdom'sdispatchEventruns no microtasks between listeners, so no test saw it. Now, as in React, the delegated listener puts controlled values back after it has delivered the event (changeis delegated too), and a controlled element registers that delegation even without a handler.test/browser/typing.mjstypes into accepting, transforming and truncating controlled inputs of both libraries with real key events in headless Chrome; it fails on the old code with every field empty, passes now, and runs in CI.