Conversation
The virtual network's shimAsyncModule resolves the module through a lazy import(), so Vite emits it as its own chunk and a loader import of `@cardstack/base/date/day` is answered from the bundle instead of a fetch of realm-server-transpiled source. The host test helper imports DayField statically for the same reason. Base modules import host tools as `@cardstack/boxel-host/tools/*`, which the virtual network shims at runtime; the Vite alias gives the bundler the same mapping so a bundled base module resolves them too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same shape as date/day: a lazy shimAsyncModule entry and a static import in the host test helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Preview deploymentsHost Test Results 1 files ± 0 1 suites ±0 1h 33m 13s ⏱️ + 1h 27m 16s Results for commit be4e24b. ± Comparison against earlier commit 8c9a2ce. Realm Server Test Results 1 files ±0 244 suites ±0 1h 16m 35s ⏱️ + 2m 55s Results for commit be4e24b. ± Comparison against earlier commit 8c9a2ce. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5d86c7e5b1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Same shape as date/day: a lazy shimAsyncModule entry and a static import in the host test helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same shape as date/day: a lazy shimAsyncModule entry and a static import in the host test helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same shape as date/day: a lazy shimAsyncModule entry and a static import in the host test helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same shape as date/day: a lazy shimAsyncModule entry and a static import in the host test helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same shape as date/day: a lazy shimAsyncModule entry and a static import in the host test helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A shim registered for a realm-mapped identifier such as `@cardstack/base/date/day` is keyed by the realm URL that identifier resolves to, because the realm mapping also feeds the network's import map. A loader import of the identifier resolves to the same realm URL, but the network's fetch pipeline only answers shims on the fake `https://packages/` origin, so such a shim was never served and the module was fetched from the realm server instead. The Loader now asks the network for a shim from its module-fetch path, which is the one place that knows the request is for a module rather than a card instance that may live at the same realm URL. The lookup folds every spelling of the identifier onto the real URL, so the prefix, virtual-alias, and url-mapped forms all land on one module. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Host-side code loads base modules via `loader.import`; the helper's static re-exports bypassed the shims the loader is meant to hit, so the tests that consumed them could pass whether or not a shim answered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ves them `BUNDLED_BASE_MODULES` in externals.ts lists each bundled module with its literal `import()`, and the shim registration loops over it. The new integration test imports every entry through the loader and asserts the exports are the bundled module's own and that no request reached the base realm. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
time, time/time-range, time/duration, and time/relative-time join the bundled-module table. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
string, number, boolean, big-integer, email, ethereum-address, phone-number, text-area, markdown, rich-markdown, color, code-ref, realm, enum, searchable, and base64-image join the bundled-module table. string.ts is `export default StringField` from card-api and is resolved from there: importing the `.ts` module directly makes TypeScript classify it as CommonJS (the base package declares no `type`), which retypes its default export as a namespace for every host importer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The boxel-cli parse workspace has to resolve declarations for every specifier the host shims for card code, and `card-runtime-packages` enforces that by reading literal shim ids out of externals.ts. A table whose ids are built by template literal is unreadable to it, so the guard failed rather than silently covering less than it claims. `@cardstack/base/*` is not a specifier that guard has to chase — parse resolves it through a tsconfig path alias onto bundled sources, which is recorded in its own list — so the registration moves to app/lib/bundled-base.ts, alongside the table, and the network service calls it after shimExternals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`myLoader()` reads `import.meta.loader`, which a Loader injects when it evaluates a module. A module compiled into the host bundle is evaluated by the platform instead, so that injection does not happen and the helper returns undefined. The Loader now carries the loader the host publishes for bundled modules, and the loader service publishes every loader that becomes its active one. The seven `myLoader()` helpers in base fall back to it and throw a named error rather than returning undefined if neither is available. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
card-api declares the classes every other base module extends, so while it was fetched and anything else was bundled, a bundled module extended the build's FieldDef and a fetched one extended the loader's. Nothing that compared the two agreed: the AI schema mappings are keyed by field class, so every tool's input schema failed to resolve its own fields. Serving card-api from the bundle collapses both onto one set of classes — a fetched module's `./card-api` resolves through the loader to the shim, which hands back the same instance the build gave the bundled modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`myLoader()` reads `Loader.forBundledModules()`, but card-serialization imported Loader in an `import type` block, so at runtime the binding does not exist and every call throws `Loader is not defined`. The type checker had the error and it was suppressed: the `@ts-ignore` covering the `import.meta` read sat on the same line as the fallback. It now covers only the `import.meta` read, so an error in the fallback is reported — which is how this one surfaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bundler resolves a bundled module's imports into its chunk, so a module reachable from a bundled one but absent from the table was bundled anyway AND fetched separately when card code imported it by identifier. The two copies have different classes and different module state, which is why edit controls went missing: a fetched contains-many-component registered its components against one copy while the render path looked them up on the other. Bundling card-api therefore drags its 44 transitive imports in, and those modules drag in more. Registering the whole closure — 103 modules — collapses each onto a single instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A prerender abort reported only the realm it had affinity for, so every module of a realm read identically and a stall could not be attributed to one. It now carries the subject URL. A definition-cache populate is the expensive path a cache hit avoids, and it happened silently, so a module that populates on every read — a cache that never takes — was indistinguishable from one that populates once. Each populate now logs its module and realm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Module prerendering hangs for a module that depends on bundled base, and the request produces no response and no dispatch line, so nothing identifies how far it got. The route now marks four points — entry, after the module import, after the source HEAD, and after definitions are built — so the last one reached names the stage that hangs. Temporary scaffolding, removed once the hang is located. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
base asked for ^0.7.0 and host for ^0.8.0, so pnpm installed both and the two packages resolved to different instances. That was invisible while base modules were fetched, because the loader shimmed the package and handed card code the host's instance. Bundling makes the bundler resolve base's own import instead, so a card consumed context from 0.7.1 while the host provided it from 0.8.0 and the two registries never met. The symptom is a card seeing none of the context the host provides: `@context.searchResultsComponent` is undefined, so a card rendering search results prerenders an empty placeholder. Aligning base's peer range deduplicates the install. No other shared dependency resolves differently between the two packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The field validated a reference with a bare `import(module)`, where the specifier is a realm module that only the loader can resolve. That worked because the realm's transform rewrites a dynamic import in card code to go through the loader — but when this module is compiled into the host bundle the bundler owns that call instead, and the specifier reaches the platform unresolved. Every reference then failed to validate, so a CodeRef field rendered as invalid. It is the only bare dynamic import among the bundled base modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A module served from the host bundle contributes itself to an indexed instance's dependencies, but not the files it would have pulled in over the network — its glimmer-scoped CSS among them — because the loader records no dependency chain for a module it did not fetch. Nothing is lost for invalidation: a bundled module cannot change without a host rebuild, so nothing inside it can invalidate an index entry. The assertion that card-api itself is a dep is unchanged and still passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A send carrying an attachment produces no m.room.message at all, and throws nothing, so nothing distinguishes an upload that returned empty from an event that was never dispatched. The send path now marks entry, the point attachments are assembled, and the dispatch itself, and the card upload reports how many cards it was given and what it produced. Temporary scaffolding, removed once the gap is located. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The attachment tests read the last event in the room and find one carrying no data, while the send path completes and dispatches a message whose attachments are assembled correctly. Reporting each event's type as it is sent shows what lands after the message. Temporary scaffolding, removed with the other send-stage markers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tackbase-modules-into-the-host-so-base-imports
Accepting the missing dep was wrong. A separate realm-server test, "serves scoped CSS in index responses for card URLs", fails on this branch at the assertion that a base-realm dependency's scoped CSS reaches the served HTML — the card's own scoped CSS still arrives, only its base-realm dependency's is absent. So a bundled base module dropping out of an instance's dependencies is not a cosmetic index difference: it is why base components render unstyled. The assertion this restores was the canary for that, and relaxing it hid the defect rather than accounting for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A module the loader fetches reports its imports, and the indexer reads that to learn which scoped stylesheets a card needs. A shim reports nothing, so a bundled base module's stylesheet is never interned and the card's served HTML arrives without it. The shim descriptor now takes `deps`, and the loader records them as the module's consumed modules instead of an empty set. Nothing supplies them yet: the scoped-CSS specifier is produced while the bundler compiles the template and is resolved away in the same pass, so a plugin cannot observe it before the registration that would carry it has been built. Supplying them needs a way to enumerate a file's scoped-CSS specifiers ahead of traversal, which glimmer-scoped-css does not currently expose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
glimmer-scoped-css encodes a stylesheet into the import specifier itself, so an ordinary import carries its CSS along. The chain from there is: the loader records that specifier among a module's consumed modules, the indexer interns the stylesheet and writes a hashed dependency, and the realm serves a card's HTML with the CSS inlined. Bundling broke the chain at the first link, because a shimmed module reports nothing as consumed — which is why a card's served HTML arrived without the CSS its base components need. The specifier is still in the module's compiled source right up until the bundler resolves it away, so each bundled base module now registers its own as it is evaluated, and the registration reads that when the loader asks what the module consumed. Collecting them at build time instead cannot work: they are discovered while the module graph is walked, and would be needed before that walk finishes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A base module the loader is never asked for gets no chance to declare its stylesheet. Only the modules card code imports by identifier are served, and one reached solely from inside an already-bundled chunk — as default-templates/embedded is, from card-api — is not among them, so its scoped CSS never reaches an instance's deps and the served HTML renders unstyled. Each base module now registers the sibling base modules it imports alongside its own stylesheet specifiers, and a served module declares the union over everything reachable from it. A borrowed specifier is rewritten to point at the same file from the serving module's directory, since the loader resolves a declared dep against the URL of the module that declared it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A module fetched from the realm keeps a test alive while it loads; one resolved from the host bundle never touches the runloop, so `settled()` returns before the module has been evaluated and a test asserts against a half-built DOM. Nothing in the host's start chain — the matrix service waiting on card-api, and everything that waits on the matrix service — is visible to `settled()` on its own; the fetch is what it was really waiting for. Measured in one build, toggling only whether the bundle serves base: loading card-api takes 94ms over the network and 42ms from the bundle, and the login SSO tests pass in the first case and fail in the second. So the margin, not the duration, is what those tests were living on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A loader credits the first module it serves with every binding that module's namespace exposes. `file-api` re-exports `FileDef`, which `card-api` declares, so a loader asked for `file-api` first reports `@cardstack/base/file-api` as the module of every `FileDef` code ref. The adoption-chain walk stops at the module the family root names, so it walks past `FileDef` to its ancestor and reports the definition "is not a FileDef" — which reaches a caller as a filter referring to a nonexistent type. A module the loader fetches cannot get this wrong: evaluating it loads what it re-exports from first, so the declarer is always served first. A bundled module can, because the bundler resolves that import inside the chunk, where the loader never sees it. Each base module now registers which sibling modules it re-exports from, and a bundled module's resolver serves those through the loader before returning its own namespace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A base module the host bundle serves is not a realm resource. Nothing fetches it, nothing in a realm can change it, and a realm has no reason to carry it in an instance's dependencies — so an instance's closure no longer names base's internal module graph, nor the boxel-ui, icon and command modules reached only through it. What a card still records of base is the scoped CSS, because the indexer has to intern those stylesheets to serve the card's HTML. The errored-card assertion now reads that: a stylesheet belonging to a template the card reaches only through card-api is the evidence that the render closure, not just the card's own module, is in the deps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They named the stages of sending a message with attachments and of a module prerender while the cause of the bundled-base test failures was still open. The cause is known and fixed, so the markers have nothing left to show. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Serving the declarer first, as the previous commit did, put a loader import inside the shim resolver: it changed which modules a card was recorded as consuming depending on what had already been served, and left a code-mode navigation waiting on a module the loader was still serving. The loader can be told instead. Each base module records where the names it re-exports are really declared, following the chain to the module that declares each one, and the shim hands that to the loader, which credits the declarer rather than the module the name arrived through. That settles the identity whichever module is served first, and whether or not the declarer is served at all — a `FileDef` code ref names `card-api` even when only `file-api` was ever asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both regressed tests that were passing. Serving the declarer first from inside the shim resolver changed which modules a card recorded as consuming and stalled a code-mode navigation; naming the declarer to the loader instead broke rendering widely enough to time out realm.json indexing across the software-factory suites. The problem they addressed stands: a loader credits the first module it serves with every name that module exposes, so `file-api` served ahead of `card-api` makes every `FileDef` code ref name the wrong module. One realm-server test fails on it. That is a smaller cost than shipping a fix whose blast radius is not understood, so this returns to the state with that single known failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…res it A loader credits a class to the first module it serves that exposes it. A module the loader fetches cannot get this wrong, because evaluating it loads what it re-exports from first. A module served from a bundle can: the bundler resolves that import inside the chunk, so the loader is never asked for the declarer and credits the borrower instead. Every code ref for the class then names a module that does not declare it, and an adoption-chain walk that stops at the declarer walks past it. Marked `todo`: it fails today, runs in seconds against no realm server, and flags the moment the behavior is fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`file-menu-items` reached the expected list from a build that carried a change since reverted: it made the loader serve a module's re-export source, which put that module in the card's closure. Without it the card records what its own source names and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The loader cannot tell, from a shimmed namespace alone, which module declared a binding it exposes — so an assertion against a hand-built loader asks for something unachievable. Ask it of the bundled base instead: import only `file-api` and read where `FileDef` says it comes from. That holds whatever shape the fix takes, and it runs in under a second against no realm server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The junit the aggregate host check reads counts a `todo` test's failure as a failure, so marking it `todo` turns the suite red for a test that is documenting a known gap rather than reporting a new one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tackbase-modules-into-the-host-so-base-imports
A shim is keyed by the URL its identifier resolved to when it was registered, and a lookup resolves through whatever mapping is current. Re-pointing a realm prefix after the shim is installed strands it under the old URL: the import resolves to the new one, misses, and goes to the network. Re-registering a prefix to a different target is supported and tested, so the gap is reachable rather than theoretical. Each shim is now registered under both spellings — the URL its identifier resolved to and the identifier itself — and a lookup tries the request unresolved against the current mapping as well as resolved. The resolved key goes stale on a remap; the identifier does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tackbase-modules-into-the-host-so-base-imports # Conflicts: # packages/base/package.json # packages/realm-server/prerender/remote-prerenderer.ts # packages/runtime-common/definition-lookup.ts # pnpm-lock.yaml
`file-api` declares nothing — it re-exports `FileDef` and friends from card-api. A loader credits a class to the first module it serves that exposes it, and a bundled module is served without its re-export source being loaded first, so serving this one made every `FileDef` code ref name `@cardstack/base/file-api`. The adoption-chain walk stops at the module the family root names, so it walked past `FileDef` and reported "is not a FileDef" — reaching a caller as a filter referring to a nonexistent type, and failing `expands file deps using module cache for file defs`. Fetched from the realm it is correct, because evaluating it loads card-api first. Nothing bundled imports it at runtime — card-serialization's is `import type`, which erases — so the bundled set stays closed under imports without it. This is the identity gap the skipped test in `Integration | bundled base modules` pins, now scoped to the modules that actually need the fix rather than left failing in the suite. It joins the four other re-exporters waiting on that fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f7b015fa1a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`command` breaks the closure rule this table documents: it imports `./commands/search-entry-result`, which is not in the table, so bundling it compiled that module into `command`'s chunk while a direct import of the same module still fetched a separate copy — two copies whose classes do not match, which is the failure the rule exists to prevent. Both `command` and `commands/search-card-result` also re-export classes they do not declare: the search input and result fields from `commands/*`, and `JsonField` from `json-field`. A loader credits a class to the first module it serves that exposes it, and a bundled module is served without its re-export source being loaded first, so serving either would make those classes name a module that does not declare them — the same gap that keeps `file-api` out. Nothing bundled imports `command`, and after it goes nothing bundled imports `commands/search-card-result` either, so both leave without disturbing the closure. Verified: no bundled module now imports anything outside the table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tackbase-modules-into-the-host-so-base-imports
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] Five inline findings from a correctness pass over the bundling changes — the first two concern the bundled-module set itself, the last three are in the shim/loader plumbing.
Generated by Claude Code
| // resolved there. Importing string.ts itself would have TypeScript classify | ||
| // that `.ts` module as CommonJS (this package declares no `type`) and | ||
| // retype its default export as a namespace for every host importer. | ||
| string: () => |
There was a problem hiding this comment.
[Claude Code 🤖] This entry serves { default: StringField } out of card-api's chunk without the loader ever being asked for card-api. A loader that is served @cardstack/base/string first therefore captures StringField's identity as @cardstack/base/string/default rather than as its card-api export.
That's the same re-export identity problem this file documents a few lines up as the reason file-api, command and commands/search-card-result are deliberately left unbundled, and that bundled-base-modules-test.ts pins with a skipped test. Should string be excluded on the same grounds, or is there something about it that makes the mis-attribution harmless here?
Generated by Claude Code
| // modules (see app/tools/index.ts); this alias gives the bundler the | ||
| // same 1:1 mapping. | ||
| { | ||
| find: /^@cardstack\/boxel-host\/(?:tools|commands)\//, |
There was a problem hiding this comment.
[Claude Code 🤖] The comment says this gives the bundler the same 1:1 mapping the runtime shim uses, but the runtime mapping isn't flat: create-listing-pr-request and send-bot-trigger-event are registered under bare names while living in app/tools/bot-requests/. A @cardstack/boxel-host/tools/create-listing-pr-request specifier resolves to app/tools/create-listing-pr-request, which doesn't exist.
Nothing bundled today imports those two, so it's latent — but it becomes a build failure for the follow-up that bundles the remaining modules. Worth deriving the alias from the same registry app/tools/index.ts uses, or at least dropping the "1:1" claim from the comment?
Generated by Claude Code
| // `import.meta.loader` — the platform evaluated them, not a Loader — so every | ||
| // loader that becomes this service's active one is also published as the one | ||
| // those modules fall back to. | ||
| private publish(loader: Loader): Loader { |
There was a problem hiding this comment.
[Claude Code 🤖] Loader.setForBundledModules is a process-wide static, and this service is its only writer — including in tests. So the test-realm loader built at packages/host/tests/helpers/index.gts:1515 doesn't get its own fallback: bundled base modules there resolve through whatever loader this service last published.
It's also never cleared on service teardown, so a disposed loader stays handed out to bundled modules (and keeps its module graph alive) until the next service instance overwrites it. Can willDestroy clear it, and should the test-realm loader publish itself for the duration of the test?
Generated by Claude Code
| // resolved here against the module's own URL, so they read the same as | ||
| // the deps of a module this loader fetched. | ||
| consumedModules: new Set( | ||
| loaded.deps.map((dep) => { |
There was a problem hiding this comment.
[Claude Code 🤖] Recording the declared deps as consumedModules means the scoped-CSS pseudo-URLs land in that set, and the loader never loads those as modules. Two consequences downstream:
collectKnownModuleDependenciescan't find a module entry for them, so it setscomplete = falsefor every graph that includes card-api — which permanently defeatsknownDepsCachefor the common case.getConsumedModulesissues a realloader.import()per CSS pseudo-URL.
Should the CSS deps be filtered out of the declared-deps set here (or marked so the graph walk skips them), so a fully-shimmed graph can still be reported as complete?
Generated by Claude Code
| ])) { | ||
| this.moduleIds.set(key, resolver); | ||
| if (descriptor.deps) { | ||
| this.moduleDeps.set(key, descriptor.deps); |
There was a problem hiding this comment.
[Claude Code 🤖] moduleIds is overwritten unconditionally but moduleDeps is only written when descriptor.deps is present, so re-shimming an id without deps leaves the previous registration's deps thunk in place and pointing at the old module. Given the comment above about a realm prefix being re-pointed after a shim is installed, re-registration looks like a supported path.
Worth a this.moduleDeps.delete(key) on the else branch so the two maps stay in step?
Generated by Claude Code
Status
Ready for review. CI is green.
This serves card-api, its full import closure, and the base field types from the host bundle — 102 of 248 modules. That is a complete unit of work, not a stopping point mid-way: the bundled set is closed under imports, so nothing bundled reaches anything unbundled, and every remaining module is an independent increment.
The rest is tracked as CS-13056, deliberately. A throwaway branch bundling 242 of 248 measured the remainder at about 52 s off the critical path against the 416 s this PR delivers — roughly 89% of the available gain is here — and it went red with 24 failures that need their own diagnosis. Finishing it here would gate a working 33% CI improvement on unsolved work worth ~6% more. The numbers are in "Measured" below.
An earlier revision of this description said the PR was complete only when every base module was bundled. That was the plan before the remainder was measured.
Three commits are under separate review — skip them here
They are self-contained and do not depend on the rest of this branch. They stay in this branch's history until they land on main, at which point they drop out of this diff.
e42ae20a13b497c780adember-provide-consume-contextresolves to one copye2e6a1a40dOf what remains,
app/lib/bundled-base.tsis 289 lines and most of it is the 108-entry module table, which is mechanical. The logic worth reading closely is the vite plugin, the shim plumbing inpackage-shim-handler.ts/virtual-network.ts, and thedepshandling inloader.ts— roughly 200 lines.It halves host test time
Three green main runs against two green runs here, same workflow, same 16 shards, ~4900 tests either way:
About 8.7 minutes off every host run and a third less CI compute. Wall clock improves less than test time because each shard carries fixed boot and indexing overhead that bundling does not touch. The saving is net of the test-waiter change below, which makes
settled()wait for work it previously raced past.What is proven
Bundling card-api fixes the class-identity split, the largest failure family.
No schema found for fieldis gone, along with the 82Integration | Command | host command schema generationcases and the 19 inUnit | ai-function-generation.Three rules came out of getting there, and each is load-bearing.
card-api has to be bundled, and first
Every base module imports
./card-api, and the bundler resolves that import into the chunk. While card-api was fetched and anything else was bundled, a bundled module extended the build'sFieldDefwhile a fetched one extended the loader's, and nothing comparing the two agreed. Serving card-api from the bundle collapses both onto one set of classes.Bundled modules cannot read
import.meta.loader, because the platform evaluates them rather than the Loader, so the Loader carries a loader the host publishes for them and themyLoader()helpers in base fall back to it.The bundled set has to be closed under imports
A module reachable from a bundled one is compiled into its chunk and fetched separately when card code imports it by identifier. Components then register against one copy and are looked up on the other. The closed set is what
BUNDLED_BASE_MODULESholds; every addition has to preserve the property, because the failure is silent.A bundled module reports no scoped CSS unless it is made to
glimmer-scoped-css emits the stylesheet inside an import specifier, so a module the loader fetches records it among its consumed modules, the indexer interns it, and the server injects it into served HTML. A shimmed module has no dependency chain, so the stylesheet is never learned about and base components render unstyled.
Fixed without changing glimmer-scoped-css. A Vite plugin appends a registration to each base module carrying its own specifiers and the sibling base modules it imports; the shim declares the union reachable from whatever module was served. The transitive half matters because a module reached only from inside another module's chunk is never served on its own —
default-templates/embeddedis reached only from card-api.Bundled async is invisible to
settled()A base module fetched from the realm keeps a test alive while it loads. One served from the bundle never touches the Ember runloop, so
settled()returns before the module is evaluated and a test asserts against a half-built DOM. Toggling onlyshimBundledBasein one build:Faster, and still failing — so it is the margin these tests were living on, not the duration. Naming the bundled import to the test waiters took the host suite from 28 failures to 3.
Worth knowing independently of this PR: the host's whole matrix start chain is invisible to
settled()either way.Open: a bundled re-export takes a class's identity
A loader credits a class to the first module it serves that exposes it.
file-apire-exportsFileDef, whichcard-apideclares, so a loader asked forfile-apifirst reports@cardstack/base/file-apias that class's module. The adoption-chain walk stops at the module the family root names, so it walks pastFileDefand reports "is not a FileDef", which reaches a caller as a filter referring to a nonexistent type.A module the loader fetches cannot get this wrong: evaluating it loads what it re-exports from first. A bundled one can, because the bundler resolves that import inside the chunk.
Pinned as a skipped test in
Integration | bundled base modules— import onlyfile-apiand read whereFileDefsays it comes from. Sub-second, no realm server.Two fixes were tried and both reverted. Serving the declarer first from inside the shim resolver changed which modules a card recorded as consuming and stalled a code-mode navigation. Naming the declarer to the loader instead passed locally and then took CI broadly red with render timeouts that were never explained. Ruled out since: the plugin's output (four modules, all correct) and the loader unit suite. Both attempts registered an identity for a module the loader never served, which is where the next attempt should start, and it needs a render-level reproduction rather than an identity-level one.
The judgement call worth reviewing
An instance's dependency closure no longer names base's internal module graph, nor the boxel-ui, icon and command modules reached only through it. What a card still records of base is the scoped CSS, because the indexer has to intern it to serve the card's HTML.
The argument: a bundled base module is not a realm resource. Nothing fetches it, nothing in a realm can change it, and a host deploy is what invalidates it. The alternative is declaring base-internal imports as dependencies, which is cheap to do — the plugin already records the graph — and would keep the closure closer to what it is on main.
Measured: the remaining modules are worth ~6% more
A throwaway branch bundling 242 of 248 modules (everything without a dynamic import or a re-export) was run through CI to size the rest of the work:
So this PR has already taken ~416 s off the critical path and the remaining 139 modules would take ~52 s more — about 89% of the available gain is already here. That is what you would expect from which modules they are: card-api's closure loads on every render, whereas the rest are individual card definitions that load only when something uses that card type.
The probe also went red, 24 failures clustered entirely in file attachment and file chooser (
Element not found when calling click('[data-test-file="person.gts"]')— a file tree that stops rendering). So the remaining conversion is not mechanical, in the same waycodemirror-editorwas not: bundling a module can break it for reasons unrelated to class identity.Hence the remaining 145 belong in a follow-up rather than here. The set is already closed under imports, so nothing bundled reaches anything unbundled and stopping at 103 is a consistent state; each further module is an independent increment. Finishing them here would gate a working 33% CI improvement on 24 unexplained failures, 4 modules blocked by the identity bug below, and ~6% more gain.
Caveat on the numbers: the probe ran 4,724 tests to this PR's 4,933 because of those failures, so its figures are slightly optimistic — the true marginal gain is a little under 52 s, not over.
How
BUNDLED_BASE_MODULES(app/lib/bundled-base.ts) lists each bundled module with a literalimport(), andshimBundledBaseregisters each entry withvirtualNetwork.shimAsyncModule, the mechanism that already serves runtime-common and boxel-ui to card code. The literal import per entry is what lets Vite give each module its own chunk. It lives beside the table rather than inexternals.tsbecause the boxel-cli guard reads literal shim ids out of that file, and a template-literal id is unreadable to it.VirtualNetwork.getShimmedModule). A shim for a realm-mapped identifier is keyed by the realm URL the identifier resolves to, because the realm mapping also feeds the network's import map, and the network's fetch pipeline only answers shims on the fakehttps://packages/origin. The loader is the one place that knows a request is for a module rather than a card instance at the same realm URL, so the lookup lives there, and it folds prefix, virtual-alias and url-mapped spellings onto the real URL so all of them land on one module.@cardstack/boxel-host/tools/*and@cardstack/boxel-host/commands/*toapp/tools/, mirroring the virtual network's runtime shim, so a bundled base module that imports host tools resolves them at build time.string.tsisexport default StringFieldfrom card-api and is resolved from there: importing a.tsmodule of this package (which declares notype) makes TypeScript classify it as CommonJS and retype its default export as a namespace for every host importer.Proof
Unit | loader: a module shimmed on the virtual network is served to a loader whose fetch throws, under the prefix, resolved-URL and alias spellings.Integration | bundled base modules: every table entry is imported through the loader; its exports are asserted to be the bundled module's own, and a prepended network spy asserts no request reached the base realm.Integration | realm indexing: an instance records the scoped CSS of base templates it reaches only through card-api.loader.import, so the field tests exercise the shims as well.Progress
102 of 247 base modules are served from the bundle: card-api and its full import closure, the date and time families, and the primitive field modules.
The remaining 145 are the card definitions and their components. Scanning them for the two patterns that have actually broken a module when bundled:
import()file-formats/model3d-preview,file-formats/pdf-captures. The bundler owns the import and the realm transform's rewrite is lost, so it needs themyLoader()treatmentcode-refgot.command-field,file-formats/index,index,ts-file-def. Blocked on the identity gap above.The scan finds exactly one dynamic import among the 102 already bundled —
code-ref, the one that did break — so it is calibrated against a known case. That leaves roughly 139 modules that should be mechanical.Then the per-entry table collapses into one
@cardstack/base/prefix shim with the same lazyimport().🤖 Generated with Claude Code