-
Notifications
You must be signed in to change notification settings - Fork 12
Change loader module cache to use canonical RRI #5540
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -210,6 +210,13 @@ export class Loader { | |||||||||||||||||||||||||||||||
| private fetchImplementation: Fetch; | ||||||||||||||||||||||||||||||||
| private resolveImport: (moduleIdentifier: string) => string; | ||||||||||||||||||||||||||||||||
| private virtualNetwork: VirtualNetwork | undefined; | ||||||||||||||||||||||||||||||||
| // Unsubscribe for the realm-mapping-change listener registered below. The | ||||||||||||||||||||||||||||||||
| // VirtualNetwork outlives any single loader (LoaderService replaces the | ||||||||||||||||||||||||||||||||
| // loader on every module edit / session boundary), so a loader that isn't | ||||||||||||||||||||||||||||||||
| // unsubscribed when it's discarded stays pinned — along with its whole | ||||||||||||||||||||||||||||||||
| // module cache — by the listener the network still holds. `dispose()` | ||||||||||||||||||||||||||||||||
| // releases it; the owner calls that before dropping the loader. | ||||||||||||||||||||||||||||||||
| private unsubscribeMappingChange: (() => void) | undefined; | ||||||||||||||||||||||||||||||||
| // When the host runs inside a prerender, `setTimeout` is suppressed by | ||||||||||||||||||||||||||||||||
| // the render-timer-stub so the default sleep used by | ||||||||||||||||||||||||||||||||
| // `fetchWithTransientRetry` would never resolve and a transient 5xx on | ||||||||||||||||||||||||||||||||
|
|
@@ -231,6 +238,23 @@ export class Loader { | |||||||||||||||||||||||||||||||
| resolveImport ?? ((moduleIdentifier) => moduleIdentifier); | ||||||||||||||||||||||||||||||||
| this.retrySleep = options?.retrySleep; | ||||||||||||||||||||||||||||||||
| this.virtualNetwork = options?.virtualNetwork; | ||||||||||||||||||||||||||||||||
| // Module caches are keyed by canonical RRI form (see moduleCacheKey), whose | ||||||||||||||||||||||||||||||||
| // relationship to a real URL is only stable between realm-mapping changes. | ||||||||||||||||||||||||||||||||
| // Discard the RRI-keyed caches whenever a mapping is added or removed so an | ||||||||||||||||||||||||||||||||
| // entry can't outlive the spelling it was keyed under. | ||||||||||||||||||||||||||||||||
| this.unsubscribeMappingChange = this.virtualNetwork?.onMappingChange(() => { | ||||||||||||||||||||||||||||||||
| this.modules.clear(); | ||||||||||||||||||||||||||||||||
| this.moduleCanonicalURLs.clear(); | ||||||||||||||||||||||||||||||||
| this.knownDepsCache.clear(); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
Comment on lines
+241
to
+249
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] Confirmation — the clear-on-change design is correct as landed, and here are the conditions under which it stays that way. No change requested; this documents what I verified so the trade-off is on record. What this replaces. The previous key was deliberately mapping-insensitive (virtual-alias URL via Why the blast radius is acceptable in production. I enumerated every production caller of The condition under which this stops being safe. If realm-prefix mappings ever become runtime-dynamic in the host (e.g. registering user-realm prefixes at login or on workspace creation — a plausible direction for RRI-native work), each add wipes every live loader's compiled modules. Already-rendered card instances keep holding classes evaluated from the discarded cache while fresh imports re-evaluate new class objects, so Mid-flight safety, verified. A clear during an in-flight import degrades to a refetch, not a wedge: One caveat worth knowing. |
||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Release the realm-mapping-change subscription so this loader can be | ||||||||||||||||||||||||||||||||
| // garbage-collected once discarded. Only detaches the listener — it does not | ||||||||||||||||||||||||||||||||
| // clear the caches, since a clone made from this loader carries them forward. | ||||||||||||||||||||||||||||||||
| dispose() { | ||||||||||||||||||||||||||||||||
| this.unsubscribeMappingChange?.(); | ||||||||||||||||||||||||||||||||
| this.unsubscribeMappingChange = undefined; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
+252
to
258
Comment on lines
+252
to
258
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] The comment's rationale for not clearing the caches is factually wrong: clones do not carry the caches forward. Non-blocking, but worth fixing before merge — it misdescribes What The real reasons not to clear, which the code gets right. (1) The discarded loader may still be draining in-flight work — Suggested rewording:
Suggested change
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| getVirtualNetwork(): VirtualNetwork | undefined { | ||||||||||||||||||||||||||||||||
|
|
@@ -833,36 +857,23 @@ export class Loader { | |||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Cache key for the per-module maps. Collapses the virtual-alias URL and the | ||||||||||||||||||||||||||||||||
| // resolved real URL of the same module onto one key, so a base module | ||||||||||||||||||||||||||||||||
| // imported via the alias (`https://cardstack.com/base/X`) and via the RRI | ||||||||||||||||||||||||||||||||
| // prefix (`@cardstack/base/X` → resolveImport → resolved real URL) share one | ||||||||||||||||||||||||||||||||
| // cached module — and therefore one class object. Without this, the alias | ||||||||||||||||||||||||||||||||
| // and RRI forms evaluate as two distinct modules and `instanceof` / | ||||||||||||||||||||||||||||||||
| // polymorphic-field identity checks across them diverge. Mirrors the | ||||||||||||||||||||||||||||||||
| // real→virtual convention used by `canonicalizeTrackingKey`. | ||||||||||||||||||||||||||||||||
| // Cache key for the per-module maps: the canonical RRI form. Every spelling | ||||||||||||||||||||||||||||||||
| // of a module — its resolved real URL, the virtual-alias URL, and the RRI | ||||||||||||||||||||||||||||||||
| // prefix — folds to one RRI via `unresolveURL`, so a base module reached by | ||||||||||||||||||||||||||||||||
| // any of them shares one cached module and therefore one class object. | ||||||||||||||||||||||||||||||||
| // Without this collapse the spellings evaluate as distinct modules and | ||||||||||||||||||||||||||||||||
| // `instanceof` / polymorphic-field identity checks across them diverge. | ||||||||||||||||||||||||||||||||
| // Modules with no realm-prefix mapping (user realms, bare package specifiers) | ||||||||||||||||||||||||||||||||
| // are returned unchanged by `unresolveURL`. | ||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||
| // The key deliberately does NOT use the realm-prefix (RRI) form even | ||||||||||||||||||||||||||||||||
| // though that's the canonical form for identifiers flowing out of the | ||||||||||||||||||||||||||||||||
| // loader: realm-prefix mappings can be registered and removed while a | ||||||||||||||||||||||||||||||||
| // loader is live (tests scope temporary prefixes via | ||||||||||||||||||||||||||||||||
| // `addRealmMapping`/`removeRealmMapping`), and a mapping-sensitive key | ||||||||||||||||||||||||||||||||
| // would orphan already-cached entries whenever a mapping changes — | ||||||||||||||||||||||||||||||||
| // re-evaluating the same module under a new key and splitting class | ||||||||||||||||||||||||||||||||
| // identities across the old and new copies. | ||||||||||||||||||||||||||||||||
| // The RRI→URL relationship is only stable between realm-mapping changes, so | ||||||||||||||||||||||||||||||||
| // the caches keyed here are discarded whenever a mapping is added or removed | ||||||||||||||||||||||||||||||||
| // (see the `onMappingChange` subscription in the constructor). | ||||||||||||||||||||||||||||||||
| private moduleCacheKey(moduleIdentifier: string): string { | ||||||||||||||||||||||||||||||||
| let trimmed = trimModuleIdentifier(moduleIdentifier); | ||||||||||||||||||||||||||||||||
| if (this.virtualNetwork) { | ||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||
| let virtual = this.virtualNetwork.mapURL(trimmed, 'real-to-virtual'); | ||||||||||||||||||||||||||||||||
| if (virtual) { | ||||||||||||||||||||||||||||||||
| return virtual.href; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||
| // not a parseable URL (e.g. a bare specifier) — fall through | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| return trimmed; | ||||||||||||||||||||||||||||||||
| return this.virtualNetwork | ||||||||||||||||||||||||||||||||
| ? this.virtualNetwork.unresolveURL(trimmed) | ||||||||||||||||||||||||||||||||
| : trimmed; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| private getModule(moduleIdentifier: string): Module | undefined { | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] Nit, non-blocking: this assertion's message overclaims what it observes — it would pass even if the add discarded nothing. The dispose test's own comment explains the mechanism: a mapping add shifts the cache-key form itself. After
addRealmMapping('@test-loader-discard/', testRealmURL),isModuleLoadedunresolves the lookup to@test-loader-discard/f, while an undiscarded entry would still sit under its import-time key${testRealmURL}f— a miss either way. I traced both paths throughmoduleCacheKey→unresolveURLto confirm.The assertion that actually pins the discard is the final one: after the remove restores the key form, a surviving entry would resurface and
isModuleLoadedwould return true. (Strictly, the pair pins "at least one of add/remove discards" — a remove-only implementation would also pass — but that disjunction is the observable contract; the stale-resurrection scenario is exactly what the last assertion catches, and there's no public-API probe that can distinguish which event did the discarding.)Suggested message that matches what's observed:
The test comment at the top is accurate as written; only this message oversells. Also: the net-zero add+remove probe in the dispose test below, with its explanatory comment, is genuinely good test design — that comment will save the next person real time.