Skip to content

refactor(resources): extract AssetResidency from Loader (Loader split, Slice 3/4)#416

Merged
Exoridus merged 5 commits into
mainfrom
worktree-loader-split-slice1
Jul 26, 2026
Merged

refactor(resources): extract AssetResidency from Loader (Loader split, Slice 3/4)#416
Exoridus merged 5 commits into
mainfrom
worktree-loader-split-slice1

Conversation

@Exoridus

Copy link
Copy Markdown
Owner

Summary

  • Extracts claim/refcount tracking, in-flight fetch dedup, the resident-resource store, deferred-handle/value-ref healing (the "multi-handle fill" and "free on arrival" invariants), and the background-loading queue from Loader into a standalone AssetResidency class (src/resources/AssetResidency.ts) — the largest and highest-risk slice of the internal Loader-split effort (Slices 1/2: AssetTypeRegistry refactor(resources): extract AssetTypeRegistry from Loader (Loader split, Slice 1/4) #414, AssetDecoder refactor(resources): extract AssetDecoder from Loader (Loader split, Slice 2/4) #415, both merged).
  • Loader.ts shrinks from 2319 to ~1200 lines. It keeps its call-shape dispatch methods (load/get/unload, the @internal _loadClaimed/_getClaimed/_releaseScope/_peekResource entry points SceneLoader/serialize.ts depend on) with their branching bodies intact, redirecting only the specific lines that touched now-moved state into this._residency.*.
  • Pure internal refactor — no public API change. pnpm docs:api:check confirms zero drift in the generated API docs.
  • AssetResidency dispatches onProgress/onLoaded/onError through Loader's own Signal instances, passed in at construction — the same pattern as Slice 2's storeResource callback injection.

Test plan

  • pnpm typecheck — clean
  • pnpm test:core — 330/330 files, 5344 passed, 15 skipped, 0 failed
  • pnpm verify:quick (typecheck + guides/examples/type-tests/packages typecheck + lint + format + docs:api:check) — clean, only pre-existing unrelated warnings
  • New test/resources/asset-residency.test.ts exercises AssetResidency standalone against real AssetTypeRegistry/AssetDecoder collaborators, including the multi-handle-fill and free-on-arrival invariants and the signal-dispatch boundary
  • Executed via subagent-driven-development at elevated review scrutiny (opus reviewers) given the risk level: both tasks needed one fix round, the final whole-branch review needed one more fix wave — all real findings (an undocumented destroy() deviation now documented as deliberate; two vacuous tests rewritten to test what they claim; ~60 lines of dropped correctness-rationale comments restored; a genuine undefined-vs-null behavior drift in a legacy lookup branch, caught and fixed) verified addressed by scoped re-reviews

@Exoridus
Exoridus enabled auto-merge (squash) July 26, 2026 05:56
Exoridus added 5 commits July 26, 2026 08:00
Standalone class owning claim/refcount tracking, in-flight fetch
dedup, the resident-resource store, deferred-handle/value-ref healing
(asset-system v2 section 7), and the background-loading queue —
everything left in Loader that isn't type identification (Slice 1) or
raw fetch/decode (Slice 2). Dispatches onProgress/onLoaded/onError
through Loader's own Signal instances, passed in at construction
(AssetResidencySignals) — mirrors Slice 2's storeResource-callback
pattern, simpler here since Signal already has the right shape. Not
yet wired into Loader — this commit is additive only, proven correct
in isolation. Loader.ts's own copy of this logic still exists
unchanged.

Also adds a narrow eslint.config.ts override for the new file
(complexity, no-unsafe-assignment) mirroring Loader.ts's existing
exemption for this exact, relocated-verbatim branchy logic.
Six Important findings from task review:

1. destroy() clears _claims/_evicted, a deliberate deviation from the
   original Loader.destroy() (ruled: keep it, a full teardown SHOULD
   clear claim/eviction bookkeeping too) — now documented inline as
   intentional rather than silent.
2. Test file reformatted with prettier (format:check is a separate
   gate from eslint and wasn't run in the first pass).
3. 'claim on an evicted key re-drives the fetch and heals the same
   handle' now snapshots the request count immediately before the
   re-claim and asserts an increase, plus asserts adapter.fill and
   _peekResource on the SAME handle object — the old assertion
   (requests.length > 0) was already true from the first _getSeamless
   call and would have passed even with the re-fetch branch deleted.
4. 'setConcurrency changes how many entries drain concurrently' now
   actually exercises drain behavior at two concurrency levels instead
   of asserting `residency` is truthy.
5. Added coverage of the AssetResidencySignals dispatch boundary — the
   one piece of this file that isn't a verbatim relocation: onLoaded
   assertion in the multi-handle-fill test, a new onError test for a
   rejected fetch, and an onProgress assertion in the background-queue
   test.
6. Restored ~60 lines of correctness-rationale comments that existed
   in the original Loader.ts (still present there pre-Task-2) but were
   dropped during the verbatim relocation: the begin()-before-fill
   'failed' handle rationale, the §4.7 free-on-arrival explanation, the
   in-flight identity-guard rationale in _trackInFlight, the audit-A5
   fallback in _evictKey, the unloadOne snapshot-before-delete note,
   per-branch _adopt rationale, the _areOptionsEquivalent exotic-type
   note, and the unloadAll settled-vs-live paragraph.

Also both optional minors: swapped the unresolvable {@link Loader} to
plain backtick-code, and replaced the test setup's `as never` signals
cast with `as unknown as AssetResidencySignals`.
…lice 3b)

Loader composes one AssetResidency instance and delegates claim/
release/releaseScope/adopt/peekResource/keyFor/unloadAll/
awaitBackground/setConcurrency to it as thin same-named wrappers,
preserving the exact @internal entry-point contract SceneLoader.ts and
serialize.ts depend on. load()/get()/unload()'s call-shape dispatch
methods (_loadClaimed/_getClaimed/unload) keep their branching bodies
on Loader, with the handful of lines that touched now-moved state
(loadSingle/loadSingleAsset/getSeamless/getRef/the legacy-token
resource lookup/the identity-alias lookup/the handle-key lookup)
redirected through this._residency. No public API change.

Loader.ts drops the bulk of its remaining private surface; the
extracted logic is unchanged, only relocated and proven independently
in AssetResidency's own test file (prior commit).

Updates the handful of white-box test casts that reached directly
into fields moved onto AssetResidency (_claims/_deferred/_resources/
_backgroundQueue/_evicted/_isQueuedInBackground) to go one level
deeper through loader['_residency'], mirroring how Slice 1's Task 2
handled its _key white-box casts.

Claude-Session: https://claude.ai/code/session_01VBChHCH6BZqQsLNfpGAXfa
…d nullish resource

_getClaimed's legacy-token branch (Loader split, Slice 3b) reused
AssetResidency._peekResource for its "is this loaded?" throw check,
but _peekResource collapses "no entry" and "entry present with a
null/undefined value" to the same null return via `?? null`. A
bindAsset-bound custom type whose handler legitimately resolves to
null/undefined (the branch's actual use case — loadContainer-loaded
assets) would incorrectly throw "Missing resource" on a subsequent
get(), even though the resource was genuinely stored.

Adds AssetResidency._hasStored(type, source), a Map.has()-based
presence check that distinguishes those two cases, and gates the
throw on it instead of on the peeked value. _peekResource itself is
unchanged and still backs the actual return value.

Covering test: loader-seamless.test.ts's new "legacy alias lookup
round-trips a bindAsset-bound type whose handler legitimately
resolves to null/undefined" case, which fails against the prior
_peekResource-gated check and passes with _hasStored.

Claude-Session: https://claude.ai/code/session_01VBChHCH6BZqQsLNfpGAXfa
_getClaimed's legacy-token branch returned its resource through
_peekResource, which coalesces a stored undefined to null. That
accessor's null-collapsing contract predates this slice and is
correct for its other callers, but this branch never routed through
it before the residency split (it read the Map directly). Add
AssetResidency._getStored, a raw (non-coalescing) lookup, and use it
here so a bindAsset-bound type whose handler legitimately resolves
undefined still round-trips through loader.get() as undefined instead
of null.
@Exoridus
Exoridus force-pushed the worktree-loader-split-slice1 branch from 76bcf62 to 2e19490 Compare July 26, 2026 06:04
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 16.66kB (0.07%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
exo-esm-esm 874.56kB 1.25kB (0.14%) ⬆️
exo-esm-modules-esm 3.94MB 9.53kB (0.24%) ⬆️
exo-iife-min-Exo-iife 882.3kB 1.25kB (0.14%) ⬆️
exo-full-iife-Exo-iife 2.8MB 4.63kB (0.17%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: exo-iife-min-Exo-iife

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.iife.min.js 1.25kB 882.3kB 0.14%

Files in exo.iife.min.js:

  • ./src/resources/Loader.ts → Total Size: 30.97kB

  • ./src/resources/AssetResidency.ts → Total Size: 48.83kB

view changes for bundle: exo-esm-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.esm.js 1.25kB 874.56kB 0.14%

Files in exo.esm.js:

  • ./src/resources/Loader.ts → Total Size: 30.97kB

  • ./src/resources/AssetResidency.ts → Total Size: 48.83kB

view changes for bundle: exo-esm-modules-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
resources/AssetResidency.js (New) 49.35kB 49.35kB 100.0% 🚀
resources/Loader.js -46.27kB 31.91kB -59.18%
resources/Loader.d.ts -7.08kB 27.97kB -20.19%
resources/AssetResidency.d.ts (New) 13.53kB 13.53kB 100.0% 🚀

Files in resources/AssetResidency.js:

  • ./src/resources/AssetResidency.ts → Total Size: 48.83kB

Files in resources/Loader.js:

  • ./src/resources/Loader.ts → Total Size: 30.97kB
view changes for bundle: exo-full-iife-Exo-iife

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.full.iife.js 4.63kB 2.8MB 0.17%

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.53498% with 46 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/resources/AssetResidency.ts 90.04% 46 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Exoridus
Exoridus merged commit e0d2724 into main Jul 26, 2026
18 checks passed
@Exoridus
Exoridus deleted the worktree-loader-split-slice1 branch July 26, 2026 06:13
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.

1 participant