Fix stale children committing when refresh() supersedes an async generator yield - #379
Open
brainkim wants to merge 1 commit into
Open
Fix stale children committing when refresh() supersedes an async generator yield#379brainkim wants to merge 1 commit into
brainkim wants to merge 1 commit into
Conversation
…yield When an async generator component calls refresh() while its next iteration is still pending, the yielded children are stale before they commit: the refresh enqueues a run which immediately resumes the generator. Whether the stale children ever reached the DOM depended on a race between two microtask chains -- the parent's commit walk and the enqueued run's diff -- whose relative order is not specified. V8 and older JavaScriptCore resolved the race one way (stale children never appear); JavaScriptCore as of Safari 26 resolves .finally() two ticks faster than V8 (measured: Promise.resolve().finally().then() lands on tick 2 vs tick 4), which flips it: the stale children flash into the DOM and the render promise resolves with them. Caught by "async generator refresh during await with direct yield" in test/cascades.tsx on webkit 26.5, which playwright-test pins too old to see. The fix makes the supersede explicit instead of racing for it. refresh() sets an IsSupersededByRefresh flag when a run is already in flight; when the pending iteration then arrives, its children are not diffed at all and the parent's diff resolves with the enqueued run's diff instead. The block chain is derived from the iteration via a closure rather than from the parent diff, because the enqueued run only starts once the block settles -- deriving it from the now-chasing parent diff would deadlock. The promise graph is unchanged whenever no supersede occurs. This matters: external re-renders must coalesce without chasing (the "for...of enqueues" test: the in-flight run commits its own children), and several tests sample the DOM mid-flight and encode the exact microtask hop counts of the current implementation. Verified: 577 passed (chromium, playwright-test), 602 passed 0 failed on webkit 2336 and webkit 2191 and chromium (libuild runner on the #375 branch with this crank.ts), and an instrumented trace shows identical mutation order on V8 and new JSC: a single commit of the fresh children, with the stale children never appearing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
brainkim
added a commit
that referenced
this pull request
Aug 12, 2026
Three backported fixes: the async generator self-refresh supersede race (engine-dependent on Safari 26's faster .finally(); main PR #379), the createElement props mutation fix (#356), and the jsx template adjacent text line whitespace fix (#359). Verified on this base: 573 passed, 3 skipped; test/jsx-tag.ts 29/29 run explicitly. The built UMD bundle traces identically on chromium and webkit 26.5: the superseded children never commit and render resolves with the fresh ones. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the webkit failure discovered on #375.
The bug
When an async generator component calls
refresh()before its nextyieldhas committed, the yielded children are stale: the refresh immediately resumes the generator, and only the fresh children should commit.Whether that held depended on a race between two unsynchronized microtask chains: the parent's commit walk vs. the enqueued run's diff. On V8 and older JavaScriptCore the enqueued diff won (Hello never appears — the behavior the cascades test asserts). On JSC as of Safari 26 the commit walk wins: Hello flashes into the DOM and
render()resolves with it.Root cause, measured
Pure-JS probe, no crank involved:
Promise.resolve().finally().then()catch().finally().then()JSC optimized
.finally()by two microtask ticks. Crank's enqueue machinery (block.finally(advance),inflight[0].finally(runComponent)) sits exactly on those ticks, so the race flipped with no crank code changing. Generator resumption andawaithops are identical across all three engines — it is.finallyalone.Main's CI cannot see this:
playwright-testpins playwright-core 1.54 → webkit-2191. #375's CI installs current webkit → 2336, which is how it surfaced. It reproduces deterministically (3/3 locally), file run alone.The fix
Make the supersede explicit instead of racing for it:
refresh()sets a newIsSupersededByRefreshflag when a run is already in flight — self-refresh origin only.done), the stale children are not diffed at all, and the parent's diff resolves with the enqueued run's diff.Two constraints shaped this, both enforced by existing tests:
"for...of enqueues": p1 resolves showing "Hello 1" while p2–p5 coalesce). The flag keys on refresh origin for exactly this reason.Verification
crank.ts): 602 passed, 0 failed eachtsc --noEmitclean, eslint cleanThe 22 other webkit failures on #375 were fallout from this one (libuild's browser runner runs
afterEachinside itstry, so the failing test leaked a Sinonconsole.errorstub into every later test that stubs — being reported to libuild separately).Intended for a 0.7.11 patch release via
release/0.7as well; this is the main-line landing.🤖 Generated with Claude Code
https://claude.ai/code/session_019pggktip8wsuxzy2VCY9p7