Skip to content

FEATURE: add Context#perform_microtask_checkpoint#418

Open
ursm wants to merge 1 commit into
rubyjs:mainfrom
ursm:perform-microtask-checkpoint
Open

FEATURE: add Context#perform_microtask_checkpoint#418
ursm wants to merge 1 commit into
rubyjs:mainfrom
ursm:perform-microtask-checkpoint

Conversation

@ursm
Copy link
Copy Markdown

@ursm ursm commented May 27, 2026

Summary

  • Adds MiniRacer::Context#perform_microtask_checkpoint, a thin wrapper around V8's MicrotasksScope::PerformCheckpoint(isolate).
  • Lets Ruby callbacks invoked during context.call / context.eval synchronously drain the microtask queue mid-execution.

Motivation

V8's default kAuto microtask policy drains the queue when JS script execution returns to the embedder. When JS calls a host (Ruby) function that itself calls back into JS — for example, a synthetic dispatchEvent simulating browser-style event propagation — all the JS runs inside one script execution, so microtasks queued by listener N do not drain before listener N+1. This diverges from the HTML "clean up after running script" spec, where the UA's dispatcher runs each listener via a fresh microtask checkpoint.

Currently the only escape hatch is pump_message_loop, which drags in PumpMessageLoop / RunIdleTasks and is bool-returning. A dedicated primitive is cleaner.

API

context = MiniRacer::Context.new
context.attach('drain', -> { context.perform_microtask_checkpoint })
context.eval(<<~JS)
  Promise.resolve().then(() => log('microtask-fired'));
  log('before-drain');
  drain();
  log('after-drain');
JS
# Order: before-drain, microtask-fired, after-drain

Notes

  • The implementation mirrors pump_message_loop's structure (HandleScope + verbose TryCatch). It deliberately does not call CancelTerminateExecution — if a microtask trips OOM via the GC epilogue or watchdog termination fires, the enclosing v8_call / v8_eval frame surfaces the error to Ruby as usual.
  • Works in both threaded and :single_threaded modes (uses the same dispatch tag mechanism).
  • Tested at top level and from inside a callback during context.eval.

Test plan

  • test_perform_microtask_checkpoint_returns_nil
  • test_perform_microtask_checkpoint_drains_from_callback
  • Full existing test suite passes (113 runs, 0 failures)

🤖 Generated with Claude Code

Synchronously drains the V8 microtask queue, useful when a Ruby
callback invoked during context.call/eval needs spec-compliant
microtask ordering between synchronous `dispatchEvent` listeners
(or any other sync JS sequence that schedules microtasks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ursm added a commit to ursm/capybara-simulated that referenced this pull request May 27, 2026
…ion click

Per HTML spec "clean up after running script", a microtask checkpoint
runs when the JS execution context stack is empty after a callback
returns. UA-dispatched events (Playwright/CDP native click) hit this
between every listener — the engine-native dispatcher leaves no JS on
the stack — so framework microtasks queued by an earlier listener
drain before the next one fires. JS-side `dispatchEvent` does not: the
caller's frame stays on the stack and the checkpoint is suppressed.

Glimmer/Ember Backburner schedules its autorun as
`Promise.resolve().then(flush)` — a microtask. Without the
mid-bubble drain, an `OnModifier`'s destructor never runs in time to
remove an ancestor's listener, and a child handler's tracked-state
mutation leaks `updateIndex(...)`-style state changes into the parent
when bubble reaches it. Discourse's
`admin_editing_objects_typed_theme_setting_spec.rb:100` was the visible
fallout: clicking "link 1" landed on link 2 because the section li's
`{{on "click" (fn updateIndex 1)}}` listener was still registered
when bubble reached it.

The fix adds `dispatchEventForUserAction(target, event)` that shares
`dispatchEvent`'s walk but invokes a host-provided `__csim_yield()`
after every listener that fires. `__csim_yield` maps to
`Context#perform_microtask_checkpoint` on V8 (rubyjs/mini_racer#418)
and `vm.drain_jobs!` on QuickJS — both wrap the underlying engine's
microtask drain. `__csimClickResolve` dispatches its `click` event via
the new path; the rest of the click chain (pointerdown / mousedown /
pointerup / mouseup) stays on plain `dispatchEvent`.

`fireListeners` / `fireWindowListeners` now return whether any
handler actually ran, so the drain is gated on actual work — most
ancestors in a deep DOM walk have no listener and don't need a
checkpoint round trip.

Falls back to a no-op `__csim_yield` when the runtime lacks the
drain API, leaving dispatch correct but losing the
listener-interleaved drain — gem suite stays at 1466/0/35 V8 and
QuickJS.

Resolves: Discourse admin_editing_objects_typed_theme_setting_spec
:100 ("displays the validation errors when an admin tries to save the
setting with an invalid value").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ursm added a commit to ursm/capybara-simulated-vs-world that referenced this pull request May 27, 2026
The capybara-simulated user-action click dispatch (introduced in the
sibling gem commit) calls a host-provided `__csim_yield()` between
listener invocations to drain V8's microtask queue, matching the
HTML spec "clean up after running script" checkpoint that CDP-driven
native clicks rely on. The drain maps to mini_racer's
`Context#perform_microtask_checkpoint`, which is the rubyjs/mini_racer#418
addition — not yet released.

Bundler's "same gem, different sources" check rejects two `gem`
declarations for `mini_racer`. Wrap the upstream Discourse Gemfile's
eval and drop its `mini_racer` dependency before adding our pin, so
the merge is a single declaration from the PR branch.

Drops admin_editing_objects_typed_theme_setting_spec :100 off the
Discourse failure list — the only failing test we have whose root
cause is the listener-interleaved microtask-drain gap.

Co-Authored-By: Claude Opus 4.7 <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