From 3e3987c30831581af0082632b3fec3d6b3043d5d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:38:41 +0000 Subject: [PATCH] Version Packages --- .tegami/2026-09-25-flushsync.md | 13 - .tegami/2026-09-25-quickjs-0-17.md | 17 - .tegami/2026-09-25-unmount-quadratic.md | 12 - .tegami/2026-09-26-element-pooling.md | 39 -- .tegami/2026-09-26-filter-render-count.md | 23 -- .tegami/2026-09-26-json-commands.md | 17 - .tegami/2026-09-26-moot-isolation.md | 18 - .tegami/2026-09-26-nested-filters.md | 27 -- .tegami/2026-09-26-ordinal-css-lookups.md | 15 - .tegami/2026-09-26-urp-backdrop-grab.md | 20 - .tegami/2026-09-27-class-matched-pooling.md | 23 -- .tegami/2026-09-27-css-engine-reads.md | 25 -- .tegami/2026-09-27-excss-parse.md | 11 - .tegami/2026-09-27-flat-perspective.md | 24 -- .tegami/2026-09-27-idle-frames.md | 18 - .tegami/2026-09-27-mount-cost.md | 29 -- .tegami/2026-09-27-stale-statics.md | 16 - .tegami/2026-09-27-stencil-clip-path.md | 23 -- .tegami/2026-09-27-style-engine-cost.md | 26 -- .tegami/2026-09-27-style-invalidation.md | 24 -- .tegami/publish-lock.yaml | 36 +- CHANGELOG.md | 360 ++++++++++++++++++ packages/create/package.json | 2 +- .../create/scaffold/Packages/manifest.json | 4 +- packages/create/scaffold/react/package.json | 4 +- packages/material/package.json | 2 +- packages/renderer/CHANGELOG.md | 26 ++ packages/renderer/package.json | 2 +- packages/scripts/package.json | 2 +- unity/clearscript/package.json | 2 +- unity/core/package.json | 2 +- unity/jint/package.json | 2 +- unity/quickjs/package.json | 2 +- 33 files changed, 404 insertions(+), 462 deletions(-) delete mode 100644 .tegami/2026-09-25-flushsync.md delete mode 100644 .tegami/2026-09-25-quickjs-0-17.md delete mode 100644 .tegami/2026-09-25-unmount-quadratic.md delete mode 100644 .tegami/2026-09-26-element-pooling.md delete mode 100644 .tegami/2026-09-26-filter-render-count.md delete mode 100644 .tegami/2026-09-26-json-commands.md delete mode 100644 .tegami/2026-09-26-moot-isolation.md delete mode 100644 .tegami/2026-09-26-nested-filters.md delete mode 100644 .tegami/2026-09-26-ordinal-css-lookups.md delete mode 100644 .tegami/2026-09-26-urp-backdrop-grab.md delete mode 100644 .tegami/2026-09-27-class-matched-pooling.md delete mode 100644 .tegami/2026-09-27-css-engine-reads.md delete mode 100644 .tegami/2026-09-27-excss-parse.md delete mode 100644 .tegami/2026-09-27-flat-perspective.md delete mode 100644 .tegami/2026-09-27-idle-frames.md delete mode 100644 .tegami/2026-09-27-mount-cost.md delete mode 100644 .tegami/2026-09-27-stale-statics.md delete mode 100644 .tegami/2026-09-27-stencil-clip-path.md delete mode 100644 .tegami/2026-09-27-style-engine-cost.md delete mode 100644 .tegami/2026-09-27-style-invalidation.md diff --git a/.tegami/2026-09-25-flushsync.md b/.tegami/2026-09-25-flushsync.md deleted file mode 100644 index 3fd733c4..00000000 --- a/.tegami/2026-09-25-flushsync.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -packages: - npm:@reactunity/renderer: - type: patch ---- - -### `flushSync` works again - -`flushSync` from `@reactunity/renderer` threw `TypeError: not a function` on every call. It called -the reconciler's `flushSync`, which react-reconciler 0.33 renamed to `flushSyncFromReconciler`, and -the reconciler's typings still declare the old name, so nothing caught it. It now commits the -updates its callback schedules before it returns, including on roots rendered with -`disableBatchRendering`. diff --git a/.tegami/2026-09-25-quickjs-0-17.md b/.tegami/2026-09-25-quickjs-0-17.md deleted file mode 100644 index 6e7ffb68..00000000 --- a/.tegami/2026-09-25-quickjs-0-17.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -packages: - upm:com.reactunity.quickjs: - type: patch ---- - -### QuickJS runs JavaScript about a third faster - -The engine moves to quickjs-ng 0.17.0 plus the fork's interpreter work: inline caches on property -access, cached global variable slots, fused compare-and-branch and faster Map, Set, JSON and string -paths. Octane scores 1.33× the previous build on Windows and Linux. Inside Unity, parsing a large -bundle is 1.4–1.7× faster and general JavaScript 1.2–1.5×. Rendering cost is unchanged, because -building UGUI objects on the C# side dominates it. - -Recursion depth on Windows is slightly lower than before. The interpreter's frame on MSVC grew with -this release, so the deepest React tree the Editor can commit at the default -`ScriptRuntime.MaxStackSize` went from 36 levels to 32. diff --git a/.tegami/2026-09-25-unmount-quadratic.md b/.tegami/2026-09-25-unmount-quadratic.md deleted file mode 100644 index ce413f43..00000000 --- a/.tegami/2026-09-25-unmount-quadratic.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### Unmounting a long list is no longer quadratic - -Removing an element re-resolved the styles of every sibling it left behind, on the spot, on top of -the deferred resolve its removal already scheduled. Clearing a list of 1000 elements took 5.8 s; -it now takes 100 ms. Styles still update on the parent's next update, the same way they already -did when a child was added. diff --git a/.tegami/2026-09-26-element-pooling.md b/.tegami/2026-09-26-element-pooling.md deleted file mode 100644 index 74c58f5d..00000000 --- a/.tegami/2026-09-26-element-pooling.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### `PoolingType.All` pools elements under the default renderer, and a reused one starts clean - -Under `PoolingType.All`, the batched renderer (the default) never pooled an element. It sends the -pool key of an element without a `pool` prop as `null`, and the command reader turned that into -`""`, which means `pool={false}`. The unbatched renderer pooled them all along, so the two -disagreed. A missing key now reaches the context as `null` and pools under `All` in both. `pool={false}` -still opts an element out. `Basic`, the default, only pools text and pseudo elements and is -unchanged. - -A reused element also used to keep every prop its previous owner set that the new one does not, -because a create command carries only the props that are present. Styles were already reset. Props -were not, so a reused element could still be hidden by `active={false}`, still checked or disabled, -still pointing at another element's `href`, camera, prefab target, video, SVG content or icon set, -or still limited by an input's `characterLimit` and `contentType`. Each UGUI element now puts its -props back to what a new one has. An element with a camera, prefab target or video lets go of it -when it is unmounted, not when it is reused. - -- A pooled element is dropped from the ref table, so its old ref id no longer resolves to whatever - element reuses it. -- An element whose `Pool()` refuses it is destroyed. It used to be left in the scene. -- Disposing a context no longer pools anything, so a portal under `All` is destroyed with its - context. Before, it was pooled and outlived the context. -- A reused scrollbar has its `data-horizontal`/`data-vertical`/`data-direction` again, and an - input no longer drives a scrollbar that went back to the pool. -- A filtered element lets go of its filter when it is pooled. Its offscreen surface and the - composite it left in its old parent were kept, so whatever reused that parent showed a stale - capture. A reused element that no longer had a filter was also moved back under its first - owner's parent. -- An element whose `backdrop-filter` or `outline` goes away, whether through a style change or - through reuse, stops drawing it. Both used to stay once set. -- UIToolkit and Editor elements are no longer pooled under `All`. A `VisualElement` is cheap to - build, and each kind has native fields a reuse would have to reset one by one. Text and pseudo - elements are pooled as before. diff --git a/.tegami/2026-09-26-filter-render-count.md b/.tegami/2026-09-26-filter-render-count.md deleted file mode 100644 index d148eaf4..00000000 --- a/.tegami/2026-09-26-filter-render-count.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### Filters and backdrops render less often - -Every `filter`, `clip-path`, `mask-image`, `isolation`, `mix-blend-mode` and `backdrop-filter` costs a -camera render, roughly 2 ms each under URP whatever it draws. So a page is as fast as the number of -those it takes per frame. The kitchen-sink Game HUD went from ~52 ms to ~41 ms a frame in the editor: - -- **A filtered element that only moves keeps its capture.** Translating or rotating the element, or - moving its descendants without changing where they sit relative to it, used to re-capture. Now only - the composite moves. -- **A `backdrop-filter` inside a filter re-renders only when something behind it changed**, as ones on - the page already did. -- **Backdrop readers with nothing drawn between them share a render.** -- **Scroll views clip once a frame** rather than once per render. UGUI re-culls every graphic under a - `RectMask2D` on every camera render, which was ~0.5 ms a render on a page with three scroll views. -- **Offscreen cameras skip HDR, MSAA and occlusion culling.** -- **Animating only `translate`, `rotate`, `scale`, `opacity` or layout properties no longer re-applies - the whole style.** The element is moved, faded and laid out again, and nothing it draws is rebuilt. diff --git a/.tegami/2026-09-26-json-commands.md b/.tegami/2026-09-26-json-commands.md deleted file mode 100644 index 569bb753..00000000 --- a/.tegami/2026-09-26-json-commands.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: minor ---- - -### Newtonsoft.Json is no longer a dependency - -The renderer's command buffer is now read by a small JSON reader of ReactUnity's own instead of -Newtonsoft's token tree, which parses it about four times as fast. Mounting a list of 1000 styled -elements takes about 45 ms less, and updating it 16% less. - -A string prop that looks like a date now arrives as written. Newtonsoft turned it into a `DateTime`, -so a prop like `"2026-09-26T10:00:00.000Z"` reached C# reformatted in the current culture. - -`com.unity.nuget.newtonsoft-json` is no longer pulled in by `com.reactunity.core`. A project that -used Newtonsoft without depending on it itself needs to add it to its own manifest. diff --git a/.tegami/2026-09-26-moot-isolation.md b/.tegami/2026-09-26-moot-isolation.md deleted file mode 100644 index bbb5a2a7..00000000 --- a/.tegami/2026-09-26-moot-isolation.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### An opaque, clipped `isolation: isolate` no longer takes a capture - -`isolation: isolate` renders the element off screen so its descendants' `mix-blend-mode` can only -blend within it. When the element has an opaque background, full opacity and `overflow: hidden`, that -group holds exactly what the page would, so the capture is now skipped. It comes back as soon as any -of the three changes, animated opacity included. The kitchen-sink Game HUD's `isolate` on its screen -was a full-screen camera render every frame. - -URP's backdrop pass also stops re-walking the canvas every frame when nothing was added, removed or -reordered, which took it from 1.4 ms to 0.4 ms on the Game HUD. The walk found a bug on the way: a -`Mask` added to a graphic after it started reading the backdrop drew its stencil pop too early, so -the masked content disappeared. diff --git a/.tegami/2026-09-26-nested-filters.md b/.tegami/2026-09-26-nested-filters.md deleted file mode 100644 index 0dbc3688..00000000 --- a/.tegami/2026-09-26-nested-filters.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### Pointer events and captures work through nested filters - -An element with `filter`, `clip-path`, `mask-image`, `isolation`, `mix-blend-mode` or `perspective` -is drawn from an offscreen capture. When one of those sat inside another, several things went -wrong, and a page built from many of them, like the kitchen-sink Game HUD, showed all of them: - -- **Clicks stopped reaching the page.** The inner filter's raycaster mapped the pointer straight - into the outer filter's camera instead of through the outer capture. It also reported that - offscreen camera as its event camera, which outranks the page camera, so its misplaced hits won - every click. A pointer now goes through each capture it is nested in, and the outermost filter - casts for everything inside it. -- **Overlapping filters took each other's clicks.** Two filtered siblings tied, so which one got a - click came down to the order their raycasters registered in. The one painted on top now wins. -- **Captures flickered.** The object that renders queued captures is created by the first one, so - on that frame a filter could be queued twice. It was moved into the shared render twice and - back once, left there, and drew into other elements' captures for a frame at a time. -- **An outer filter could show nothing, or a stale copy, of an inner one.** Captures ran in an - order that only counted nesting up to the first surface, so an outer clip could be captured - before the fill inside it. And an inner filter that re-captured never told the outer one to - do the same. Inner captures now run first, and a finished one marks its outer filter for - re-capture. diff --git a/.tegami/2026-09-26-ordinal-css-lookups.md b/.tegami/2026-09-26-ordinal-css-lookups.md deleted file mode 100644 index 7871fa48..00000000 --- a/.tegami/2026-09-26-ordinal-css-lookups.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### Faster element creation - -The style system's name tables compared keys with the invariant culture, which under Mono builds a -sort key on every hash, so looking up a property name cost about 13 µs. They compare ordinally now, -which matches the same ASCII names, and an inline `style` prop applies in about half the time. - -UGUI elements are also created with their `RectTransform` in place, instead of adding one that -replaces the `Transform` Unity started them with. Together these take about 80 ms off mounting a -list of 1000 elements. diff --git a/.tegami/2026-09-26-urp-backdrop-grab.md b/.tegami/2026-09-26-urp-backdrop-grab.md deleted file mode 100644 index 565069e1..00000000 --- a/.tegami/2026-09-26-urp-backdrop-grab.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: minor ---- - -### `backdrop-filter` and `mix-blend-mode` no longer cost a render under URP - -URP has no `GrabPass`, so every element that reads the backdrop used to cost a camera render of its -own, about 1.5-2.5 ms each in the editor. On URP 17 (Unity 6) and later the canvas is now drawn by a -pass inside the camera's own render instead, which copies the screen just before each reader, as a -`GrabPass` does. A reader costs about 0.03 ms, and the result is pixel-identical to the render it -replaces. The kitchen-sink Game HUD's page backdrops went from 5.5 ms a frame to 0.5 ms. - -- It needs the render graph, so a project in URP's *Compatibility Mode* keeps rendering per reader. -- Readers inside a `filter` still render, because the filter's content is drawn by a camera of its own. -- Other objects on the canvas's layer are drawn after the scene's transparent geometry rather than - sorted among it. - -It lives in the new `ReactUnity.UGUI.URP` assembly, which only compiles where URP 17 is installed. diff --git a/.tegami/2026-09-27-class-matched-pooling.md b/.tegami/2026-09-27-class-matched-pooling.md deleted file mode 100644 index 337bd462..00000000 --- a/.tegami/2026-09-27-class-matched-pooling.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### With `Pooling: All`, a pooled element comes back in the role it had - -A pool used to hand back whichever element went in last, so a remount usually gave each element -someone else's role. Its background, border, shadow and mask graphics then had to be rebuilt for the -new one. Each spare is now filed under the `className` it had, and an element created with the same -`className` gets that spare first. When no spare matches, the pool falls back to the latest one as -before. This covers the default batched renderer. The `disableBatchRendering` path still gets the -latest spare, because its props arrive as a script object that is only read once the element exists. - -`IPoolableComponent.PoolStack` is now a `PoolStack`, not a `Stack`. -`IPoolableComponent` also gains `PoolHint`: the `className` that files the component in its pool. - -An element now parks at most two surplus box shadows and destroys the rest. Before this, every -pooled element kept as many shadows as any role had ever given it, and on the kitchen-sink Game HUD -the count grew from 165 to over 740 across 60 tab switches. - -A revived element with no `className` also no longer reports the one it had before it was pooled. diff --git a/.tegami/2026-09-27-css-engine-reads.md b/.tegami/2026-09-27-css-engine-reads.md deleted file mode 100644 index 9b1228b6..00000000 --- a/.tegami/2026-09-27-css-engine-reads.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### Animating an element no longer restyles everything inside it - -A change to `opacity`, `translate`, `rotate` or `scale` on an element used to re-apply the full -style of every descendant each frame, although a descendant can only see those through `inherit`. -Descendants now compare the values they would inherit and stop there when nothing changed. Across a -520-element Tailwind page, a keyframe animation on the root costs 2.1 ms a frame instead of 27.9 ms, -and a transition on 40 cards 3.4 ms instead of 28.2 ms. - -Reading a computed style is cheaper as well. An inherited `color` or `font-size` that fell back to its -default was resolved by walking every ancestor on every read, and each read went through up to four -dictionary lookups. Resolved values are now kept until the style changes, and lookups are indexed by -property. Applying a restyled page takes 36 ms instead of 48 ms, and hovering a card 0.3 ms instead -of 1.5 ms. - -A transition that has finished is no longer checked every frame for good. Inserting a large stylesheet -is faster too: the 112 KB kitchen-sink Tailwind build inserts in 145 ms instead of 237 ms, most of it -from ExCSS no longer copying the whole sheet to read each rule's selector. - -A point `transform-origin` on an element that has not been laid out yet no longer gives it a NaN position. diff --git a/.tegami/2026-09-27-excss-parse.md b/.tegami/2026-09-27-excss-parse.md deleted file mode 100644 index a9ab1777..00000000 --- a/.tegami/2026-09-27-excss-parse.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### Stylesheets parse in half the time - -The bundled ExCSS parser allocates a third of what it did and does not lex a plain declaration twice -to decide whether it is a nested rule. In the Editor, the 112 KB kitchen-sink Tailwind build parses in -36 ms instead of 72 ms, and inserting it takes 95 ms instead of 149 ms. The parsed sheet is unchanged. diff --git a/.tegami/2026-09-27-flat-perspective.md b/.tegami/2026-09-27-flat-perspective.md deleted file mode 100644 index 7c854434..00000000 --- a/.tegami/2026-09-27-flat-perspective.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### A `perspective` over one flat plane is captured face-on - -A `perspective` used to render its subtree through a camera of its own. That is a whole pipeline -entry per element on every frame the subtree moves, and it could never be packed with other captures. -When everything under the element lies in one plane, which is the usual tilted card or receding -floor, the subtree is now captured face-on and packed with the frame's other filters. The composite -draws that capture through the projection, divided per pixel so the texture stays perspective-correct, -and pointer events are mapped back through the same projection. - -When the plane is nothing but one inner filter's composite, the outer element takes no capture at all -and warps the inner one's texture. On the kitchen-sink Game HUD, whose horizon grid is a -`mask-image` inside a `perspective`, that removes one render from every frame, cutting the frame from -22.6 ms to 20.4 ms. - -A plane nearer the eye than half the `perspective` distance would be magnified more than twice, so -it keeps the camera path. So does anything the flat capture cannot express: a subtree spread over -several planes, or a perspective element that also has its own `mask-image`, `clip-path`, or a filter -that samples neighbouring pixels such as `blur()` or `drop-shadow()`. diff --git a/.tegami/2026-09-27-idle-frames.md b/.tegami/2026-09-27-idle-frames.md deleted file mode 100644 index e1d033df..00000000 --- a/.tegami/2026-09-27-idle-frames.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### A page that is not changing costs less every frame - -A settled UGUI page still did work every frame in proportion to its size. A scroll view re-culled -every graphic inside it, whether or not anything had moved. Every element and text node got its own -`LateUpdate` or `Update` from Unity, and on a settled page Unity's per-component dispatch cost more -than the callbacks did. Every element also opened a profiler marker, even when its style state had -nothing to do, and a paused or finished animation re-applied the same value each frame. - -In the Editor, the kitchen-sink Material page now takes 3.3 ms a frame instead of 5.6 ms; its canvas -update drops from 1.6 ms to 0.6 ms. The Home page takes 1.6 ms instead of 2.6 ms. On the Filter page, -pointer handling across its 81 filters takes 0.65 ms instead of 1.44 ms. Nothing renders differently: -captures of the Material page, scrolled and not, are pixel-identical with and without the change. diff --git a/.tegami/2026-09-27-mount-cost.md b/.tegami/2026-09-27-mount-cost.md deleted file mode 100644 index cfd31b01..00000000 --- a/.tegami/2026-09-27-mount-cost.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### Mounting a subtree costs less, most of all with pooling on - -Reading a style property used to probe each declaration block an element matched, one at a time, -and hash the property's name again for every probe, since Mono does not cache a string's hash. The -blocks are now merged into one lookup the first time the element reads a property, and each property -hashes its name once. A first read takes about 515 ns instead of 1100 ns, and applying an element's -layout styles to Yoga dropped from 6–9 ms to about 3 ms across a mount of 150 elements. It also -produces a tenth of the garbage it used to. - -An element's background and border graphics are now created under their parent, not at the scene -root and then moved there. Each move made the graphic find its canvas again and rebuild its -material. An element with a `border-radius` and no border no longer gets a `[Border]` graphic that -draws nothing, because the mask already rounds its background. - -With `Pooling: All`, a pooled element is usually reused in a different role from the one it had -before. When the new role needed fewer box shadows or no `clip-path` stencil, the extra graphics -used to be destroyed, and they were then built again for the next element that needed them. They -are now parked: disabled and kept for the next time. A shadow or stencil whose values have not -changed also no longer rebuilds its mesh on every restyle. The backdrop pass under URP now skips the -inactive pool when it walks the canvas. - -On the kitchen-sink Game HUD, switching between the side panel's tabs takes a 53–61 ms frame in the -editor, down from 71–93 ms. Each switch mounts 100 to 150 elements. diff --git a/.tegami/2026-09-27-stale-statics.md b/.tegami/2026-09-27-stale-statics.md deleted file mode 100644 index b3a48205..00000000 --- a/.tegami/2026-09-27-stale-statics.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### Entering play mode again no longer throws `MissingReferenceException` - -With domain reload off in Enter Play Mode Options, the user-agent stylesheet, polyfills, default -sprites, materials and fonts ReactUnity keeps in static fields survived from one play session to the -next. When one of them was destroyed in between, which a reimport does, the next session read the -destroyed object and threw `MissingReferenceException: The object of type 'UnityEngine.TextAsset' has -been destroyed`. They are now loaded again when that happens. - -Warnings that are shown once, such as an unknown pseudo-class, are shown once per play session -rather than once per domain reload. diff --git a/.tegami/2026-09-27-stencil-clip-path.md b/.tegami/2026-09-27-stencil-clip-path.md deleted file mode 100644 index f4d829c3..00000000 --- a/.tegami/2026-09-27-stencil-clip-path.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: minor - npm:@reactunity/renderer: - type: minor ---- - -### `clip-path` can be cut with the stencil, and `shape-rendering` chooses when - -A `clip-path` rendered its element off screen and composited it back through the shape: an -antialiased edge, a render target per element, and a camera render whenever anything inside changed. -A plain rectangle -- `inset()`, `rect()` or `xywh()` without `round`, or a bare geometry box -- is now -cut with the stencil instead, as `overflow: hidden` is, since an axis-aligned edge loses nothing. - -The new, inherited `shape-rendering` property extends that: `optimizeSpeed` or `crispEdges` cuts rounded -boxes, circles, ellipses and simple polygons with the stencil too, with an aliased edge. -`geometricPrecision` keeps even a rectangle on the antialiased path. - -- An element that needs the render anyway (`filter`, `mix-blend-mode`, `mask-image`, `perspective`, - `isolation: isolate`), or that also has `overflow: hidden`, keeps its clip on the render. -- A stencil clip does not isolate a descendant's `mix-blend-mode`; `isolation: isolate` restores that. -- Hit testing follows the shape either way. diff --git a/.tegami/2026-09-27-style-engine-cost.md b/.tegami/2026-09-27-style-engine-cost.md deleted file mode 100644 index 1168d565..00000000 --- a/.tegami/2026-09-27-style-engine-cost.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### The style engine does less work per element - -Measured against the kitchen-sink's Tailwind build on a 520-element tree: -- Inserting the stylesheet takes 88 ms instead of 112 ms. -- Styling a freshly mounted tree takes 155 ms instead of 197 ms. -- Applying a recomputed style to every element takes 29 ms instead of 46 ms. -- Reading every property off fresh styles takes 24 ms instead of 43 ms. -- A frame with forty cards mid-transition takes 2.2 ms instead of 4.0 ms. - -Most of the saving is in how a property is looked up: -- An element's declarations are merged into one table on first read. -- The lookup passes the property's slot around instead of probing for it again. -- A resolved value is not wrapped in a fresh copy of itself on every read. -- `var()` looks up its interned property instead of allocating a new one. -- A keyword, a value with no `var()` in it, and a value with no function in it are recognised without the parsers that used to try each one. -- Rule matching sorts its candidates in place, and selectors and `@media` conditions are split without regular expressions. - -A transition or an animation used to throw away every cached value in each child of the element it moved, every frame. A compositor-only frame (`opacity`, `translate`, `rotate`, `scale` or layout) now leaves the children's caches alone unless one of them reads its parent through `inherit`. Writing one of those four properties no longer drops the element's other resolved values either. - -Custom property names are case-sensitive, as CSS says. `--Foo` and `--foo` used to share one entry in the property registry. diff --git a/.tegami/2026-09-27-style-invalidation.md b/.tegami/2026-09-27-style-invalidation.md deleted file mode 100644 index d21a0b96..00000000 --- a/.tegami/2026-09-27-style-invalidation.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -packages: - upm:com.reactunity.core: - type: patch ---- - -### Interacting with a styled page no longer restyles most of it - -A class change, a hover, or an element arriving used to restyle the element and every descendant, -and often its siblings too, whether or not any rule could tell the difference. Now the rules are -matched again first, and an element whose matched declarations did not change keeps its computed -style, along with everything that inherits from it. Arriving and leaving siblings rematch only the -elements that a `:nth-child`, `:last-child`, `+` or `~` rule actually reads. Matching itself goes -through an index of each rule's id, class, and tag, so an element is only tested against the rules -that could apply to it. Across the 410 elements of the kitchen-sink Game HUD, matching takes 7 ms -instead of 68 ms. - -A `var()` used to parse its text again on every read, and a `var()` inside a shorthand re-expanded -the whole shorthand on every read. Each converter and shorthand now keeps what it parsed. Transition -and animation timings are also read once per style rather than once per tick. Together these cut the -Game HUD's idle garbage from 326 KB to 21 KB a frame, which is what made its periodic GC stutters. - -On that page, taking a hit went from a 246 ms frame to 33 ms, showing a tooltip went from about -100 ms to 40 ms, and the idle median frame went from 17.8 ms to 14.5 ms. diff --git a/.tegami/publish-lock.yaml b/.tegami/publish-lock.yaml index 1b292442..7e0cafbe 100644 --- a/.tegami/publish-lock.yaml +++ b/.tegami/publish-lock.yaml @@ -1,27 +1,9 @@ core:changelogs: - - content: "---\npackages:\n npm:@reactunity/renderer:\n type: minor\n upm:com.reactunity.core:\n type: minor\n---\n\n### Logical borders and sizing\n\nThe painted border properties have their logical spellings — `border-inline-color`,\n`border-start-start-radius` and the rest — so `direction: rtl` turns a coloured edge or a rounded corner\naround, and Tailwind's `border-s-*` and `rounded-s-*` do something. `inline-size`, `block-size` and their\n`min-`/`max-` forms are aliases for the width and height properties.\n" - filename: 2026-09-12-logical-borders-and-sizing.md + - content: "---\npackages:\n npm:@reactunity/renderer:\n type: patch\n---\n\n### `flushSync` works again\n\n`flushSync` from `@reactunity/renderer` threw `TypeError: not a function` on every call. It called\nthe reconciler's `flushSync`, which react-reconciler 0.33 renamed to `flushSyncFromReconciler`, and\nthe reconciler's typings still declare the old name, so nothing caught it. It now commits the\nupdates its callback schedules before it returns, including on roots rendered with\n`disableBatchRendering`.\n" + filename: 2026-09-25-flushsync.md v: 0.0.0 - - content: "---\npackages:\n npm:@reactunity/renderer:\n type: minor\n upm:com.reactunity.core:\n type: minor\n---\n\n### `scroll-padding`, `scroll-margin` and `scroll-snap-stop`\n\nThe three scroll-snap properties that were missing are in: `scroll-padding` insets the box a snap target\nlines up against, `scroll-margin` outsets the target itself, and `scroll-snap-stop: always` keeps a fling\nfrom carrying past a target on its way to one further along.\n" - filename: 2026-09-12-scroll-padding-margin-stop.md - v: 0.0.0 - - content: "---\npackages:\n npm:@reactunity/renderer:\n type: minor\n upm:com.reactunity.core:\n type: minor\n---\n\n### `background-clip`\n\n`background-clip` takes one value per background layer, and all four of CSS's values now do what they say.\n`text` cuts a layer down to the element's own glyphs — a gradient or an image painted through the text,\nwhich with `color: transparent` is the gradient-text effect. The glyphs are rasterised into a coverage\ntexture by a command buffer and only when they move, so an idle element costs nothing.\n\nThe three boxes used to be one box. Every background was masked at the padding edge, so a background never\nreached under its own border: a dashed, dotted or semi-transparent border showed the page through its gaps\nrather than the element, and a rounded one took the border's inner radius instead of its own. The mask is the border box now, which is both\nwhat `border-box` means and what CSS makes the default; `padding-box` and `content-box` are cut per layer\nin the fragment shader, with the radii that box has left, and cost nothing when there is no border or\npadding to separate them.\n\nMoving that mask corrected an inset `box-shadow` with it. One is cast by the padding edge, which is where\nit is now drawn and rounded — it used to be cast by the border box and then trimmed back to the padding\nbox by the mask, so on a bordered element it sat a border-width out from where CSS puts it.\n" - filename: 2026-09-13-background-clip.md - v: 0.0.0 - - content: "---\npackages:\n npm:@reactunity/renderer:\n type: patch\n upm:com.reactunity.core:\n type: patch\n---\n\n### `calc()` keeps a percentage\n\nA `calc()` that works out to a percentage is one now, on every property Yoga can hold a percentage for:\n`width: calc(1/2 * 100%)` is `50%`, and so `w-1/2`, `left-1/2`, `-translate-x-1/2` and `basis-1/3` — which\nis how a CSS framework spells every fraction it has — lay out instead of dropping. The percentage is\ncarried through the arithmetic rather than resolved on sight, which is what used to throw the answer away:\nthe parent it was resolved against had not been laid out yet, so the declaration came out as nothing at\nall.\n\nIt survives a sub-expression too, parenthesised or a nested `calc()` — `calc(calc(1 / 2 * 100%) * -1)`,\nwhich is what a framework emits for every *negative* fraction utility. A group is worked out before the\ncalculation around it, and the length or percentage it came out as is an operand again exactly as it\nstands, rather than something the arithmetic around it could not read.\n\nOn a property Yoga does not hold as a percentage -- `border-left-width`, `scroll-margin-left` -- a\ncalculated percentage now resolves to the same number the percentage written out gets, rather than\nreaching the property as a value of the wrong type.\n\nA calculation mixing a percentage with a length, `calc(100% - 2rem)`, is still dropped. Yoga has one unit\nper value and no calc of its own, so there is nothing to hand it.\n\n`infinity` is a number as CSS Values 4 asks — the largest length the implementation supports, not a real\ninfinity. `border-radius: calc(infinity * 1px)`, which is what a `rounded-full` utility compiles to, used\nto come out of the corner reduction as NaN and leave the element painting nothing.\n\nUnder `REACT_UNITY_DEVELOPER`, a declaration that resolves to nothing now says so once in the console,\nnaming the property and the value. A dropped declaration was silent before, which is the part of this that\ncost the most to find.\n" - filename: 2026-09-13-calc-percentage.md - v: 0.0.0 - - content: "---\npackages:\n npm:@reactunity/renderer:\n type: minor\n upm:com.reactunity.core:\n type: minor\n---\n\n### `overscroll-behavior`\n\n**Scrolling now chains.** A `` that has reached its end hands the rest of the gesture to the\nnearest scroll box above it, as a page does on the web. Until now the innermost box swallowed every\ntick and every drag it was given, so a short list inside a long page was a dead spot: reaching its\nend stopped the scroll rather than carrying on with the page behind it.\n\n`overscroll-behavior` is how a box keeps a scroll to itself instead. `contain` stops the handover,\n`none` stops it and drops the local overscroll effect with it -- which here is ``'s\n`elasticity` prop, so `none` on an elastic box takes its bounce away where `contain` leaves it. Both\naxes are separate: the shorthand takes one value or two with `x` first, `overscroll-behavior-x` and\n`-y` set them individually, and `overscroll-behavior-inline` and `-block` are the same two under\ntheir logical names. The axis a gesture runs along is the one that decides, so a contained `y` does\nnot trap a sideways scroll.\n\nA gesture stays with whichever box took it, rather than being handed over the moment that one\nreaches its own end: dragging a list down to its end and on past it does not then start dragging the\npage. That is the latching a browser does, and it is measured from where the drag began, so a slow\none is not mistaken for a stuck one.\n\nChaining walks the element tree rather than the transform hierarchy, so a `filter` or a\n`perspective` in between -- both of which reparent their subtree onto an offscreen canvas -- does\nnot break it.\n" - filename: 2026-09-13-overscroll-behavior.md - v: 0.0.0 - - content: "---\npackages:\n npm:@reactunity/renderer:\n type: minor\n upm:com.reactunity.core:\n type: minor\n---\n\n### `perspective`, `perspective-origin` and `backface-visibility`\n\n3D transforms have always composed here — `rotateX` and `rotateY` set a real rotation, and children keep\ntheir own depth the way `transform-style: preserve-3d` describes — but nothing projected them, so a\nrotation only ever foreshortened linearly and never converged anywhere.\n\n`perspective` on a parent now does. Its subtree is captured through a camera of its own, the same offscreen\nmachinery `filter` uses, with an off-axis frustum standing the viewer wherever `perspective-origin` puts\nthem; the capture grows to hold whatever the projection throws outside the element's box, so a card leaning\nout of its stage is not clipped. `backface-visibility: hidden` is separate and needs no perspective: it\nwatches the element's winding — which catches a mirror as well as a rotation, as CSS does — and takes the\nelement out of both drawing and hit testing while its back is turned.\n\nThere is still no `transform-style`. Its `preserve-3d` is what everything already does, and its default\n`flat` would need a shear the transform model has nowhere to put.\n\nA `transform` whose functions run together with no space between them -- `rotateX(35deg)rotateY(-30deg)`,\nwhich is what every CSS minifier emits -- now parses. It was being read as one token naming no function,\nso a built stylesheet silently lost the whole declaration while the same rule worked in source.\n\n**`translate-z` has changed sign.** A positive value now comes *towards* the viewer, as CSS says and as\n`rotateX`/`rotateY` already did. Nothing could show the difference under an orthographic screen-space\ncanvas, which is why it went unnoticed; a `perspective` shows it immediately. Code that leaned on the old\ndirection — most likely ordering elements in a world-space canvas — wants the opposite sign now.\n" - filename: 2026-09-13-perspective-and-backface.md - v: 0.0.0 - - content: "---\npackages:\n npm:@reactunity/renderer:\n type: patch\n---\n\n### Logical border radii survive the Vite build\n\nThe preset names an old CSS target so that Lightning CSS lowers what ReactUnity's CSS subset has no\nparser for. `border-start-start-radius` and its three siblings were caught by that too, and their\nlowered form is a pair of rules selected by `:-webkit-any(:lang(ae),:lang(ar),...)` naming every\nright-to-left language -- pseudo-classes ReactUnity reads as custom states, which never match, so the\nradius was dropped and the console carried a warning for each. Lowering is excluded for the logical\nproperties now, the way it already was for `light-dark()`: both are things the renderer resolves\nitself, and against the element's own direction rather than the document's language.\n" - filename: 2026-09-14-lightningcss-logical-properties.md - v: 0.0.0 - - content: "---\npackages:\n npm:@reactunity/renderer:\n type: minor\n upm:com.reactunity.core:\n type: minor\n---\n\n### Relative color syntax\n\nEvery color function takes a `from ` origin, which is CSS Color 5's relative color syntax:\n`rgb()`, `hsl()`, `hsv()`, `lab()`, `lch()`, `oklab()` and `oklch()`. The origin is converted into the\nfunction's own space and its channels become keywords the channel slots can use, on their own or inside\n`calc()` — so `rgb(from var(--brand) r g b / 50%)` is a brand color at half opacity without repeating it,\nand `oklch(from currentcolor calc(l * 0.8) c h)` is a hover shade of whatever the element's own color\nturns out to be. A keyword may appear in any slot, so `rgb(from red b g r)` is blue.\n\nEach keyword carries the range CSS gives it — `r` is `0`–`255`, `hsl()`'s `s` is `0`–`100`, `oklch()`'s\n`l` is `0`–`1`, a hue is degrees, `alpha` is `0`–`1` — so a literal mixes with one exactly as it would in\na browser. An omitted alpha is the origin's own rather than `1`, so `rgb(from r g b)` is that\ncolor unchanged.\n\nThe origin stays lazy, so it may be a `var()`, `currentColor`, a `color-mix()` or another relative color,\nand one parsed rule resolves against each element it lands on. A literal origin is decomposed while\nparsing instead, so the whole function still folds to a constant.\n\nTwo things the color functions have always got wrong were in the way, and are fixed:\n\n`hsl()` and `hsv()` read saturation and lightness on CSS's `0`–`100` scale rather than `0`–`1`, so the\n`%` may be left off as every framework that emits `hsl(152 37 59)` expects. Written with percentages\nthey are unchanged; written as bare fractions — `hsl(152, 0.37, 0.59)`, which was the only spelling that\nused to work — they now mean what CSS says they mean, which is very nearly black.\n\nOut-of-range components are clamped rather than carried through the conversion, which is what CSS\nColor 4 asks of a specified color and what this arithmetic reaches constantly: `rgb(300 0 0)` is red,\n`hsl(0 150 50)` is a saturation of `100%`, and `calc(l * 4)` is a lightness of `1` rather than a color\nthat paints nothing. A saturation past `100%` used to come out of the conversion as a different hue\nentirely. Alpha is clamped everywhere, hue wraps, and a channel written as `none` is `0` in `rgb()`,\n`hsl()` and `hsv()` as it already was in the Lab family.\n" - filename: 2026-09-14-relative-color-syntax.md + - content: "---\npackages:\n upm:com.reactunity.core:\n type: minor\n npm:@reactunity/renderer:\n type: minor\n---\n\n### `clip-path` can be cut with the stencil, and `shape-rendering` chooses when\n\nA `clip-path` rendered its element off screen and composited it back through the shape: an\nantialiased edge, a render target per element, and a camera render whenever anything inside changed.\nA plain rectangle -- `inset()`, `rect()` or `xywh()` without `round`, or a bare geometry box -- is now\ncut with the stencil instead, as `overflow: hidden` is, since an axis-aligned edge loses nothing.\n\nThe new, inherited `shape-rendering` property extends that: `optimizeSpeed` or `crispEdges` cuts rounded\nboxes, circles, ellipses and simple polygons with the stencil too, with an aliased edge.\n`geometricPrecision` keeps even a rectangle on the antialiased path.\n\n- An element that needs the render anyway (`filter`, `mix-blend-mode`, `mask-image`, `perspective`,\n `isolation: isolate`), or that also has `overflow: hidden`, keeps its clip on the render.\n- A stencil clip does not isolate a descendant's `mix-blend-mode`; `isolation: isolate` restores that.\n- Hit testing follows the shape either way.\n" + filename: 2026-09-27-stencil-clip-path.md v: 0.0.0 core:packages: - id: npm:@reactunity/monorepo @@ -31,14 +13,8 @@ core:packages: - id: npm:@reactunity/material updated: true - changelogIds: - - 2026-09-12-logical-borders-and-sizing.md - - 2026-09-12-scroll-padding-margin-stop.md - - 2026-09-13-background-clip.md - - 2026-09-13-calc-percentage.md - - 2026-09-13-overscroll-behavior.md - - 2026-09-13-perspective-and-backface.md - - 2026-09-14-lightningcss-logical-properties.md - - 2026-09-14-relative-color-syntax.md + - 2026-09-25-flushsync.md + - 2026-09-27-stencil-clip-path.md id: npm:@reactunity/renderer updated: true - id: npm:@reactunity/scripts diff --git a/CHANGELOG.md b/CHANGELOG.md index 93080ddc..ae1fe144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,363 @@ +## 0.26.0 + +### `flushSync` works again + +`@reactunity/renderer` + +`flushSync` from `@reactunity/renderer` threw `TypeError: not a function` on every call. It called +the reconciler's `flushSync`, which react-reconciler 0.33 renamed to `flushSyncFromReconciler`, and +the reconciler's typings still declare the old name, so nothing caught it. It now commits the +updates its callback schedules before it returns, including on roots rendered with +`disableBatchRendering`. + +### QuickJS runs JavaScript about a third faster + +`com.reactunity.quickjs` + +The engine moves to quickjs-ng 0.17.0 plus the fork's interpreter work: inline caches on property +access, cached global variable slots, fused compare-and-branch and faster Map, Set, JSON and string +paths. Octane scores 1.33× the previous build on Windows and Linux. Inside Unity, parsing a large +bundle is 1.4–1.7× faster and general JavaScript 1.2–1.5×. Rendering cost is unchanged, because +building UGUI objects on the C# side dominates it. + +Recursion depth on Windows is slightly lower than before. The interpreter's frame on MSVC grew with +this release, so the deepest React tree the Editor can commit at the default +`ScriptRuntime.MaxStackSize` went from 36 levels to 32. + +### Unmounting a long list is no longer quadratic + +`com.reactunity.core` + +Removing an element re-resolved the styles of every sibling it left behind, on the spot, on top of +the deferred resolve its removal already scheduled. Clearing a list of 1000 elements took 5.8 s; +it now takes 100 ms. Styles still update on the parent's next update, the same way they already +did when a child was added. + +### `PoolingType.All` pools elements under the default renderer, and a reused one starts clean + +`com.reactunity.core` + +Under `PoolingType.All`, the batched renderer (the default) never pooled an element. It sends the +pool key of an element without a `pool` prop as `null`, and the command reader turned that into +`""`, which means `pool={false}`. The unbatched renderer pooled them all along, so the two +disagreed. A missing key now reaches the context as `null` and pools under `All` in both. `pool={false}` +still opts an element out. `Basic`, the default, only pools text and pseudo elements and is +unchanged. + +A reused element also used to keep every prop its previous owner set that the new one does not, +because a create command carries only the props that are present. Styles were already reset. Props +were not, so a reused element could still be hidden by `active={false}`, still checked or disabled, +still pointing at another element's `href`, camera, prefab target, video, SVG content or icon set, +or still limited by an input's `characterLimit` and `contentType`. Each UGUI element now puts its +props back to what a new one has. An element with a camera, prefab target or video lets go of it +when it is unmounted, not when it is reused. + +- A pooled element is dropped from the ref table, so its old ref id no longer resolves to whatever + element reuses it. +- An element whose `Pool()` refuses it is destroyed. It used to be left in the scene. +- Disposing a context no longer pools anything, so a portal under `All` is destroyed with its + context. Before, it was pooled and outlived the context. +- A reused scrollbar has its `data-horizontal`/`data-vertical`/`data-direction` again, and an + input no longer drives a scrollbar that went back to the pool. +- A filtered element lets go of its filter when it is pooled. Its offscreen surface and the + composite it left in its old parent were kept, so whatever reused that parent showed a stale + capture. A reused element that no longer had a filter was also moved back under its first + owner's parent. +- An element whose `backdrop-filter` or `outline` goes away, whether through a style change or + through reuse, stops drawing it. Both used to stay once set. +- UIToolkit and Editor elements are no longer pooled under `All`. A `VisualElement` is cheap to + build, and each kind has native fields a reuse would have to reset one by one. Text and pseudo + elements are pooled as before. + +### Filters and backdrops render less often + +`com.reactunity.core` + +Every `filter`, `clip-path`, `mask-image`, `isolation`, `mix-blend-mode` and `backdrop-filter` costs a +camera render, roughly 2 ms each under URP whatever it draws. So a page is as fast as the number of +those it takes per frame. The kitchen-sink Game HUD went from ~52 ms to ~41 ms a frame in the editor: + +- **A filtered element that only moves keeps its capture.** Translating or rotating the element, or + moving its descendants without changing where they sit relative to it, used to re-capture. Now only + the composite moves. +- **A `backdrop-filter` inside a filter re-renders only when something behind it changed**, as ones on + the page already did. +- **Backdrop readers with nothing drawn between them share a render.** +- **Scroll views clip once a frame** rather than once per render. UGUI re-culls every graphic under a + `RectMask2D` on every camera render, which was ~0.5 ms a render on a page with three scroll views. +- **Offscreen cameras skip HDR, MSAA and occlusion culling.** +- **Animating only `translate`, `rotate`, `scale`, `opacity` or layout properties no longer re-applies + the whole style.** The element is moved, faded and laid out again, and nothing it draws is rebuilt. + +### Newtonsoft.Json is no longer a dependency + +`com.reactunity.core` + +The renderer's command buffer is now read by a small JSON reader of ReactUnity's own instead of +Newtonsoft's token tree, which parses it about four times as fast. Mounting a list of 1000 styled +elements takes about 45 ms less, and updating it 16% less. + +A string prop that looks like a date now arrives as written. Newtonsoft turned it into a `DateTime`, +so a prop like `"2026-09-26T10:00:00.000Z"` reached C# reformatted in the current culture. + +`com.unity.nuget.newtonsoft-json` is no longer pulled in by `com.reactunity.core`. A project that +used Newtonsoft without depending on it itself needs to add it to its own manifest. + +### An opaque, clipped `isolation: isolate` no longer takes a capture + +`com.reactunity.core` + +`isolation: isolate` renders the element off screen so its descendants' `mix-blend-mode` can only +blend within it. When the element has an opaque background, full opacity and `overflow: hidden`, that +group holds exactly what the page would, so the capture is now skipped. It comes back as soon as any +of the three changes, animated opacity included. The kitchen-sink Game HUD's `isolate` on its screen +was a full-screen camera render every frame. + +URP's backdrop pass also stops re-walking the canvas every frame when nothing was added, removed or +reordered, which took it from 1.4 ms to 0.4 ms on the Game HUD. The walk found a bug on the way: a +`Mask` added to a graphic after it started reading the backdrop drew its stencil pop too early, so +the masked content disappeared. + +### Pointer events and captures work through nested filters + +`com.reactunity.core` + +An element with `filter`, `clip-path`, `mask-image`, `isolation`, `mix-blend-mode` or `perspective` +is drawn from an offscreen capture. When one of those sat inside another, several things went +wrong, and a page built from many of them, like the kitchen-sink Game HUD, showed all of them: + +- **Clicks stopped reaching the page.** The inner filter's raycaster mapped the pointer straight + into the outer filter's camera instead of through the outer capture. It also reported that + offscreen camera as its event camera, which outranks the page camera, so its misplaced hits won + every click. A pointer now goes through each capture it is nested in, and the outermost filter + casts for everything inside it. +- **Overlapping filters took each other's clicks.** Two filtered siblings tied, so which one got a + click came down to the order their raycasters registered in. The one painted on top now wins. +- **Captures flickered.** The object that renders queued captures is created by the first one, so + on that frame a filter could be queued twice. It was moved into the shared render twice and + back once, left there, and drew into other elements' captures for a frame at a time. +- **An outer filter could show nothing, or a stale copy, of an inner one.** Captures ran in an + order that only counted nesting up to the first surface, so an outer clip could be captured + before the fill inside it. And an inner filter that re-captured never told the outer one to + do the same. Inner captures now run first, and a finished one marks its outer filter for + re-capture. + +### Faster element creation + +`com.reactunity.core` + +The style system's name tables compared keys with the invariant culture, which under Mono builds a +sort key on every hash, so looking up a property name cost about 13 µs. They compare ordinally now, +which matches the same ASCII names, and an inline `style` prop applies in about half the time. + +UGUI elements are also created with their `RectTransform` in place, instead of adding one that +replaces the `Transform` Unity started them with. Together these take about 80 ms off mounting a +list of 1000 elements. + +### `backdrop-filter` and `mix-blend-mode` no longer cost a render under URP + +`com.reactunity.core` + +URP has no `GrabPass`, so every element that reads the backdrop used to cost a camera render of its +own, about 1.5-2.5 ms each in the editor. On URP 17 (Unity 6) and later the canvas is now drawn by a +pass inside the camera's own render instead, which copies the screen just before each reader, as a +`GrabPass` does. A reader costs about 0.03 ms, and the result is pixel-identical to the render it +replaces. The kitchen-sink Game HUD's page backdrops went from 5.5 ms a frame to 0.5 ms. + +- It needs the render graph, so a project in URP's *Compatibility Mode* keeps rendering per reader. +- Readers inside a `filter` still render, because the filter's content is drawn by a camera of its own. +- Other objects on the canvas's layer are drawn after the scene's transparent geometry rather than + sorted among it. + +It lives in the new `ReactUnity.UGUI.URP` assembly, which only compiles where URP 17 is installed. + +### With `Pooling: All`, a pooled element comes back in the role it had + +`com.reactunity.core` + +A pool used to hand back whichever element went in last, so a remount usually gave each element +someone else's role. Its background, border, shadow and mask graphics then had to be rebuilt for the +new one. Each spare is now filed under the `className` it had, and an element created with the same +`className` gets that spare first. When no spare matches, the pool falls back to the latest one as +before. This covers the default batched renderer. The `disableBatchRendering` path still gets the +latest spare, because its props arrive as a script object that is only read once the element exists. + +`IPoolableComponent.PoolStack` is now a `PoolStack`, not a `Stack`. +`IPoolableComponent` also gains `PoolHint`: the `className` that files the component in its pool. + +An element now parks at most two surplus box shadows and destroys the rest. Before this, every +pooled element kept as many shadows as any role had ever given it, and on the kitchen-sink Game HUD +the count grew from 165 to over 740 across 60 tab switches. + +A revived element with no `className` also no longer reports the one it had before it was pooled. + +### Animating an element no longer restyles everything inside it + +`com.reactunity.core` + +A change to `opacity`, `translate`, `rotate` or `scale` on an element used to re-apply the full +style of every descendant each frame, although a descendant can only see those through `inherit`. +Descendants now compare the values they would inherit and stop there when nothing changed. Across a +520-element Tailwind page, a keyframe animation on the root costs 2.1 ms a frame instead of 27.9 ms, +and a transition on 40 cards 3.4 ms instead of 28.2 ms. + +Reading a computed style is cheaper as well. An inherited `color` or `font-size` that fell back to its +default was resolved by walking every ancestor on every read, and each read went through up to four +dictionary lookups. Resolved values are now kept until the style changes, and lookups are indexed by +property. Applying a restyled page takes 36 ms instead of 48 ms, and hovering a card 0.3 ms instead +of 1.5 ms. + +A transition that has finished is no longer checked every frame for good. Inserting a large stylesheet +is faster too: the 112 KB kitchen-sink Tailwind build inserts in 145 ms instead of 237 ms, most of it +from ExCSS no longer copying the whole sheet to read each rule's selector. + +A point `transform-origin` on an element that has not been laid out yet no longer gives it a NaN position. + +### Stylesheets parse in half the time + +`com.reactunity.core` + +The bundled ExCSS parser allocates a third of what it did and does not lex a plain declaration twice +to decide whether it is a nested rule. In the Editor, the 112 KB kitchen-sink Tailwind build parses in +36 ms instead of 72 ms, and inserting it takes 95 ms instead of 149 ms. The parsed sheet is unchanged. + +### A `perspective` over one flat plane is captured face-on + +`com.reactunity.core` + +A `perspective` used to render its subtree through a camera of its own. That is a whole pipeline +entry per element on every frame the subtree moves, and it could never be packed with other captures. +When everything under the element lies in one plane, which is the usual tilted card or receding +floor, the subtree is now captured face-on and packed with the frame's other filters. The composite +draws that capture through the projection, divided per pixel so the texture stays perspective-correct, +and pointer events are mapped back through the same projection. + +When the plane is nothing but one inner filter's composite, the outer element takes no capture at all +and warps the inner one's texture. On the kitchen-sink Game HUD, whose horizon grid is a +`mask-image` inside a `perspective`, that removes one render from every frame, cutting the frame from +22.6 ms to 20.4 ms. + +A plane nearer the eye than half the `perspective` distance would be magnified more than twice, so +it keeps the camera path. So does anything the flat capture cannot express: a subtree spread over +several planes, or a perspective element that also has its own `mask-image`, `clip-path`, or a filter +that samples neighbouring pixels such as `blur()` or `drop-shadow()`. + +### A page that is not changing costs less every frame + +`com.reactunity.core` + +A settled UGUI page still did work every frame in proportion to its size. A scroll view re-culled +every graphic inside it, whether or not anything had moved. Every element and text node got its own +`LateUpdate` or `Update` from Unity, and on a settled page Unity's per-component dispatch cost more +than the callbacks did. Every element also opened a profiler marker, even when its style state had +nothing to do, and a paused or finished animation re-applied the same value each frame. + +In the Editor, the kitchen-sink Material page now takes 3.3 ms a frame instead of 5.6 ms; its canvas +update drops from 1.6 ms to 0.6 ms. The Home page takes 1.6 ms instead of 2.6 ms. On the Filter page, +pointer handling across its 81 filters takes 0.65 ms instead of 1.44 ms. Nothing renders differently: +captures of the Material page, scrolled and not, are pixel-identical with and without the change. + +### Mounting a subtree costs less, most of all with pooling on + +`com.reactunity.core` + +Reading a style property used to probe each declaration block an element matched, one at a time, +and hash the property's name again for every probe, since Mono does not cache a string's hash. The +blocks are now merged into one lookup the first time the element reads a property, and each property +hashes its name once. A first read takes about 515 ns instead of 1100 ns, and applying an element's +layout styles to Yoga dropped from 6–9 ms to about 3 ms across a mount of 150 elements. It also +produces a tenth of the garbage it used to. + +An element's background and border graphics are now created under their parent, not at the scene +root and then moved there. Each move made the graphic find its canvas again and rebuild its +material. An element with a `border-radius` and no border no longer gets a `[Border]` graphic that +draws nothing, because the mask already rounds its background. + +With `Pooling: All`, a pooled element is usually reused in a different role from the one it had +before. When the new role needed fewer box shadows or no `clip-path` stencil, the extra graphics +used to be destroyed, and they were then built again for the next element that needed them. They +are now parked: disabled and kept for the next time. A shadow or stencil whose values have not +changed also no longer rebuilds its mesh on every restyle. The backdrop pass under URP now skips the +inactive pool when it walks the canvas. + +On the kitchen-sink Game HUD, switching between the side panel's tabs takes a 53–61 ms frame in the +editor, down from 71–93 ms. Each switch mounts 100 to 150 elements. + +### Entering play mode again no longer throws `MissingReferenceException` + +`com.reactunity.core` + +With domain reload off in Enter Play Mode Options, the user-agent stylesheet, polyfills, default +sprites, materials and fonts ReactUnity keeps in static fields survived from one play session to the +next. When one of them was destroyed in between, which a reimport does, the next session read the +destroyed object and threw `MissingReferenceException: The object of type 'UnityEngine.TextAsset' has +been destroyed`. They are now loaded again when that happens. + +Warnings that are shown once, such as an unknown pseudo-class, are shown once per play session +rather than once per domain reload. + +### `clip-path` can be cut with the stencil, and `shape-rendering` chooses when + +`@reactunity/renderer`, `com.reactunity.core` + +A `clip-path` rendered its element off screen and composited it back through the shape: an +antialiased edge, a render target per element, and a camera render whenever anything inside changed. +A plain rectangle -- `inset()`, `rect()` or `xywh()` without `round`, or a bare geometry box -- is now +cut with the stencil instead, as `overflow: hidden` is, since an axis-aligned edge loses nothing. + +The new, inherited `shape-rendering` property extends that: `optimizeSpeed` or `crispEdges` cuts rounded +boxes, circles, ellipses and simple polygons with the stencil too, with an aliased edge. +`geometricPrecision` keeps even a rectangle on the antialiased path. + +- An element that needs the render anyway (`filter`, `mix-blend-mode`, `mask-image`, `perspective`, + `isolation: isolate`), or that also has `overflow: hidden`, keeps its clip on the render. +- A stencil clip does not isolate a descendant's `mix-blend-mode`; `isolation: isolate` restores that. +- Hit testing follows the shape either way. + +### The style engine does less work per element + +`com.reactunity.core` + +Measured against the kitchen-sink's Tailwind build on a 520-element tree: +- Inserting the stylesheet takes 88 ms instead of 112 ms. +- Styling a freshly mounted tree takes 155 ms instead of 197 ms. +- Applying a recomputed style to every element takes 29 ms instead of 46 ms. +- Reading every property off fresh styles takes 24 ms instead of 43 ms. +- A frame with forty cards mid-transition takes 2.2 ms instead of 4.0 ms. + +Most of the saving is in how a property is looked up: +- An element's declarations are merged into one table on first read. +- The lookup passes the property's slot around instead of probing for it again. +- A resolved value is not wrapped in a fresh copy of itself on every read. +- `var()` looks up its interned property instead of allocating a new one. +- A keyword, a value with no `var()` in it, and a value with no function in it are recognised without the parsers that used to try each one. +- Rule matching sorts its candidates in place, and selectors and `@media` conditions are split without regular expressions. + +A transition or an animation used to throw away every cached value in each child of the element it moved, every frame. A compositor-only frame (`opacity`, `translate`, `rotate`, `scale` or layout) now leaves the children's caches alone unless one of them reads its parent through `inherit`. Writing one of those four properties no longer drops the element's other resolved values either. + +Custom property names are case-sensitive, as CSS says. `--Foo` and `--foo` used to share one entry in the property registry. + +### Interacting with a styled page no longer restyles most of it + +`com.reactunity.core` + +A class change, a hover, or an element arriving used to restyle the element and every descendant, +and often its siblings too, whether or not any rule could tell the difference. Now the rules are +matched again first, and an element whose matched declarations did not change keeps its computed +style, along with everything that inherits from it. Arriving and leaving siblings rematch only the +elements that a `:nth-child`, `:last-child`, `+` or `~` rule actually reads. Matching itself goes +through an index of each rule's id, class, and tag, so an element is only tested against the rules +that could apply to it. Across the 410 elements of the kitchen-sink Game HUD, matching takes 7 ms +instead of 68 ms. + +A `var()` used to parse its text again on every read, and a `var()` inside a shorthand re-expanded +the whole shorthand on every read. Each converter and shorthand now keeps what it parsed. Transition +and animation timings are also read once per style rather than once per tick. Together these cut the +Game HUD's idle garbage from 326 KB to 21 KB a frame, which is what made its periodic GC stutters. + +On that page, taking a hit went from a 246 ms frame to 33 ms, showing a tooltip went from about +100 ms to 40 ms, and the idle median frame went from 17.8 ms to 14.5 ms. + ## 0.25.0 ### Logical borders and sizing diff --git a/packages/create/package.json b/packages/create/package.json index 2d2721f4..76dbd4fd 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -1,6 +1,6 @@ { "name": "@reactunity/create", - "version": "0.25.0", + "version": "0.26.0", "description": "Scaffold a ReactUnity app using npm init", "license": "MIT", "//type": "ESM, as of the chalk 6 / commander 15 upgrade: both dropped CommonJS. index.ts is emitted next to itself as ESM, so `bin` keeps pointing at index.js.", diff --git a/packages/create/scaffold/Packages/manifest.json b/packages/create/scaffold/Packages/manifest.json index d4c15c5d..4c639a4a 100644 --- a/packages/create/scaffold/Packages/manifest.json +++ b/packages/create/scaffold/Packages/manifest.json @@ -1,7 +1,7 @@ { "dependencies": { - "com.reactunity.core": "0.25.0", - "com.reactunity.quickjs": "0.25.0", + "com.reactunity.core": "0.26.0", + "com.reactunity.quickjs": "0.26.0", "com.unity.ai.navigation": "2.0.14", "com.unity.editorcoroutines": "1.1.0", "com.unity.ide.visualstudio": "2.0.26", diff --git a/packages/create/scaffold/react/package.json b/packages/create/scaffold/react/package.json index 748bbdc1..5ea5659d 100644 --- a/packages/create/scaffold/react/package.json +++ b/packages/create/scaffold/react/package.json @@ -10,11 +10,11 @@ "lint": "react-unity-scripts lint" }, "dependencies": { - "@reactunity/renderer": "^0.25.0", + "@reactunity/renderer": "^0.26.0", "react": "^19.1.0" }, "devDependencies": { - "@reactunity/scripts": "^0.25.0", + "@reactunity/scripts": "^0.26.0", "typescript": "^5.8.3" } } diff --git a/packages/material/package.json b/packages/material/package.json index 90b117b8..0fe945a3 100644 --- a/packages/material/package.json +++ b/packages/material/package.json @@ -1,6 +1,6 @@ { "name": "@reactunity/material", - "version": "0.25.0", + "version": "0.26.0", "description": "Material Design components and utilities for React Unity", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/renderer/CHANGELOG.md b/packages/renderer/CHANGELOG.md index e373daec..ee87fa45 100644 --- a/packages/renderer/CHANGELOG.md +++ b/packages/renderer/CHANGELOG.md @@ -1,3 +1,29 @@ +## @reactunity/renderer@0.26.0 + +### `flushSync` works again + +`flushSync` from `@reactunity/renderer` threw `TypeError: not a function` on every call. It called +the reconciler's `flushSync`, which react-reconciler 0.33 renamed to `flushSyncFromReconciler`, and +the reconciler's typings still declare the old name, so nothing caught it. It now commits the +updates its callback schedules before it returns, including on roots rendered with +`disableBatchRendering`. + +### `clip-path` can be cut with the stencil, and `shape-rendering` chooses when + +A `clip-path` rendered its element off screen and composited it back through the shape: an +antialiased edge, a render target per element, and a camera render whenever anything inside changed. +A plain rectangle -- `inset()`, `rect()` or `xywh()` without `round`, or a bare geometry box -- is now +cut with the stencil instead, as `overflow: hidden` is, since an axis-aligned edge loses nothing. + +The new, inherited `shape-rendering` property extends that: `optimizeSpeed` or `crispEdges` cuts rounded +boxes, circles, ellipses and simple polygons with the stencil too, with an aliased edge. +`geometricPrecision` keeps even a rectangle on the antialiased path. + +- An element that needs the render anyway (`filter`, `mix-blend-mode`, `mask-image`, `perspective`, + `isolation: isolate`), or that also has `overflow: hidden`, keeps its clip on the render. +- A stencil clip does not isolate a descendant's `mix-blend-mode`; `isolation: isolate` restores that. +- Hit testing follows the shape either way. + ## @reactunity/renderer@0.25.0 ### Logical borders and sizing diff --git a/packages/renderer/package.json b/packages/renderer/package.json index d6dea31a..5249baeb 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -1,6 +1,6 @@ { "name": "@reactunity/renderer", - "version": "0.25.0", + "version": "0.26.0", "description": "React renderer for Unity3D", "types": "dist/index.d.ts", "typings": "dist/index.d.ts", diff --git a/packages/scripts/package.json b/packages/scripts/package.json index e4e04ae8..27baf86f 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -1,6 +1,6 @@ { "name": "@reactunity/scripts", - "version": "0.25.0", + "version": "0.26.0", "description": "Configuration and scripts for React Unity.", "repository": { "type": "git", diff --git a/unity/clearscript/package.json b/unity/clearscript/package.json index 930bfd99..e52d7f01 100644 --- a/unity/clearscript/package.json +++ b/unity/clearscript/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/KurtGokhan" }, "displayName": "React Unity ClearScript", - "version": "0.25.0", + "version": "0.26.0", "unity": "2019.4", "description": "ClearScript (V8) Plugin for ReactUnity", "keywords": ["ReactUnity", "ClearScript", "V8"], diff --git a/unity/core/package.json b/unity/core/package.json index 0f4cb010..61685e7a 100644 --- a/unity/core/package.json +++ b/unity/core/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/KurtGokhan" }, "displayName": "React Unity", - "version": "0.25.0", + "version": "0.26.0", "unity": "2021.3", "description": "React renderer for Unity", "keywords": ["React", "Framework", "UI", "UGUI", "UIToolkit"], diff --git a/unity/jint/package.json b/unity/jint/package.json index e8965c8d..a4e2bf82 100644 --- a/unity/jint/package.json +++ b/unity/jint/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/KurtGokhan" }, "displayName": "React Unity Jint", - "version": "0.25.0", + "version": "0.26.0", "unity": "2019.4", "description": "Jint Plugin for ReactUnity", "keywords": ["ReactUnity", "Jint"], diff --git a/unity/quickjs/package.json b/unity/quickjs/package.json index 0b639c95..1e846e46 100644 --- a/unity/quickjs/package.json +++ b/unity/quickjs/package.json @@ -6,7 +6,7 @@ "url": "https://github.com/KurtGokhan" }, "displayName": "React Unity QuickJS", - "version": "0.25.0", + "version": "0.26.0", "unity": "2019.4", "description": "QuickJS Plugin for ReactUnity", "keywords": ["ReactUnity", "QuickJS"],