Skip to content

Interactive page load fires DOMContentLoaded before <script defer> runs (HTML spec inversion) #71

Description

@codymullins

The interactive page-load path fires DOMContentLoaded before any <script defer> has executed. The HTML spec ("the end") requires the opposite order: parser finishes, defer scripts execute in document order, then DOMContentLoaded, then (after subresources settle) load. This is the second of two bugs behind x.com's "Something went wrong" fallback page.

What happens

On the interactive (progressive first paint) path:

  • src/Starling.Engine/Engine.cs:737 calls RunCriticalScripts(session, deferAsync: true, includeParserDeferred: false, ct).
  • Inside, RunOrderedScripts returns early before the defer batch when includeDefer is false (Engine.cs:1431-1432), and module scripts are skipped (Engine.cs:1343-1344).
  • s.Session.FireDomContentLoaded() still runs unconditionally (Engine.cs:1353). So DOMContentLoaded fires with zero defer scripts executed.
  • The defer bundles are only fetched after first paint (FetchDeferredAsync, Engine.cs:769-772) and only execute in RunDeferredScriptsAsync (Engine.cs:7801374/1381).

The headless path is spec-correct: Engine.cs:1213 passes includeParserDeferred: true, so defer, async, and module scripts all run before DOMContentLoaded. The load event ordering is also already correct (FireLoad at Engine.cs:1392 runs after the bundles plus a quiescence pump).

Sites that gate boot checks on DOMContentLoaded conclude their scripts failed to load. x.com's watchdog checks window.__SCRIPTS_LOADED__['main'], which is set at the end of its defer bundle main.565e4e9a.js — so the check always fails and the error panel shows.

Verified 2026-06-10 with a live engine repro: DOMContentLoaded logged before any defer script ran, and the fallback panel was display:block at first paint.

Fix

Real fix: on the interactive path, do not fire DOMContentLoaded in RunCriticalScripts. Fire it in RunDeferredScriptsAsync, after RunDeferredClassicScripts and RunModuleScripts and before the async-script step (async scripts carry no order guarantee relative to DOMContentLoaded). First paint before DOMContentLoaded is fine and matches real browsers — they paint progressively during parse. Also start fetching defer scripts during parse instead of after first paint (the spec fetches in parallel and executes at the end), so DOMContentLoaded is not delayed by network on top of execution time.

Why the current shape exists (the band-aid pressure): the early DOMContentLoaded lets "synchronous handlers see the parsed DOM" before a fast first paint. That pressure comes from slow bundle execution in the Starling JS engine — x.com's bundles take about two minutes in the deferred pump (deferred.summary: pump2=110668ms total=147706ms from the live session). Spec order is the real fix. First paint does not need DOMContentLoaded to have fired.

Context

Found during the x.com/nasa boot investigation on 2026-06-10 (branch feat/js-stack-trampoline). Interlocks with two sibling issues filed from the same investigation: "document.readyState is always complete" (either bug alone shows x.com's fallback) and "ScriptFetcher turns large defer scripts into async scripts" (which would keep the bundles after DOMContentLoaded even once this fires in the right place).

Metadata

Metadata

Assignees

No one assigned

    Labels

    browserRelates to the browser subsystembugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions