WebGL2 backend program · Part 3 of 8
Where a type is imported from is not where the dependency lives
A post-mortem on a refactor that went the wrong way. To strip @webgpu/types from @xgis/map we re-exported the WebGPU types through a barrel — and made the coupling worse, not better. The revert, and the rule that would have caught it up front.
By the X-GIS team 6 min read
The task was crisp: @xgis/map should depend on @xgis/engine alone. Map had
~700 references to ambient @webgpu/types globals (GPUDevice, GPUBuffer,
GPUTextureView, …) and a "types": ["@webgpu/types"] in its tsconfig that
pulled the whole WebGPU ambient namespace into scope. The engine already lives
without it ("types": []). We wanted map to do the same, so that the backend —
WebGPU or WebGL2 — is chosen inside the engine and map never names a
backend-specific symbol.
My first move looked reasonable and was wrong.
The move: re-export the types through a barrel
If map can’t have @webgpu/types ambient, I reasoned, then map should import the
GPU* names it uses from a package it is allowed to depend on. So I added a
barrel:
// rhi-webgpu/src/webgpu-types.ts — the wrong ideaexport type { GPUDevice, GPUBuffer, GPUTextureView, GPUTexture /* …60 more… */ }and rewrote map’s annotations to pull from it:
import type { GPUDevice, GPUBuffer } from '@xgis/rhi-webgpu'tsc went green. Ambient @webgpu/types gone from map’s tsconfig. The ~700
references — clustered in about forty files that actually annotate GPU*
shapes — now had an explicit import source. It looked like progress: the diff
was all “add explicit import,” the kind of change that reads as tightening, and
a green typecheck plus a cleaner tsconfig is exactly the signal that says ship
it. I was one git commit away from doing so — the compiler had no way to tell
me the arrow now pointed the wrong way, because to tsc a satisfied import is a
satisfied import.
The owner stopped it in one sentence: map must depend on the engine only, and
the backend is selected in the engine. Re-exporting WebGPU types from
@xgis/rhi-webgpu and importing them into map does the opposite of the goal.
It takes a dependency that was ambient and diffuse and makes it explicit and
structural — map now has a hard, named, compile-time edge to the concrete
WebGPU backend package. That is precisely the edge the whole milestone exists to
cut.
The distinction that matters
Here is the trap, stated plainly:
Removing an ambient global is not the same as removing a dependency. Giving a dependency an explicit import path can strengthen it.
Before: map used GPUDevice as a free-floating ambient name. Ugly, but it named
a structural type, not a package. There was no import edge from map to
rhi-webgpu for these — TypeScript resolved them from the global @webgpu/types
declaration.
After the wrong move: map had import type { GPUDevice } from '@xgis/rhi-webgpu'
in forty files. Now there was an edge — a real, greppable,
dependency-graph-visible edge from the map package to the concrete backend
package. I had converted “map happens to use a WebGPU-shaped type” into “map
imports from the WebGPU backend,” which is a worse architecture even though the
tsconfig looked cleaner.
The consequence is concrete, not aesthetic. The whole milestone exists so a
build can ship one backend — the canary CI job already proves the engine
compiles against @xgis/rhi alone. Picture a WebGL2-only bundle that pulls in
@xgis/rhi-webgl2 and drops @xgis/rhi-webgpu entirely. With the barrel,
map’s forty files still import type … from '@xgis/rhi-webgpu' — a package no
longer in the build — and map fails to resolve its own type imports. The
ambient-global version, ugly as it was, would have compiled fine: @webgpu/types
is just a shape declaration, coupled to no package I need to ship. The barrel
took a dependency that survived dropping the WebGPU backend and made it one that
doesn’t.
The direction of a dependency is not decided by whether an import statement
exists. It’s decided by which package owns the symbol and which package must
change when it changes. A neutral type that both map and the backend agree on
must live upstream of both — in @xgis/rhi (the interface package, zero deps)
or @xgis/engine (which depends only on @xgis/rhi). Sourcing it from
@xgis/rhi-webgpu points the arrow at the leaf backend, the one package that
should have no inbound edges from map at all.
The right move
The correct migration doesn’t re-home the WebGPU types — it replaces them with neutral types that already belong upstream:
- Values that are only ever routing keys become
RhiPipelineHandle { label: string }in@xgis/rhi. (Most of map’sGPURenderPipelinerefs were this — covered in a separate post.) - Context handles become
RhiContext,RhiDevice,RhiTextureView— the RHI interface, injected into the engine, re-exported by the engine. GPUDeviceLostInfoon map’s public error callback becomesRhiDeviceLostInfo, defined in@xgis/rhi.
Every one of these lives in a package upstream of both map and the backends.
Map imports them from @xgis/engine (which re-exports the RHI surface). The
arrow points the right way: map → engine → rhi. The concrete rhi-webgpu
package sits off to the side as a leaf that the engine selects at runtime, with
zero inbound edges from map.
The barrel got stashed and reverted in full — and the fullness is the point.
The tempting salvage was “keep the barrel for just the handful of files where
defining an upstream type is more work, revert the rest.” But a partial keep
leaves a partial map → rhi-webgpu edge, and a partial version of the exact
edge the milestone exists to delete is not a smaller problem — it’s the same
problem in fewer files, still enough to break the WebGL2-only build above. The
revert was cheap precisely because the right move reuses none of it: replacing a
type with a neutral one defined upstream shares no lines with re-exporting the
concrete one downstream. When the premise is wrong, there’s nothing to salvage,
because every line encodes the wrong premise.
The rule
When you’re removing a dependency, don’t measure success by “did the ugly import / ambient global go away.” Measure it by which way the arrows point in the package graph afterward. Draw the edge you’re about to create and ask: does the new import point upstream (toward interfaces and shared contracts) or downstream (toward a concrete leaf implementation)? An explicit import to a leaf backend is a tighter coupling than an ambient global that named only a shape.
The tell that should have stopped me before the owner did: I was adding
from '@xgis/rhi-webgpu' to map. The moment a consumer package names a
concrete backend package in an import, the arrow is pointing the wrong way — no
matter how clean the tsconfig looks afterward. Neutral contracts live upstream;
if the type you need isn’t there yet, the work is to define it upstream, not to
re-export the concrete one downstream.
Read next
architecture
8 min read
The issue said to move the code back
An issue prescribed relocating a context type to kill an adapter-to-adapter dependency edge. By pickup, that mechanism meant reverting a merged refactor. We cut the edge with composition-root injection instead, and locked it with a shrink-only ratchet.
architecture
5 min read
Your render pipeline is just a string
217 GPURenderPipeline references stood between @xgis/map and backend-neutrality. Most of them turned out not to be pipelines at all — they were labels being matched. Collapsing them to a neutral { label } handle, and how prove-or-refute kept us from doing the wrong migration.
rhi
7 min read
Swapping the command encoder without stopping the frame
Moving map's render passes behind the RHI one at a time meant two encoders — native and RHI — had to share a single frame and a single submit. Why createCommandEncoder was the wrong tool, and the wrapper that let a native encoder be driven through the RHI interface for one pass at a time.