0013 — Build-time RequireJS combined-bundle seed (re-enable cachejs)¶
Status¶
Accepted (2026-06).
Context and Problem¶
With $CFG->cachejs = false (the prior default), Moodle's lib/requirejs.php
serves JavaScript in dev mode: every AMD module is a separate, uncached
PHP execution. A first page load pulls ~50–120 such requests, all serialized
through the worker's single PHP request queue — the slowest moment of in-session
navigation.
cachejs = true (production mode) would collapse that to ONE combined request
per page (/lib/requirejs.php/<rev>/core/first.js returns all non-lazy modules).
But enabling it previously failed silently with "No define call for core/first":
core_requirejs::find_all_amd_modules() relies on realpath() /
RecursiveDirectoryIterator::getRealPath(), which is unreliable on the
Emscripten VFS (the same reason this project already patches code_manager).
The runtime combine wrote a poisoned-but-existing cache file that
js_send_cached() then served.
Options Considered¶
- Keep
cachejs = false(status quo): dozens of serial PHP requests per page. - Let the runtime build the combine with
cachejs = true: fails on the Emscripten VFS. - Build the combined bundle at build time (where filesystem iteration is reliable), ship it in the localcache seed, and forbid the runtime from ever building it.
Decision¶
Build the combine at build time and seed it; the WASM runtime never combines.
Build time¶
scripts/generate-install-snapshot.shruns a PHP warmup that replicatesrequirejs.php's production "all non-lazy modules" combine (core_requirejs::find_all_amd_modules()→ stripsourceMappingURL→requirejs_fix_define()→ concatenate) and writes it viajs_write_cache_file_content()tolocalcache/requirejs/<sha1(1)>. It fail-hards if the file is missing, < 1 MB, or lacks thecore/firstdefine.requirejs/is added to the seed tripwire (no build-machine paths) and to thelocalcache.zip.scripts/patch-moodle-source.shpatcheslib/requirejs.php(two hunks, fail-loud needle checks, applied to all CI branches):- Guard the cache-miss build branch with
!defined('MOODLE_PLAYGROUND'), so the playground never builds the combine — on a miss it falls through to the existing dev-mode single-module serving. - Serve the seeded combine only for
core/first: for any other non-lazy module, blank the candidate so it falls through to per-module serving. The seed contains only the modules present at build time, so this keeps AMD from runtime-installed plugins working (they are served individually). scripts/build-moodle-bundle.shprobes the seed zip (unzip -l | grep ' requirejs/') and passes--snapshotRequirejs 1;generate-manifest.mjsrecordsmanifest.snapshot.requirejs = true. The flip is keyed off the actually shipped artifact, so cached pre-warmup seeds and legacy bundles keepcachejs = false.
Runtime¶
bootstrap.js passes requirejsSeeded = Boolean(manifest.snapshot.requirejs) to
createMoodleConfigPhp. When seeded, config.php emits:
jsrevis pinned to 1 so the URL revision matches the seededsha1(1)file. config.php overrides DB config, sojs_reset_all_caches()'sset_config('jsrev', time())cannot desync the revision across journaled reloads. Bundle JS is immutable per build, so in-session JS cache-busting being a no-op is acceptable. (A future feature that mutates JS at runtime — e.g. theme designer mode — would need to revisit this.)is_dir()self-heals after "Purge all caches": a purge wipeslocalcache/requirejs, socachejsflips tofalsefor the rest of the session (per-module dev serving); the next boot re-extracts the seed and recovers. (localcache is intentionally never journaled.)
Consequences¶
Positive¶
- First page load per scope: ONE combined
requirejs.phprequest instead of ~50–120 serial per-module PHP executions. This is the largest in-session navigation win. - The runtime never runs the broken
find_all_amd_modulescombine. - Graceful degradation: no seed (legacy/old bundle) →
cachejs = false, exactly today's behavior.
Negative / Risks¶
- The
requirejs.phpneedles must match across all CI branches; the patch fails the build loudly on a mismatch (never ships an unpatched bundle withcachejs = true), and the manifest flag is derived from the produced seed, so a failed branch degrades tocachejs = false. - The
core/first-only serving rule is correctness-critical: without it, a plugin installed at runtime would receive the combined bundle (missing its define) for every module request and break. Verify with an e2e that installs an AMD-bearing plugin. requirejs_fix_define()is duplicated fromlib/requirejs.php(it is not an autoloadable function); keep it in sync with upstream.- localcache.zip grows by the combined bundle (~few MB, JS compresses well).
Why *.map is NOT excluded from the bundle¶
ADR 0011 left the *.map exclusion to this ADR, expecting cachejs = true to
make maps dead weight. That is true only for the non-lazy modules in the
seeded combine. It is NOT safe here:
- Lazy modules (
chartjs, TinyMCE'scodemirror,videojs, …) are excluded from the non-lazy combine, so they are always served individually via the dev-mode path, which reads themin.jsonly if its.mapexists, else falls back toamd/src. - Runtime-installed plugin AMD modules and the post-purge fallback use the same dev-mode path.
Because ADR 0011 already removed amd/src, the .map files are required for
all of the above in normal operation (not just post-purge). The invariant
amd/src XOR *.map therefore resolves to: keep *.map. The bundle stays at
~73.8 MB (the ADR 0011 trim). Excluding maps would break lazy/plugin module
loading.
Implementation Notes¶
scripts/generate-install-snapshot.sh,scripts/patch-moodle-source.sh,scripts/build-moodle-bundle.sh,scripts/generate-manifest.mjs.src/runtime/config-template.js(emission),src/runtime/bootstrap.js(wiring). Requiresnpm run build-worker.lib/requirejs.phpin the SW cacheable-PHP-asset set (ADR 0011 / sw.js).- Tests:
tests/runtime/config-template.test.js(both cachejs emissions).
Review Criteria¶
- Re-evaluate
jsrev = 1pinning if any runtime feature begins mutating JS (theme designer mode, runtime recompiles). - Re-verify the
requirejs.phpneedles whenever a Moodle branch is added/bumped (the fail-loud patch surfaces drift as a build error). - Re-test the
core/first-only rule whenever the AMD loading flow changes upstream, and keeprequirejs_fix_define()in sync.