npm.io
0.1.0 • Published 1 month ago

metro-slices

Licence
MIT
Version
0.1.0
Deps
0
Size
29 kB
Vulns
0
Weekly
0
Stars
1

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

npm


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 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. npm run check runs typecheck, tests, and the build.

License

MIT Dmitry Kurkin

Keywords