Skip to content

Rethrow dirtied function exceptions after the Wasm call returns - #2033

Open
greekr4 wants to merge 1 commit into
react:mainfrom
greekr4:fix-dirtied-exception
Open

greekr4 wants to merge 1 commit into
react:mainfrom
greekr4:fix-dirtied-exception

Conversation

@greekr4

@greekr4 greekr4 commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Summary

A dirtied function runs while Yoga marks nodes dirty: from style setters, insertChild, removeChild, markDirty, copyStyle, and setIsReferenceBaseline. If it throws, the exception unwinds the WebAssembly frames, which causes three problems:

  • Stack leak: Emscripten's stack pointer isn't restored (emscripten#21350). On main, the module fails with memory access out of bounds after about 4,000 throws.
  • Missed layout changes: dirty propagation stops on the first throw. The ancestors stay clean, so the next layout misses the change.
  • Out-of-sync JS tree: a throw from insertChild() or removeChild() skips the JS-side children list and parent update, while Yoga's tree has already changed.

Changes

  • The dirtied function bridge holds the exception, so the Wasm call finishes and dirty propagation completes. The call that marked the nodes dirty rethrows it once it returns, so callers still get the original error.
  • A Yoga call made from inside a dirtied function gets its own error, and doesn't see the one held for the outer call.
  • insertChild() and removeChild() keep the JS tree in sync when a dirtied function throws. When Yoga itself aborts (for example, inserting a child that already has an owner), they leave the JS tree unchanged, as on main.
  • The dirtying calls are listed explicitly. They are wrapped (a closure plus a try/catch) only while at least one dirtied function is set, so code that never sets one runs exactly as before.

Two behaviors change when a dirtied function throws:

  • Dirtied functions on the ancestors still run, as they do when nothing throws.
  • If more than one dirtied function throws during the same call, only the first error is rethrown. A WebAssembly.RuntimeError takes precedence over an earlier plain error, so an abort isn't hidden.

#2032 handles the same stack problem for measure functions. The two PRs don't depend on each other.

Test Plan

New tests in YGDirtiedTest.test.ts:

  • dirtied_func_exception_propagates_to_caller
  • layout_works_after_repeated_dirtied_func_exceptions: 5,000 throws, then a normal layout is correct. On main it fails with memory access out of bounds.
  • dirtied_func_exception_still_marks_ancestors_dirty: on main, isDirty() returns false for the ancestors.
  • dirtied_func_exception_keeps_children_in_sync
  • dirtied_func_exception_is_rethrown_by_the_outermost_call
  • dirtied_func_exception_is_thrown_by_the_call_that_caused_it
  • dirtied_func_runtime_error_takes_precedence and dirtied_func_runtime_error_keeps_children_in_sync: these throw a WebAssembly.RuntimeError from JS, because a real abort ends the Jest run.

A real abort in insertChild() itself can't run under Jest, so I checked it with this script in a separate Node process. The result matches main, whether or not a dirtied function is set on another node:

Abort check
const p1 = Yoga.Node.create();
const p2 = Yoga.Node.create();
const child = Yoga.Node.create();
p1.insertChild(child, 0);
try {
  p2.insertChild(child, 0); // Yoga aborts: the child already has an owner
} catch (e) {
  // e is a WebAssembly.RuntimeError
}
p2.getChildCount(); // 0
child.getParent() === p1; // true

Checks run with Node 20 and emsdk 4.0.23 on an Apple M2:

  • In javascript/: yarn test passes 38 suites and 581 tests (573 before this change). yarn lint and yarn tsc are clean. yarn pack succeeds, and the generated .d.ts files are identical to main.
  • yarn benchmark: the benchmarks don't set a dirtied function, so the wrappers aren't installed. The only change on that path is the try/catch in insertChild()/removeChild(). Three runs each gave 3.7–5.3 ms per case on main and 3.0–4.9 ms with this change.
  • At the repo root: yarn format-check-javascript and yarn format-check-cpp pass. I moved my local, untracked javascript/.emsdk aside first, because Prettier would otherwise check it. I didn't run the Kotlin and Python checks, which need a local JDK; this change doesn't touch those files.

A dirtied function runs while Yoga marks nodes dirty, from style
setters, insertChild, removeChild, markDirty, copyStyle and
setIsReferenceBaseline. If it throws, the exception unwinds the
WebAssembly frames: the stack pointer is not restored, so repeated
throws eventually break the module, and dirty propagation stops, so
the ancestors stay clean and the next layout misses the change. A throw
from insertChild or removeChild also leaves the JS children list out of
sync with Yoga's tree.

The dirtied function bridge now holds the exception so the Wasm call
can finish, and the call that marked the nodes dirty rethrows it once
it returns. A Yoga call made from inside a dirtied function keeps its
own error, and a WebAssembly.RuntimeError takes precedence over an
earlier error. The calls are wrapped only while a dirtied function is
set. insertChild and removeChild keep the JS tree in sync when a
dirtied function throws, and leave it unchanged when Yoga aborts.
@meta-cla meta-cla Bot added the CLA Signed label Sep 30, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Sep 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant