Skip to content

fix(core): use latest children & options after async app.init()#650

Open
mathedu4all wants to merge 1 commit into
pixijs:mainfrom
mathedu4all:fix/create-root-stale-children-after-init
Open

fix(core): use latest children & options after async app.init()#650
mathedu4all wants to merge 1 commit into
pixijs:mainfrom
mathedu4all:fix/create-root-stale-children-after-init

Conversation

@mathedu4all

Copy link
Copy Markdown

Problem

createRoot's render() is async and awaits app.init() on the first call. If a second render() arrives while the first is still awaiting init (e.g. the parent re-renders due to a resize/layout change), the second call sees isInitialising === true, skips the init branch, applies its newer applicationOptions and commits its newer children — correct so far.

But when the first call's await app.init() finally resolves, it continues using the stale children and applicationOptions captured in its own closure: it re-applies the old options to the app (potentially reverting canvas width/height, etc.) and calls reconciler.updateContainer(...) with the old tree. This reverts both the app state and the fiber to the older values.

render(childrenA, optsA)  →  isInitialising = true  →  await app.init() … (suspended)
                                                          │
   parent re-renders (resize) during init                │
   render(childrenB, optsB) → isInitialising === true     │
                            → applies optsB, commits B  ✓ │
                                                          │
   app.init() resolves ◄──────────────────────────────────┘
   render(childrenA, optsA) resumes
                            → re-applies optsA, commits A  ✗ STALE — reverts B

We hit this on hard page refresh (not on SPA navigation): surrounding UI finishes loading and shrinks the container within the app.init() window, so a ResizeObserver-driven second render lands mid-init. The result is children rendered with the pre-resize tree/size — in our app, coordinate-bound objects snap to the screen origin instead of their correct positions.

Root cause

The first render invocation closes over both its children and applicationOptions arguments and re-uses them after the await, unaware that a later invocation already pushed newer values into the fiber/app.

Fix

Track the most recent children and applicationOptions in closure variables shared across render calls, and after await app.init() completes, use those latest values instead of the stale closure arguments.

Why this is safe

  • Only the post-init branch changes; the applicationOptions apply loop and the final updateContainer are untouched.
  • With no concurrent second render, the latest values equal the closure arguments, so behaviour is identical to before.
  • In the race case, updateContainer is called a second time with the children the concurrent render already committed — React bails out on referential equality, so there's no extra render cost.
  • This is purely "prefer the newest arguments", matching React's own last-write-wins reconciliation semantics.

Test

Adds a unit test in test/unit/core/createRoot.test.ts that reproduces the race by gating Application.prototype.init behind a controllable promise, issuing a second render() during the init window, then resolving init. It asserts the final committed children and applied options both reflect the second render. The test fails without the fix (the resumed first render overwrites with stale 'A') and passes with it.

Verified locally: vitest run unit → all unit tests pass; xs lint → no new errors.

createRoot's render() is async and awaits app.init() on the first call.
If a second render() arrives during the init window (e.g. a parent
re-render from a ResizeObserver), it skips init, applies its newer
options and commits its newer children. But when the first call's
await resolves, it resumes with the stale children and applicationOptions
from its own closure, reverting both the app state (e.g. canvas size)
and the committed fiber tree to the older values.

Track the latest children and options in shared closure variables and,
after init completes, use those instead of the stale arguments. When no
race occurs the values are identical, so behaviour is unchanged; in the
race case React bails out on the repeated children by referential
equality, so there is no extra render cost.

Adds a unit test that gates Application.prototype.init behind a
controllable promise, interleaves a second render(), and asserts the
final committed children and applied options reflect the latest call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant