8000 melact/CHANGELOG.md at main · jongleb/melact · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
112 lines (104 loc) · 34.4 KB

CHANGELOG.md

File metadata and controls

112 lines (104 loc) · 34.4 KB

Changelog

Unreleased

  • Melact renders on a server without a DOM: melact-ppx -server with melact.server (Melange, in Node) or melact.server.native (native OCaml) writes byte for byte what React 18's renderToString and renderToStaticMarkup write, useId and 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.

0.1.0

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, ReactDOM and ReactDOMServer, ReasonReactRouter, ReasonReactErrorBoundary, React.Suspense, React 19's experimental hooks, ReactDOMTestUtils, and bindings to npm's react rewritten to Melact's own functions. An app moves over by changing two lines of dune.
  • Meltea (meltea, meltea-ppx): the Elm architecture in mlx, with a compiler that reads update and view to re-render only what a message changed, commands, subscriptions and transitions.

Before 0.1.0

How the five packages got here, step by step.

  • Milestone 1: melact.view (templates with holes, hole patcher, keyed reconcile, delegated events), melact.program sandbox, hand-written counter example, 26 vitest + jsdom tests.
  • Milestone 2: component instances and hooks (Hook keys, 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-ppx for Reason, OCaml [@JSX] and mlx: one template per JSX tree, hook keys per call site, [@react.component] and signatures; expect tests in test/ppx.
  • Runtime trimming: no Printf, Hashtbl or Map in 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/nextSibling walk 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_attr and 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, localStorage saved through jsonkit's [@@deriving json], checked by a model-based law over real DOM events. Meltea gains Sub.hash, Cmd.focus and Cmd.port; subscriptions are typed by their source. Controlled inputs: value and checked are 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 no lazy and no bounds checks, uses Js.null positions and a closed type of states; sandbox no longer pulls in Sub and Cmd; 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 one textContent = "". 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 to View.each, which reuses a row whose item is physically the same and whose outer variables (found by expanding local definitions) did not change, without calling row. Meltea is 1.09× vanilla JS on the keyed benchmark, Melact 1.10×, React 18 1.42×. React.element and Html.t are abstract, with %identity conversions for generated code.
  • Server rendering and hydration: Served.render writes HTML with markers around dynamic parts, Served.hydrate takes 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.hydrate in Meltea, ReactDOMServer.renderToString / ReactDOM.Client.hydrateRoot in Melact. The browser path only pays a small View.medium record: 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.plan finds 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 in melhole.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, and Typed.component (Compiled, Of_props). First use: a binding to npm's Fragment whose 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 the Immediate ones 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 update through helper lets and through a wrapper, and finds the masks of another module (open! over a fallback, with analysed == M.update checked once), so TodoMVC is tracked; the Rules of Hooks are checked by the type checker through a hook alert on every React hook, re-enabled by melact-ppx in 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.plan returns at once when one side is empty: clearing 1,000 rows went from 1.34× to 1.16× vanilla JS.
  • Meltea's compiler reads update and view for 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's React.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 old ReactEvent: every family, read straight from the native event. Delegated handlers see the element they were set on as currentTarget, 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 writes makeProps next to every [@react.component] (and its val), as a call to make with a key. ReasonReactRouter with 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 its dune file changed: compat/run.sh walks 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. Controlled value keeps the value attribute in step, as React does.
  • Transitions, as Vue's <Transition> and <TransitionGroup>: Html.transition ~name and Html.transition_group ~name in Meltea, over Melhole.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 ~duration or 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 key compiles to View.rows, wh 9FFC ose rows are one record each (Mounted_row: root, scope, holes and a Type.Id witness) with the refresh called directly; no child description, with_key or 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 --alloc samples 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 becomes None goes; 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 from currentTarget and 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 through Html.map, and checks every message.
  • No closures per item in the loop patch, and no Curry in the runtime's hot loops: compare_inputs, the browser's built, the projections at mount and the in-place path are hand-written loops instead of Array.for_all2, Array.init, Array.map and Array.mapi, whose callbacks Melange calls through Curry. In profiles of select and partial update Curry._2 had 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.Witness replaces Type.Id for templates: the stdlib module brought Obj.extension_constructor into 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): textContent when the element has no text node yet, then firstChild.data. No createTextNode, no insertBefore, 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, select or textarea and 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 ~duration a phase ends when the element's running animations finish (getAnimations(), every finished), 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 gets inert, 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.render writes what React's renderToString writes: no markers, <!-- --> only between two texts. Hydration runs code the compiler generates per template under the new -hydratable flag (as Svelte's hydratable), 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's renderToString, 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.capture is a script for the page's <head> that records clicks, typing, key presses and submits on the server's HTML, with each target's value and checked at 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 -server a view compiles to code that writes its HTML through melhole.server, plain OCaml that builds natively and under Melange, in the format of React's renderToString. meltea.server has Meltea's Html, Cmd, Sub and Program, so one module builds for the browser and for the server. examples/served is 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 checked attribute it had at mount, as React's does (defaultChecked on 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 key outside 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 json writes JSON into a <script type="application/json"> with every < escaped, and Served.data ~id reads it back, so a hydrating program starts from the server's model without running init again. A law sends text full of </script> and <!-- through a full HTML parse.
  • Fanout alerts in Meltea: meltea-ppx follows each frequent event (onInput, keys, pointer moves, scroll, drag) through the message it sends and the fields that message's branch of update writes to the lists of view that read them, through record patterns, model.field and lets; where they meet, an alert named fanout at 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 init decoded 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.
  • useId stays a client counter: Melact's hydration writes every attribute from the client render, so ids in id, htmlFor and aria-* agree on the page whatever format the server used.
  • Remembered values in Meltea views: a let at the top of view that calls functions on model fields (let shown = List.filter … model.items) compiles to View.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 moveBefore where the browser has it, so focus, media, iframes and animations inside a moved row survive; insertBefore otherwise. A law over random edits of both compiled loops checks that no row is inserted again while moveBefore exists.
  • Hot reload in Meltea: ~start:Program.hot takes the model of the program running on the root when the shape of the module's types (a digest meltea-ppx writes next to update) is the same, and starts from init otherwise; 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 inside document.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-ppx reads subscriptions as it reads view, 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.rows takes 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 react and react-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 for React.useReducer; useEffect, useMemo, useCallback and friends with a dependency array become their …1 forms. Bindings to anything else stay as they are. reason-react's demo now runs whole, the component that binds npm's useReducer included: 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 from Dom_props), ReactDOM.createElement tag ~props children, createDOMElementVariadic, jsx/jsxs, stringToComponent and React.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 has componentDidCatch: 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 the try around 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 own useEffect3 : (…, ('a, 'b, 'c)) => unit) pick React.useEffect3, an array picks useEffect1, unit picks useEffect0, 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 a Hooks module of bindings works from other files, useEffect8 with 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 (polymorphic useState, useEffect8), and an expect test pins the rewrite.
  • Dependency tuples longer than seven (useEffect8 : (…, ('a, …, 'h)) => unit) pick …1 and 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 a useMemo8 over 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) and componentLike, as in reason-react: a [@react.component] is make : props -> React.element with an abstract, unboxed props that makeProps builds (the element itself, with its key), and make is %identity, so JSX stays one call with no props object. Code typed against React.component now 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.provider and ReasonReactErrorBoundary take props objects as in reason-react; React.jsx, jsxs, jsxKeyed, jsxsKeyed and ReactDOM.jsxKeyed/jsxsKeyed are added; ReactDOM.domProps is the props object and stringToComponent gives a domProps React.component. A dual law drives a wrapper, a hand-written module, a memo and React.component under React 18 and Melact, and keyed rows built with React.jsxKeyed must match JSX. The benchmark and the bundle do not change. One break: a make written 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: useInsertionEffect4 to 7, useSyncExternalStoreWithServer (the client reads its own snapshot and hydration patches what differs), useDeferredValue with ~initialValue (Melact renders without interruption, so it is the value) and useTransitionAsync; npm bindings to useDeferredValue and to a three-argument useSyncExternalStore are rewritten too. A dual law checks them under React 18 and Melact; two mutants are caught. reason-react's useTransitionAsync cannot start a transition under React 18 itself: React's start function has length 2, so Melange's Curry._1 applies 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: change is input, focus is focusin, blur is focusout; ...WithEvent lends the event its fields, and its target.value or target.checked is written to the element), DOM.value and the findBy… helpers, prepareContainer, cleanupContainer, getContainer, isElement, isDOMComponent and isCompositeComponent (always false: no class components). isElementOfType raises, 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: createElement from "react" (children as arguments or a [@mel.variadic] array), jsx, jsxs and jsxDEV from "react/jsx-runtime" (with or without a key) and Fragment no longer reach npm's React when they return React.element: they become Melact_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.act also puts controlled inputs back at once, as React does, instead of two microtasks later (found by the ReactDOMTestUtils law).
  • 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 under open React and React.( … ), through a module of the file that includes React, and through -hook-modules for one in another file; a custom hook polymorphic in its value compiles, because a call site's key is hoisted as unit Hook.key and used through Hook.unchecked (the order of hooks is still checked); a [@react.component] external to a JavaScript React library's component is a clear compile error instead of a confusing one; dangerouslySetInnerHTML compiles whatever order the ppx run in, because the compiler reads __html through Melhole.View.html_of instead of writing ##; the generated props type is make_props, so a file's own type props no longer clashes; ReactDOM.Ref.t, currentDomRef and callbackDomRef exist; 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's Fragment with 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 without as), preloadModule, preinit (a script, or a stylesheet placed after the last one of its precedence) and preinitModule add 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. Every as value carries [@mel.as]: when only object did, as in reason-react's declaration, Melange turned every other value into undefined.
  • A file that starts with include React;, the usual shape of a module of hooks in reason-react code, has its hooks found as under open 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) and useContext; 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). useActionState is minimal: reason-react 0.17's binding types its action as returning nothing and gives a formAction no form accepts. usePromise and promise wait for Suspense. A new dual suite runs on React 19 (test/dual19, its own React in test/dual19/env, mapped by vite.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 to useOptimistic, useActionState and use of a context are rewritten too.
  • React.Suspense, with React 19's use of a promise (React.Experimental.usePromise) and React.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 try covers 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, whose Option, Array, List, String, Printf, Fun, Int and Float are 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 names Stdlib.Option, Stdlib.Array and Stdlib.List, so a project's own modules under those names no longer break it, and Stdlib.Array.map and Stdlib.List.map compile to keyed loops as Array.map and List.map do; onLoad is in the DOM prop table (<img onLoad>, a React.Event.Image), the one prop of reason-react's domProps that 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 React and could import the other, leaving hooks undefined with only a bundler warning. Every file melact-ppx compiles now calls React.reason_react_is_also_in_this_build_remove_it (), which only Melact's React has: 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 (--gap through ReactDOM.Style.unsafeAddProp) are written with setProperty and removed with removeProperty, as React does, instead of a style[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.null carries a TAG of its own, so Melange's equal and compare tell 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 with React.null both ways round; a mutant of each is caught. Reading props back 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 read event.target.value. jsdom's dispatchEvent runs 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 (change is delegated too), and a controlled element registers that delegation even without a handler. test/browser/typing.mjs types 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.
0