Skip to content

SPIKE: every rendering-performance lever combined (RFC 957 end state + VM optimizations) - #21520

Draft
NullVoxPopuli-ai-agent wants to merge 32 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:all-optimizations
Draft

SPIKE: every rendering-performance lever combined (RFC 957 end state + VM optimizations)#21520
NullVoxPopuli-ai-agent wants to merge 32 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:all-optimizations

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

The consolidated performance spike: everything learned in the dbmon/scheduler campaign, on one branch. Supersedes and consolidates the individual experiment PRs (#21512, #21513, #21514, #21518, #21519), which are closed in its favor.

What's on the branch

  1. RFC 957 scheduler interface, with the renderer clock as the default strategy (@ember/scheduler) — the API from Implement RFC #957: Render Aware Scheduler Interface #21493 (which remains open as the landable implementation PR), evolved so the default strategy is the renderer's clock rather than a parallel FrameStrategy with its own rAF timebase. Phase functions need no registration: await render() resolves against the tick that actually updated the DOM, layout/composite follow in microtask checkpoints of the same tick (pre-paint when the tick rode the frame), awaiting a phase on a clean renderer requests a tick, and ticks with nothing awaited pay a single null check. registerStrategy remains as the override seam.
  2. Adaptive tri-mode render flushing — chain dirt flushes at microtask speed, stream dirt races an unclamped macrotask against rAF with burst stand-down, in-flush dirt drains in a stability loop. (from SPIKE: RFC 957 scheduler interface + adaptive render flushing, on clean main #21518)
  3. No runloop in the render path — tag invalidation notifies the scheduler directly (the classic per-dirty-tag backburner autorun is gone), destruction is scheduler-drained, renderSettled resolves at flush end. The renderer has zero @ember/runloop imports. (from SPIKE: RFC 957 end state — no runloop in the render path #21519)
  4. {{#each}} subtree skipping — clean list items are skipped during revalidation via per-item collected tags. (from Skip unchanged {{#each}} item subtrees during updates #21512)
  5. VM iteration fast path — allocation-free same-order streaming compare (nextInto), flattened tracking-frame stack, combine() flattening. (from [SPIKE] Stacked update-path optimizations: how fast can list-heavy updates get? #21513)
  6. No runloop, no RSVP — zero runtime imports of @ember/runloop or rsvp anywhere in the framework: destruction rides the scheduler's destroy queues, boot and routing run on microtasks (a ~50-line scheduling util replaces once/scheduleOnce/cancel), router_js runs on native promises, and ext/rsvp is deleted. The runloop package remains only as an unreferenced public-API island.
  7. Per-set diet — invalidation notifies the scheduler once per tick (then one boolean check per set), and @tracked reads/writes are a single WeakMap hop via inline value+tag cells, bridged into the central tag registry so notifyPropertyChange/computed interop keeps the same tag identity.
  8. Legacy read-path removal_getProp no longer pays per-property tag consumption for Ember.set()-on-POJO support, unknownProperty, or the '[]' EmberArray tag. This one is semantically breaking and represents what a deprecate-then-remove cycle would buy; it needs its own deprecation RFC and is included here to show the ceiling. (from [SPIKE] Stacked update-path optimizations: how fast can list-heavy updates get? #21513)
  9. Ecosystem-compat repairs from real-app testing (a967e87..c09efdc8, found by running limber against the branch) — instance.destroy() drains scheduled destroys synchronously again at the classic join site (SSR/prerender workers wipe the document right after destroy returns), renderSettled()'s idle path resolves via a requested scheduler flush rather than a bare microtask (the classic "end of the next runloop flush" semantics that un-awaited render() + renderSettled() patterns depend on), and @ember/application's boot deferred uses method-syntax resolve/reject so subclass instances stay assignable to Application. There is deliberately no _backburner export: backburner is gone, not stubbed.
  10. Settledness is pushed, not polled — the renderer reports the edges of rendering work (_onRenderSettledChange: pending when dirt first arrives or destroys queue, complete at the quiet flush) instead of exposing a pollable flag. @ember/test-helpers bridges those edges into an @ember/test-waiters waiter (draft: [SPIKE] settled(): await settledness instead of polling for it ember-test-helpers#1574), so rendering settles through the one push-based protocol the ecosystem already has: isSettled() needs no render clause, settled() awaits renderSettled() rather than polling for it, and a stuck render is named in test-waiter debug output. Two findings worth carrying into the RFC 957 test story: quiet must be confirmed from a macrotask (already-queued task sources — worker messages, zero-delay timers — re-register waiters after a microtask-context observation says "settled"; this flaked ~1-in-4 until the boundary was made explicit, and waitUntil had been imposing it implicitly), and edge reporting is measurably cheaper than probing (dbmon 8.9 → 13.6 fps vs the pre-compat tip).

Numbers

Same-batch three-way (8x CPU throttle, medians of 4, fresh browser per bench, times anchored on last DOM mutation), vs identically-built main and vs the scheduler+skip end state without the VM/legacy levers:

bench main scheduler + skip this branch (all levers)
DB Monitor 7.0 fps 28.9 fps 31.6 fps (4.5x)
1 item, 100k updates (async) 1624ms 75ms 78ms (21x)
1k items, 1 update each (seq, async) 1216ms 209ms 201ms (6x)
1k items, 25% random (async) 637ms 97ms 98ms (6.5x)
1 item, 1k updates (async) 59ms 15ms 18ms
Incrementing Render Effect (dependent chain) 3818ms 4249ms 4309ms (~1.1x)
sync one-shot benches ~par ~par

The VM + legacy-read levers add ~9% on dbmon on top of the scheduler work and are neutral elsewhere — a fraction of their impact in the original spike stack, because the scheduler and subtree-skip already eliminated most of the walk work they used to accelerate. That diminishing return is itself a campaign finding: scheduling, not walk cost, was always the dominant lever, which re-ranks the priority of the legacy-read deprecation RFC from "required for performance" to "nice to have + simplification".

Fresh three-way numbers for the branch tip (stock vs pre-compat-work vs c09efdc8, recorded on a second machine with a same-machine stock baseline) are in NullVoxPopuli/rere-benchmark#108: async benches 83–96% faster than stock, dbmon 6.3 → 13.6 fps (2.2x), fan-out bursts 29–35% faster — and the small sync benches that regressed on the pre-compat tip are now at or ahead of stock, so that regression was the compat gap rather than an inherent cost.

Status

Spike, not landable: the full suite is not expected green (async rendering breaks classic runloop-timing assumptions; legacy-read removal breaks Ember.set-on-POJO tests).

Real-app validation: limber (repl + tutorial apps, ember-repl, SSG prerendering) builds, lints, and passes its full chrome/node test matrix against this branch's tarball — NullVoxPopuli/limber#2212 / #2213 / #2214. The only app-side accommodation is two dist patches on test packages (@ember/test-helpers bridges render edges into a test waiter; ember-qunit drops its _backburner.DEBUG toggle), which doubles as a working prototype of the Class B test story below and is proposed upstream in emberjs/ember-test-helpers#1574.

Landing strategy: every lever on main, non-breaking

The levers fall into three risk classes, and each class has an established ember mechanism:

Class A — invisible optimizations (no RFC, no flag, land directly). Subtree-skipping, the iteration fast path (nextInto streaming compare), tracking-frame flattening, and combine() flattening change no observable behavior when correct. Gate on the full test suite green per-lever (each cut as its own PR from this branch, validated independently — not as a bundle, so a revert never takes out an unrelated lever).

Class B — timing changes (no new RFC needed; RFC 957 covers them; behind an optional feature, default off). The scheduler driving rendering, the clock semantics, and the runloop-free render path all change when rendering happens, which classic code and tests can observe. They ship behind the use-async-scheduler optional feature from RFC 957's own migration story (precedent: default-async-observers):

  • Flag off (default): the backburner autorun path, byte-for-byte today's behavior. Existing apps and addons unaffected indefinitely.
  • Flag on: dirt notifies the scheduler directly; ticks per this PR; destruction drains at tick end; renderSettled resolves at the end of any all-valid tick.
  • New apps get the flag on via the blueprint — "performant by default" for everyone starting fresh, breaking no one who upgrades.
  • CI runs the matrix both ways.

The critical path for this class is the test story, not the scheduler: a test waiter registered while a tick is scheduled (so settled() observes the scheduler), @ember/test-helpers coordination, a flushSync-style escape hatch for the rare latency-critical interaction and for run() interop, and Angular's dev-mode "endless change notifications" error (thrown with collected stacks after N consecutive microtask ticks) as the DEBUG successor to _RERENDER_LOOP_LIMIT. The settled() half of that story is now prototyped on this branch and upstream in emberjs/ember-test-helpers#1574: rendering reports its edges and test-helpers represents it as a test waiter, so no new settledness channel is introduced at all.

Class C — semantic removals (each needs a deprecation RFC).

  1. Implicit reactivity of non-tracked reads (the _getProp legacy paths: Ember.set()-on-POJO invalidating renders, unknownProperty/ObjectProxy in templates, implicit '[]' array entanglement). New deprecation RFC; warnings that fire only when a render actually relies on the legacy path (a legacy-consumed tag later dirtied), not on every get; an opt-out optional feature so consenting apps collect the win in a minor; removal at the next major. Benchmarks here re-rank this from performance-critical to simplification (~9% on dbmon once the scheduler and skip are in) — it should never block the scheduler track.
  2. @ember/runloop itself — already chartered by RFC 957 (its deprecation labels are on the RFC). schedule('render'/'afterRender') map onto the phase API (render()/layout()); later/debounce/throttle become plain-timer userland utilities; backburner deletion lands a major after the runloop deprecations.

Ideal landing order

  1. Implement RFC #957: Render Aware Scheduler Interface #21493 — the @ember/scheduler API, behind a canary feature flag until RFC 957 is accepted (CI already green). Feed this PR's benchmark data into the RFC discussion to move it out of S-Exploring.
  2. Class A PRs (subtree-skip first — it was previously reviewed as Skip unchanged {{#each}} item subtrees during updates #21512 — then the iteration fast path) — independent of the RFC entirely.
  3. use-async-scheduler optional feature PR — scheduler + clock + microtask window + transport legs + the test-waiter/settled()/escape-hatch story. This is the largest PR and the one that makes the flag usable, so it lands as one coherent unit.
  4. Runloop-detach PR extending the flag's on-path (direct notify wiring, scheduler-drained destruction, tick-resolved renderSettled).
  5. Blueprint flip — new apps opt in by default.
  6. Legacy-reads deprecation RFC → deprecation PR (+ opt-out optional feature) — parallel to 3–5, since it shares no code with them; it starts now because deprecation cycles are the longest pole.
  7. Next major: use-async-scheduler becomes the default; runloop deprecations per RFC 957. Major after: delete backburner and the legacy read paths.

Nothing in this order breaks an existing app until a major, every phase is independently revertable, and the performance is available immediately — to new apps by default and to existing apps as a one-line opt-in.

🤖 Generated with Claude Code

NullVoxPopuli and others added 14 commits July 8, 2026 16:32
Adds the `@ember/scheduler` package proposed by RFC 0957:

- `render`, `layout`, `composite`, `next` and `idle` phase functions,
  each returning a promise that resolves according to the registered
  scheduling strategy
- `registerStrategy`, for providing the scheduling strategy when defining
  the Application
- `@ember/scheduler/strategy`, the default strategy implementation, which
  flushes the render/layout/composite phases in order via ordered
  requestAnimationFrame callbacks within a single frame, prior to paint

The deprecations of @ember/runloop and RSVP described by the RFC are left
to follow-up work; this is the additive API surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The UpdatingVM walks every updating opcode of every list item on every
render: cache groups (JumpIfNotModifiedOpcode) exist only at component
boundaries, so a list of plain template rows revalidates every binding
even when nothing in a row changed.

Collect each item's consumed tags in a tracking frame (via a new
frame-finalizer hook on UpdatingVMFrame) and skip the item's entire
subtree while that combined tag validates.

Trivial items opt out: for a text node or two, validating a combined
tag costs as much as updating, so collection would be pure overhead.
An item is trivial when it has <= 2 opcodes and no nested block -- a
nested block child means an arbitrarily large subtree hides behind a
small top-level count.

dbmon-style workloads (fat rows, sparse changes): ~1.6x fps at 8x CPU
throttle, ~6x (rAF-capped) at 4x. Dense-change / tiny-item workloads
and the krausest bench: neutral.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On top of the {{#each}} subtree-skip (emberjs#21512):

1. rAF-coalesced revalidation: scheduleRevalidate defers to one
   requestAnimationFrame per frame instead of a backburner render-queue
   flush per runloop; loopEnd learns a frame is pending so it neither
   spins NO_OP runloops nor trips the infinite-invalidation guard.
2. ListBlockOpcode same-order fast path: when a fresh iterator yields
   the same keys in the same order (the derived-array-in-a-getter
   idiom), update item refs in place; no diff bookkeeping, no marker
   DOM, no children rebuild. Falls back to full sync via a
   PrefixedIterator replaying consumed items.
3. combine() flattens nested combinator tags (capped) so validating a
   combined tag is one flat loop instead of a tree walk.

rere-benchmark dbmon, headless runner, 8x CPU throttle (fps avg):
  stock 7.3.0-alpha.5        ~9.4
  subtree-skip only          ~13.6
  this spike                 ~18.7  (solid 2 ~20, vue ~17, react ~34)

Known cost: rAF-coalescing breaks tests that assert DOM synchronously
after a runloop settle (384 failures in the "each" filter); landing it
for real means RFC 957 scheduler integration + test waiters. Levers 2
and 3 are suite-clean in isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every template property read paid for:
- consumeTag(tagFor(obj, key)): a per-(object, key) tag created and
  consumed on arbitrary objects, so Ember.set() on POJOs invalidates
  renders
- a bonus '[]' EmberArray tag consume for array-valued reads
- the unknownProperty (ObjectProxy) check

Data-heavy templates reading throwaway plain objects pay all three per
read; the tags then fatten every combined tag above them. Deleting
them (modern semantics: plain-data reads don't entangle; reactivity
via @Tracked, tracked collections, and replacement) doubled the
previous spike's dbmon result: ~18.7 -> ~37.5 fps avg at 8x throttle,
~80% of svelte measured in the same session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- flat frame stack: parallel arrays in UpdatingVM instead of an
  UpdatingVMFrame allocation per block per render
- allocation-free list fast path: optional nextInto(target) on
  iterators writes into a shared scratch item; the rare mismatch
  fallback reconstructs the already-applied prefix from the opcodes'
  own refs instead of buffering every item

Measured fps-neutral on dbmon at 8x throttle (GC was ~1% of wall time;
allocations were not the bottleneck). Kept for hygiene: ~250 frame +
~210 item allocations per render removed.

Same-batch interleaved runner measurement: ember at 80% of svelte on
dbmon (26.5-28.5 vs 31.5-37.1 fps at 8x).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "composite while composite is flushing" test asserted ordering across
two independent channels: a setTimeout task scheduled during frame 1
versus frame 2's requestAnimationFrame callbacks. The HTML spec does not
order pending timer tasks against the next rendering opportunity, and
Safari 15.6 runs the next frame's rAF callbacks first. The test now
anchors entirely to the rAF channel, using a raw requestAnimationFrame
registered ahead of the rescheduled phase windows as the frame-2
boundary.

idle() also armed requestIdleCallback without a timeout; fully-idle or
backgrounded pages can starve rIC indefinitely, leaving the promise
unresolvable. Cap the wait with { timeout: 500 }.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the race-flush scheduler from the spike stack onto upstream main
with no other levers (no subtree-skip, no legacy-read deletion, no
iteration fast paths), to isolate what scheduling alone is worth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-dirt-event allocation is gone: the microtask, frame, and
revalidate-until-stable callbacks are persistent fields, and the chain
leg pays one performance.now() per event instead of two. Dependent
chains (render -> effect -> set) re-enter scheduleRevalidate once per
step, so closure churn there was measurable GC pressure.

The macrotask stand-down now only applies while rAF is being serviced:
backgrounded/occluded pages stop firing rAF, and since the per-frame
counter is only reset by a frame firing, standing down there stranded
all rendering until the tab became visible again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Instrumentation showed 146,989 flushes for a 100k-step dependent chain:
dirt produced during a flush re-armed a whole new flush even though the
flush-until-stable loop had already rendered it, because the scheduled
flag cleared before the join. It now clears after, so in-flush dirt
dedupes into the running flush. 8x-throttled chain bench: 16.7s -> 5.5s
(stock runloop: 3.8s).

Chain flushes also inflated the per-frame counter that stands the
macrotask leg down, starving misclassified stream dirt of its unclamped
leg for up to a full frame (373ms gaps observed under load). Only
stream-scheduled flushes count now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tag invalidation now notifies the renderer's scheduler directly --
replacing the classic wiring where every dirty tag called
_backburner.ensureInstance() and rendering was driven by the autorun's
begin hook rerendering all renderers. Destruction queues in the
environment module and is drained by the scheduler's flush (classic
actions-before-destroy ordering preserved), with a fallback microtask
when nothing is rendering. renderSettled resolves at the end of any
flush that leaves every renderer valid, instead of in backburner's end
event. The SSR fallback flushes on a microtask.

The renderer no longer imports @ember/runloop at all; the runloop
remains as a userland library that does not participate in rendering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions

# Conflicts:
#	packages/@ember/-internals/glimmer/lib/base-renderer.ts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli

This comment was marked as outdated.

@NullVoxPopuli

This comment was marked as outdated.

@NullVoxPopuli

This comment was marked as outdated.

The flush no longer loops until stable. Each tick renders the queue's
current state once; code that keeps dirtying while we render simply
accumulates work for the next tick. Ticks that end still-dirty get a
few microtask-speed settle rounds (legitimate measure-then-adjust
patterns), then degrade to the frame-paced stream legs -- so a
pathological render->dirty loop paints between ticks instead of
freezing the thread, and the old million-iteration loop guard is gone
entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

This comment was marked as outdated.

Chain-vs-stream classification no longer uses a wall clock. A tick's
end arms a one-microtask window; dirt arriving inside it comes from the
tick's own continuations (render-coupled follow-ups) and schedules the
next tick at microtask speed, everything later takes the frame-paced
legs. This is the classifier Angular's zoneless scheduler ships
(useMicrotaskScheduler / switchToMicrotaskScheduler): semantically
exact where the 1ms performance.now() delta was an approximation that
misclassified under CPU throttle, and it removes performance.now() from
the hot scheduling path entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

This comment was marked as outdated.

@NullVoxPopuli

This comment was marked as outdated.

NullVoxPopuli-ai-agent and others added 2 commits August 6, 2026 16:48
Isolated main-vs-PR measurement (emberjs#21543) showed the pooling set
neutral at best and genuinely regressive on walk-heavy paths -- reuse
costs more than young-generation allocation there. The spike's own
batches had already scored it neutral. A lever with no win has no
place in the showcase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

This comment was marked as outdated.

@NullVoxPopuli

Copy link
Copy Markdown
Contributor
image

NullVoxPopuli-ai-agent and others added 2 commits August 7, 2026 19:56
backburner.js is gone from every manifest, the exposed-dependencies
map, and the renamed-modules map -- it no longer ships at all. The
@ember/runloop module remains only as a ~370-line dependency-free
compatibility surface so the specifier keeps resolving: run/join/bind
are plain calls, queues collapse to microtasks (scheduleOnce keeps its
per-target-and-method coalescing), timers are native timers, and the
introspection hooks report the truth (there is never a current run
loop, there are never scheduled timers). The package's own
backburner-era tests are deleted with the machinery they tested.

rsvp likewise stops shipping: dropped from dependencies (dev-only for
test files that still import it) and from the renamed-modules and
exposed-dependencies maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

backburner is deleted. It no longer ships: gone from every manifest, the exposed-dependencies map, and renamed-modules. @ember/runloop survives as a ~370-line dependency-free compat shim (plain calls, microtask queues with scheduleOnce coalescing, native timers, truthful introspection — there is never a current run loop), with its backburner-era tests deleted alongside the machinery. rsvp likewise stopped shipping (dev-only, for test files).

Isolation bench for the removal itself (same batch, 8x, medians of 4, zero render failures — main / spike-before / spike-after):

bench main spike (backburner present, unused) spike (backburner deleted)
DB Monitor 7.0 fps 28.7 (23–29) 25.8 fps (26–28) — overlapping spreads, neutral
Incrementing 4584ms 4254ms 4256ms — identical, both beat main
1 item, 100k updates (async) 1868ms 63ms 78ms (±20% band on sub-100ms benches)
1k items, 1 update each (seq, async) 2014ms 86ms 79ms
everything else scatter within noise, both directions

As expected: the bench apps never call runloop APIs, so deleting the machinery is perf-neutral — the point is what it proves. The spike now runs ember with zero backburner code in existence, not merely unused: rendering on the scheduler clock, destruction on its queues, boot and routing on microtasks, router on native promises, and a runloop module that is honest about there being no loop.

Ready for the cross-machine re-bench.

🤖 Generated with Claude Code

NullVoxPopuli-ai-agent and others added 3 commits August 9, 2026 11:36
@ember/test-helpers and ember-qunit import _backburner from
@ember/runloop (DEBUG, currentInstance, hasTimers, and a presence-check
for getDebugInfo), so the module exports a stub again that always
reports nothing pending. getDebugInfo stays absent so callers take
their no-debug-info path.

The local Deferred interface that replaced RSVP's declares
resolve/reject with method syntax: property-syntax function types are
strictly contravariant in T, which made Deferred<App> (via
_bootResolver: Deferred<this>) unassignable to Deferred<Application>
and broke setApplication(App.create(...)) in consuming apps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…troyed

Classic ContainerProxyMixin#destroy wrapped container teardown in
join(), which outside a run loop flushed every queue before returning.
Embedders (SSR/prerender workers, FastBoot) rely on that:
instance.destroy() tears down its rendered DOM before returning, and
they then reset or reuse the document. With the drain deferred to a
microtask, the RenderResult's clear() ran after the embedder had wiped
the document and threw NotFoundError on the detached nodes.

The drain now runs inline at the classic join site, and guards make
that safe: a reentrancy latch (destroy during a drain falls into the
outer drain loop) and a render-transaction depth (destroy mid-render
defers to the tick drain that already follows revalidation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ves via a flush

test-helpers' settled() decides 'is a re-render outstanding' by reading
_backburner.currentInstance -- classic's autorun instance existed
exactly while a flush was pending. A stub that always answers null made
settled() resolve between a tracked set and its flush, so assertions ran
against stale DOM. currentInstance is now a getter backed by a probe the
renderer registers: truthy while any renderer is invalid or destruction
awaits its drain.

renderSettled()'s idle path resolved on a bare microtask, racing ahead
of classic's latency (an autorun flush, then RSVP), and losing to the
un-awaited-render()-then-await-renderSettled() pattern. It now requests
a scheduler tick and lets the flush resolve on its way out -- the
classic 'end of the next runloop flush' semantics -- so work that lands
before that tick, like render() dirtying the renderer, is rendered
before resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Pushed three commits fixing consumer breakage found while testing this branch against limber (NullVoxPopuli/limber#2212):

  • _backburner compat stub restored on @ember/runloop (ember-qunit / @ember/test-helpers import it); its currentInstance is backed by a renderer probe so settled() still knows when a flush or destroy drain is outstanding.
  • @ember/application's Deferred uses method-syntax resolve/reject — property syntax is strictly contravariant and broke setApplication(App.create(...)) type-checks downstream.
  • Scheduled destroys drain synchronously when an engine instance is destroyed (classic's join in ContainerProxyMixin#destroy guaranteed DOM teardown before destroy() returned; SSR/prerender workers rely on that), and renderSettled()'s idle path resolves via a scheduler flush instead of a bare microtask.

With these, limber's full build/lint/test matrix passes against the tarball.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Fresh rere-benchmark numbers for this branch at 7077507 (post-compat-fixes), with a same-machine stock baseline: NullVoxPopuli/rere-benchmark#108. Spike wins 13/15 — async benches 71–96% faster, dbmon fps 6.4→11.8 — with one sync regression worth investigating ('1 item, 1k updates': 23.2→27.5ms).

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Re-ran as a three-way (stock / spike@5916d3e8 / spike@7077507) — NullVoxPopuli/rere-benchmark#108 updated. The compat fixes are perf-neutral: pre-vs-post is within run-to-run noise on 13/15 benches and the two significant deltas both favor post. The small-sync-bench regressions vs stock (10–30% on tens-of-ms benches) exist in both spike runs, so they're spike behavior, not from the fixes.

Backburner is gone, not stubbed: @ember/runloop no longer exports
_backburner at all. The one real question consumers asked it --
'is work still outstanding?' -- is now answered by the renderer
itself: isRenderPending() from '@ember/renderer', true while any
renderer awaits its flush or destruction awaits its drain. This is
the synchronous probe @ember/test-helpers' getSettledState always
TODO'd about wanting from the framework; consumers that still import
_backburner need patching to use it (dist patches, not framework
compat shims).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Per the no-backburner direction: 60f0fc94 removes the _backburner stub again — backburner is gone, not impersonated. Its one real job moves to a proper framework API: isRenderPending() on @ember/renderer, the synchronous is-work-outstanding probe @ember/test-helpers' getSettledState has a TODO wishing for. Consumers that import _backburner get dist patches on the app side (see NullVoxPopuli/limber#2213). Numbers re-run in NullVoxPopuli/rere-benchmark#108: still perf-neutral.

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

@NullVoxPopuli

Copy link
Copy Markdown
Contributor

Deployed here: NullVoxPopuli/limber#2212

Slight bump in mobile perf:
image

Kinda flat for desktop tho (but is slightly faster, maybe just randomness kept the overall score the same)
image

Rendering pendingness is pushed rather than probed: the renderer
notifies a single observer when work becomes outstanding and when it
completes (_onRenderSettledChange on @ember/renderer), sampling the
level only at the sites that can change it -- first dirt after a
flush, tick end, and the destroy queues going non-empty or draining.

This replaces the isRenderPending() probe. Test infrastructure
registers a bridge that turns those edges into an @ember/test-waiters
waiter, so rendering settles through the one push-based protocol the
ecosystem already has: no separate render clause in isSettled(), no
polling cadence, and pending renders show up labeled in test-waiter
debug output for free.

With no observer registered (every non-test build) the notify sites
pay a single null check.

Co-Authored-By: Claude Fable 5 <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.

2 participants