Level: an async option on load/reload/next/previous, and a microtask deferral - #1647
Level: an async option on load/reload/next/previous, and a microtask deferral#1647obiot wants to merge 10 commits into
Conversation
`level.load()` deferred its work with a timer so the current frame could unwind before the world is reset. The deferral is still needed — it is routinely called from a trigger handler mid-loop, and `safeLoadLevel` resets and destroys the very container the loop may be iterating, while `state.stop()` only sets a flag — but the timer is a 2011 artefact. That line and its comment date to v0.9.0, four years before promises existed; there was never a macrotask semantic to preserve. Browsers clamp a timer to at least a second in a background tab, so a load queued as the tab hides was stranded behind that clamp. A microtask drains when the JS stack empties, which unwinds the frame just the same and is not clamped. The no-loop branch stays synchronous exactly as before: with no loop there is no frame to unwind, and deferring would change when the level exists for anyone loading one before the game starts. `loadAsync()` then returns that completion instead of discarding it. `load()` is unchanged and still returns `true` — the emitted type stays `boolean`, so a typed consumer doing `const ok: boolean = level.load(id)` keeps compiling, which is why this is a sibling rather than a changed return type. `options.onLoaded` still fires either way. An unknown level id throws synchronously rather than rejecting: that is a typo, not a load failure, and it should not need `await` to surface. `load()` rethrows a rejection on a clean stack so a failure still surfaces as an uncaught error the way it did under the timer, rather than as a silent unhandled rejection. `Trigger` stops rewriting its caller's options. Its fade/mask path sequenced hide → load → reveal by replacing `settings.onLoaded` with its own function and calling the user's from inside it; awaiting the load removes that interception. The viewport is deliberately re-read after the load — `game.reset()` reassigns `app.viewport`, which is exactly why the callback this replaces read it late. Tests: no spec called `level.load()` at all before this, so both files are new. Fifteen tests over the legacy contract, the new method, the scheduling, and the trigger paths; all eight mutations of the changed behaviour fail as they should, including a source guard on the viewport re-read, which the reveal path cannot cover behaviourally because its tween needs a live loop. Closes #1646 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
There was a problem hiding this comment.
🟡 Changes recommended
There are a few correctness/docs issues to address (notably loadAsync() error-surface consistency and a non-Markdown {@link ...} tag in the changelog).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR modernizes level-loading scheduling by replacing the legacy setTimeout deferral with a microtask-based deferral, and introduces an awaitable level.loadAsync() to let callers observe completion without changing the existing level.load() boolean-return contract. It also updates Trigger’s level-transition path to avoid mutating caller-owned settings.onLoaded, and adds new Vitest coverage for the behavior and scheduling.
Changes:
- Add
level.loadAsync(levelId, options): Promise<void>while keepinglevel.load()’sbooleanreturn and “fire-and-forget” behavior. - Replace timer-based deferral with a microtask deferral when the game loop is running; preserve synchronous load behavior when no loop is running.
- Refactor trigger level transitions to await the load (via
loadAsync) instead of wrapping/overwritingsettings.onLoaded, and add new tests.
File summaries
| File | Description |
|---|---|
| packages/melonjs/src/level/level.js | Adds loadAsync(), refactors load() to delegate and rethrow failures, and replaces timer deferral with a microtask deferral when the loop is running. |
| packages/melonjs/src/renderable/trigger.js | Updates transition path to use level.loadAsync(...).then(...) for reveal instead of rewriting settings.onLoaded. |
| packages/melonjs/tests/level_load_async.spec.js | New tests for loadAsync, legacy load() contract, and microtask-vs-timer scheduling behavior. |
| packages/melonjs/tests/trigger_level_change.spec.js | New tests ensuring trigger transition behavior doesn’t overwrite caller callbacks and that load is deferred until hide completes. |
| packages/melonjs/CHANGELOG.md | Documents the new API and the background-tab timer clamp fix. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| this.loadAsync(levelId, options).catch((error) => { | ||
| queueMicrotask(() => { | ||
| throw error; | ||
| }); | ||
| }); |
| safeLoadLevel(levelId, options); | ||
| return Promise.resolve(); |
| .catch((error) => { | ||
| // same loudness as the fire-and-forget form | ||
| queueMicrotask(() => { | ||
| throw error; | ||
| }); | ||
| }); |
| ## [20.4.0] (melonJS 2) - _unreleased_ | ||
|
|
||
| ### Added | ||
| - `level.loadAsync(levelId, options)` — the same load as {@link level.load}, resolving once the level is in the world instead of discarding the completion. `options.onLoaded` still fires, so the two forms mix freely, and `load()` is unchanged and still returns `true`. An unknown level id throws synchronously rather than rejecting: that is a typo, not a load failure, and it should not need `await` to surface ([#1646](https://github.com/melonjs/melonJS/issues/1646)) |
`loadAsync()` on its own left the other three loading calls with no
awaitable form, so a game could await its first level but not a reload or
a level transition.
Each twin resolves with exactly what its synchronous counterpart returns,
which makes a port mechanical: `if (level.next())` becomes
`if (await level.nextAsync())`. That is why `loadAsync()` now resolves
`true` rather than `void` — the rule is worth more than the slightly
noisier type.
Running out of levels resolves `false` without loading anything rather
than rejecting: `next()` returns `false` there, and reaching the end of a
game is an ordinary outcome, not an error.
The four originals are untouched, and their emitted types are unchanged —
`load`, `next` and `previous` still declare `boolean`.
Not included: `reload()` declares `object` from a stale `@returns {object}
the current level`, but it returns whatever `load()` returns. Correcting
that would change an emitted type, which is the one thing this change set
is careful not to do, so it is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
|
Added the three missing twins — Each resolves with exactly what its synchronous counterpart returns, so a port is mechanical: if (level.next()) → if (await level.nextAsync())That is why Running out of levels resolves Emitted types, verified from
Six more tests, and five mutations of the new behaviour all fail as they should: Noticed but deliberately not fixed: Suite now 276 files, 6713 tests, 0 failures. |
There was a problem hiding this comment.
🟡 Changes recommended
It introduces unconditional queueMicrotask usage (risking runtime ReferenceError in unsupported environments) and the PR description’s stated scope conflicts with the included async twin APIs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
packages/melonjs/src/level/level.js:198
load()rethrows rejections usingqueueMicrotask, but this global is not guaranteed to exist in all runtimes. If it’s missing, a rejectedloadAsync()will cause aReferenceErrorhere instead of surfacing the original failure. Consider a small fallback tosetTimeoutwhenqueueMicrotaskis unavailable.
load(levelId, options) {
// Fire-and-forget by contract: this returns `true`, not the promise, so
// existing (including typed) callers are unaffected. Use `loadAsync()`
// to await the load. The rejection is rethrown on a clean stack so a
// failure still surfaces as an uncaught error the way it did when the
// deferral was a timer, rather than as a silent unhandled rejection.
this.loadAsync(levelId, options).catch((error) => {
queueMicrotask(() => {
throw error;
});
});
packages/melonjs/src/renderable/trigger.js:209
- This
catchpath rethrows viaqueueMicrotask, which may be undefined in some runtimes; in that case the code would throw aReferenceErrorand potentially mask the real level-load failure. Using a simple fallback (e.g.setTimeout) keeps the intended “uncaught” loudness without requiringqueueMicrotasksupport.
})
.catch((error) => {
// same loudness as the fire-and-forget form
queueMicrotask(() => {
throw error;
});
});
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
| ### Added | ||
| - `level.loadAsync()`, `reloadAsync()`, `nextAsync()` and `previousAsync()` — awaitable twins of the four level-loading calls, resolving once the level is actually in the world instead of discarding the completion. Each resolves with exactly what its synchronous twin returns, so a port is mechanical: `if (level.next())` becomes `if (await level.nextAsync())`, and running out of levels still resolves `false` rather than rejecting. `options.onLoaded` still fires, so the two forms mix freely, and the originals are unchanged — `load()` still returns `true`, and its emitted type is still `boolean`. An unknown level id throws synchronously rather than rejecting: that is a typo, not a load failure, and it should not need `await` to surface ([#1646](https://github.com/melonjs/melonJS/issues/1646)) |
`@returns {object} the current level` was never true. `reload()` is
`return this.load(...)`, and `load()` returns `true` — and the 2011
original returned nothing at all, so the declaration has been wrong for
the method's entire life. `getCurrentLevel()` is the call that hands back
the level object.
This corrects the emitted type from `object` to `boolean`. A
`const lvl: object = level.reload()` that compiled while receiving `true`
now fails to compile, which surfaces a bug that was already there rather
than introducing one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core level-loading scheduling and trigger transition sequencing in a way that can have subtle runtime/event-loop effects best validated by a human reviewer.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Lite
Review catch. The deferred branch turned a failing load into a rejection, but the synchronous one let the exception escape the call — so the error surface depended on whether the loop happened to be running, and `loadAsync(...).catch()` could never see the synchronous case, because the throw beat the handler being attached. The unknown-id check still throws synchronously, before either branch: that is a typo rather than a load failure, and should not need `await`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
There was a problem hiding this comment.
🟡 Changes recommended
level.load() now forces some previously synchronous failures (when the loop isn’t running) to become asynchronous throws via loadAsync().catch(...), which is a behavior/contract change that should be addressed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
| load(levelId, options) { | ||
| // Fire-and-forget by contract: this returns `true`, not the promise, so | ||
| // existing (including typed) callers are unaffected. Use `loadAsync()` | ||
| // to await the load. The rejection is rethrown on a clean stack so a | ||
| // failure still surfaces as an uncaught error the way it did when the | ||
| // deferral was a timer, rather than as a silent unhandled rejection. | ||
| this.loadAsync(levelId, options).catch((error) => { | ||
| queueMicrotask(() => { | ||
| throw error; | ||
| }); | ||
| }); | ||
| return true; |
Eight loading methods for four operations was too much surface. The switch
moves into the options object the calls already take, so `load`, `reload`,
`next` and `previous` each keep one name and gain a flag.
No type break, which is the part that had to be got right. The signatures
are preserved as JSDoc `@overload` pairs rather than a
`boolean | Promise<boolean>` union — a union would fail every existing
`const ok: boolean = level.load(id)`, verified with tsc. The overload form
compiles both that and `await level.load(id, { async: true })` against the
real emitted build.
The bounds check `next` and `previous` each spelled out is now a shared
`levelIdAt(offset)` helper, so the two cannot drift.
The cost of putting the switch in the options is that `await level.load(id)`
without the flag is silent — `await true` is valid. It happens to be
harmless today, since the deferral is a single microtask queued before the
await's continuation, so the load still runs first; that is incidental
ordering rather than a contract. Documented on the options typedef and
pinned by a test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
|
Responses to the review, plus a design change since it was written.
That rethrow exists specifically to preserve the uncaught error the timer-based version produced. The suggested form would turn it into an unhandled rejection, which is the behaviour change the code is there to avoid. On availability:
PR description out of step with scope: it was, and it is now further out of step, because the API changed after this review. The description has been rewritten. Design change: the four The signatures are preserved as JSDoc |
There was a problem hiding this comment.
🟡 Changes recommended
There are a few correctness/robustness issues in the newly added docs/tests and a missing queueMicrotask fallback that could cause runtime failures in unsupported environments.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
packages/melonjs/src/renderable/trigger.js:208
queueMicrotaskis used here to rethrow load failures, but it isn’t feature-detected or polyfilled in this repo. In runtimes withoutqueueMicrotask, this catch handler will throw a ReferenceError and may mask the original error. Consider falling back tosetTimeoutfor the rethrow path.
// same loudness as the fire-and-forget form
queueMicrotask(() => {
throw error;
});
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Lite
| queueMicrotask(() => { | ||
| throw error; | ||
| }); |
| * Note that awaiting a call WITHOUT `async: true` is not an error — `await true` | ||
| * is valid and resolves immediately — so the level will not be loaded yet. Pass | ||
| * the flag whenever you intend to await. |
| const load = triggerSource.indexOf( | ||
| "load(gotolevel, { ...settings, async: true })", | ||
| ); | ||
| const reveal = triggerSource.indexOf("addCameraEffect", load); |
Left over from the rename to the `async` option. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
`melonjs-tilemaps` described the deferral as a `setTimeout` and told the reader the only way to sequence work after a load was `onLoaded` or `LEVEL_LOADED`. Both are now out of date: the deferral is a microtask, and `async: true` gives a promise to await. Also states the caveat that comes with putting the switch in the options — `await level.load(id)` without the flag returns a boolean, so it does not await the load. It happens to finish first today, because the deferral is a single microtask queued ahead of the await's continuation, but that is incidental ordering rather than a contract, and the skills say so rather than implying either that it is safe or that it is broken. `melonjs-3d-assets` gains the `async` row in its `level.load` options table and the same note; glTF/GLB scenes load through the same call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
There was a problem hiding this comment.
🟢 Approval recommended
The changes are cohesive, preserve legacy behavior, and are backed by targeted tests covering the new async contract, scheduling, and Trigger sequencing.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
There are a couple of correctness/maintainability issues in newly added documentation/tests (e.g., an inaccurate await note and a brittle source-string assertion) that should be adjusted before merge.
Review details
Suppressed comments (2)
packages/melonjs/src/level/level.js:111
- The LevelLoadOptions note says that
await level.load(id)"resolves immediately — so the level will not be loaded yet", but that’s not generally true (it loads synchronously when the loop is stopped, and while running it may complete before the await continuation due to microtask ordering). The docs should instead say that omittingasync: truedoes not await completion, and callers must not rely on ordering.
* Note that awaiting a call WITHOUT `async: true` is not an error — `await true`
* is valid and resolves immediately — so the level will not be loaded yet. Pass
* the flag whenever you intend to await.
packages/melonjs/tests/trigger_level_change.spec.js:104
- This source-based assertion searches for an exact, whitespace-sensitive substring (including
{ ...settings, async: true }formatting). That makes the test fragile to harmless formatting/refactor changes in trigger.js. Consider using a regex/search()that tolerates whitespace/newlines while still asserting.load(gotolevel, … async: true …)ordering relative to the reveal.
const load = triggerSource.indexOf(
"load(gotolevel, { ...settings, async: true })",
);
const reveal = triggerSource.indexOf("addCameraEffect", load);
expect(load).toBeGreaterThan(-1);
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
Two problems, both in what the docs render rather than what the code does. `LevelLoadOptions` was declared but never re-exported from the public entry point, so the reference showed `load`/`reload`/`next`/`previous` taking an opaque type name with no properties — the `async` option, and every other option, was undocumented. Exporting the type gives it a page, lists its properties, and puts it in `llms.txt`. TypeDoc documents each `@overload` block and ignores the implementation's comment, so the description and examples written there rendered nowhere: the page was signatures and nothing else. Each overload now carries its own prose, which reads better than one shared blurb — the awaited form and the boolean form describe what they each do. `@public` had to come off the overload blocks. Inside one it makes tsc emit `function load(): any`, erasing the parameters and the return type from the declarations. Bisected against `@category` and `@example`, which are both harmless. The methods stay documented without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
There was a problem hiding this comment.
🔵 Needs a closer look
The new queueMicrotask-based rethrow paths should defensively handle runtimes where queueMicrotask is unavailable to avoid masking failures with a ReferenceError/unhandled rejection.
Review details
Suppressed comments (2)
packages/melonjs/src/level/level.js:270
- The fire-and-forget path rethrows failures via
queueMicrotask, but the project explicitly avoids bundling polyfills. IfqueueMicrotaskis missing at runtime, this handler can throw aReferenceErrorand potentially hide the original error (or produce an unhandled rejection). Consider falling back tosetTimeoutwhenqueueMicrotaskis unavailable.
queueMicrotask(() => {
throw error;
packages/melonjs/src/renderable/trigger.js:207
queueMicrotaskis used to rethrow load failures, but it is a relatively new platform API and the engine intentionally avoids shipping polyfills. IfqueueMicrotaskis unavailable in a runtime that can still parse the bundle, this catch handler will throw aReferenceErrorand can turn the original failure into an unhandled rejection (or mask it). Consider a small fallback tosetTimeoutwhenqueueMicrotaskis missing.
queueMicrotask(() => {
throw error;
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Lite
The `LevelLoadOptions` note claimed that awaiting without `async: true` leaves the level unloaded. It does not: with no loop the load is already synchronous, and with one running the deferral is a single microtask queued ahead of the await's continuation, so it finishes first either way. The point stands — there is no completion point to await without the flag — but the ordering is incidental and the doc now says so instead of promising the opposite. The same wording was already fixed in the skills; this is the copy that was missed. The trigger guard searched for the full call text including argument spacing, so reformatting or an added option would have failed it for no reason. Anchored on `load(gotolevel` instead — still fails when the viewport is captured before the load, which is the only thing it is for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
|
Second round. Options doc claiming the level is unloaded when you await without the flag — correct, fixed. That is the same error I had already corrected in the skills and missed in this copy: with no loop the load is synchronous, and with one running the deferral is a single microtask queued ahead of the await's continuation, so it finishes first either way. The point still stands — without the flag there is no completion point to await — but the ordering is incidental, and the doc now says that rather than the opposite. Brittle source-substring test — fair, fixed. Anchored on
safeLoadLevel(levelId, options);
return true;so a setup-time failure still throws synchronously and
|
There was a problem hiding this comment.
🔵 Needs a closer look
New queueMicrotask usage is unguarded and not polyfilled in this repo, which can cause runtime TypeErrors that mask the original load error in environments lacking queueMicrotask.
Review details
Suppressed comments (2)
packages/melonjs/src/level/level.js:276
queueMicrotaskis used to rethrow async load failures, but it’s not polyfilled anywhere in this repo. In runtimes wherequeueMicrotaskis undefined, this will throw a TypeError and can mask the original load failure. Consider a small fallback so errors remain loud without introducing a new hard runtime requirement.
deferred.catch((error) => {
queueMicrotask(() => {
throw error;
});
});
packages/melonjs/src/renderable/trigger.js:209
- This
.catchrethrows viaqueueMicrotask, butqueueMicrotaskis not polyfilled in melonJS (polyfills are intentionally Canvas/DOM-only). If a consumer runs in an environment withoutqueueMicrotask, this handler itself throws and may hide the original level-load error. Add a small fallback (e.g., tosetTimeout) to keep the error reporting robust.
.catch((error) => {
// same loudness as the fire-and-forget form
queueMicrotask(() => {
throw error;
});
});
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Lite
**TMX.** Every test stubbed a glTF scene, so `safeLoadLevel`'s format branch was only ever exercised on the non-TMX arm — and Tiled maps are the main use of `level.load`. A real map now loads in both forms, passed inline through the loader's `data` field so it needs no fixture file. The map carries an object group, because `flatten: false` wrapping it in a named `Container` is behaviour only `loadTMXLevel` produces: routing a map through the generic `addTo` arm passes the whole options object as its positional `flatten` argument and flattens everything, which that assertion now catches. **The trigger reveal.** Previously source-guarded, because its tween needed a live loop. Driving the tween by hand with `_onTick` removes that, so the sequencing is asserted for real: the load happens, then the reveal, and on the viewport that exists AFTER the load — `game.reset()` reassigns it. The test runs with the loop RUNNING; with it stopped the load is synchronous and the ordering proves nothing. **LEVEL_LOADED and onLoaded ordering.** Both must land before the promise resolves, which is what a caller awaiting the load then reading world state depends on. **A throwing callback.** The async form rejects, and the boolean form with no loop still throws synchronously. Three mutations that survived the first pass were equivalent mutants rather than gaps — a microtask queued after the load's own microtask still runs after it, so "emit late" and "reveal without waiting" needed genuinely-late variants (`setTimeout`, and a synchronous reveal) to express the bug. Both fail now, as does disabling the TMX arm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Aa37KGXZcnVrbn1yG4j1N
There was a problem hiding this comment.
🟢 Approval recommended
The changes are internally consistent with the stated compatibility goals, and the new/updated behavior is thoroughly covered by targeted tests for scheduling, ordering, and trigger integration.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Lite
Closes #1646.
The timer
level.load()deferred its work with a timer so the current frame could unwind before the world is reset. The deferral is still needed — it is routinely called from a trigger handler mid-loop, andsafeLoadLevelresets and destroys the very container the loop may be iterating, whilestate.stop()only sets a flag.The timer itself is a 2011 artefact: that line and its comment trace to v0.9.0, four years before promises existed. Browsers clamp a timer to ≥1 s in a background tab, so a load queued as the tab hides sat behind that clamp. A microtask drains when the JS stack empties — the end of the rAF callback holding update and draw — so it unwinds the frame identically and is not clamped.
The no-loop branch stays synchronous exactly as before.
The
asyncoptionload,reload,nextandpreviouseach keep one name and gain a flag:options.onLoadedstill fires either way, so the forms mix.No type break. The signatures are preserved as JSDoc
@overloadpairs, not aboolean | Promise<boolean>union — a union fails every existingconst ok: boolean = level.load(id)with TS2322, which I verified before choosing. The overload form compiles both that and the awaited call against the real emitted build:Running out of levels still reports
falserather than rejecting — reaching the end of a game is an ordinary outcome. An unknown level id throws synchronously in both forms: that is a typo, not a load failure, and it should not needawaitto surface.The bounds check
nextandpreviouseach spelled out is now a sharedlevelIdAt(offset)helper, so the two cannot drift.Trigger stops rewriting its caller's options
The fade/mask path sequenced hide → load → reveal by replacing
settings.onLoadedwith its own function and calling the user's from inside — mutating an option object the caller owns. Awaiting the load removes the interception.The viewport is deliberately re-read after the load:
game.reset()reassignsapp.viewport, which is precisely why the callback this replaces read it late.The cost of putting the switch in the options
await level.load(id)without the flag is silent, becauseawait trueis valid JavaScript. It happens to be harmless today — the deferral is a single microtask queued before the await's continuation, so the load still runs first — but that is incidental ordering, not a contract. Documented on theLevelLoadOptionstypedef and pinned by a test that records exactly this.Tests
No spec called
level.load()at all before this, so both files are new — 24 tests across the legacy contract, the flag, the scheduling, the reload/next/previous paths, and the trigger.Nine mutations of the changed behaviour fail as they should: the flag ignored, the microtask reverted to a timer, the unknown id rejecting instead of throwing, the loop not stopped, the synchronous branch throwing instead of rejecting,
next/previousoff-by-one, theonLoadedwrap reintroduced, and a stale viewport captured before the load.A tenth (rewriting the bounds helper as
levelIdx[index] ?? null) survives, and is an equivalent mutant rather than a gap: out-of-range array access is alreadyundefined, so the explicit bounds check is belt-and-braces. The observable behaviour is covered by the two off-by-one mutations.The viewport guard is a source check rather than a behavioural test — the reveal only runs when the hide tween completes, which needs a live game loop the suite does not have. It was vacuous on the first attempt (the explanatory comment in the inspected slice contained the string it asserted on), so comment lines are stripped before matching, and it was re-verified against the mutation.
Also
reload()was documented as returningobject— "the current level" — but returns whateverload()returns. The 2011 original returned nothing at all, so the declaration was never right. Corrected toboolean;getCurrentLevel()is the call that hands back the level object.Verification
276 files, 6716 tests, 0 failures. Lint 0 errors, build clean, emitted overloads and legacy compatibility both checked against the built types.