# metro-slices

> Dev-only module fakes for Metro, resolved the way platforms are: foo.ts → foo.offline.ts, switched on by slice name.

Latest version **0.1.0** (published 2026-08-26) · MIT license · 0 weekly downloads

## Install

```sh
npm install metro-slices
pnpm add metro-slices
yarn add metro-slices
bun add metro-slices
```

## Health

**Score 65/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high quality score.

Warnings: low downloads; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.1.0 |
| Published | 2026-08-26 |
| First published | 2026-08-26 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=18 |
| Dependencies | 0 |
| Unpacked size | 29.3 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| GitHub stars | 1 |
| Author | Dmitry Kurkin |
| Maintainers | sclown |
| Keywords | metro, metro-config, react-native, expo, resolver, mock, fake, stub, offline, developer-experience |

## Links

- npm: https://www.npmjs.com/package/metro-slices
- Repository: https://github.com/sclown/metro-slices
- Homepage: https://github.com/sclown/metro-slices#readme
- Issues: https://github.com/sclown/metro-slices/issues
- npm.io page: https://npm.io/package/metro-slices

## Alternatives

- [pagerjs](https://npm.io/package/pagerjs.md) — 60 weekly downloads
- [whistle.savefor-mock](https://npm.io/package/whistle.savefor-mock.md) — 4 weekly downloads
- [jabber](https://npm.io/package/jabber.md) — 0 weekly downloads
- [@simulacrum/ldap-simulator](https://npm.io/package/@simulacrum/ldap-simulator.md) — 0 weekly downloads
- [@crvouga/mockingbird-service-paddle](https://npm.io/package/@crvouga/mockingbird-service-paddle.md) — 0 weekly downloads

## Recent versions

- 0.1.0 (latest) — 2026-08-26

## README

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

```js
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.

```bash
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
```

[![npm](https://img.shields.io/npm/v/metro-slices.svg)](https://www.npmjs.com/package/metro-slices)

---

## 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

```bash
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:

```ts
// 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:

```js
// 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:

```js
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`:

```json
{
  "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 `Map` and 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.** `offline` and `promo` can
  be enabled apart or together; a single `fake` slice 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 `split`
  away. 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.ts` opts 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.** `createSliceResolver` returns 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_modules` is sliced, and nothing there
  is probed. A fake for a dependency would have to live inside `node_modules` to
  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.ts` looks
  for `upload.ios.offline.ts`, and a platform-free `upload.offline.ts` is 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](CONTRIBUTING.md). `npm run check`
runs typecheck, tests, and the build.

## License

[MIT](LICENSE) © Dmitry Kurkin

---
_Source: https://npm.io/package/metro-slices · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
