@matb85/abcd
v0.6.1
Published
File-convention A/B testing SDK for Expo and React Native
Readme
@matb85/abcd
Detour Experiments: A/B testing and feature flags for Expo and React Native, declared in TypeScript.
An experiment or a flag is a declaration in the package that owns the code. The app lists every declaration it uses in a registry, and hands the registry to <DetourExperimentsProvider>. Feature flags and every kind of experiment work from there. A component experiment swaps a whole component per user through one committed file: while it runs, Header.tsx is the experiment file, so import Header from "./Header" keeps working and renders the assigned variant.
Install
npx @matb85/abcd initThe package ships the detour-experiments command. init installs the package with your package manager if it is missing, writes detour-experiments.config.json and an empty registry (src/experiments.ts, or experiments.ts in an app with no src folder), stores the App's CLI Key, and adds the MCP server to your editors.
Then wrap the app root:
import { DetourExperimentsProvider } from "@matb85/abcd";
import registry from "./experiments";
const detourConfig = {
apiKey: process.env.EXPO_PUBLIC_DETOUR_API_KEY,
appID: process.env.EXPO_PUBLIC_DETOUR_APP_ID,
};
export default function Root() {
return (
<DetourExperimentsProvider registry={registry} detourConfig={detourConfig}>
<App />
</DetourExperimentsProvider>
);
}Users are bucketed by the device id Detour keeps, so there is no user id to pass. That id, the event queue and the remote config live in AsyncStorage, the storage Detour uses, so a device keeps its bucket across restarts. Install @react-native-async-storage/async-storage if the app does not have it. For another storage, set detourConfig.storage as in Detour's custom storage.
Every variant and flag is decided once per launch, so a user never watches one change. At startup the SDK loads what it decides from: the device id and the remote config. The provider renders at once. Only a component that reads an experiment or a flag while the SDK is still loading waits, by suspending until it has loaded: a few tens of milliseconds, and on the first launch after install up to 400 ms for the first config (firstLaunchTimeoutMs). What you change on the dashboard reaches a device at a later cold start, never while the app is running; refreshTimeoutMs makes a launch wait for the fresh config, so a change lands at the very next one.
An app with an experiment or a flag on a screen it can open on covers that wait with its splash screen. useExperimentsReady() is false while the SDK is loading and true once it has loaded, so the app hides its splash screen when it turns true:
import * as SplashScreen from "expo-splash-screen";
import { Stack } from "expo-router";
import { useEffect } from "react";
import { useExperimentsReady } from "@matb85/abcd";
SplashScreen.preventAutoHideAsync();
function RootNavigator() {
const ready = useExperimentsReady();
useEffect(() => {
if (ready) SplashScreen.hide();
}, [ready]);
if (!ready) return null;
return <Stack />;
}Render RootNavigator inside <DetourExperimentsProvider>. Call SplashScreen.preventAutoHideAsync() at module scope, next to the imports: by the first render the splash screen can already be hiding. The hook depends on no splash library, so an app with another one, such as react-native-bootsplash, hides it the same way. On the web, in a server render and under a test runner the hook is always true, because no hook waits there.
detourConfig carries the App ID and the Publishable Key. In an Expo app they come from EXPO_PUBLIC_DETOUR_APP_ID and EXPO_PUBLIC_DETOUR_API_KEY, the variables the Detour SDK uses, so an app already set up for Detour has no variable to add. Read them in the app's own code, as above: Expo inlines EXPO_PUBLIC_* only there, not inside a package, so the provider cannot read them itself. A bare React Native app fills detourConfig from wherever it keeps its configuration.
With the Detour SDK
detourConfig is the object DetourProvider takes as config. An app that uses both builds it once and passes it to both providers, so they share the App and the device id:
import { DetourProvider, type Config } from "@swmansion/react-native-detour";
import { DetourExperimentsProvider } from "@matb85/abcd";
import registry from "./experiments";
const detourConfig: Config = {
apiKey: process.env.EXPO_PUBLIC_DETOUR_API_KEY!,
appID: process.env.EXPO_PUBLIC_DETOUR_APP_ID!,
shouldUseClipboard: true,
};
export default function Root() {
return (
<DetourProvider config={detourConfig}>
<DetourExperimentsProvider registry={registry} detourConfig={detourConfig}>
<App />
</DetourExperimentsProvider>
</DetourProvider>
);
}Detour Experiments reads appID, apiKey and storage from it, and ignores Detour's other fields.
A custom storage goes in the same object, and both providers read it from there:
const mmkv = createMMKV();
const storage = {
getItem: (key: string) => mmkv.getString(key) ?? null,
setItem: (key: string, value: string) => mmkv.set(key, value),
};
const detourConfig: Config = {
apiKey: process.env.EXPO_PUBLIC_DETOUR_API_KEY!,
appID: process.env.EXPO_PUBLIC_DETOUR_APP_ID!,
storage,
};
export default function Root() {
return (
<DetourProvider config={detourConfig}>
<DetourExperimentsProvider registry={registry} detourConfig={detourConfig}>
<App />
</DetourExperimentsProvider>
</DetourProvider>
);
}Feature flags
detour-experiments new flag DarkModeimport { useFeatureFlag } from "@matb85/abcd";
import { DarkMode } from "./DarkMode";
const { enabled } = useFeatureFlag(DarkMode);No further setup. A new flag starts off, at a 0% rollout. Turn it on, or roll it out to a percentage of users, from the dashboard or with detour-experiments update DarkMode --rollout 30: the rollout is the share of users who get the flag on. A device picks the change up at a later cold start, so a flag is not an instant kill switch.
Experiments
A code-level experiment needs no setup either:
// src/CheckoutCopy.ts
import { defineExperiment } from "@matb85/abcd";
export const CheckoutCopy = defineExperiment({
key: "CheckoutCopy",
metrics: ["purchase"],
});const { variant, logOutcome } = useExperiment(CheckoutCopy);
logOutcome({ metric: "purchase" });A component experiment needs none either:
detour-experiments new experiment PromoBanner --component src/components/Header.tsx --metric cta_clickThis moves the code of Header.tsx into Header.A.tsx and Header.B.tsx, rewrites Header.tsx as the experiment file (the declaration, and the component that renders the assigned variant), adds it to the registry and creates the dashboard record. The experiment file is committed and yours to edit; nothing is generated at build time.
conclude writes the winner back over Header.tsx. After that, plain git blame points at the conclude commit; git blame -C follows the lines back to their authors.
Upgrading from Header.experiment.tsx
Earlier versions kept a component experiment in Header.experiment.tsx, a standalone declaration in <Key>.experiment.ts or <Key>.flag.ts, and needed three setup lines for imports to resolve. There is no migration command, and the old names are no longer recognised:
- Rename each
X.experiment.tsxtoX.tsx, and eachX.experiment.tsorX.flag.tstoX.ts. Update the imports that name the old files, in the registry and in the variants. - Delete the three setup lines:
.experimentfrommoduleSuffixesin the tsconfig,experiment.tsx/experiment.jsxfrom Metro'ssourceExts, and from Jest'smoduleFileExtensions. - Run
detour-experiments doctor.
Lifecycle
detour-experiments new experiment PromoBanner --component src/components/Header.tsx --metric cta_click
detour-experiments doctor PromoBanner # also the command to run in CI
detour-experiments update PromoBanner --rollout 50 # after a build with the experiment has shipped
detour-experiments stats PromoBanner
detour-experiments conclude PromoBanner --winner BA new experiment starts at a 0% rollout: no user is enrolled, so everyone sees A until you raise it. The rollout is the share of users in the experiment. The enrolled are split evenly across all variants, A included, unless you set weights (update --weights A=40,B=30,C=30; --weights even returns to the even split). The rollout and the weights are two settings: raising the rollout in stages moves nobody who is already enrolled, while changing the weights moves users between variants. A device picks a new rollout up at a later cold start.
Features
- Flags and code-level experiments out of the box: install, add the provider, declare.
- Zero-refactor imports: for a component experiment,
./Headerstill resolves toHeader.tsx, which is now the experiment file. No resolver configuration. - Typed from declarations: variants, metrics, payloads and provider
overridesare inferred; nothing is generated. - Deterministic bucketing: MurmurHash3 keeps each device on the same variant.
- No flicker: variants and flags are decided once per launch and never change under a running app; a screen that reads one while the SDK is loading waits until it has loaded instead of rendering twice.
- Screen capture (Expo Router):
detour-experiments capturephotographs each variant's screens and publishes them to the dashboard. - MCP:
detour-experiments mcp --config <path>gives an editor agent the CLI's operations on experiments and flags as tools;init,loginandcapture-consentstay in the terminal.
