Skip to content

fix(spa): survive boot on browsers without scheduler.yield - #177

Merged
emrecdr merged 1 commit into
mainfrom
fix/spa-boot-tdz
Jul 28, 2026
Merged

fix(spa): survive boot on browsers without scheduler.yield#177
emrecdr merged 1 commit into
mainfrom
fix/spa-boot-tdz

Conversation

@emrecdr

@emrecdr emrecdr commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Summary

Critical, live on the published demo: on every browser without scheduler.yield — all Safari and iOS Safari, Chrome/Edge <129, Firefox <142 — the SPA boot died on an unhandled promise rejection and 22 of 23 widgets never rendered (header above ~14,000px of blank white). The boot loop's first synchronous pass calls yieldToMain() before the later-concatenated helper file's module-scope let bindings exist; on Chrome ≥129 the scheduler.yield early-return jumps over the temporal dead zone, which is exactly why the maintainers' own browsers never showed it.

Fix is structural, per the repo's own SPA rule (no module-scope state in later files): the lazy MessageChannel state now lives on the hoisted function object itself — reachable from the IIFE's first statement under any concatenation order, no TDZ possible, no cross-file moves. The same-class audit of every post-boot file found zero other mutable IIFE-scope bindings; the one safe-but-latent const is documented with a load-order constraint comment.

The regression test ships with the fix (the durable guard): a headless variant injects a pre-evaluation script forcing window.scheduler undefined, boots the full-widget dashboard, and asserts a vacuity guard (scheduler truly absent), all 23 widget bodies non-empty, and zero uncaught rejections. Proven bidirectionally: with only the JS fix stashed it fails with exactly the report's signature (1 of 23 widget bodies rendered); with the fix, 23/23.

Verification

  • Browser suite 23/23 (including the new no-scheduler test), spa integration 11/11
  • lib 99 binaries pass (independently re-run); clippy all-features clean; fmt clean; self-gate PASS (152 files)
  • Found by an adversarially-validated audit with a byte-faithful headless reproduction; present in every tree back to at least the v0.22 era — the drawer refactors neither introduced nor surfaced it

The cooperative widget-boot loop yields between widgets through a
yieldToMain helper whose MessageChannel fallback stored its lazy state
in two module-scope `let` bindings in a later-concatenated script. The
boot loop is defined in an earlier script of the same shared IIFE and
calls yieldToMain during its first synchronous pass, before those
bindings' declarations run. On every engine without scheduler.yield
(all Safari and iOS Safari, Chrome and Edge below 129, Firefox below
142) the first yield read a binding inside its temporal dead zone, the
un-awaited boot loop rejected, and only the first widget rendered —
the header sat above a blank page. Desktop Chrome 129+ returns on the
scheduler.yield path before the fallback, which hid the defect.

Move the fallback state onto the yieldToMain function object itself. A
function declaration is hoisted to the top of the shared IIFE, so it is
reachable from the first statement under any concatenation order, and
its properties are created at call time — no dead-zone read is possible.

Add a headless regression test that boots the dashboard with `scheduler`
removed before any page script runs and asserts all widget bodies
render; it fails on the unfixed tree (1 of 23) and passes with the fix.

Also document the load-order constraint on BIVARIATE_PALETTE, the sole
remaining IIFE-scope binding in a later-concatenated file, whose readers
stay off the synchronous boot path.
@emrecdr
emrecdr merged commit cded98d into main Jul 28, 2026
11 checks passed
@emrecdr
emrecdr deleted the fix/spa-boot-tdz branch July 28, 2026 22:27
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