Skip to content

Serve card-api and the base field modules from the host bundle - #6087

Open
backspace wants to merge 45 commits into
mainfrom
cs-12937-bundle-cardstackbase-modules-into-the-host-so-base-imports
Open

backspace wants to merge 45 commits into
mainfrom
cs-12937-bundle-cardstackbase-modules-into-the-host-so-base-imports

Conversation

@backspace

@backspace backspace commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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.

PR commit what
#6182 e42ae20a13 the loader asks the virtual network for a shim before fetching
#6183 b497c780ad ember-provide-consume-context resolves to one copy
#6185 e2e6a1a40d name the module in a prerender stall, log definition-cache populates

Of what remains, app/lib/bundled-base.ts is 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 in package-shim-handler.ts / virtual-network.ts, and the deps handling in loader.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:

measure main here change
test execution time, summed 9,664 s 4,374 s −55%
slowest shard, which sets wall clock 1,358 s 835 s −39%
runner-seconds across all 16 shards 18,607 s 11,814 s −37%

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 field is gone, along with the 82 Integration | Command | host command schema generation cases and the 19 in Unit | 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's FieldDef while 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 the myLoader() 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_MODULES holds; 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/embedded is 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 only shimBundledBase in one build:

base served from card-api load login SSO tests
realm over HTTP 94 ms pass
host bundle 42 ms fail

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-api re-exports FileDef, which card-api declares, so a loader asked for file-api first reports @cardstack/base/file-api as that class's module. The adoption-chain walk stops at the module the family root names, so it walks past FileDef and 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 only file-api and read where FileDef says 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:

main this PR (103) all mechanical (242)
slowest shard, sets wall clock 1,245 s 829 s 777 s
runner-seconds, 16 shards 17,049 s 12,158 s 10,787 s
per-test time 1.73 s 0.924 s 0.825 s

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 way codemirror-editor was 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 literal import(), and shimBundledBase registers each entry with virtualNetwork.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 in externals.ts because the boxel-cli guard reads literal shim ids out of that file, and a template-literal id is unreadable to it.
  • The Loader's module-fetch path asks the virtual network for a shim before fetching (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 fake https://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.
  • A Vite alias maps @cardstack/boxel-host/tools/* and @cardstack/boxel-host/commands/* to app/tools/, mirroring the virtual network's runtime shim, so a bundled base module that imports host tools resolves them at build time.
  • string.ts is export default StringField from card-api and is resolved from there: importing a .ts module of this package (which declares no type) 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.
  • The host test helper loads base modules through 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:

pattern count why it matters
dynamic import() 2 file-formats/model3d-preview, file-formats/pdf-captures. The bundler owns the import and the realm transform's rewrite is lost, so it needs the myLoader() treatment code-ref got.
re-exports a sibling 4 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 lazy import().

🤖 Generated with Claude Code

backspace and others added 2 commits September 10, 2026 22:07
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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-21T10:22:09.636630Z f7b015f Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files  ±    0      1 suites  ±0   1h 33m 13s ⏱️ + 1h 27m 16s
4 929 tests +4 500  4 914 ✅ +4 487  15 💤 +13  0 ❌ ±0 
4 944 runs  +4 515  4 929 ✅ +4 502  15 💤 +13  0 ❌ ±0 

Results for commit be4e24b. ± Comparison against earlier commit 8c9a2ce.

Realm Server Test Results

    1 files  ±0    244 suites  ±0   1h 16m 35s ⏱️ + 2m 55s
3 622 tests +4  3 622 ✅ +4  0 💤 ±0  0 ❌ ±0 
3 673 runs  +4  3 673 ✅ +4  0 💤 ±0  0 ❌ ±0 

Results for commit be4e24b. ± Comparison against earlier commit 8c9a2ce.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/host/app/lib/externals.ts Outdated
Comment thread packages/host/tests/helpers/base-realm.ts Outdated
backspace and others added 5 commits September 11, 2026 18:15
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>
@backspace backspace changed the title Serve base date/day and date/month modules from the host bundle Serve @cardstack/base modules from the host bundle Sep 11, 2026
@backspace
backspace marked this pull request as draft September 11, 2026 16:18
backspace and others added 18 commits September 11, 2026 19:30
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
backspace and others added 14 commits September 16, 2026 17:15
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>
backspace and others added 3 commits September 17, 2026 22:28
…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>
@backspace
backspace marked this pull request as ready for review September 21, 2026 10:17
@backspace backspace changed the title Serve @cardstack/base modules from the host bundle Serve card-api and the base field modules from the host bundle Sep 21, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/host/app/lib/bundled-base.ts Outdated
backspace and others added 2 commits September 21, 2026 12:30
`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 habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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: () =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)\//,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  • collectKnownModuleDependencies can't find a module entry for them, so it sets complete = false for every graph that includes card-api — which permanently defeats knownDepsCache for the common case.
  • getConsumedModules issues a real loader.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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

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.

2 participants