7. Islands are built in one module graph, and the singleton guarantee is per invocation
Date: 2026-08-29
Status
Accepted. Decided on #64 and shipped in the PR that closes it: checkClientGraph
(packages/core/src/client-build.ts) refuses a build whose emitted graph has a
hole in it or whose resolution produced two copies of one package, and
packages/core/src/singleton.build.test.ts measures the guarantee over a real
bundler, including the incremental path. Binding on buildClient's
configuration and on anything that would emit island code from more than one
bundler invocation.
Context
Spec §8 wants a provider stack that resolves to one instance across every island root, and spec §11 forbids a partial-graph client build. Both are the same requirement seen from two ends: a module two islands import has to be evaluated once, so its module-level state is one store rather than one store per island.
Nothing outside a single bundler invocation guarantees that. Rolldown documents it — "Ensure every JavaScript module is singleton in the final bundle output" — and the words that carry the weight are final bundle output. The statement is about one output of one invocation. Two invocations produce two outputs, and no bundler documents anything across them, because there is no shared module registry for either of them to write into: the browser's own module map keys on URL, and two invocations that each emitted a copy of one module emitted it at two URLs.
The framework said so in prose in two places — entryInputs' docblock and
client-build.ts' header — and nothing measured any of it. buildClient takes
the whole EntryPlan and hands entryInputs(plan) to one build() call, which
is the property both docblocks assert, but a reader had only their word for it.
Three layers can break the guarantee, and they are blind to each other:
- Placement. One module the bundler resolved once, copied into two chunks.
- Resolution. Two physical copies of one package (pnpm hoisting, a linked workspace package, a transitive duplicate), resolving to two different ids, so a per-id placement check counts one chunk each and sees nothing wrong.
- The invocation boundary. A module the build did not emit at all — an externalized dependency, loaded by the browser on its own.
#64's research addendum asked for the first two to be measured and for the third to be documented as a constraint that fails rather than degrades silently.
Decision
Island code is built in one full-graph bundler invocation, and the singleton
guarantee holds per invocation. buildClient is the only place the framework
configures a shipping client build; it takes the whole EntryPlan and passes
entryInputs(plan) as the input map, and it sets no external. That is the
mechanism, and there is no second one.
The graph must be closed, and that is now checked rather than asserted.
Every import in every emitted chunk — static and dynamic alike — has to name a
chunk this same invocation emitted. A module reached through an import that
names anything else is loaded by the browser separately, keyed by URL in the
browser's module map, and shared with whatever else happens to resolve to that
URL. That is a module the build cannot keep singleton, so checkClientGraph
refuses it, names every such import and says the fix in the same sentence
(docs/error-messages.md rules 3 and 5). Nothing in this repo can produce that
state today; the check exists so that the day something can, the run stops
instead of shipping.
Two copies of one package at one version are refused by the same check, at
the resolution layer. Two ids whose path after the last node_modules
segment is the same package and the same file, and whose nearest
package.json declares the same version, are two installs of one package — the
linked-package and hoisting case #64's research addendum names. The report names
both paths so the reader can tell which to remove.
Two different versions of one package are not that, and pass. They are
ordinary npm: a graph that transitively reaches two minors of a shared utility
holds two modules, not one module in two instances, and no lockfile edit
collapses them. The path key alone cannot tell the two apart — under pnpm both
versions live at …/.pnpm/<name>@<version>/node_modules/<name>/…, and the cut
after the last node_modules drops the segment that differs — so the version is
read as well.
When a version cannot be read, the check says nothing. An absent,
unreadable or version-less package.json is a layout this reasoning does not
understand, and the two errors it could make there are not symmetric: staying
quiet costs a warning about an install that was already shipping, while
reporting costs a correct site a refused build at exit 2 and a fix sentence
about a lockfile that has nothing wrong with it. It fails open.
No site-facing surface declares which modules are singletons, because every module is one. #64's notes asked for "a way for a site to declare 'these modules are singletons' (config or convention)", designed as part of the issue. The design is that there is no such surface, and the reason is above: the guarantee is universal. It covers every module in the emitted graph, every import out of it, and every package resolution produced two copies of — so a declared list could only narrow what already holds of everything, and a module left off it would be one the site had silently given up on. Nothing is added and nothing has to be maintained in step with a site's dependencies.
Vite's resolve.dedupe is deliberately not configured, and it is not what the
error message recommends. It is that declared surface in Vite's spelling — a
list of package names a site keeps in step — so the paragraph above is the first
reason against it. It was the obvious candidate all the same, and it was
measured against the installed toolchain (Vite 8.2.2, tryNodeResolve) before
being rejected — see Alternatives considered.
A partial-graph build stays unspecified, and would need an externalization
mechanism before it could be allowed. For one entry to be rebuilt against a
previous build's chunks, that invocation would have to not emit those chunks
and instead import them at their existing URLs — which is an external plus a
mapping from module id to the previous build's emitted file name, plus a rule
for what happens when the two builds disagree about a module's contents, plus a
story for how the tier plan's groups are honoured by a graph that contains none
of their modules. None of that is specified anywhere. Until it is, the closed-
graph check above is what stops it arriving by accident.
Owner: buildClient. Whoever adds an external, a second build() call
for island code, or a plugin that resolves an island's import to a bare
specifier owns this decision and has to reopen it here first.
Consequences
An incremental build re-emits every chunk, and that is the design rather than
a shortcoming. Spec §11's "island-edit → one entry re-emitted" is a statement
about which bytes change, not about which chunks the bundler visits.
incremental.build.test.ts measures the criterion that way: not "no chunk was
rebuilt" — every chunk was, over the whole graph — but "every chunk came back
the same bytes at the same address". What makes that cheap enough to
be the answer is that the tier plan is pinned (IncrementalPlan.pinned.tiers)
and Rolldown's output is deterministic, so an unchanged module lands in an
unchanged chunk at an unchanged content hash and the deploy diff is empty.
The pinned tier plan alone did not hold that: a shared module no tier claims
was left to Rolldown's default split, and moved when an island left the graph.
#720 also pins that split from the previous build's manifest, and where a
chunk's bytes still change, renders the reused pages that name it again and
bundles again.
singleton.build.test.ts measures the guarantee on exactly that path: an island
edit, the pinned tiers from the previous build, one entry re-emitted at a new
address, the pinned core chunk at its old address, and store.js in that one
chunk and nowhere else.
The cost is build time on a large site. Spec §11's headline case is 50k
pages, and every incremental build over it walks the whole client graph.
Nothing here makes that cheaper; what it does is make the alternative — walking
part of it — a decision somebody has to argue for rather than one that can be
reached by handing buildClient a shorter plan.
A caller handing buildClient a plan short of the site's pages is not
detected, and is not a singleton fault. This is the one thing #64's
"configuration that would split them across invocations fails" does not cover,
and the honest statement is worth more than a check that pretends to. Such an
invocation builds a complete, closed, internally consistent graph over fewer
pages: every module in it is still singleton. What it loses is the other pages'
entry chunks, which is a missing-output fault that buildManifest and the
manifest diff see, not a duplicated-store fault. buildClient could not detect
it in any case — it holds a plan and a tier plan and no route table, and every
subset of a site's pages is a valid plan for a smaller site. TierPlan.pageCount
is the one number that looks like evidence and is not: pages are legitimately
removed between builds (IncrementalPlan.remove), so a plan with fewer entries
than the pinned tiers were planned over is an ordinary deletion, and a check on
it would refuse correct builds.
The two new refusals are ConfigError, exit 2 (docs/error-messages.md
rule 7). Both are the site's or the framework's wiring and neither comes right
on a retry: an install that holds two copies of a package holds them again next
run, and an externalized module stays externalized.
The package-copy check keys on paths, which makes it a heuristic about a
convention. node_modules is a filesystem convention rather than a fact the
bundler reports, and a resolver that placed packages somewhere else would defeat
it. That is accepted: it is the layout every package manager this framework can
be installed by produces.
It reads one field off disk, and no more than one. The version comes from
the nearest package.json at or above the module, bounded below the
node_modules directory the path was cut at, and it is read only for the paths
that already collided — a graph with no collision opens no file. That is not
the general package-identity system #64 declined to build: no name, no
resolution record, no version ranges and no comparison beyond string equality.
It is the one fact without which the check refuses correct builds.
Alternatives considered
Configure resolve.dedupe instead of detecting the duplicate. Rejected on
measurement, and it was the first thing tried. Three findings, all against it:
- It is a declared list of package names, and the framework has no list. The
duplicate could be any package a site's islands share, which is the same
argument
checkClientGraph's universal per-id count already makes: there is nothing for a site to declare, and a declared list would only narrow what has to be true of every module in the graph. - Its whole effect is to force one variable. In
tryNodeResolveit replaces the base directory of thenode_moduleswalk with the project root and changes nothing else. Bare specifiers only — a relative or absolute id never reaches that branch — so a duplicate reached by path is untouched. - It can turn a shipping build into a broken one. With the base directory forced to the root, a package with no copy on the root's upward walk resolves to nothing, and the optional-peer-dependency fallback is skipped because it is guarded on the base directory not being the root. A site that lists a package its root does not hold gets an unresolved import where it previously had a working, if duplicated, build. That is a setting to reach for deliberately, with a specific package in hand — which is a site's decision, not a default this framework should be making on its behalf.
Detection has none of those properties: it is universal, it reads the graph the bundler actually produced rather than trying to steer it, and its failure mode is a refused build with both paths in the message.
Configure resolve.dedupe and detect. Rejected as the worst of both. The
setting would silently collapse the copies the check exists to report, so the
check would be dead for exactly the sites that needed it, and the site would
never learn its lockfile holds two copies of a package.
Recommend resolve.dedupe in the error message. Rejected for the third
finding above. A fix sentence that can leave the reader with an unresolved
specifier is not a fix (docs/error-messages.md rule 3). The message names the
lockfile and the site's own dependencies instead, which are levers with no
failure mode of their own.
Check that buildClient's plan covers the route table. Rejected: it would
have to be given the route table, which is a new input for a check about a fault
that is not a singleton fault (see Consequences). The place that already holds
both the route table and the demands is planIncremental, and its partition is
total by construction — every routed page is rendered or reused — so the fact is
established where it is knowable rather than re-asserted where it is not.
Leave the constraint in the docblocks. Rejected, and it is why this file
exists. entryInputs and client-build.ts each stated the invariant in prose,
which served a reader already looking at them. What neither can say is what
would have to be true before it could be relaxed, or why an incremental build
re-emitting every chunk is the design rather than an optimisation nobody got to
— and those are the two questions somebody arrives with when a 50k-page build is
slow. ADR-0002 is the precedent: a constraint the code obeys and no reader can
find the argument for is a decision the project cannot defend.