Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Versioning is lockstep across all distribution channels (npm, GitHub Release, ESP-IDF Component Registry, PlatformIO) — a single version drives every artifact. See Releasing for the release process.
Unreleased
Changed
- Flow B: a state-driven
&&or ternary builds the branch it picks and destroys it when another is picked, so a package needs nodes for its largest screen rather than all of them.visible={…}keeps a branch built and toggles it.
0.20.0 - 2026-11-05
Added
- One Flow B package for every panel size. The compiler finds the layouts from the app's breakpoints, proves every
size has one, and the panel's resolution picks one at boot. A project names no sizes, and
--screenis now optional: it only drops layouts a product doesn't need. - Flow B: a state's initial value derived from
screenis worked out at boot, and so iswidth={screen.width}on an<Svg viewBox>, which then scales. A component can return a ternary onscreen. npm run aottakes--screen.- The Flow B runtime in
runtime/: it verifies a package, then runs it in a fixed arena, with no QuickJS and no heap. embedded-react build --aotwrites a Flow B package and prints the RAM, nodes, and flash it needs. It takes state, handlers, timers, effects, lists, animations, PanResponder,<Svg>,setKeyboardConfig, and values worked out from the screen size, so the thermostat demo builds as a package.- A package carries a debug table, so the error screen names the source line (
App.jsx:41).--releaseleaves it out, writing it beside the package asapp.erdbg, and--bakewrites the package as a C array (app.erpkg.c) for boards with no spare partition. npm run erb:frameschecks packages against a golden image of each scenario, and CI runs it.npm run erb:frames:recordrecords the images.- One red error screen for both flows, drawn by the engine without the app's scene. It shows the reason, the source line, and the amounts involved. An uncaught JS exception in Flow A now shows it too.
- A native fault or watchdog reset can be recorded in no-init RAM and shown after the reset
(
er_fault_record,er_fault_show_recorded). The RP2040 and ESP32-S3 examples do this. - CI fuzzes the Flow B loader, verifier, and VM with mutated packages, under ASan and UBSan.
er_set_screen_boundslimits the scene to the panel. Flow B sets it from the panel it loads.- Flow B boards keep two package slots in flash, active and staging. The last good package stays until a new one passes verification, and a package that fails falls back to it. With none installed, the first boot shows the error screen.
embedded-react flashwrites a package to the board's staging slot, andembedded-react packwrites a slot's image to a file.- The CYD and RP2040 examples load their UI from a slot, so changing it needs no firmware rebuild. The ESP32-S3
example has a Flow B build beside its Flow A one, and
-DER_BOARD_CRASH_TESTforces a fault or a hang on the RP2040 and that build. - On-device hot reload for Flow B:
embedded-react dev --device --aotsends a package to the staging slot on every save. One that fails verification leaves the running app as it was and shows the reason. The ESP32-S3, CYD, and RP2040 examples take it when built withER_HOTRELOAD. - The Flow B runtime is on every firmware install channel:
embedded-react-runtimeon the ESP-IDF Component Registry, the PlatformIO library, and the GitHub Release tarball, now namedembedded-react-vX.Y.Z-src.tar.gz.
Changed
- Breaking: abbreviated public names are spelled out. In C that covers functions, struct fields, constants and
ERUI_*build flags:ERRect::wis nowwidth,er_anim_*iser_animation_*,ERB_ERR_*isERB_ERROR_*andERUI_SCRATCH_WisERUI_SCRATCH_WIDTH, for example. In JS, the keyboard'sgridColsis nowgridColumns, and the SVGfillGradandstrokeGradprops arefillGradientandstrokeGradient. - Breaking: the npm packages need Node 22.18 or later. Node 18 and 20 are past their end of life.
- Flow B refuses two image imports that share a file name, which used to show one image for both.
- A container whose asset packs fail to load, or don't fit the image and font registries, now fails
with
ER_CONTAINER_BAD_ASSETSinstead of running with missing images and fonts. That includes an image format this firmware doesn't know, from a newer emitter, and RGB565 pixels on an odd address. er_assets_load_packand friends now check the whole pack first and register nothing when it fails.- Flow A node handles carry a generation, so one kept after its node is destroyed does nothing instead of reaching the next node to use its slot. Handles are no longer small integers.
npm run aotand theexamples/linux-aotdesktop host now use a package, and--packageis gone.
Removed
- Breaking (Flow B): the generated C output:
embedded-react build --aot --c,app.gen.c,compileSourceandnpm run aot:same. Use a package, in a flash slot or linked with--bake.
Fixed
- The engine's ESP-IDF component publishes as
embedded-react, the name the install command uses. It was going out asengine. - The engine compiles on ESP-IDF and PlatformIO without setting
ERUI_MAX_VECTOR_NODES. - The on-screen keyboard's done key now calls
onSubmitEditing. It only took the focus away, so a touch-only board could never submit. - Flow B applies the stale-handle rule to a row built before a destroyed node, which used to go last, and to an animation of a destroyed node, which didn't stop a debug package.
- A node sized far past the panel no longer stalls a Flow B frame painting off-screen.
- NaN and out-of-range floats in percentages, dials, animated colors, and vectors no longer reach a float-to-int conversion.
- The Flow B verifier rejects a branch to just before the start of a function. It passed, and the VM read outside the code.
0.19.0 - 2026-10-27
Changed
- The npm package moved from
bridges/quickjs/jstopackages/embedded-react. - The container reader,
er_assetsander_hotreloadmoved to a newruntime/library that doesn't link QuickJS.er_hotreload_applyis now declared iner_runtime.h.
Fixed
- On 32-bit targets, a corrupt section length in a container could pass the bounds check.
0.18.0 - 2026-10-26
Added
opacityonTextandImage, static and animated, in both flows.TextInputtakes an animatedopacitytoo.
Changed
onLayoutnow reportsx/yrelative to the parent instead of the screen, in both flows, and the rect is also ate.nativeEvent.layout, as in React Native. A hidden node reports an empty box at 0,0.- AOT: reading
e.layoutoutside anonLayouthandler is now a compile error.
Fixed
- Animated
backgroundColorandcolornow work, throughinterpolatewith anoutputRangeof colors. A value bound to a color directly warns in Flow A and is a compile error in Flow B. - A prop that stops being animated now repaints at its static value.
- A static
opacity: 0now hides aTextInputorModalinstead of drawing it opaque. - With the fade cache on, a node fading inside a faded parent now updates instead of freezing.
- Paint props on
<Svg>now apply to its shapes in both flows, like a<G>around them all. Flow B no longer refuses them. - Named colors now resolve the same in Flow A styles, SVG paint, and Flow B: one shared list with CSS
values (
greenis#008000), matched in any case. - Types:
index.d.tsnow matches the runtime forStyleSheet,Animated,EasingandflexBasispercentages. - AOT:
useEffect(fn)with no dependency list is now a compile error instead of running only once. - Creating a node past
ERUI_MAX_NODESnow logs a line to stderr instead of failing silently, and unmounting a render that overflowed the pool frees all of its nodes. - AOT: Flow B's fixed limits no longer drop data silently. An initial value or constant string too big for its buffer is a compile error, a node pool smaller than the app fails the C build, and a full list, a string cut short, or a full timer table logs a line to stderr.
- AOT: a multi-line
<Text>keeps the space beside a{value}({count} itemsno longer renders5items). - AOT: a constant
==or!=now folds with JS's loose equality (undefined == nullis true) rather than strict equality (===/!==). - AOT: a name on
Object.prototype(toString,constructor, …) used as an enum style value, a named color, or an undeclared identifier is now a compile error instead of invalid C or a silent wrong value. - AOT: a
setKeyboardConfiglayout with no layers, or a layer with no rows, is now a located compile error instead of C that fails to build. - AOT: a list append resets the fields the new item leaves out to 0 or
""instead of keeping a cleared item's values. - AOT: an empty slot in a list append or an
updateVectordirty rect ([...items, , item]) is now a located compile error instead of a compiler crash. - AOT: a component's prop default can now use a module constant (
function Box({ size = SIZE })). - A view or
ImagewithaspectRatioand only a height (or only a width in a column) now takes its other size from the ratio instead of from its content, and keeps its ratio when it flexes or is stretched. - A translated view now moves its children with it, and a
Pressableinside one can be pressed where it is drawn. - With
ERUI_TRANSFORMS=FULL, a rotated, scaled, or 3D-transformed node no longer leaves a trail when it moves after a commit whose only change was off-screen (a node outside the screen, or scrolled out of view, changing). That commit painted nothing, but recorded the node at its untransformed box, so the next move erased that box instead of the transformed area its pixels cover. - A translated, rotated, or scaled node inside an
overflow: 'hidden'container no longer leaves a trail when it moves after a frame in which an opaque view painted over it repainted only its own content (a pill sliding along a clipping bar under a panel whose child changes every frame). That frame recorded the node at its untransformed box, so the next move erased that box instead of the node's pixels. A container holding a rotated, scaled, or 3D-transformed node is now always painted under an opaque cover rather than skipped, as one without clipping already was. - A
<Text>with no height is as tall as the lines it wraps into at the width it gets, at mostnumberOfLines. It used to be laid out one line tall, so everything after its first line was cut off, andnumberOfLinesreserved that many lines even for a one-line label. As in React Native, a child is measured no wider than its parent's content box, so a long Text in a row wraps at the row's edge instead of running past it; aflex: 1Text wraps at the width it flexes to, and the row grows to hold it; a shrink-wrapped Text is as wide as its widest line. - An
<Image>withborderRadiusor per-corner radii clips its bitmap to the rounded corners, with the same anti-aliased edge a background with those radii has. It used to draw square corners, so a rounded cover image, orborderRadiusin an<ImageBackground>'simageStyle, had no effect. Flow B accepts the radius keys on<Image>; it used to reject them with anAOT:error. - Bold text, and a bold or letter-spaced span inside a
<Text>, wraps at the width it is drawn with. A bold title slightly wider than its box used to be broken as one line at the regular advance and drawn past the box edge, cut off, instead of wrapping onto a second line; its centered or right-aligned lines sat a pixel per glyph off. - In Flow A (QuickJS),
<Pressable disabled>now withholds its press event handlers (onPress,onLongPress,onPressIn,onPressOut) so the engine skips the node during touch hit-testing, matching React Native and Flow B behavior. - An
.svggradient coordinate that does not parse (x1="abc") now takes its SVG default instead of being drawn as 0, so a badx2no longer collapses the gradient to a flat color. - Flow B: an
<Image>with a constant source and a state-driven style (or an<Svg>drawn from a raster fallback with one) now shows its image.app_update()sets the node's props from defaults and left the constant image name out. - Flow B: a
.start(onComplete)on a spring or decay beside a longer delay (Animated.parallel([Animated.spring(b, {toValue: 1}), Animated.delay(500)])) compiles and calls its callback once both the spring and the delay have ended, whichever is last, as Flow A does. Interrupting or stopping the spring calls it at once withfinished: false. It was anAOT:error since 0.16.0. - Flow B: an
Animatedtime that folds toNaNorInfinity, is negative, or is past the int range (Animated.timing(a, {toValue: 1, duration: 0 / 0}), adelay,Animated.delay(ms), theAnimated.stagger(ms, …)interval) fails with anAOT:error that names it and points at it, as do the times of a composition, or of a counted loop's passes, that add up past the int range. It used to be written into the generated C as it was (duration_ms = NaN;), which does not build. - Flow B: a screen size or buffer cap that isn't a whole number in range (
ER_AOT_LIST_CAP=-1,ER_AOT_LIST_STR_CAP=1.5, a--screendimension past 32767) fails with anAOT:error that names the option or variable. Screen dimensions andlistCap/listStrCaptake 1–32767,maxTextSpans1–255. They used to be written into the generated C as they were (s_items[-1]), which does not build, and a variable that didn't parse silently took its default; only a blank one does now.
0.17.0 - 2026-10-15
Added
- Flow B: values derived from
screen.width/screen.heightare worked out at boot from the real panel instead of folded at compile time, so one build runs on every panel that picks the same layout. Module constants may useMath.min,max,floor,ceil,round,truncandabs, and ascreenbranch whose sides differ only in values becomes one tree whose values are picked at boot. - Flow B:
er_app_fits(w, h)reports whether a panel picks the layout the app was compiled for.
Fixed
- The CYD (no-PSRAM ESP32) example boot-looped on
assert failed: vApplicationGetIdleTaskMemorysince 0.14.0: the engine's static RAM had grown until FreeRTOS could not allocate its first tasks. The example now sizes its node pool to the thermostat's 41 nodes plus one spare.
Changed
- Breaking (Flow B):
er_app_build()returnsbool. On a panel that picks another layout than the one the app was compiled for, it builds nothing and returnsfalse. The board examples check it and log how to regenerate, instead of_Static_asserting the exact panel size. - Flow B: a state change now re-sets only the nodes and drawings that read that state, instead of every state-driven node. On the thermostat, a setpoint step re-sets 2 nodes instead of 19, which halves the handler's cost on the ESP32-S3.
0.16.0 - 2026-10-14
Changed
-
Breaking (Flow B): every element declares the props, style keys, and children it lowers, and anything else is an
AOT:error at the prop: an unknown prop, a style key the engine doesn't draw on that element (opacityon a<Text>), text outside a<Text>, or children on an<Image>. Props that were accepted and ignored (<TextInput autoFocus>,<Modal transparent>, a disabled<Switch>) are refused too. All of these used to compile and do nothing on the device. -
The Flow B compiler is now a module per stage, written in TypeScript. The generated C and the npm package are unchanged; running it from a repo checkout needs Node 22.18 or later.
-
Rendering uses far less stack, with identical pixels. Each level of the node tree costs 288 B instead of 456 B on the RP2040, and 400 B instead of 1,488 B on the ESP32-S3. Drawing a Text costs 848 B instead of 3,192 B. Touch hit-testing drops its per-level child array as well.
-
Anti-aliased text draws faster: 2- and 4-bit fonts look up each coverage level from a small per-glyph table, which replaces four divides per pixel.
-
A repaint that touches part of a scaled or tinted image (or of an RGB565 image drawn without a
copy_rect_fmtbackend) converts only the repainted part instead of the whole image. A small repaint over a 1880×880 bilinear-scaled image took about 20 ms on a desktop host, and now takes under 1 ms.
Added
compileSourcetakes the screen size and the buffer caps as options (screen,listCap,listStrCap,maxTextSpans), so one process can compile for several boards. One left out still falls back to itsER_AOT_*variable, now read on each compile rather than at import.
Fixed
-
The RP2040 watch example gives its stack an explicit 8 KB with a guard below it. The watch face needs about 3.1 KB, past the SDK's 2 KB default, and a deeper app now reboots the board instead of silently overwriting the framebuffer.
-
Flow B lowers
<Text numberOfLines ellipsizeMode>,<TextInput onSubmitEditing onFocus onBlur>,<Pressable disabled>,<ActivityIndicator style={{color}}>, a percentage<Svg width>, fragment children, nested ternaries (a ? <A/> : b ? <B/> : <C/>) and the gradients of a constant<Svg>shape. Each used to be dropped without an error. -
In Flow B, a
<Switch>,<Dial>,<TextInput>,<ActivityIndicator>or<Modal>under a state-driven&&or ternary, or in a row of a state list, shows only while it is chosen. It used to stay visible. -
A Flow B
<Svg source>with a style width or height bakes its drawing at that size, as Flow A does. It used to bake at the attribute's size and draw it clipped into the styled box. -
In Flow B, a later style size replaces an earlier one in the other unit:
[{width: '50%'}, {width: 30}]is 30 pixels. Both used to be written, and the percentage won. -
A Flow B transform entry beside an animated one, an animated
rotateX, and a fifth<Dial>gradient stop fail with anAOT:error. They used to be dropped. -
A Flow B
Math.<name>ore.layout.<name>that names a built-in object property (Math.toString, say) fails with anAOT:error. It used to compile to invalid C. -
In Flow B, a
.start(onComplete)on an animation that moves nothing (an emptysequence,parallelorstagger, or only delays) calls its callback once the delays have passed, as Flow A does. It used to never call it. -
In Flow B, a
.start(onComplete)waits out a trailing delay before calling its callback, as Flow A does. It used to call it as soon as the last animation finished. A callback on a spring or decay beside a longer delay is now anAOT:error. -
A Flow B
updateVectorshape with too few numbers (arc: [10, 20]) or an empty slot ([1, , 3, 4, 5]) fails with anAOT:error. It used to writeundefinedinto the generated C, or crash the compiler. -
A Flow B list state item holding
NaNorInfinity(useState([{v: 0 / 0}])) fails with anAOT:error. It used to start at 0, or writeInfinityinto the generated C. A field an item leaves out still starts at 0. -
A Flow B gradient on a state-driven
<Svg>shape with a value that folds toNaN,Infinityor a non-number (fillGrad={{type: 2, ax: 0 / 0, …}},ax: 'oops') fails with anAOT:error. It used to be computed at runtime, or written into the generated C as an invalid cast. -
In Flow B, the parameter of a
setX(prev => …)updater keeps the state's boolean type, soprev === 1is false for a boolean, as in JS. It used to compare the boolean as the number 1. -
In Flow B, a whole number past the int range that isn't a
Date.now()time behaves as a JS number: it converts to float and divides by any number. It used to fail with "a 64-bit time value" errors. -
In Flow B, a
useStateoruseRefthat starts at a whole number past the int range starts 64-bit (past 64 bits, a float). It used to write the value into anint. -
An auto-height row with
flexWrap: 'wrap'grows to hold every wrapped line. It used to reserve a single line's height, so the lines below painted over the siblings that follow it.
0.15.0 - 2026-09-27
Changed
-
The starter that
npm create embedded-reactscaffolds is redesigned and responsive: it lays itself out from the panel size, so the same file fits a 240×240 watch face and an 800×480 panel. -
The documentation site opens in dark mode for new visitors, whatever their system theme. The toggle still switches to light, and the site remembers that choice.
Added
-
The browser simulator's host API gains
er_web_clear_persist(), which forgets the stateusePersistentStatekeeps across hot reloads, so a host can offer a true reset. -
A documentation site under
website/(Docusaurus), deployed to https://embedded-react.dev by GitHub Pages on every push tomaster. It republishes this changelog and the roadmap from the repo-root files. -
The READMEs are now short pointers to the site, which is the one source of truth; the material they held (the engine internals, the bridge's external-RAM notes, the boards' WiFi and backend detail, the demo write-ups) moved there.
Fixed
- The documentation site's menu opens on phones in dark mode. It had been clipped to the height of the top bar, so tapping the menu button seemed to do nothing.
0.14.1 - 2026-09-21
Added
- Flow B compiles
Animated.looparound anAnimated.sequence, such as a pulse that grows and shrinks, as well as counted loops (iterations) and looped springs. An animation can be kept in aconstand stopped later withanim.stop(), which the TypeScript types now declare as always present.
Fixed
-
The default starter now builds with
build --aotand without font-size warnings. Flow B rejected its looped logo pulse, and its title and button text used sizes that aren't baked (17 and 15, now 20 and 16). -
In Flow B, a sequence now stops when one of its steps is interrupted, as it does in the simulator. It used to carry on, and could leave two animations driving the same value.
-
The CYD example README's quick start closes its first code block before the orientation note, so the note renders as text, and the copy button grabs only the shell commands.
0.14.0 - 2026-09-17
Added
-
The ESP32-S3 example has a Wi-Fi build (
sdkconfig.defaults.wifi). The thermostat's settings get a Wi-Fi page (scan, pick a network, type its password on the on-screen keyboard) and a time-zone picker, both saved on the board, and its clock sets itself over NTP. Wi-Fi is a build of its own because it needs internal RAM the board is short of, which it takes from the main task's stack headroom. The saved settings are encrypted; the first boot writes the key into the chip's eFuses, which is permanent. -
<TextInput secureTextEntry>draws a dot for each character typed, in both flows; Flow B used to accept the prop and show the text anyway. The thermostat's Wi-Fi password field uses it, with a SHOW button. -
ErRuntimeConfig.install_host_globalsgives a host's own JS globals to every context the app runs in, including the fresh one afterer_runtime_reset, before any app code runs. -
Date.now()andperformance.now()now compile in Flow B, with the same surface as Flow A's lite profile. The generated C holds them as 64-bit whole milliseconds, so divide them by a constant with%orMath.floor/ceil/round/trunc(a / b); a host sets the real time wither_app_set_wall_clock(). The engine clock is also exposed aser_now_ms64(), which does not wrap at 49.7 days. -
The thermostat demo has a clock: the time and date in its header, set from the settings sheet. It runs on
Date.now(), so it needs no RTC, but it has to be set again after a power cycle. -
The frame instrumentation now splits the JS phase the way it already split raster: dispatch, reconcile, prop/tape marshaling, and the commit the pump drove, with
JSS/PKJoverlay lines. Turning "JS is 40 ms" into "React is 30 of it" is what the bridge-performance work needs to be ranked by measurement instead of by reading the code.
Changed
-
A list no longer repaints on scroll steps that move it by less than a whole pixel, such as the slow end of a fling, since nothing on screen changes.
-
A Modal with an opaque
backdropColorno longer redraws the screen it hides each time something in it changes, such as a list scrolling inside a settings sheet. -
The ESP32-S3 example keeps the QuickJS bridge's tables in PSRAM, which frees 22 KB of internal RAM at no measurable cost per frame, and compiles in the on-screen keyboard, so a
TextInputworks on its touch panel. Its JS stack guard is now three quarters of the main task stack, so changing the stack size no longer means changing the guard either. -
CI now runs the QuickJS runtime tests, from source and as bytecode, against both of the bridge job's Linux builds (the native and the bare-metal allocator); they had only ever run locally.
ER_BRIDGE_BUILD_DIRpoints the test runners at a bridge build other thanbridges/quickjs/build. -
Building a board example against an AOT app generated for a different board is now a compiler error instead of firmware that boots and lays out wrong. All three examples consume the same
dist/app.gen.c, and a responsive demo folds its layout to the screen size at generate time, soapp.gen.hnow records what it was generated for and each board asserts it. The desktop AOT host also sizes its window from that, instead of defaulting to 800x600 whatever the app expects. -
One
ERUI_DIAGNOSTICSknob replacesERUI_VECTOR_DIAGNOSTICS,ERUI_IMAGE_DIAGNOSTICSandERUI_VECTOR_STORE_WARN. A target that must not link<stdio.h>can now say so once:0drops every warning,1keeps the ones invisible on a real panel,2is everything. Defaults are unchanged. -
Constants that two places must agree on now live in one place: the shared engine pool sizes, the degrees/radians conversion the bridge and the engine apply in opposite directions, the CRC-32 the container and the hot-reload frame share, the spring timestep the two animation systems integrate at, and the bridge's vector staging caps — which now derive from the engine's and
_Static_assertthe ordering. Also named the unexplained tuning values around them (arc bbox sampling, keyboard row height, stroke tessellation, elevation shadows). -
Flow A now renders and paints a frame once, however many callbacks changed state in it. Timers, promise continuations and native events each used to run their own render and engine commit, so N animations on a frame cost N of both; they now share one. Measured on an ESP32-S3, each additional thing updating on a frame now costs 9.9 ms instead of 16.6 ms.
-
<Svg>no longer reparses path data it has already seen. An<Svg>recompiles its whole op-tape whenever any part of it changes, so a moving needle re-ran the SVGdparser over every static shape beside it; parsed paths (and their transformed geometry) are now cached bydstring. Measured on an ESP32-S3, re-rendering a 14-shape gauge dropped from 100 ms to 47 ms of JS per frame. Output is unchanged. -
Applying props is 1.2-2.4x faster. The bridge was re-hashing, reparsing, and re-comparing the same style on every commit: colors are now parsed once per string, enum tokens mapped once per value, and the change-detection hash over the ~1 KB prop bag reads a word at a time instead of a byte. Measured on an ESP32-S3, a
setPropscarrying threergba()colors dropped from 114 us to 48 us and an unchanged one from 54 us to 38 us. Uploading an<Svg>op-tape is ~1.2x faster too, or ~13x on a build with the typed-array intrinsic, where the tape can be passed as aFloat32Array.
Fixed
-
er_scroll_view_set_offsetgiven NaN now leaves that axis where it is. It used to store the NaN, which the renderer and touch handling then converted to an integer, which is undefined behavior. -
<TextInput editable={false}>no longer accepts typing, in either flow. The engine was turningeditable0 back into 1. -
In Flow A,
<Modal backdropColor>now works as a prop, as it already did in Flow B. Flow A read it only from the style and silently dropped the prop, so the thermostat's settings sheet had the default see-through dim instead of its solid backdrop. -
Dragging a scrollable list no longer presses the row the drag started on when you let go. Once the ScrollView (or a pan) takes the gesture, the row gets its press-out and no press, as in React Native.
-
A container that takes the gesture on touch-down with
onStartShouldSetResponderCaptureno longer lets the<Pressable>under the finger fireonPresson release. A container that claims without capturing still leaves the press alone, as in React Native. -
A node that a touch handler unmounts now drops out of that touch. A
<Dial>removed from itsonTouchStartoronResponderGrantno longer starts a drag, and a node mounted while the finger is still down, even by that same handler, no longer picks up the rest of the touch. -
A touch handler that replaces the touched node or one of its ancestors no longer hands the touch to the node that took its place. The touch still bubbles up to the original ancestors that are left, even when the touched node unmounted itself, and the new node can't claim the gesture.
-
Unmounting the focused
<TextInput>now drops the focus with it. A node mounted right after could receive the typing meant for the old input, and the on-screen keyboard could stay drawn with nothing focused. -
er_reset()now ends every node of the old scene. A handler that resets the scene in the middle of a touch no longer has the rest of that touch reach the old nodes, ander_node_in_use_count()reports 0 afterwards instead of the old scene's count. -
On boards that render on more than one core, reloading from a scene with an
<Svg>,<Dial>or shadow into one without no longer leaves rendering on a single core for the rest of the session. -
Beside a timestamp, whole-number math inside
Math.floor,ceil,round,trunc,abs,minandmax, and%, is now worked out in 64 bits in Flow B, as JS would. It was cut to 32 bits first, soDate.now() + Math.trunc(n * 100000 / 2)saturated.Math.abs,minandmaxof ints also stay whole numbers now: they went through a float, soMath.max(n, 0)printed 1000000 as "1e+06". -
Flow B text now prints a float that is Infinity, -Infinity, NaN, or -0 the way JS does: "Infinity", "-Infinity", "NaN" and "0". It printed C's "inf", "-inf", "nan" and "-0", as after a division by zero.
-
Math.floor,Math.ceilandMath.roundof an int divided by an int are now exact in Flow B. They went through a float, which rounds a number past 2^24, soMath.floor(16777217 / 1)gave 16777216.Math.trunc, which Flow B did not support, now compiles too. -
A Flow B list whose float field holds a whole number (
0,2) now compiles. The generated C wrote it as0f, which no C compiler accepts. -
A Flow B whole-number constant past 2^53 now reaches the generated C exactly. It was written the way JS prints it, which past 2^53 is a different number.
-
A Flow B float constant past about 3.4e38 is now a compile error naming it, as NaN and Infinity already were, and one too small for a float is written as 0. Both reached the C as literals the compiler warns about. A whole number from 1e21 up in a float field is no longer written as invalid C.
-
%on a float now compiles in Flow B, as JS's remainder; it used to emit a C%on a float, which no C compiler accepts.%=on a float ref works the same way. -
An
updateVectordirty rect with a NaN edge is now dropped in both flows, leaving the engine's own damage (what changed in the tape, or the whole node), and a huge one is bounded at its edges and clipped at its corners, so it still covers what it says instead of wrapping or stopping short; the engine'ser_node_set_vector_dirty_rect()does this for every caller. Flow A cast the rect to int unguarded, and Flow B turned a NaN edge into a zero-size hint that repainted nothing. An<Svg>whose changed geometry runs that far out repaints the whole node, where the damage rect the engine works out itself wrapped, or past the int range hit an undefined cast. -
An
<Svg>arc swept through a huge or infinite angle no longer hangs the engine, and neither does a<Dial>with a huge start angle. A turn or more draws the whole circle, as a canvas arc does, even from a huge start angle or between ends too far apart to subtract, and a NaN or infinite angle draws nothing. A huge stroke width or radius, or a path point past the int range, no longer hits an undefined cast in the rasterizer. -
An
<Svg>update that changes a gradient and moves a shape at once now repaints the whole node. Only the moved shape was repainted, so a shape filled with the changed gradient kept its old colors. -
A transform with a huge, lopsided, or infinite scale or rotation, or a 3D corner near the camera plane, no longer reaches an undefined float-to-int cast in the engine. Its coordinates clamp, and a matrix whose determinant or inverse overflows or goes NaN counts as singular, so the view paints untransformed, as it already did for a scale of 0. A view whose transform or shadow reaches far past the screen no longer leaves a trail when it moves or shrinks back.
-
A NaN opacity, transform or shadow offset (a 0/0 in app math, say) now has a defined result instead of undefined behavior in C: the view is fully transparent, the transform component is ignored, and the shadow is not shifted. Flow B already made a NaN opacity transparent; Flow A and Animated now agree.
-
A Flow B app that sets state nothing on screen reads now compiles. Its handlers called an
app_update()that was never generated, which every C compiler rejects. -
A Flow B list setter's
items.slice(0, n)now keeps what JS keeps, with a negativencounting back from the end. It used to leave the list's count negative, so the next append wrote outside the array.items.slice(0)no longer crashes the compiler, and a slice that does not start at 0 is now a compiler error instead of being treated as one that does. -
Integer division and float-to-int conversion in Flow B no longer hit undefined behavior in the generated C. A zero divisor gives JS's answer kept whole (
x % 0is 0,x / 0saturates), and a float that is NaN or out of range becomes 0 or the nearest int.Math.roundnow rounds halves up as JS does, and a state-driven opacity clamps and rounds as Flow A does. -
Whole-number
+,-and*in Flow B no longer overflow into undefined behavior in the generated C. A result too big for its C type now saturates at the type's limit, and constant math is worked out at compile time, so30 * DAY_MSstays exact. -
An AOT app whose timers are only cleared from a mount effect's cleanup no longer carries an unused
er_timer_clear(), which warned under-Wall. That cleanup never runs on an MCU, so nothing called it. -
Date.now()no longer throws in Flow A. The default JS profile leavesDateout, so any handler that called it died on the spot; it now runs on the engine clock, and a host that knows the real time can hand it over wither_runtime_set_wall_clock().new Date()still needs the Date intrinsic, and now says so.performance.now()also no longer wraps back to zero after 49.7 days. -
String concatenation in AOT text now works:
<Text>{'page ' + n + '/' + PAGES}</Text>lowers to a printf format built from the parts. It used to be typed as arithmetic and emit invalid C, so the failure surfaced in the host compiler over a generated source the author never wrote. Also supported in a stringuseStatesetter and a<TextInput value>; anywhere else it is a located error. -
The AOT now lowers the four per-corner
border*Radiusstyles, closing a gap where Flow A accepted a style Flow B could not express. -
An AOT style key with no lowering is now reported as unsupported instead of as "a state-driven value ... (static only)", advice that could not be followed when the value was already a literal.
-
Booleans in the AOT now behave the way they do in Flow A:
{'on: ' + flag}printson: true, a bare{flag}child draws nothing (React's rule), nested spans included, andflag === 1is false. Flow B printed1and treatedtrue === 1as true. -
A string state setter that reads its own slot (
setLabel(label + '!')) now builds the new value in a temporary first.snprintfmay not read and write overlapping objects, so an embedded libc was free to truncate or corrupt it. -
A ternary mixing a string branch with a numeric one (
{ok ? 1 : 'none'}) is now a located AOT error instead of ill-typed C for the host compiler to reject. -
AOT text that mixes a string with anything else (
{'n=' + name}) now builds under GCC. Every string goes into a fixed-size slot, so an over-long value truncates by design; GCC reports that intent and ESP-IDF compiles with-Werror, which failed the build. The generated file now says it is deliberate. -
Names now resolve the way JavaScript scopes them, in styles and props as well as text. A runtime binding — state, a memo, a prop, a
.maprow item, a handler or event parameter — always beats a module constant of the same name, and an inlined child component sees module scope plus its own props and consts, never the caller's locals. Previously the constant fold could quietly substitute the module value. -
Strings in positions C cannot express are handled instead of reaching the host compiler: string equality lowers to
strcmp(ordering is refused: JS orders by UTF-16 code unit, the device holds UTF-8), a string used as a condition tests for non-empty (a barechar[]compared its address, which GCC rejects), a handlerconstcopied from a string state gets its own buffer, and arithmetic on a string —-,*,/,%, a unary+/-, or a string driving a numeric style — is a located error. -
undefinedbehaves as it does in Flow A: a prop or style entry that isundefined— written out, or a prop a child component was never given — is omitted, and in text{undefined}renders nothing whiles + undefinedappends "undefined". Previously an absent prop could hide a node (visible) or put the word "undefined" in aplaceholder. Aconstwhose value isundefinedfolds like any other; a binding namedundefinedis a located error. -
A local, parameter, or state that shadows a module constant now wins when AOT text is folded. The constant fold ran before the runtime bindings were consulted, so a shadowed name silently rendered the module value instead of the one the code uses.
-
AOT text refuses
{cond && 'yes'}with a located error instead of printing1. JS evaluates&&and||to one of their operands, which the emitted C has no way to reproduce; write the branch out as{cond ? 'yes' : ''}. A logical whose sides are both boolean still lowers. -
A board example built against an AOT app generated from a different demo is now a compiler error naming the mismatch, rather than a pile of implicit declarations for host setters that demo never generated. The marker encodes the app name one-to-one, so two apps can never share one.
-
A dep-driven
useEffectin the AOT now runs its cleanup before re-running, so asetIntervalit starts is stopped again on the next dep change instead of accumulating for the life of the app. Timer ids are generation-tagged so clearing an already-finished one cannot stop an unrelated timer. A cleanup returned from inside anifwould still be dropped, and is now a located error. -
The AOT no longer drops an early
returnfrom auseEffectbody — the guard is lowered, so the code after it stops running unconditionally. Areturnanywhere else (a handler, a timer or animation callback, an inlined helper) is now a located error rather than silence. -
A node moved by a native-driver transform animation no longer vanishes on hosts that repaint only the damage rect. Whether a subtree may be pruned is decided from a flag the layout pass caches. An animation-only frame skips that pass — so a translate that appeared after the last layout left the node prunable, and it was pruned away as soon as it slid clear of its layout box.
-
Paint order and hit order can no longer drift apart: the compositor and hit-testing had byte-identical copies of the child-collection and z-order sort that define the relationship, and now share one. Same for the "can this node capture a transform" rule, spelled out by hand at seven sites, and the shadow/gradient prop copy — whose duplicated half of the multi-worker safety counter meant a fix to one branch either left parallel rendering off for good or raced on a modal's shadow.
-
The four LCD backends disagreed about whether their dirty box was inclusive or exclusive while using identical function and field names, so lifting a flush loop from one into another left a one-pixel column of stale panel content. They are all exclusive now, matching
ERRect, with the convention in the field names. -
The frame split no longer counts the same commit twice. React commits from inside the pump, so that
er_commit()landed in the JS phase and in layout+raster — the four phases claimed more time than the frame lasted andother_ussat at 0. JS now reports JS alone. -
A moving
<Svg>shape no longer leaves part of itself behind. The incremental damage rect for a re-uploaded op-tape covered only the control points that changed, not the point each segment starts from — so a rotating needle repainted its tip and left the body stale, and the end appeared to detach from the rest for a few frames. Segments and curves are now bounded by their anchor too. -
A host that tore down its JS runtime and started another could have the second one inherit the first's cached prop names and parsed styles, because a fresh
JSRuntimeis often allocated at the freed one's address.er_runtime_shutdown()now drops that state; a host driving QuickJS directly can caller_bridge_release_runtime()itself. -
The ESP32-S3 example now brings its panel up instead of falling back to headless when the RGB bounce buffers cannot get 10 scanlines of internal DMA RAM — it tries smaller ones first. The reason a panel did not come up is also readable now: bring-up flips the board's USB mux, so those errors were logged while the console was down, and they are repeated once it is back.
0.13.0 - 2026-09-04
Added
<TouchableOpacity>now dims on press, in both flows. It was a bare alias of<Pressable>that did nothing on touch; it is now that same one node with its opacity bound to a press animation the engine runs in C, so the feedback costs no re-render (and no JS at all on a Flow B device). It owns itsopacity, so animate that one on a<Pressable>instead — every other style prop is yours.- Added
<Button>,<ImageBackground>and<SectionList>— the React Native components that are just JSX over the primitives, implemented the way RN implements them, so RN source pastes in and runs. Flow A only for now: an AOT build rejects them by name and prints the tree to write by hand. - Added
PanResponder, in both flows. Drags, swipes, and flings come with the start point, the travel, and the velocity already worked out, and the gesture rides the engine's real responder negotiation: a granted pan owns the touch stream — a ScrollView won't scroll under it and yields a started scroll — with capture, rejection, and termination-request all supported. Flow B compiles the same source, the AOT lowering it onto that C responder system instead of transpiling the JS state machine, so an AOT app needs no JS on the device. The multi-finger join/leave callbacks (onPanResponderStart/End) are Flow A only, thoughnumberActiveTouchescounts fingers in both; RN's Android-specificonShouldBlockNativeResponderis out of scope everywhere. - Raw touch events now carry the gesture:
e.dx/e.dy(travel since touch-down) ande.vx/e.vy(px/ms). A flick isonTouchEnd={e => e.vx > 0.4 && next()}in either flow — no recognizer, and nothing an AOT handler could have computed for itself. - The RN responder props (
onStartShouldSetResponder,onResponderGrant, …) now work on every component (Flow A), for gestures that don't fit the pan shape. - A ScrollView that yields a drag to another gesture responder now stops dead instead of coasting on its leftover momentum.
- The watch-face demo's swipe pager is now a
PanResponderin both flows (the hand-rolled variant is gone). It gains flick-to-turn and no longer wedges mid-drag when a touch is canceled. - The shipped TypeScript declarations now cover what the runtime actually supports. Touch handlers
(
onTouchCanceland the rest),pointerEvents,<Image resizeMode>andtintColor, per-edge borders and radii, shadows, absolute positioning,fontStyle/lineHeight/letterSpacing, andonLayout/onScroll/onFocuswere all honored by the engine but missing from the types, so reaching for one was a compiler error. The SVG components are typed too —<Circle cx r fill>instead ofRecord<string, unknown>— along with node refs,updateVector, andsetKeyboardConfig. extraGlyphsinassets.config.js— add your app's own characters to a font bake by writing them (or their codepoints) directly, instead of hoping a named glyph set covers them.- Every asset bake now warns about characters the app renders that it has no glyph for, naming the
font and the
extraGlyphsline that fixes it. The engine silently substitutes?for them, so previously you only found out by looking at the device. - Font-size discovery now folds constants, so a token-driven type scale bakes the sizes it means.
A scale read as
fontSize: TYPE.title— or handed to a component as a prop — used to bake nothing and collapse the whole app onto one size. - Every asset bake now warns about a
fontSizewith no baked size, naming what it will render at instead, and about sizes it could not read at build time. Unbaked sizes snap silently at runtime, so previously you only found out by looking at the device. - Added
<Dial>, a native arc widget. Dials, gauges, and progress rings are now one engine node instead of a hand-built<Svg>. It draws a track, a value indicator, an optional backing band and segment gaps, and a knob — a circle, an image, or any child you want anchored to the value. The value is animatable, so a ramp runs in the engine with no JS per frame, andadjustablegives you drag-to-set handled entirely in C. Available in both flows. - Dual-setpoint dials.
rangegives a dial two ends with a knob on each, for something like a thermostat's AUTO band.minSpankeeps the pair a set distance apart, pushing the far end along instead of stopping dead.onChangereports both values. rx/ryon<Rect>. Rounded rects no longer need hand-built<Path>arc data. Both flows, plus imported.svgfiles withrx/ryon a<rect>.onValueChangeon<Switch>now works in Flow A. It had only ever fired in AOT builds.- The engine now caches a static
<Svg>'s built geometry, so repainting one under a moving sibling no longer re-tessellates it every frame — the repaint goes straight to the rasterizer. Animated tapes are unaffected, output is pixel-identical, andERUI_VECTOR_EDGE_CACHE=0compiles the cache (and its RAM) out on tight boards. - Per-phase raster timings. The perf overlay's RASTER number now splits into the four things it's
made of — damage pre-pass, compositing, backend blits, and the dirty-flag sweep — so a slow frame
names a culprit instead of a phase. A new
blit_pxcounter shows how many pixels actually reached the backend, which againstdirty_pxgives you the frame's write amplification. Two new overlay lines (RSTfor the last frame,PKRfor the worst), soER_PERF_OVERLAY_LINESis now 7. All of it still compiles out withER_PERF_STATS=0. - A README section for the React Native APIs that are intentionally absent —
StatusBar,SafeAreaView,AppState,Appearance,Linking,AccessibilityInfoand friends — saying why each is N/A without an OS underneath, and what to reach for instead. - A README section documenting
<FlatList>as aScrollViewalias — no virtualization, every row mounts and stays mounted — with the node budget a list costs on real boards and what to do with a long list instead. List virtualization is now a stated non-goal rather than deferred work: these panels show a few dozen rows, not thousands. gc_thresholdonErRuntimeConfig, pluser_runtime_run_gc(). QuickJS re-derives its collection schedule from the live set after every GC, so a big external-RAM heap mark-swept far more often than it needed to, with no way to change it. You can now hold a longer interval — or take the collector off automatic and run it where the pause doesn't show.- A bridge README section on external-RAM hosts: what
gc_thresholdis worth (18% measured on the ESP32-S3), and that QuickJS recurses on the calling task's C stack — already fast RAM on ESP-IDF and STM32, and whatmax_stack_sizeneeds to stay under. It also records why a size-tiered heap was built, measured (~2% on that board's cache-fronted PSRAM), and dropped.
Changed
- Every AOT compile error now ends with the screen size the build folded its layout at, and says when
that was the default. A responsive app picks its branch from
screen, so compiling at the wrong size fails somewhere in a branch meant for another board with nothing pointing back at the cause. - Anti-aliased vector edges no longer reach the display one run at a time. A row's AA pixels are built
up in fast RAM and handed over a stretch at a time, with a long solid run still going out as a plain
fill. Same picture pixel for pixel, 4-12x fewer driver calls, and a quarter off the render time of a
swept
<Svg>dial on an ESP32-S3. - The software and Pico SPI display drivers stopped rescaling pixels by a factor of one, and no longer read the framebuffer for pixels that cover it.
ViewStyleaccepts object-valued entries, soshadowOffsetandtransformOrigintypecheck instead of colliding with the index signature.<ScrollView horizontal>is gone from the types. The runtime never read it — lay the content out withflexDirection: 'row'instead.maxWidth/maxHeightno longer accept a percentage string. Onlywidthandheightresolve one; everywhere else the engine ignored it.- A
.svgimport is typed as the baked vector artifact it actually is, not as a path string. <Image source>and<Dial knobImage>no longer accept RN's numericrequire()id. It carries no engine-side asset name, so it never resolved — import the asset instead.- A rotated or scaled node that is too big to transform no longer costs trigonometry on every idle commit. The damage pass built the transform matrix and its bounding box first and only then asked whether the node could be transformed at all, throwing the work away when the answer was no.
- The engine no longer draws layers that something opaque covers. Painting was strictly bottom-up, so every layer inside a repaint region was drawn even where an opaque one buried it — a page background over a wallpaper over a root cost three fills where one would do. Measured on a 800x480 ESP32-S3 with a page stacked over two full-screen layers: a full repaint drops from 95 ms to 33 ms, and a single row update went from 8.2 ms to 4.3 ms.
- Changing only a node's layout props no longer repaints its box. The node still looks the same, so the repaint now covers what the layout pass actually moved. A layout tweak on a full-screen container that lands every child back where it was used to damage the whole screen; on the same S3 panel that frame goes from 90 ms to 4.4 ms, and the screen updates 3.6x as often.
- Screens with many small independent updaters repaint far less. Damage tracking kept only four
disjoint rects per frame, so a grid of dials cascade-merged toward one big box — and since a vector
or arc node repaints wherever its background was erased, every dial then re-rasterized in full. The
budget is now 16 (
ER_DAMAGE_RECTS_MAX). Measured on an 800x480 ESP32-S3 with twelve dials all stepping at once: 12x fewer pixels repainted and 4.5x less rasterizing per frame. The two smallest no-PSRAM boards (CYD, RP2040) keep the old budget — they have a handful of updaters and no RAM to spare — so their builds are byte-for-byte unchanged. <Svg>shapes repaint much faster. Every render pass used to flatten, stroke, and sort a node's whole op-tape before the clip was applied — once per damage rect it straddled. Shapes now drop out against the clip before any of that, edges outside it never enter the list, the edge sort is a counting sort, and a stroke's smooth corners fold into the segments instead of each drawing its own join. Measured 17–35% faster on a full repaint and 35–85% on a damage-rect one, with output unchanged bar a subpixel corner.<Svg>arcs are much faster now. A plain<Arc>or<Circle>now goes through the same analytic rasterizer<Dial>uses rather than being tessellated, so the two render identically at a fraction of the cost. Shapes the fast path can't express exactly — a filled partial arc, a square cap, a gradient stroke, an arc joined to another segment — still take the old route, so nothing renders differently than it describes.- Touch moves are coalesced to one per frame. Host report moves faster than frames, and under Flow A
each one used to trigger a React render for a position already stale by the time it painted. The engine
now dispatches only the newest move per finger, at the frame boundary. Down, up, and cancel are never
coalesced, and a move that repeats the last position is dropped entirely, so a finger held still costs
nothing. Nothing changes for a host — keep calling
embedded_renderer_touch()for every sample. Two new entry points come with it:embedded_renderer_flush_touch()to dispatch pending moves immediately, andembedded_renderer_set_touch_coalescing(false)to get every point back for something like freehand drawing. - The thermostat demo's dial is a
<Dial>now, in both flows. That removed about 800 lines of op-tape building, per-touch repainting, and hand-expanded trig. delayLongPresssets the hold time beforeonLongPressfires, per node, in both flows. It was fixed at 500 ms for every press on the screen.
Fixed
- A long press no longer fires
onPressas well when the finger lifts. React Native treats the long press as replacing the tap, and an app that acted on both ran two actions for one gesture. A node with noonLongPresshandler is unaffected — a long hold there is still an ordinary press. setTimeout,setIntervalandAnimatedcompletion callbacks now fire in the WASM simulator. Its engine clock ran off the browser's animation frames, which stop while the page is hidden and cap each step at 100 ms — so time crawled or stopped, and nothing scheduled ever came due. The simulator now advances the clock by real elapsed time, and keeps pumping when the browser stops drawing.- A decay (fling) animation no longer does an unbounded amount of physics when a frame runs long. Its integrator caps its step count, as the spring ones already did, so a fling caught by a stall coasts on for a few frames instead of jumping to where it would have landed.
<Svg>no longer drops shapes wrapped in a fragment (both flows) or in a{list.map(...)}(Flow A). It only walked direct children, so a mapped list of shapes silently drew nothing. A child it still cannot draw now says so instead of vanishing: Flow A warns in the console, an AOT build fails.pointerEvents="box-none"no longer swallows touches that land on one of its own inert children. The hit test already skipped the node. However, every dispatch path then walked back up from the hit child and handed it the touch anyway — so an overlay wrapping a plain<Text>stole presses, raw touch events, and responder claims from whatever sat behind it. The node's own handlers are now skipped on that walk; its ancestors still receive the touch as before.- A
ScrollViewwithpointerEvents="box-none"no longer takes the pan. Auto-scroll was the one grant path that never checked pointer events; it now skips such a scroller and lets an outer scrollable ancestor scroll instead. - Each host engine test now starts from an empty node pool.
er_tree_set_root()only swaps the root, so every scenario's tree used to outlive it — a ScrollView flicked in one test kept coasting into later ones, firing callbacks into a stack frame that had already returned. - Flex sizes now land on the same pixel grid as positions, so a row of
flex: 1children fills its parent exactly instead of ending a pixel short. A leftover that does not divide evenly is spread across the children the way Yoga spreads it — three equal children in 100px are 33/34/33, not 33/33/33 — and the same rule now sizesalignContent: 'stretch'wrap lines. - A child too big for its parent now overhangs under
justifyContent: 'center'/'flex-end', as it does in React Native, instead of packing against the start edge. Negative free space was clamped to zero beforejustifyContentsaw it, so the overflow lost its sign. - Centred content now lands on the same pixel React Native does. Layout positions were truncated where
Yoga rounds, so anything centred in an odd amount of free space sat a pixel early — about half of all
centred layouts, on either axis.
justifyContent,alignItems,alignContentand the space-* modes are all affected, for flow and absolutely positioned children alike. - A child on a reversed flex axis (
flexDirection: 'column-reverse'/'row-reverse') is now held off the edge the axis starts from by the margin on that side —marginBottomforcolumn-reverse. Asymmetric margins landed the wrong one against the start edge, so a flow child and an identical uninset absolute child disagreed inside a reversed parent. flexWrap: 'wrap-reverse'now moves the wrap lines to the far cross edge, not just reverse their order. They stayed packed against the cross-start, so every child was off by the leftover cross space — a single line in a 160px-wide column sat 110px from where React Native puts it.- An absolutely positioned child now measures from its parent's padding edge, as in CSS and React
Native, instead of being pushed inward by the parent's padding.
left: 0means the parent's edge, and percentage sizes are a fraction of the whole parent. The thermostat's settings sheet was the visible case: its full-screen overlay hung 18px off the right and bottom of the display. - An absolute child with no
left/right(or notop/bottom) now lands where it would have sat in flow, so the parent'sjustifyContentandalignItemsplace it — centring a parent centres an uninset overlay instead of parking it in the top-left corner. left,top,rightandbottomnow take a percentage (left: '50%'), in both flows. Onlywidthandheightdid, so a percent-positioned overlay silently stacked in the corner.- A percentage size on
<Dial>,<Switch>or<ActivityIndicator>now beats their built-in default box in Flow B, instead of being overwritten by it. - A
0%length now means 0px instead of being read as "not set", in both flows. It matters most forflexBasis: '0%'(theflex: 1 1 0%idiom) and for an inset computed from a value that reaches 0, such as a slider driven byleft: \${value}%``. - An absolutely positioned node with no explicit size on an axis now grows to fit its content, like a
flow node. It collapsed to 0 — invisible on screen, since children still painted, but hit-testing
skipped the whole subtree and every touch inside it fell through to whatever was behind.
widthandheightpercentages andaspectRationow resolve on absolutes too. - Flow A now rounds fractional style dimensions to the nearest pixel instead of truncating them, so a layout built from a scale factor lands where Flow B's AOT already put it. The thermostat's dial and center readout drew a pixel off between the two flows because of it.
npm run paritynow renders Flow A from a packedapp.erpkgcontainer instead of a bare JS bundle, so both flows see the same baked images. The desktop host only registers assets from a container, so any demo with an<Image>was guaranteed to diverge — the thermostat's settings cog painted in Flow B and was simply missing from Flow A.npm run paritynow compares the two flows at the same size on a Retina/HiDPI display. Both desktop hosts asked SDL for a HiDPI framebuffer, so a 320×480 scenario rendered at 640×960 — Flow A reads the screen size at runtime and filled the frame, while Flow B's layout had been folded from the scenario size at compile time and drew in one corner, reporting a 75% divergence that wasn't one. The harness now pins both hosts to the scenario size, and fails loudly if either frame comes back another size.paddingnow insets what a leaf actually DRAWS, not just the space layout reserved for it.<Image>,<Svg>,<ActivityIndicator>,<Switch>and<Dial>all painted across their whole box, so padding on any of them typechecked, marshaled, and then made no visible difference. A<TextInput>now takes its text inset frompaddingtoo, replacing the built-in 4px/3px per edge — set none and a field looks as it always did.<TextInput>painted nothing at all. It read its opacity from the wrong field, so every field composited at alpha 0 and vanished — background, border, text, and cursor alike.paddingon a<Text>now lays out, in both flows. The engine measured an auto-sized text node's glyph run and stopped there, so padding on a label typechecked, marshaled, and then did nothing — the box never grew and the glyphs never moved. Padded labels and text-only buttons hug their letters no longer.<FlatList>now renders in Flow A.dataandrenderItemwere dropped as unknown props, so a list drew an empty scroller with no warning; it now maps rows into a<ScrollView>exactly as the AOT already did. Props neither flow supports (horizontal,onEndReached, …) warn instead of vanishing.- A prop that stops being animated now takes its new value. Binding an
Animated.Valueto a prop handed it to the engine for good, so setting that prop back to a plain number — or to a differentAnimated.Value— left the old value still writing it, and the number you set was overwritten on every commit. Applies to any animated style prop and to<Dial value>. - Translucent regions on the ESP32-S3 no longer get a darker bar down their right edge when their width is not a multiple of 8. The SIMD blend does 8 pixels at a time, and the leftover columns were finished by a different rounding rule, so they landed a shade darker than the rest of the row.
- A
<Modal>with a scale or rotate transform scrimmed only its own box instead of the page behind it, and left that scrim behind when it closed. The backdrop covers the whole screen, but a transformed modal was measured down a path that never knew that. A zoom entrance settling at scale 1.0 was enough to hit it. - A scaled or rotated view too big for the transform scratch — or sitting inside another transformed view — repainted its whole region on every commit, forever. It renders untransformed in that case, and damage tracking didn't know.
- A view scaled down almost to nothing — or turned edge-on with
rotateY— was left half-drawn, and stayed that way. Past that point the transform can no longer be undone, so the view renders untransformed, and damage tracking was still measuring it at its vanishing size. - A shadow on one of those views was left behind when it moved, and clipped at its new position. Only the view's own box was repainted, and a shadow is drawn outside it.
- A shadow was left on screen when its view was unmounted, hidden with
display: none, or simply had its shadow turned off. Only the bare box was erased in each case; the shadow is drawn outside it. - Taps on one of those views missed it. It draws untransformed, but touches were still mapped through the transform, so the area that answered didn't overlap the area you could see.
- Taps were dropped for one commit after a scale or rotate collapsed to nothing. The view still shows
its previous frame until the repaint, but touches were already being mapped through the new transform,
which can no longer be undone. A
<Dial>animating through that point lost drag samples the same way. translateX/translateYon an<ActivityIndicator>did nothing — the spinner stayed at its layout position while taps and repaints went to the offset one, and it repainted both spots on every frame forever.- A spinning
<ActivityIndicator>carrying any transform repainted the whole screen on every frame. Its spin angle lives in the same field asrotate, so damage tracking read it as a real rotation and gave up on bounding it. On an 800x480 panel that was ~10 fps — slow enough that the dots aliased against their own spacing and appeared to turn backwards. - Unmounting a component mid-animation could hand its animation to an unrelated one. Node slots are
recycled, and a destroyed node's animations kept running against whatever took its slot — an
<ActivityIndicator>that disappeared while spinning left a plain<View>rotating on its own. Property animations, animation groups andLayoutAnimationare all cleaned up now. - Taps on a view with
rotateX,rotateYorperspectivewere matched against its flat layout box instead of the shape you see, so the parts leaning toward you didn't answer and empty space beside them did. (ERUI_3D_TRANSFORMSbuilds only.) - A change to anything inside a rotated or scaled view didn't show up. The view's contents are drawn into a scratch buffer and mapped onto the screen. However, the damage tracker measured the changed child where it was laid out rather than where the mapping puts it, so the repaint missed the pixels entirely, and the change simply stayed invisible.
- Adding or removing a node under a see-through parent — a translucent background, a faded group, the soft edge of a rotated view — darkened it. Only the immediate parent was repainted, so it composited over the old pixels instead of a fresh background.
- Toggling
overflowon a view left its children's overflowing pixels wrong. Hiding kept them on screen; showing never painted them back. Only the view's own box was ever repainted. - A child that laid out past its faded parent's box vanished.
opacitycomposited the subtree through a scratch region bounded by the parent's own rect, which cut the child out entirely — and the same child could later reappear at full opacity, unfaded, on a partial repaint. - A view with a shadow that moved left its old shadow behind and had its new one clipped. Only the layout box was repainted, and a shadow is drawn outside it.
- An
<Svg>path that closes without returning to its start left that closing side unstroked. The fill closed the shape, so a ring sector came out with one bare radial edge and a notch at the corner. - A dial's conic gradient didn't re-color until you released the drag.
- A dial with a center readout couldn't be dragged; the readout swallowed the touch.
- A fast drag could jump to the wrong end of the dial.
- A press handler that unmounted its own node left the following
onPressdispatch working from a stale node slot. The press chain now re-checks the node between callbacks. - The Flow B AOT compiler no longer declares a C slot for a
useRefthe generated code never touches, so a ref that only holds a JS value (a callback, say) stops producing anunused variablewarning in every consumer build. - The shipped ESP32-S3 example built with an
unused variablewarning from the engine compositor. The 3D-transform state was declared under a weaker#ifthan every use of it, so a build withERUI_3D_TRANSFORMS=0never read it. (GCC device builds only — clang doesn't flag it.) er_get_dirty_rects()/er_get_dirty_rect()reported nothing on Flow A. React commits insideer_runtime_pump(), so the host's owner_commit()— a no-op — was wiping the answer before the host could read it. A commit that paints nothing now leaves the last painted rects in place.er_get_dirty_rect()reported nothing on a full repaint that no node had dirtied whileer_get_dirty_rects()reported the whole screen. Both now report the region actually painted.- Adding or removing a transform on a view left a rotated or scaled child inside it half-drawn. Only the outermost transform is rendered through the scratch buffer, so a change up top silently moves the child between its transformed shape and its plain box — and the repaint, which never expected it to move, stayed on the old one. The frame did not recover on its own.
- The web-sim build now finds a Homebrew-installed Emscripten. It looked for the CMake toolchain next
to
emccon PATH, which for a packaged install is a symlink or a wrapper script pointing elsewhere. SetEMSCRIPTEN_ROOTto point it at any install it can't work out on its own. node tools/web-sim/build.mjsno longer wipes its build directory and recompiles everything on every run — it was checking a CMake variable the Emscripten toolchain never caches.- The JS-heap regression test's "the GC actually reclaims" check now churns reference cycles, which only the collector can free. It churned plain objects before — QuickJS frees those by refcount on its own — so the check passed unchanged with the collector switched off.
0.12.0 - 2026-08-19
Added
display: 'none'hides a subtree without unmounting it. The node and everything under it drop out of layout, rendering, and hit-testing. However, the native nodes stay allocated with their props intact, so showing the subtree again is a repaint rather than a rebuild (build each page once, then flip between them, instead of tearing down and recreating a few hundred nodes in interpreted QuickJS on every page change).visible, which was listed as a passthrough prop but did nothing outside<Modal>, is now an alias for it:visible={false}meansdisplay: 'none', with an explicitstyle.displaywinning over it (on<Modal>it keeps its existing meaning). Works in both flows — Flow A throughstyleor the prop, Flow B as a static or state-driven style key — and is declared in the TypeScript types.
Fixed
-
Hiding a subtree with
display: 'none'left pixels on screen when any descendant painted outside the hidden node's own box (an absolutely positioned child of a small container, for example). Layout stops maintaining a hidden node's descendants, so the damage pre-pass read them as unchanged-and-in-place, and they contributed nothing to the repaint; their footprint is now banked when the subtree is hidden, the same way a removed node is. -
A hidden subtree is no longer processed every frame. Props set on a node the paint walk prunes — which happens continuously, since React keeps rendering a cached page — used to leave dirty flags that nothing could ever clear, so the page re-damaged its own rect on every commit for the rest of the run. A hidden page now costs nothing per frame and reports no dirty rect.
-
display: 'none'collapses the hidden node's computed rect, soonLayoutreports the empty box it actually occupies instead of the one it had when it was last visible. -
Pixels vacated by a removed, destroyed, or hidden node are now reported through
er_get_dirty_rect()/er_get_dirty_rects()as well as repainted. The node that owned them is gone from the render walk, so nothing contributed them to the reported rect, and a host that transfers only that rect left the stale content on the panel.
0.11.1 - 2026-08-18
Fixed
- The STM32 DMA2D backend did not compile in 0.11.0 —
wait_pending()was missing the brace closing its polling branch, so every function after it parsed as a nested definition. The poll now waits on a named mask (DMA2D_ISR_DONE: transfer complete, transfer error, or configuration error) rather than on every interrupt flag, so a refused transfer still ends the wait and an unrelated flag cannot end it early.
0.11.0 - 2026-08-18
Added
-
Opaque images now render through a copy fast path instead of per-pixel blending. Every image is scanned once at registration; if no pixel is transparent, repaints replace destination pixels outright — no framebuffer read-modify-write and no blend math — which is the common case for full-screen backgrounds that previously paid the full ~4 B/px blend cost on every repaint.
-
Images can be baked as 16-bit RGB565 (
er_image_load_rgb565(), or per-imageimages: { name: { format: 'rgb565' } }inassets.config.jsfor the JS pipeline): half the flash footprint and half the source-read bandwidth of ARGB8888. RGB565 is for fully opaque art — the baker rejects sources with transparency — and always uses the opaque copy path. Asset packs that use it are written as ERPK version 2 (with 4-byte-aligned pixel records); packs without RGB565 images still emit version 1, so existing firmware keeps loading them. -
Backends can take opaque image blits in the image's native format: a new optional
copy_rect_fmtcallback onEmbeddedRenderBackendreceives the source buffer, its pixel format (ERImageFormat, now part of the public backend header), and the engine's guarantee that every pixel is opaque — one whole-rect call, no per-pixel alpha inspection needed. On DMA2D-class hardware that is a single M2M_PFC transfer; the bundled ESP32 RGB LCD backend implements it as a straight rowmemcpyon its RGB565 framebuffer. Without the callback the engine keeps the CPU expansion fallback, so existing backends are unaffected. This closes the gap where a 1:1 RGB565 background decayed into per-scanline CPU conversion plus one backend call per row — on a format-aware backend a full-screen 16-bit background now costs the same single transfer as the ARGB path, instead of ~1M conversions and one row blit per scanline. -
The STM32 DMA2D (Chrom-ART) backend is implemented —
backends/dma2d/is no longer a stub. Opaque fills become register-to-memory transfers, opaque copies become memory-to-memory with pixel-format conversion (includingcopy_rect_fmt, so an RGB565 image is converted by the peripheral's PFC in a single whole-rect transfer), and everything with alpha stays on a CPU source-over compositor — DMA2D's blender expects straight-alpha foregrounds while the engine emits premultiplied ones, so hardware blending would darken every anti-aliased edge. The backend is SDK-free: it carries its own register map (identical on F4/F7/H7/U5) and takes the peripheral base address in its config, so it drops into bare-CMSIS, HAL, or service-owned firmware alike, with optional hooks for interrupt-driven start/wait and Cortex-M7 D-cache maintenance and amin_dma_pixelsfloor below which tiny ops stay on the CPU. Framebuffers may be ARGB8888, RGB888, or RGB565, tightly packed or row-padded. Link it as the CMake targeter-backend-dma2d. -
For STM32 LTDC targets,
backends/dma2d/README.mdnow documents how to keep static full-screen art on a second LTDC layer so the engine (and the bus) never re-blit it at all, plus the page-flip loop (er_set_display_buffer_count+er_dma2d_backend_take_dirty/set_framebuffer/er_display_present) for double- and triple-buffered panels. -
The
copy_rect_fmtopaque native-format blit is now implemented in theesp32-spi-lcd,pico-spi-lcd, andsdlbackends, so every example gets the fast path — not just the ESP32-S3 RGB board. -
Running out of vector storage slots (
ERUI_MAX_VECTOR_NODES) now says so in release builds. A<Svg>that cannot get a slot holds no geometry and draws nothing. Because slots go out in mount order, which shapes vanish moves around as screens mount and unmount — on a panel that looks like random glitching rather than a pool that needs raising, and it previously cost a full debugging cycle to trace. The first refusal now prints onestderrline (once per process, even underNDEBUG— unlike the other vector pools, whose warnings are debug-only) and raises a flag the perf overlay shows as!FULLon its slot line (VEC 8/8!FULL), readable by hosts asERPerfFrame::vector_slots_overflow. The marker appears only once a node has actually been turned away, since a screen that exactly fills the pool renders fine.-DERUI_VECTOR_STORE_WARN=0drops thestderrline (and with it any<stdio.h>dependency) while keeping the flag.
Changed
- The ESP32-S3 example's on-screen perf overlay now shows only the four host metrics (FPS / CPU /
PSRAM / IRAM) by default. The engine's frame diagnostics — the FRM/PK timing split, PKDRT repaint
region, and VEC/IMG slot counters — are a debugging aid and are compiled out with the
instrumentation that feeds them (
ER_PERF_STATS=0) unless the firmware is built withidf.py -DER_PERF_DETAIL=1.
Fixed
-
A
<Modal>whose style did not cover the whole screen drew its backdrop only behind itself: the rest of the page was never dimmed, and closing the modal left the dimming that had been drawn. The backdrop always covers the screen, so it is now tracked that way. Modals styled to fill the screen — which is what the demos and the docs show — are unaffected. -
Borders with a per-edge width or color, and dashed or dotted borders, ignored
borderRadiusand squared off every corner: they were drawn as four straight rectangles, which then overhung the rounded background. They now follow the same corners as the rest of the node, and a dash pattern is stepped around the perimeter, so it flows through the corners as one continuous run instead of stopping at each one. -
A border with a different color per edge changed color on a hard horizontal step in each corner, because the top and bottom edges claimed their full width. Adjacent colors now meet on a miter through the corner, the way they do on the web. The seam is anti-aliased like every other edge the renderer draws.
-
A View with a border and no
backgroundColorrendered as a solid block of the border color instead of an outline. The border was painted by filling the whole shape and covering the middle back up with the background — which does nothing when there is no background to paint. Borders are now stroked as a ring whenever the background can't hide them, so transparent, translucent, and gradient-backed nodes all keep what's behind them. Opaque backgrounds are unaffected. -
Gradient backgrounds are now clipped by
borderRadius. A gradient painted its full rectangle, so its square corners poked out past the node's rounded ones — unnoticeable at a radius of 4, plainly visible by 16. It now stops at exactly the edge a solidbackgroundColorwould, anti-aliased corner fringe included.
Added
- Frame instrumentation in the perf overlay (
engine/include/er_perf.h). Each frame is now split into JS/commit, layout, raster, and present, alongside counters for dirty-rect area and vector / image slot usage — so a frame that spikes can be blamed on the right subsystem instead of guessed at. The worst frame is kept with its full breakdown until you reset it, which is what makes a one-off spike diagnosable after the fact. Wired up in the ESP32-S3 example and the desktop simulator; gated byER_PERF_STATS(defaults toER_PERF_OVERLAYunless overridden by the build).
Changed
-
The image registry is now sized by
ERUI_IMAGE_REGISTRY_MAX(-DERUI_IMAGE_REGISTRY_MAX=n, or appended toCOMPILE_DEFINITIONSon the ESP-IDF path), and the default rises from 32 to 128. It was a bare#definewith no way to change it from a build, so an icon-heavy app that baked more than 32 images simply lost the ones registered last — no crash, no layout change, just a hole where the art should be. Slots cost ~80 B each on a 32-bit target, so the new default is ~10 KB of.bss; nothing scales with it per frame, and the two RAM-tight examples pin it lower rather than absorb that. The macro was renamedIMAGE_REGISTRY_MAX→ERUI_IMAGE_REGISTRY_MAXto match every other tunable; it lives in a private engine header, so no public API changes. -
Damage tracking now keeps up to
ER_DAMAGE_RECTS_MAXdisjoint dirty rects per commit instead of one bounding box, so two widgets updating in opposite corners repaint two small areas rather than the whole span between them. Display drivers that can flush multiple windows can read the rects via the newer_get_dirty_rects();er_get_dirty_rect()still returns the covering box.
0.10.2 - 2026-08-03
Fixed
-
native_ui_bridge.c'sapply_propsno longer probes all ~90 known prop names (JS_GetPropertyStrper name — a C-string hash + atom intern + lookup) against everysetPropsobject regardless of how many keys it actually has. It now walks the object's own enumerable keys once viaJS_GetOwnPropertyNamesand dispatches each into a pre-interned atom table, so cost scales with the object's own key count instead of the full known-prop surface. A host microbenchmark (200k calls) measured ~7.6µs → ~3.0µs persetPropscall for a typical 5-key style object (~2.5x), and ~7.1µs → ~3.7µs for an 18-key object (~1.9x) — narrower than the sparse case since the atom lookup cost still scales with the object's own key count, not the constant 90-name probe it replaced. -
commitUpdate's JS-side prop marshaling no longer flattens a node'sstyleredundantly. A<Text>update used to flatten the same style object up to four times and walk its children tree twice (once each insplitAnimatedStyle,buildProps's prop bag,buildProps's text-content build, andbuildTextSpans) before a single value ever reached the bridge — costly JS/bytecode work on an interpreted MCU target, separate from and in addition to the prop re-serialization fixed below. The style is now flattened once per commit and threaded through to every consumer that needs it. -
Re-rendering a component no longer re-serializes every host node's props across the JS→C bridge when nothing actually changed. The reconciler's
prepareUpdateused to rubber-stamp every update (return true), so a single state change re-marshaled the style/props of every re-rendered node — the dominant per-node cost of a Flow A update (~10 ms/node on an ESP32-S3) — even when the values were identical. It now diffs the old and new props by value and returnsnullwhen nothing observable changed, which makes React skip the commit for that node entirely. When something did change, a section-flag payload re-applies only the affected part: a new inline event handler re-registers the handler without re-serializing props, a<Text>re-uploads spans only when its content or style changed, and an<Svg>re-uploads its vector op-tape only when the tape's real inputs changed (shape children,viewBox,width/height, or thesourceartifact/box) — so a position-only style move, the interactive-drag hot path, no longer re-flattens declarative shapes every frame. -
Any state change that touched layout (a resized View, new text, a mounted/unmounted node) cost far more than a plain repaint, and the gap grew with how deeply nested the UI was. The flex engine measures each node's natural content size (a Text node's glyph run, or a container's summed children) before laying it out. However, that measurement was neither cached nor skipped when unneeded — a node D levels deep in the tree got its content remeasured D times as the layout pass descended one level at a time, and even a flat row of auto-sized text siblings was measured twice per commit. The layout pass now measures each node's content at most once per pass. It skips the measurement entirely when a node's size is already fully pinned (explicit width and height, or driven by
flexBasis/percentage/aspectRatio) — for the common case of mostly fixed-size embedded layouts, the text-measuring work drops to zero.
0.10.1 - 2026-07-31
Fixed
- Apps running the JavaScript runtime on a bare-metal board could run out of memory over time, even
when the app itself was correct. QuickJS only cleans up unused JavaScript objects once it believes a
certain number of bytes are in use, and it works that number out by asking the system how big each
allocation was. On Mac, Windows, and Linux it gets a real answer; on a bare-metal chip (and inside the
browser simulator) it silently got zero, so the cleanup never ran and memory use only ever grew —
on one STM32 board, a few kilobytes per screen redraw until the board ran out. The same gap also
meant the
memory_limitsetting could not actually cap the JavaScript heap. The bridge now supplies its own allocator that always reports real sizes, so cleanup and the memory cap work on every board without any host changes. Boards that supply their own allocator are unaffected and are now checked at start-up: if theirs cannot report sizes, a clear warning is logged instead of the problem going unnoticed.
Added
er_runtime_gc_accounting_ok(), so firmware can check at boot whether its JavaScript heap cleanup and memory cap are working, and refuse to start or show it on screen if not.
0.10.0 - 2026-07-30
Added
- Gradients on hand-written
<Svg>shapes. Previously only an imported .svg artwork file could carry one, so a shape you wrote yourself was always a flat color. AfillGrad/strokeGradprop now works on<Arc>,<Path>,<Circle>and the rest, in both the JavaScript runtime and the compiled-to-C build, and inherits through<G>. In the compiled build the gradient may be driven by state — the thermostat's Auto band uses that for its warm-to-cool sweep, which follows the two setpoints as you drag them — and folds to a constant table when nothing about it changes. It may also be applied conditionally (cond ? { … } : null), so a shape can be gradient-filled in some states and a flat color in others. - Redesigned the thermostat demo with a new cleaner, fully responsive design
- New example for the Waveshare RP2040-Touch-LCD-1.69, a small round-corner touch board. This is the smallest board supported so far: an RP2040 with 264 KB of memory and no PSRAM. It drives the 240 by 280 screen and the capacitive touch panel, and runs the new watch-face demo. Tested building, flashing, and running on real hardware.
- New display backend for small microcontrollers that drive an SPI screen. It keeps one screen-sized image in memory and sends only the part that changed to the screen each frame. It is plain, portable C, so any board with an SPI display can reuse it by supplying two small functions.
- New watch-face demo with two pages you swipe between: a digital watch face (clock, day and date, battery, heart rate, and a live step counter) and a bubble level (a dot that rolls as you tilt the board, turning green when the board is level). It compiles ahead of time to C, so it runs on boards that have no JavaScript runtime.
- A new way to feed live device data into an app that is compiled ahead of time. You mark a value in the app with useHostValue, and the build generates a small setter function your device code calls to update it, for example, a step count or a sensor reading. This needs no engine changes and no JavaScript runtime.
- Real motion sensing on the RP2040-Touch-LCD-1.69 example. The board's accelerometer now drives a real step counter (using a simple step-detection algorithm) and the bubble level (using the direction of gravity). Both values reach the screen through useHostValue.
- Start a new project from a demo.
npm create embedded-react@latest my-app -- --template <name>scaffolds a full example app (e.g.thermostat,watch-face) instead of the minimal starter, so you can build on a real UI or try one on your hardware right away;--listshows what's available. The demo apps are now self-contained projects wired to theembedded-reactCLI. embedded-react build --aot --screen <WxH>bakes a target panel size into the ahead-of-time build, so a responsive app compiles to the layout that board actually renders (e.g.--screen 240x320for a small no-PSRAM display).
Changed
- The Waveshare 7-inch ESP32-S3 example now keeps a single screen buffer instead of rotating three, which makes dragging noticeably smoother — on the thermostat dial, 15-16 frames per second before and 20-26 after. Rotating buffers were costing more than they saved: each one remembers separately what it still needs redrawn, so a frame had to repaint its own changes plus the last two frames' as one combined area, and it had to rebuild that whole area from scratch rather than blending just what changed. With one buffer the area is smaller and the work per pixel is lower. The tradeoff is that drawing now goes into the buffer being shown, so a very large, very fast redraw could show a torn edge; dragging, theme switches, and opening and closing the settings sheet were all checked on hardware and are clean. A board that does tear can set the buffer count back to three, documented where it is set.
- Updated the examples and backends README tables to list what is actually implemented, and added rows for the new RP2040 example and its display backend.
- Rewrote the ESP32-2432S028R (Cheap Yellow Display) example README for clarity. A short intro and a quick start come first, followed by a plain-language how-it-works section, a tuning table that maps symptoms to fixes, and the pin list. The deeper detail moved below the get-it-running steps, and a duplicated explanation was merged.
Removed
- Removed the music-player demo. It was an early bring-up test for the Cheap Yellow Display and never grew into a real example. The thermostat and watch-face demos now cover the same ground — dynamic layout, animation, and a full app — and there are the two starting templates going forward.
- Removed the old thermostat demo's app code (the arc-dial climate UI, its weather panel, and their artwork) to make room for a redesigned thermostat demo.
Fixed
- Setting a text label's content directly from code (the
updateTextescape hatch) no longer re-measures every piece of text on the screen when it cannot possibly change the layout. A label whose width is fixed and that is limited to one line always occupies the same space whatever it says, so only that label is redrawn now. Live readouts driven this way get cheaper: on the 800 by 480 thermostat, dragging the dial updates the center temperature every few pixels, and that was triggering a full re-measure of the screen on about nine of every ten frames, roughly 5.7 ms each. Labels that can still change size — content-sized ones, or fixed-width ones allowed to wrap onto more lines — are unaffected and re-measure as before. The thermostat's readouts were given fixed boxes so they benefit. - Fixed a permanent slowdown after scrolling. Once a list had been scrolled, every later frame stayed expensive for the rest of the run — nothing recovered it except restarting the device, not switching screens, not scrolling back. Rows scrolled out of view kept asking to be redrawn where they used to be, forever: scrolling moves a row, so it is reported as having moved, but a row that is now out of sight is skipped when drawing, and only drawing updates the record of where it was last drawn. So it stayed "moved" and kept the changed region of every frame propped open, which in turn defeated the shortcut that normally lets the engine skip untouched parts of the screen. A row with nothing left on screen now settles after its old area is cleaned up once and is still redrawn normally when scrolled back into view. Affects every app with a ScrollView or FlatList.
- Fixed division in apps compiled ahead of time to C. JavaScript always divides as decimals, but the compiler emitted plain C division, which discards the fraction when both sides are whole numbers. Any ratio built from the whole-number state stayed at 0 until the two sides were equal and then jumped to 1 — on the thermostat's small-screen dial, a fill and handle that sat at the bottom of the range until the setpoint reached maximum. Affects every ahead-of-time app.
- Fixed vector graphics silently disappearing when a drawing is wider than the buffer the renderer reserves for one row of it. It drew nothing and said nothing, because the warning behind that limit is compiled out of release builds — the enlarged thermostat dial vanished on the Cheap Yellow Display while its drag still worked. The board's buffer now covers the dial, and the limit is documented where it is set.
- The Cheap Yellow Display example can now run rotated. A single switch in the board header turns it to landscape, swapping the screen size, the panel axes, and the touch axes together so they cannot end up disagreeing. The example still ships in the panel's native portrait.
- Fixed a blank screen in the simulator's dev server when a demo or scaffolded app carries its own
node_modules. The app's React and the library's React were resolved from two different places, giving two copies of React — every component then failed with "cannot read property 'useRef' of null". The dev server now pins React to a single copy, the same way the production bundler already did. Apps installed normally, where there is only ever one copy, are unaffected. - Corrected the ESP32-2432S028R example docs: fixed the display backend description to match the current design, fixed the documented pixel-clock speed (40 MHz, was 20), fixed the expected startup log line, and replaced the stale music-player description with the thermostat dial the example actually builds. Verified the example builds, flashes, and starts cleanly on a real board.
- Fixed the ahead-of-time (AOT) compiler so a repeating timer started from inside an effect that has dependencies — or from an event handler — generates C that compiles. The timer's callback is now declared before it is used, which the watch-face demo needs for its swipe-to-settle animation.
0.9.0 - 2026-07-23
Added
-
Multicore rendering (opt-in). On builds made with
ERUI_RENDER_WORKERSabove 1, a host can hand the engine extra render workers (embedded_renderer_set_workers) — the engine never creates threads itself — and each frame's repaint is then split into horizontal slices rendered concurrently, one worker per core. Scenes using vector graphics or shadows fall back to single-core automatically, the fade cache keeps working (reads happen in parallel; a refresh borrows one single-core frame), and everything else — nested fades, transforms, text, gradients, images — renders in parallel. The default build is unchanged and stays exactly single-core. -
The ESP32-S3 example renders on both cores, and its memory system now runs PSRAM and flash at 120 MHz (up from 80). The faster bus matters beyond speed: with both cores drawing, the RGB panel's continuous refresh from PSRAM was starved of bandwidth, and the picture visibly drifted — at 120 MHz there is headroom for both. 120 MHz PSRAM is an ESP-IDF "experimental" feature; the example also enables vsync-based panel-sync recovery.
-
Ordered dithering on RGB565 panels (
ER_LCD_DITHER, on by default with SIMD blending). Fading translucent or anti-aliased content used to shimmer as pixels flickered between color levels frame to frame; the quantization now lands as a fine, stationary checkerboard instead, which also softens gradient banding. The startup self-test covers the dithered math per pixel lane. -
SIMD blending on the ESP32-S3 (
ER_LCD_PIE, on by default). The hottest drawing operation — blending translucent content onto the screen — now uses the chip's 128-bit vector unit, eight pixels at a time, roughly halving the cost of fades on a device. A self-test at startup verifies the SIMD output against the plain C version and falls back automatically if it ever disagrees. -
The ESP32 LCD backend can now draw directly into the panel's own framebuffers (
ER_LCD_DIRECT, on by default for unrotated RGB565 panels with 2–3 framebuffers whenon_frame_buf_completecallbacks are available). The intermediate framebuffer and the per-frame copy to the panel are gone — pushing a large update dropped from 20–35 ms to ~2 ms on the ESP32-S3 example. With three panel framebuffers (the example's new default) there is always a free buffer to draw into, so frames never wait on the display. Rotated and single-framebuffer panels keep the previous path. -
Fades now work at any size. A translucent group bigger than the scratch buffer used to silently render fully opaque; the engine now composites large groups in horizontal strips, so even full-screen fades render correctly — using far less reserved RAM than before.
-
When a group truly can't be composited (very deep nesting, or a board with compositing turned off), each element is now dimmed individually instead of the transparency being dropped entirely.
-
New board-tuning flags:
ERUI_SCRATCH_BAND_H(strip height — smaller means less RAM) andERUI_XFORM_W/ERUI_XFORM_H(the largest element that can be rotated or scaled). Defaults leave existing configurations unchanged. -
A fade cache (
ERUI_FADE_CACHE_W/H, off by default). During an opacity animation the faded content doesn't change — only how transparent it is — so the engine now keeps the composited result and re-blends it each frame instead of redrawing everything. Measured 1.3–2.8× fade frame rate on device (nested fades gain the most); any change to the content invalidates the cache automatically.
Changed
- Compositing needs much less memory. One of the two full-size transform buffers is gone, and the strip pool replaces four full-size opacity buffers — on the ESP32-S3 example the compositing buffers shrink from ~1.35 MB to under 300 KB.
- Rendering is faster on microcontrollers: the hottest pixel loops were reworked, and the ESP32-S3 example keeps its compositing strips in fast internal RAM.
Fixed
- A rotating or growing element could vanish mid-animation once its on-screen footprint outgrew the scratch buffer. Any on-screen size now renders; only the element's own size is limited.
0.8.0 - 2026-07-21
Added
- A lite JavaScript profile. The runtime starts with only the built-ins the React runtime actually needs, and the same set runs everywhere — device, desktop, and simulator — so an app that works in development works on hardware. Anything extra an app needs (Date, Proxy, typed arrays, …) can be opted back in per host.
- Parser-less device builds. Firmware that only runs precompiled bytecode can drop the JavaScript parser entirely, saving about 60 KB of flash. The error overlay still works on such builds.
- Smaller release bundles. Built apps no longer embed their source text and debug tables — the thermostat demo shrinks from 1.14 MB to 260 KB. Development builds keep debug info so stack traces keep their line numbers.
- An optional hard cap on the JS heap: a runaway app fails with a catchable out-of-memory error instead of exhausting the shared system heap.
- The ESP32-S3 example uses all of the above: lite profile, no parser, and a 4 MB heap cap.
Fixed
- Demo bundles no longer break when a demo folder carries its own
node_modules. A second copy of React could sneak into the bundle and make every component throw at mount; the bundler now always uses the package's own copy.
0.7.0 - 2026-07-04
Added
- Support for page-flipped / multi-buffer displays. Some panels rotate two or more framebuffers and flip
between them in hardware, so the buffer being drawn into is a frame or two stale — with the standard
single-buffer assumption, moved elements ghosted and some changes never appeared. Tell the engine how many
buffers rotate (
er_set_display_buffer_count) and signal each hardware flip (er_display_present), and it repaints enough per buffer to keep every one correct, with no extra full-screen buffer or host-side copying. The default is unchanged for single-buffer displays.
0.6.0 - 2026-07-01
Added
- On-device hot reload over USB. Save an edit, and it streams to a connected board and swaps in live — no reflashing, no rebooting, and component state is kept across reloads. It's a development feature, turned off by default; the web simulator stays the standard way to develop, and release builds are unaffected.
- Automatic device detection for on-device hot reload. You can start it without naming a port: ESP32 boards are found automatically, and any other board is given an explicit port. New projects created with the scaffolder include a script for it, and the tool clearly explains what to do when it can't connect.
0.5.3 - 2026-06-29
Fixed
- SVG gradients render again. Linear, radial, and conic gradients on vector shapes had regressed to drawing as transparent in the simulators; they now paint correctly.
- The simulator dev server no longer crashes when a requested file is missing. It returns a normal "not found" response and stays running instead of shutting down.
0.5.2 - 2026-06-28
Fixed
- The simulators now enable every engine feature at full capacity. They previously left some limits at the lean device defaults, so features could silently disappear (for example, gradients dropping out past a certain count). Real device builds still use the lean defaults.
0.5.1 - 2026-06-28
Changed
- The JavaScript and TypeScript starter templates ship an updated app icon.
0.5.0 - 2026-06-28
Added
- TypeScript support, end to end. You can write apps in TypeScript, scaffold a TypeScript starter, and use it across every workflow — the simulator (with state kept across reloads), the static export, and both device build paths.
- Bundled type declarations. The package ships its own types for the public API, so imports are typed out of the box with no extra type packages to install.
0.4.1 - 2026-06-27
Changed
- Animated scaling and rotation do less work per frame, with no change to how they look.
Fixed
- Animated 2D and 3D transforms no longer repaint the whole screen every frame; only the area that actually changed is redrawn.
0.4.0 - 2026-06-26
Added
- Import an SVG file and render it as a live vector graphic. It works on both device paths and supports the common SVG shapes and nesting.
- Gradients for vector shapes — linear, radial, and conic — on both fill and stroke.
- Automatic fallback for SVG features the vector engine can't draw directly. Those files are turned into a baked image at build time instead of losing content, with a build warning naming what triggered it.
- Configurable memory limits for vectors, with clearer warnings when a graphic exceeds them.
Changed
- Conic gradients render faster on the device, with no visible change to output.
0.3.0 - 2026-06-16
Added
- A build command that produces the device artifact from your own project — either a bytecode bundle for PSRAM-class boards or ahead-of-time C for boards without PSRAM. It needs no native toolchain, and the scaffolder template gains a build script.
Changed
- The build command compiles device bytecode without needing a native compiler.
0.2.3 - 2026-06-15
Fixed
- Fixed a crash ("maximum call stack size exceeded") that hit newly scaffolded apps on the first run of the dev simulator. The state-preservation transform was being applied to the installed library as well as your app; it's now limited to your own source.
0.2.2 - 2026-06-15
Fixed
- Hardened looping animations so an animation that finishes instantly can't recurse into its next iteration.
0.2.1 - 2026-06-15
Maintenance release — dependency, security, and release-pipeline fixes. No changes to the component API, the engine, or runtime behavior.
Security
- Updated the build-time bundler dependency to clear two security advisories. embedded-react doesn't expose the affected functionality, so real-world impact was low, but the dependency is now patched.
Changed
- Pinned and adjusted development dependencies, so security audits report clean and installs are reproducible across platforms.
Fixed
- Fixed the release workflow so automated npm publishing completes reliably.
0.2.0 - 2026-06-15
First beta.
Added
- A browser-based simulator that runs your app with hot reload, so you can develop without any hardware. The prebuilt simulator ships in the package, so there's nothing extra to install.
- The embedded-react command-line tool: run your project in the simulator with hot reload, or export a self-contained static playground to share.
- The create-embedded-react scaffolder for starting a fresh project, published as a companion package.
Changed
- Documentation restructured for the beta — the project now carries READMEs plus a single roadmap and a contributing guide.
Fixed
- Fixed looping animations built from a sequence (the ping-pong pattern), which could overflow the stack on the second pass; sequences now reset correctly each time they start.
0.1.1 - 2026-06-14
Fixed
- Apps that use Math.PI in the ahead-of-time build now compile on all toolchains.
Added
- Continuous integration and one-step release automation.
- Distribution through PlatformIO and a finalized ESP-IDF component.
- License and notice files are included in every distributed package, and Install and Releasing sections in the README.
Changed
- Rewrote the npm-facing README and fixed links that broke on the package page.
0.1.0 - 2026-06-14
Initial public release.
Added
- The engine: a React Native-style renderer written in C, with layout, text, images, vector graphics, and animation.
- Flow A: run React apps directly on-device for instant iteration on PSRAM-class microcontrollers.
- Flow B: compile apps ahead-of-time to C for microcontrollers without PSRAM.
- Backends for desktop development and for ESP32 SPI displays.
- A desktop hot-reload simulator.
- Versioning foundation with a single source of truth propagated to every artifact.
- The first publish to npm as embedded-react.