Skip to main content

Shadowing (Theming)

Archibald builds one codebase into multiple platforms (e.g. shop, app, portal) and tenants (e.g. netconomy-b2c, netconomy-b2b). Shadowing (also called theming) lets a platform or tenant override any source file — component, style, asset — by placing a file at a matching path, without touching the original.

The resolution chain

For a given import, the compiler resolves the most specific existing file first and falls back down a fixed chain:

  1. platform + tenantsrc/{platform}/…/tenant/{tenant}/…
  2. platformsrc/{platform}/…
  3. tenant (on the default platform) — src/{defaultPlatform}/…/tenant/{tenant}/…
  4. base — the default-platform file

The rules come from project.shadowing.config in archibald.json. Each rule is one path template, using the {platform} and {tenant} tokens:

{
"project": {
"platforms": ["shop", "app", "portal"],
"tenants": ["netconomy-b2c", "netconomy-b2b"],
"shadowing": {
"active": true,
"config": ["src/{platform}/client(/tenant/{tenant})"]
}
}
}

Writing rules

Each rule is one path template. The part of the path that exists only once a tenant is bound goes in parentheses:

src/{platform}/client(/tenant/{tenant})
├─ base: src/{platform}/client
└─ tenant: src/{platform}/client/tenant/{tenant}

The group can sit anywhere in the path — src/{platform}(/tenant/{tenant})/client is equally valid. When the base form is a literal segment rather than nothing, spell both alternatives with the tenant-bound one first:

src/{platform}/resources/img/(tenant/{tenant}|base)
├─ base: src/{platform}/resources/img/base
└─ tenant: src/{platform}/resources/img/tenant/{tenant}

Rules that differ by a single path segment can be written once by naming that segment and listing its values:

{
"path": "src/{platform}/{area}(/tenant/{tenant})",
"area": ["client", "server", "service-worker", "resources"]
}

That expands to four rules, in the order the values are listed.

Wildcards

When the set of directories is open-ended, or simply not worth listing, the fixed part of a template may use wildcards. * matches exactly one directory segment:

src/{platform}/*(/tenant/{tenant})
├─ base: src/{platform}/client, src/{platform}/server, …
└─ tenant: src/{platform}/client/tenant/{tenant}, …

** matches any number of segments, including none — the separator in front of it belongs to the wildcard, so the rule also covers the directory it hangs off:

src/{platform}/resources/**(/tenant/{tenant})
├─ base: src/{platform}/resources/img/icons/cart.svg
└─ tenant: src/{platform}/resources/img/icons/tenant/{tenant}/cart.svg

The difference matters: with *, the tenant segment always lands at a fixed depth, directly under the matched directory. With ** it lands next to the file, at whatever depth the file happens to sit.

Wildcards belong to the fixed part of the path, never inside the tenant group — both halves of a rule have to carry the same ones, so that what a wildcard matched can be carried across the rewrite. src/{platform}/client(/tenant/{tenant}/*) is rejected.

Rules are free to overlap: the rule that pins down the most concrete path wins, whatever order they are declared in. So a wildcard rule can cover a platform broadly while a hand-written rule keeps its own directory:

"config": [
"src/{platform}/*(/tenant/{tenant})",
"src/{platform}/resources/img/(tenant/{tenant}|base)"
]

A file under resources/img/base is resolved by the image rule; everything else under the platform by the wildcard.

Wildcards are bound to what is on disk, and re-bound every time the layout is re-derived — so a whole new area appearing under serve is picked up like any other override, without a restart. Creating src/{platform}/server/tenant/{tenant}/… where no server directory existed at startup takes effect immediately; deleting it falls back again. Only a change to the rules needs a restart (see below).

Migrating from project.theming

Deprecated: project.theming

project.theming is deprecated as of 9.1. It still works and resolves identically to project.shadowing, and it will keep working until a future major removes it — but new rules belong under project.shadowing, and existing ones should be migrated with the codemod below. Loading a config that declares project.theming logs a one-time warning naming the rules it found.

project.theming is the previous form of this block, where each rule was a { test, replace } pair. It still works and resolves identically, but it is deprecated: because the two paths were written independently, they could describe different directory layouts, and the resolver then had to infer the tenant-less form. project.shadowing takes path templates only, and project.theming takes pairs only, so a rule is never ambiguous about which block it belongs to.

Move an existing project across with:

pnpx @archibald/codemod . shadowing-config

It rewrites every archibald.json under the given path in place — moving the whole block, converting each pair to a template, and folding rules that differ by one segment into a single enumerated rule. Pass --dry to see the result without writing. It refuses to write if the migrated rules would resolve to anything different from the originals.

The first platform in platforms is the default platform; imports are always written against it (e.g. import { Foo } from 'shop/client/components/Foo/Foo') and the compiler rewrites them to the winning override at build time. This works across all three backends: the Babel shadowing loader, the Rust SWC plugin (useRustShadowing: true), and the SCSS importer.

Adding and removing overrides while serve runs

Creating or deleting an override takes effect without restarting the dev server. Shadowing is decided from what exists on disk, so both are changes the compiler would otherwise never notice: the file's own text never changed, and until it appeared nothing was watching the path it would occupy. serve watches the whole source root, and when a shadowable file appears or vanishes it re-derives the resolution chain and sends the source tree back through the compiler once, logging what it saw:

[theming] shadowing changed (+ shop/client/tenant/netconomy-b2c/components/atoms/badge/Badge.tsx), re-resolving overrides

Deleting an active override falls back to the next file down the chain instead of failing the build. Ordinary edits are unaffected and keep their normal incremental speed — the re-resolve pass only runs when a file was actually added or removed, and it costs a few seconds on a mid-sized project.

Two caveats:

  • A new shadowing rule still needs a restart. Only the files change live; project.shadowing.config, platforms and tenants are read once at startup.
  • Production builds are unaffected. A build compiles once, so there is nothing to watch.

The re-resolve pass is the largest invalidation the dev server ever does, and rspack has a known bug where a large write can leave its persistent cache inconsistent — which makes it panic and kill the process rather than fall back to a cold build. That no longer needs cleaning up by hand: the next serve or build notices that the previous compile died, discards the store, and starts cold on its own (Discarded the rspack persistent cache …). See architecture/rspack/config-base.md.

Cascading tenant inheritance — extends

A tenant can extend one or more other tenants, so a file missing in the active tenant falls back through its parent tenants before hitting the base file. Instead of a bare name, give a project.tenants entry an object with name and extends:

{
"project": {
"tenants": [
"netconomy-b2c",
{ "name": "netconomy-b2b", "extends": "netconomy-b2c" },
{ "name": "netconomy-b2b-at", "extends": "netconomy-b2b" },
{ "name": "netconomy-mixed", "extends": ["netconomy-b2b", "netconomy-b2c"] }
]
}
}
  • A bare string (or an all-strings array) means no inheritance — exactly the historical behaviour.

  • Each tenant-parameterised level of the chain expands over the tenant inheritance line, most-specific first. For active tenant T0 extending T1 … Tn the full order becomes:

    BOTH(T0) … BOTH(Tn) → PLATFORM → TENANT(T0) … TENANT(Tn) → base

    So a parent's platform+tenant override still beats the active tenant's tenant-only override — platform specificity outranks tenant specificity, exactly as with a single tenant.

  • extends may be an array for multiple inheritance. Parents are consulted left-to-right, and the overall order is computed with C3 linearization (the same algorithm as Python's MRO), so diamond hierarchies resolve deterministically. A hierarchy with contradictory orderings fails at config load with a clear error, rather than silently picking one.

Validation runs at config load: unknown, self- or duplicate parents, cycles, and unresolvable orderings all fail fast. Keep hierarchies shallow, and prefer multiple parents for orthogonal concerns (e.g. brand + region) rather than as a merge tool — when two sibling parents both override the same file, the left-most one wins.

Inheritance also applies to per-tenant app configuration: the tenants block of your app config is merged through the same chain (ancestors first, active tenant last), so a child tenant inherits its parents' settings and only needs to declare what differs.

Platform inheritance

project.platforms supports the exact same extends syntax (single or multiple parents, C3-linearized):

{
"project": {
"platforms": [
"shop",
{ "name": "app", "extends": "shop" },
{ "name": "portal", "extends": "app" }
]
}
}

Building portal now resolves portal → app → shop instead of just portal → shop. Platform is the outer axis of the resolution chain and tenant the inner one — platform specificity outranks tenant specificity, so a parent platform's platform+tenant override still beats a nearer platform's tenant-only override, consistent with the single-platform behaviour. The first entry in platforms is the default (base) platform and always terminates the chain; if a platform's extends does not transitively reach it, the default is appended automatically so resolution always falls back to base. @original/ hops through the platform chain too (e.g. from a portal file to the same file in app, then shop).

Importing the original — @original/

An override often only wants to extend the file it shadows, not reimplement it. Importing the base path directly does not work — the shadowing rewrite would resolve it straight back to the override itself, creating a self-import cycle. Use the @original/ prefix instead:

// src/portal/client/tenant/netconomy-b2b/components/Foo/Foo.tsx
import { Foo as OriginalFoo } from '@original/shop/client/components/Foo/Foo';

export function Foo(props: FooProps) {
return (
<Wrapper>
<OriginalFoo {...props} />
</Wrapper>
);
}
// override style.modules.scss
@use '@original/shop/resources/scss/base/constants/colors' as original;

The path after @original/ is the base file, written module-style relative to the source root (the same form as a normal shadowed import). @original/ resolves to the next file down the chain from the importing file:

Importing file is…@original/… resolves to…
the platform+tenant overridethe next existing element down the chain (parent tenants' platform+tenant overrides, then the platform override, then tenant overrides, then base)
the platform overridethe tenant override if it exists, otherwise base
a tenant overridethe next parent tenant's override if it exists, otherwise the base file
not part of the chainthe base file
a path with no shadowing rulethe base file (prefix simply stripped)

With cascading inheritance this makes @original/ a natural "extend my parent tenant" mechanism: from a child tenant's override it resolves to the same file in the nearest parent tenant that defines it.

Because @original/ always resolves to a real module path, source maps, HMR, lazy-loading chunk names, and jest all keep working. A tsconfig.json paths entry ("@original/*": ["src/*"]) gives you full type-checking and go-to-definition on the imported original.

Requiring explicit override markers

Large projects can lose track of which files are overrides. Enable overrideMarking to make the build fail (or warn) when a shadowing file is not explicitly marked:

{
"project": {
"shadowing": {
"overrideMarking": {
"mode": "error",
"marker": "@override",
"requirePath": true
}
}
}
}
OptionDefaultDescription
modeoffoff disables the check, warn logs, error fails the build.
marker@overrideThe pragma token that marks a file as an override.
requirePathtrueWhen true, the marker must also name the overridden file.

Mark an override with a leading comment naming the base file it shadows:

/* @override shop/client/components/Foo/Foo.tsx */

The path uses the same canonical form as @original/ (base file, relative to the source root). The validator does more than check that the marker exists — it verifies the declared path actually matches the file being shadowed, so a marker left behind after a base-file rename is reported too. Binary assets and JSON are exempt (they cannot carry a comment), and files added under an override directory that shadow nothing (tenant-only additions) are not required to be marked.

overrideMarking defaults to off, so it is fully opt-in and backwards compatible.

ESLint guard

The @archibald/core ESLint config ships a rule, archibald/no-shadowed-self-import (severity warn), that flags an override file importing a module which the shadowing rewrite would resolve back to itself:

'shop/client/components/Foo/Foo' resolves back to this override file at build time
(self-import cycle). Import the shadowed original with the '@original/' prefix instead.

The rule is inert outside an Archibald project (it reads archibald.json to know the shadowing rules) and never touches the filesystem per import, so it stays fast in the editor.