Skip to content

Commit 2557e41

Browse files
committed
feat(scheduler): commit loader swaps in frame phase
1 parent 84157b2 commit 2557e41

15 files changed

Lines changed: 622 additions & 44 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
## Unreleased
44

5+
- Added scheduler commit-phase ordering for app-level loader swaps and streamed
6+
boundary patches, including frame-backed commits and deterministic
7+
non-browser fallbacks.
8+
59
## 0.14.0 - 2026-06-26
610

711
- Updated the Flow bridge to consume `@async/flow` 0.10.0 through the
812
scheduler-free integration subpaths and shared protocol brands.
9-
- Bundle size from bundled TypeScript source: `browser.ts` raw 228,465 B (228.5 KB / 0.228 MB), gzip 42,518 B (42.5 KB / 0.043 MB), br 35,193 B (35.2 KB / 0.035 MB) -> `browser.min.js` raw 96,205 B (96.2 KB / 0.096 MB), gzip 27,993 B (28.0 KB / 0.028 MB), br 24,820 B (24.8 KB / 0.025 MB); delta raw -132,260 B (-132.3 KB / -0.132 MB), gzip -14,525 B (-14.5 KB / -0.015 MB), br -10,373 B (-10.4 KB / -0.010 MB).
13+
- Bundle size from bundled TypeScript source: `browser.ts` raw 236,170 B (236.2 KB / 0.236 MB), gzip 44,028 B (44.0 KB / 0.044 MB), br 36,421 B (36.4 KB / 0.036 MB) -> `browser.min.js` raw 99,320 B (99.3 KB / 0.099 MB), gzip 29,136 B (29.1 KB / 0.029 MB), br 25,662 B (25.7 KB / 0.026 MB); delta raw -136,850 B (-136.9 KB / -0.137 MB), gzip -14,892 B (-14.9 KB / -0.015 MB), br -10,759 B (-10.8 KB / -0.011 MB).
1014

1115
## 0.13.0 - 2026-06-26
1216

‎specs/framework.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,6 @@ tree or hidden hydration model.
125125
- [12-composition-patterns.md](./framework/12-composition-patterns.md) -
126126
composition pattern guidance for children, regions, templates, presenters,
127127
outlets, boundaries, and anti-patterns.
128+
- [13-scheduler-and-commit-phase.md](./framework/13-scheduler-and-commit-phase.md)
129+
- scheduler timing, visual commit phase, background work, and
130+
`Async.loader.swap(...)` completion semantics.

‎specs/framework/02-runtime-kernel.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ returned from `Async.start(...)` / `createApp(...).start()`.
4949

5050
- The kernel owns registry materialization and runtime lifecycle.
5151
- The loader owns DOM scanning once a root is attached.
52-
- The app-level loader facade owns only bootstrap ordering. The concrete
53-
runtime loader keeps synchronous DOM semantics.
52+
- The app-level loader facade owns bootstrap ordering and promise completion for
53+
scheduled commit work. The concrete runtime loader keeps synchronous
54+
validation and return shapes while DOM mutation runs through the scheduler
55+
commit phase.
5456
- The router owns navigation once started for a root.
5557
- The scheduler owns queued work ordering.
5658
- Signal and cache registries own mutable data state.

‎specs/framework/04-dom-protocol.md‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ Default protocol attributes include:
3737
- `intersect:*`
3838

3939
`Loader({ root, ... }).start()` scans a root. `loader.scan(fragment)` scans
40-
newly inserted content. `loader.swap(boundary, html, options?)` replaces a
41-
boundary and rescans inserted element roots by default. `strategy: "morph"` is
42-
an opt-in boundary update strategy for stable shell markup: it preserves
40+
newly inserted content. `loader.swap(boundary, html, options?)` validates and
41+
targets a boundary synchronously, then schedules replacement or morph work in
42+
the scheduler commit phase and rescans inserted element roots by default.
43+
`strategy: "morph"` is an opt-in boundary update strategy for stable shell
44+
markup: it preserves
4345
matching nodes by `async:key`, `data-key`, `id`, or sibling order and tag name,
4446
then cleans up removed or replaced nodes. `scan: "full"` scans the boundary
4547
element and its subtree, while `scan: "none"` leaves inserted content inert
@@ -61,9 +63,10 @@ do not become refresh dependencies unless the render function reads the signal
6163
itself or the path is listed in `deps`.
6264

6365
`Async.loader.scan(...)`, `Async.loader.swap(...)`, `Async.loader.refresh(...)`,
64-
and `Async.loader.mount(...)` are promise-returning app-level facade methods. They
65-
queue until a concrete runtime loader exists, then delegate to the same
66-
synchronous loader operations.
66+
and `Async.loader.mount(...)` are promise-returning app-level facade methods.
67+
They queue until a concrete runtime loader exists. `Async.loader.swap(...)` and
68+
refresh calls that perform swaps resolve only after the scheduled commit,
69+
inserted-DOM scan and binding, and post-commit flush complete.
6770

6871
## Subsystem Boundaries
6972

@@ -73,8 +76,9 @@ synchronous loader operations.
7376
- The signal registry reads and writes state.
7477
- The component system may emit protocol attributes in rendered fragments.
7578
- The boundary receiver and router call loader swaps for replacement.
76-
- The app-level loader facade may buffer work before bootstrap, but it does not
77-
change boundary replacement semantics once a concrete loader exists.
79+
- The app-level loader facade may buffer work before bootstrap, but it delegates
80+
replacement to the concrete loader and waits for commit completion when it
81+
returns a promise to app code.
7882

7983
## Protocol Contract
8084

‎specs/framework/08-resume-and-streaming.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ Patch effects apply in this order:
8080

8181
1. Signal patches.
8282
2. Browser cache restore.
83-
3. Boundary HTML swap and rescan.
84-
4. Scheduler flush for affected work.
83+
3. Boundary HTML swap scheduled through the commit phase, with rescan.
84+
4. Scheduler flush for affected work, including post-commit completion.
8585
5. Redirect.
8686

8787
## Resume Contract
@@ -93,7 +93,8 @@ Resume must preserve document continuity:
9393
- Boundary swaps must clean removed scopes before inserted HTML is scanned.
9494
- Child patches with destroyed parent scopes are ignored.
9595
- Same-boundary patches are serialized so later sequence numbers cannot commit
96-
before earlier in-flight patches finish.
96+
before earlier in-flight patches finish their scheduled DOM commit and
97+
post-commit flush.
9798

9899
## Invariants
99100

@@ -117,8 +118,10 @@ Resume must preserve document continuity:
117118

118119
- Browser activation restores signal and cache snapshots and binds existing
119120
DOM.
120-
- A boundary patch applies signal and cache effects before replacing HTML.
121-
- Inserted HTML is rescanned so delegated handlers and signal bindings work.
121+
- A boundary patch applies signal and cache effects before scheduling HTML
122+
replacement.
123+
- Inserted HTML is rescanned during commit so delegated handlers and signal
124+
bindings work before the patch reports success.
122125
- Stale same-boundary patches are ignored while independent boundary patches can
123126
apply out of order.
124127
- Failed DOM, scheduler, redirect, or capability errors can be retried with the
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Scheduler And Commit Phase
2+
3+
Reference file for [Async Framework](../framework.md). This file owns
4+
scheduler timing, visual commit ordering, background work, and
5+
`Async.loader.swap(...)` promise completion.
6+
7+
## Purpose
8+
9+
Async needs deterministic ordering for signal effects, lifecycle callbacks,
10+
route partials, and streamed boundary patches without turning the runtime into a
11+
component renderer. The scheduler orders explicit work that the protocol
12+
already names. It is not React Fiber, a virtual DOM reconciler, or a hidden
13+
renderer loop.
14+
15+
## Responsibilities
16+
17+
- Keep registration and validation synchronous.
18+
- Resolve signal effects, async-signal state, and lifecycle work in microtasks.
19+
- Commit visual DOM replacement and morph work in the commit phase.
20+
- Resolve post-commit promises only after inserted DOM has been scanned and the
21+
post flush has completed.
22+
- Run non-critical background work only after required visible work is done.
23+
- Provide deterministic fallbacks for server, command-line, and test runtimes
24+
that do not expose browser frame or idle callbacks.
25+
26+
## Timing Model
27+
28+
Scheduler timing is phase based:
29+
30+
1. Sync and registration: `Async.use(...)`, registry adoption, option
31+
validation, boundary lookup, signal reads, signal writes, and direct handler
32+
invocation remain ordinary synchronous JavaScript.
33+
2. Microtask resolve and effects: binding updates, lifecycle callbacks, signal
34+
effects, and async-signal work flush from the microtask scheduler unless a
35+
manual scheduler is supplied.
36+
3. Animation-frame commit: visual boundary replacement, morph work, route
37+
partial DOM commits, and streamed HTML swaps run in the commit phase. Browser
38+
runtimes use `requestAnimationFrame` for this phase when it is available.
39+
4. Post flush: post callbacks and completion promises settle after commit work
40+
has run, inserted DOM has been scanned and bound, and earlier phase work
41+
created by that scan has drained.
42+
5. Idle and background: optional non-critical work runs after the post flush.
43+
Browser runtimes may use `requestIdleCallback` for this phase.
44+
45+
`requestAnimationFrame` is only for visual commit work. `requestIdleCallback` is
46+
only for non-critical background work. Neither callback is required to run
47+
registration, signal writes, event dispatch, server result parsing, or ordinary
48+
effect scheduling.
49+
50+
## Public Contract
51+
52+
`createScheduler(...)` exposes a small deterministic scheduler with named
53+
phases, `enqueue(...)`, `afterFlush(...)`, `commit(...)`, `flush(...)`,
54+
`flushScope(...)`, scope cancellation, and inspection.
55+
56+
`Async.loader.swap(...)` is the app-level promise-returning swap API. It queues
57+
until a concrete browser loader exists, schedules DOM mutation in the scheduler
58+
commit phase, and resolves only after:
59+
60+
- the target boundary has been replaced or morphed
61+
- inserted protocol attributes have been scanned
62+
- signal, class, event, component, and lifecycle bindings created by that scan
63+
have had their required flush opportunity
64+
- post-commit scheduler callbacks have completed
65+
66+
The concrete runtime loader still validates synchronously, returns the same
67+
boundary, boundary array, or cleanup function shape as before, and is used by
68+
routers, server-result application, and streaming receivers. Integrations that
69+
need completion semantics must wait for the scheduled commit before reporting
70+
success, redirecting, or resolving app-level promises.
71+
72+
## Fallbacks
73+
74+
When `requestAnimationFrame` is unavailable, the commit phase falls back to a
75+
synchronous deterministic commit. This keeps server and test runtimes stable
76+
while preserving the same post-flush promise boundary.
77+
78+
When `requestIdleCallback` is unavailable, background work remains a normal
79+
scheduler phase after post flush. It must not be required for visible DOM
80+
correctness.
81+
82+
Manual schedulers do not auto-flush. Tests and custom runtimes that choose a
83+
manual scheduler must call `flush(...)` or `flushScope(...)` to advance queued
84+
work.
85+
86+
## Subsystem Boundaries
87+
88+
- The scheduler orders work; it does not discover DOM, diff components, own a
89+
render tree, or decide what HTML should exist.
90+
- The loader owns boundary lookup, cleanup, replacement, morphing, scanning,
91+
and binding.
92+
- The router owns navigation state and route partial selection, then waits for
93+
loader commit completion before finishing DOM-changing navigation.
94+
- The boundary receiver owns patch sequence and retry state, then waits for
95+
loader commit completion before consuming a successful sequence number.
96+
- Server-result application owns envelope effect ordering, then waits for
97+
loader commit completion before following redirects that depend on committed
98+
HTML.
99+
100+
## Invariants
101+
102+
- Sync validation errors surface before commit scheduling.
103+
- Commit jobs for the same boundary serialize when frame-backed commits are
104+
deferred.
105+
- Independent boundaries may prepare or commit independently unless a caller
106+
explicitly batches them.
107+
- Post callbacks do not resolve before earlier phase work created by a commit
108+
has had a chance to drain.
109+
- Background work never gates boundary correctness.
110+
111+
## Failure Modes
112+
113+
- A missing boundary fails before a swap is scheduled.
114+
- A scan or binding failure during commit rejects the app-level swap promise and
115+
lets streaming receivers retry the same sequence number.
116+
- Destroyed scopes cancel their pending scheduler work.
117+
- A destroyed runtime rejects queued loader work and prevents later commit
118+
attempts from targeting detached roots.
119+
120+
## Acceptance Criteria
121+
122+
- A frame-backed app-level `Async.loader.swap(...)` does not mutate the DOM
123+
before the animation-frame commit.
124+
- The same promise resolves only after replacement, scan, inserted bindings,
125+
lifecycle flush, and post flush complete.
126+
- Non-browser runtimes without `requestAnimationFrame` keep deterministic
127+
synchronous commit fallback behavior.
128+
- Same-boundary frame commits serialize so a later swap cannot commit before an
129+
earlier in-flight swap finishes.
130+
- Streamed boundary patches report success only after the scheduled loader
131+
commit finishes.

‎src/app.js‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,11 @@ function createLoaderFacade() {
608608
}
609609

610610
async function invoke(loader, method, args) {
611-
return loader[method](...args);
611+
const result = loader[method](...args);
612+
if (method === "swap" || method === "refresh") {
613+
await loader._whenCommitted?.(result);
614+
}
615+
return result;
612616
}
613617
}
614618

@@ -809,7 +813,11 @@ function createRouterLoaderFacade(getRouter) {
809813
}
810814

811815
async function invoke(loader, method, args) {
812-
return loader[method](...args);
816+
const result = loader[method](...args);
817+
if (method === "swap" || method === "refresh") {
818+
await loader._whenCommitted?.(result);
819+
}
820+
return result;
813821
}
814822
}
815823

‎src/boundary-receiver.js‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,11 @@ export function createBoundaryReceiver(options = {}) {
215215
let replacementCount = 0;
216216
if (normalized.html != null) {
217217
boundaryElement = loader.swap(normalized.boundary, normalized.html);
218+
await waitForLoaderCommit(loader, boundaryElement);
218219
}
219220
if (normalized.replace) {
220221
boundaryElement ??= findBoundaryElement(loader.root, normalized.boundary, attributes);
221-
replacementCount = applyReplacements(boundaryElement, normalized.replace);
222+
replacementCount = await applyReplacements(boundaryElement, normalized.replace);
222223
}
223224

224225
let attrs;
@@ -276,15 +277,16 @@ export function createBoundaryReceiver(options = {}) {
276277
}
277278
}
278279

279-
function applyReplacements(boundaryElement, replacements) {
280+
async function applyReplacements(boundaryElement, replacements) {
280281
let applied = 0;
281282
for (const replacement of replacements) {
282283
if (replacement.mode === "boundary") {
283284
const target = findBoundaryElement(loader.root, replacement.target, attributes);
284285
if (!containsOrEquals(boundaryElement, target)) {
285286
throw new Error(`Boundary replacement target "${replacement.target}" is outside boundary "${boundaryIdFor(boundaryElement, attributes)}".`);
286287
}
287-
loader.swap(replacement.target, replacement.html);
288+
const swapped = loader.swap(replacement.target, replacement.html);
289+
await waitForLoaderCommit(loader, swapped);
288290
applied += 1;
289291
continue;
290292
}
@@ -794,6 +796,12 @@ async function flushScheduler(scheduler, scope) {
794796
}
795797
}
796798

799+
async function waitForLoaderCommit(loader, result) {
800+
if (typeof loader?._whenCommitted === "function") {
801+
await loader._whenCommitted(result);
802+
}
803+
}
804+
797805
async function followRedirect(redirect, router, loader) {
798806
if (router && typeof router.navigate === "function") {
799807
await router.navigate(redirect);

0 commit comments

Comments
 (0)