metro-slices
Dev-only module fakes for Metro, resolved the way platforms are. A module
foo.ts may ship a foo.offline.ts sibling. Enable the offline slice and
Metro resolves every such sibling in your source for that run, the same way
foo.ios.ts wins over foo.ts on iOS. Your code imports the real module
either way, and a slice nobody enables stays out of the bundle.
One function, one input — the slices to enable:
const resolveSlice = createSliceResolver(['offline']);
Where that list comes from is yours: an env var, a CLI flag, a --slice you
parse yourself. The package reads nothing itself.
npm run slice:offline # every *.offline.* fake, real everything else
EXPO_PUBLIC_SLICES=offline,promo npm start # combine scenarios
npm start # every real service, the same resolution as a release build
Why
Working on the fourth screen of a signup flow means walking the first three every reload. Working on an upload UI means waiting on a real S3 multipart to see the last 5% of a progress bar. The usual answers each cost something:
if (__DEV__)branches — the fake ships inside the real module, and the branch is live in the release bundle.- Jest mocks — they exist only under the test runner; you cannot walk the app with them.
- A "mock mode" flag read at runtime — every service grows a fork, and the flag is one misconfiguration away from production.
A variant replaces the whole module at bundle time instead. The real module is untouched, the fake is a separate file, and Metro leaves the fake out of any build that didn't ask for it. Because the swap happens at the module boundary, the fake has to implement the same public shape as the real thing — which TypeScript checks for you.
This is the mechanism Metro already uses for platforms, pointed at scenarios
instead of devices. There is nothing to register: the filename does it, the same
way Button.ios.tsx does.
Install
npm install --save-dev metro-slices
Requires Node ≥ 18 and any Metro-based project (React Native, Expo).
Quickstart
1. Write the fake next to the real module, exporting the same shape:
// src/services/upload.offline.ts
import type * as Real from './upload';
/** ~20s emulated transfer: progress ticks, abortable, no network. */
export const uploadVideo: typeof Real.uploadVideo = async (file, { onProgress, signal }) => {
for (let percent = 0; percent <= 100; percent += 5) {
if (signal?.aborted) throw new Error('aborted');
await new Promise((resolve) => setTimeout(resolve, 1000));
onProgress?.(percent);
}
return { id: 'offline-upload', url: 'https://example.invalid/fake.mp4' };
};
typeof import('./upload') (or a shared interface) is what keeps the fake
honest — tsc --noEmit fails the moment the real module's signature moves.
2. Build a resolver over the slices to enable, and apply it in
resolveRequest. Normally the list comes from an env var:
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const { createSliceResolver } = require('metro-slices');
const config = getDefaultConfig(__dirname);
const resolveSlice = createSliceResolver(
(process.env.EXPO_PUBLIC_SLICES ?? '').split(/[,\s]+/).filter(Boolean),
);
const { resolveRequest } = config.resolver;
config.resolver.resolveRequest = (context, moduleName, platform) =>
resolveSlice((resolveRequest ?? context.resolveRequest)(context, moduleName, platform));
module.exports = config;
resolveSlice takes a resolution and returns it unchanged or pointed at the
variant, so it composes with whatever else your resolver already does — and the
line above is the whole integration, however your config is shaped.
3. Ignore the variable in release builds. Nothing in the package reads your environment, so this guard is yours to write — one line, and a value that leaks into a release build can no longer switch a fake on:
const slices =
process.env.NODE_ENV === 'production'
? []
: (process.env.EXPO_PUBLIC_SLICES ?? '').split(/[,\s]+/).filter(Boolean);
const resolveSlice = createSliceResolver(slices);
4. Add a script per scenario so the fakes are discoverable from npm run:
{
"scripts": {
"slice:offline": "EXPO_PUBLIC_SLICES=offline expo start",
"slice:promo": "EXPO_PUBLIC_SLICES=promo expo start"
}
}
Combine scenarios by naming them: EXPO_PUBLIC_SLICES=offline,promo npm start.
With both on, a file that has an offline sibling and a promo one resolves to
the first slice you listed.
How it behaves
| Situation | What happens |
|---|---|
| Empty list | Nothing is sliced; the resolver returns every resolution untouched |
| A file with a sibling for an enabled slice | Resolves to that sibling |
| A file with siblings for several enabled slices | Resolves to the first slice you listed |
| A file with no sibling | Untouched, silently — the normal case for most of your source |
| A sibling whose slice is not enabled | Untouched; the file on disk is inert |
A file that is already a variant (upload.offline.ts) |
Untouched — variants do not chain |
A platform file (upload.ios.ts) |
Resolves to upload.ios.offline.ts if that exists; upload.offline.ts is not a fallback |
A file inside node_modules |
Left alone, and not probed |
| A path with no extension | Untouched — there is nowhere to insert the slice name |
| A slice name with a dot, slash or extension | Throws — it would not match a sibling |
An import through an alias (@/services/upload) |
Sliced: the swap is on the resolved file, not the specifier |
API
createSliceResolver(slices): SliceResolver
The whole package, and the whole signature. Files with a sibling for an enabled slice resolve to it; everything else passes through.
| Argument | ||
|---|---|---|
slices |
required | A slice or an array of them — letters, digits and dashes, no dots or slashes. The order is priority: with ['offline', 'promo'], a file that has both siblings resolves to the offline one. An empty array makes the resolver a pass-through. |
There are no options. A variant either sits next to the file or it does not, so there is nothing to configure and nothing to warn about.
The returned resolveSlice is the resolver, and only that: a resolution
in, the same one or one pointed at a variant out. You apply it inside your own
resolveRequest.
Writing a good fake
- Mirror the public exports exactly, and let TypeScript prove it — a shared
interface, or
typeof import('./real')per export. - Fake the boundary, not the feature. The point is to skip the network, not
to reimplement the product. Keep state in a module-level
Mapand move on. - Keep the timings honest. If the real upload takes 20 seconds, make the fake take 20 seconds; a fake that finishes instantly hides every race the UI has.
- Say what the fake still needs, in a comment at the top of the file:
"independent of the others, so it runs against the real backend — which it
needs, since intake still calls
api.createVideo." - Don't fake the layer under test. A variant of the module you are currently debugging tells you nothing.
- One slice per scenario, not one for everything.
offlineandpromocan be enabled apart or together; a singlefakeslice is all-or-nothing. A fake you didn't mean to enable costs you time, since you end up chasing behaviour no real service has. - Delete fakes you no longer use. The file is what enables it, so a stale one comes back when somebody enables its slice.
Design notes
- CommonJS on purpose. Metro loads its config before any transform runs, so the shipped entry point is CJS. Types are included for TS configs and editors.
- One function over a list of slices. Env vars, a production guard, a CLI
flag — all of that is policy, it differs per project, and it is a
splitaway. Keeping it out means there is no configuration format to learn: the package is ~160 lines, with the edge cases already worked out. - The file is what opts a module in. There is no module list to keep in step
with the fakes: creating
upload.offline.tsopts that module in, deleting it opts out. That is looser than naming every module, since enabling a slice enables every sibling that carries it, and tighter than a runtime flag, since a slice nobody enables stays out of the bundle. - The package owns the resolver, not your config. It does not read your env
vars or rewrite
metro.config.js— you apply the resolver yourself, the way you place a Metro transformer. - A function, not an object.
createSliceResolverreturns a plain function, and the package exports that and its types — nothing else. The slice list, the pattern and the disk probe are all closed over, so there is no member to read, override, or keep working from one version to the next. - What validation is left is about slices, not policy. A slice carrying a dot, a slash or an extension throws, because it would not match a sibling. Failing quietly there would leave you on the real module while you thought you were on a fake.
- Your code only. Nothing under
node_modulesis sliced, and nothing there is probed. A fake for a dependency would have to live insidenode_modulesto be found, which you cannot commit. It also keeps the cost down: most of a Metro graph is dependencies, and one test on the path skips them all. - The variant sits next to the file Metro resolved. So
upload.ios.tslooks forupload.ios.offline.ts, and a platform-freeupload.offline.tsis not a fallback for it. Searching both would mean deciding which wins when both are on disk, which is more rule than this needs.
Contributing
Issues and PRs welcome — see CONTRIBUTING.md. npm run check
runs typecheck, tests, and the build.
License
MIT Dmitry Kurkin