Sub-issue of #402. This splits out the dev/production elimination half of that RFC and replaces its approach — see Relationship to #402 at the bottom.
Problem
Every plugin hand-writes a react-native.ts shim that re-declares its export surface, re-sniffs the environment, and hand-writes a no-op twin per function — 468 lines across 15 plugins. That shim is the only thing keeping plugin code out of production bundles.
It makes inclusion survivable rather than impossible, and it has three structural weaknesses:
- It rests on transform-order luck. Elimination depends on
process.env.NODE_ENV inlining plus Metro's ordering — inlinePlugin (177) → constantFoldingPlugin (208) → collectDependencies (246) in metro-transform-worker/src/index.js. Nothing pins that.
- A wrong stub is silent.
sqlite-plugin stubs pure src/shared string utilities, so splitSqlStatements returns [] in any production build that calls it. Nothing catches this.
- It only works for plugins that opt in. A third-party plugin that exports a hook from its package index defeats the entire design. There is no framework-level guarantee.
Point 3 is the one that matters. Everything else is a quality problem; this is a correctness problem we cannot solve with authoring conventions.
Mechanisms that cannot fix it
development export condition — metro-config defaults unstable_conditionNames: [], and RN's preset adds only require/import/react-native. A development condition never matches under Metro.
- A resolver that redirects real → stub in production —
withRozenite returns the config untouched when enabled === false or when isBundling() (packages/metro/src/index.ts:37-49). Production bundling is exactly the case where Rozenite's Metro config does not run.
Goal
Nothing reaches production except what its author explicitly declared for production.
Uniform across official and third-party plugins, requiring no cooperation from plugin authors beyond the manifest they already ship.
This is a declaration model, not a security model — the manifest is an explicit statement of intent, in the same category as sideEffects: false or "type": "module". A wrong declaration is a bug you report, not an attack we defend against.
Architecture
1. App-side seam package
A new package exposing one component, with react as its only peer dependency:
// App.tsx — unconditional, nothing for the user to guard
import Rozenite from '@rozenite/<seam>';
<Rozenite />
It must be a new package. @rozenite/runtime is the DevTools-frontend host (exports['.'] → ./dist/host.js, containing rn-devtools/, plugin-loader.ts, create-panel.ts, no react peer dep) — putting an app-side component there would drag the DevTools host into every production app bundle.
This is the one Rozenite package that ships to production, so it should be the trivial case of its own declaration: renders the noop, imports nothing else.
2. Noop by default, redirected by the resolver
The seam statically imports a real noop that it ships:
import DevEntry from './dev-entry.js'; // ships as: export default () => null
export default function Rozenite() {
return <DevEntry />;
}
In development, the Metro resolver redirects that specifier to <projectRoot>/rozenite.dev, resolved through context.resolveRequest so the user's configured sourceExts and platform extensions apply (rozenite.dev.ios.tsx, rozenite.dev.web.tsx work for free). There is precedent for the resolution surgery at packages/metro/src/index.ts:90.
No user-side __DEV__ guard. That was the weakest link in every earlier design — forget it and the whole plugin tree ships while every other check passes. Here there is nothing to forget.
No internal __DEV__ guard either. __DEV__ ? require('…') : null inside the seam is a bare require in a type: module package — fatal under rspack, which treats it as a harmony module. Static import plus resolver decision works in both bundlers.
Shipping a real noop rather than relying on the redirect keeps the failure modes graceful: no resolver installed, or no rozenite.dev file, means a no-op plus a dev warning — not an unresolvable specifier and a broken build.
3. All plugin wiring lives in rozenite.dev.tsx
An ordinary project file, so Fast Refresh works on it:
import { useRozeniteStoragePlugin, createMMKVStorageAdapter } from '@rozenite/storage-plugin';
import { storage } from './src/storage';
export default function RozeniteDevTools() {
useRozeniteStoragePlugin({ adapters: [createMMKVStorageAdapter({ mmkv: storage })] });
return null;
}
It may span as many files as the user wants — none of them are reachable in production, so none of them need special handling.
4. The guarantee: plugin import in a production build throws in the resolver
Installed unconditionally in withRozenite:
!context.dev && target resolves inside a Rozenite plugin package
→ throw, naming context.originModulePath
In production the seam resolves to the noop, so no legitimate plugin resolution can occur. Any plugin resolution in a production build is by definition a bypass. No origin rule, no path convention, no resolution-chain tracking needed.
@acme/some-plugin is a Rozenite plugin and declares no production entry points.
Imported from: src/screens/Settings.tsx
This requires a behavior change: enabled: false must stop meaning "do nothing" and start meaning "no dev server, guard still active." Today both enabled === false and isBundling() return the config untouched — the exact production path the guard needs.
5. productionEntries declaration
Some plugins genuinely need a touchpoint in code that runs in production — a per-form hook (rhf-plugin), a store enhancer (redux-devtools-plugin), an override lookup the app consults at flag-eval time (feature-flags-plugin). Those declare it:
{ "productionEntries": ["./register"] }
The resolver permits those subpaths and nothing else. Third-party authors get the same mechanism.
Deliberately not verified. We will not traverse the declared entry's import graph to check it is "really" safe. Safety is not a property of the import graph — ./format-bytes.js and ./bridge.js are both just relative imports, and the difference is semantic. Any rule we write is either loose enough to prove nothing or tight enough to block legitimate code, and both teach people to ignore the check. The declaration is the author's explicit statement; the framework holds them to it and makes it attributable.
The one check worth keeping is that a declared entry actually resolves, so a typo doesn't silently read as "declared nothing."
One marker, not two. productionEntries lives in the same file as the plugin marker (dist/rozenite.json today, read by auto-discovery.ts:172). Two markers can disagree, and discovery and the guard having different ideas about what counts as a plugin is a worse failure than any I/O cost — which is negligible anyway, since the check memoizes per package root.
6. Dev-time advisory warning
The production throw fires at release, which is late. A dev-time warning surfaces the same mistake while it's being made:
warning: @rozenite/mmkv-plugin imported from src/screens/Settings.tsx.
Plugin imports belong in rozenite.dev.tsx. This will fail your production build.
Path-heuristic based, warning only. A layout that doesn't match the heuristic costs a spurious warning, never a broken build — so the convention never becomes load-bearing. Heuristics warn, structure enforces.
It also covers the fact that a resolver throw stops at the first offending import, so someone with five bad imports would otherwise fix them one build at a time.
Relationship to #402
This subsumes the elimination half of that RFC. Once inclusion is a build error, "make inclusion safe" stops being a requirement — so the following are no longer needed:
- generated dev/production entry points
*.stub.ts sibling modules and the auto-stub return-type table
- type-level stub/implementation compatibility checks
NODE_ENV folding as the elimination mechanism
- the CI assertion that Metro's production graph contains no real modules
Still wanted from #402, unaffected by this issue:
- tsc for the
react-native/metro/sdk targets, Vite for panels only (stands on its own merits — three of four Vite runs bundle nothing)
- the
src/shared fix that removes sqlite-plugin's six incorrect stubs
- enforced UI/RN boundary, filesystem-as-manifest, per-directory tsconfigs
To verify before implementing
Migration
App-author-facing, which is a wider blast radius than the plugin-author changes in #402. rozenite init should scaffold rozenite.dev.tsx and the <Rozenite /> mount.
Escape hatch
withRozenite(config, { allowInProduction: ['some-plugin'] }) — printed loudly on every build. Without one, the first person the guard blocks incorrectly will fork the config and lose the guarantee entirely.
Problem
Every plugin hand-writes a
react-native.tsshim that re-declares its export surface, re-sniffs the environment, and hand-writes a no-op twin per function — 468 lines across 15 plugins. That shim is the only thing keeping plugin code out of production bundles.It makes inclusion survivable rather than impossible, and it has three structural weaknesses:
process.env.NODE_ENVinlining plus Metro's ordering —inlinePlugin(177) →constantFoldingPlugin(208) →collectDependencies(246) inmetro-transform-worker/src/index.js. Nothing pins that.sqlite-pluginstubs puresrc/sharedstring utilities, sosplitSqlStatementsreturns[]in any production build that calls it. Nothing catches this.Point 3 is the one that matters. Everything else is a quality problem; this is a correctness problem we cannot solve with authoring conventions.
Mechanisms that cannot fix it
developmentexport condition —metro-configdefaultsunstable_conditionNames: [], and RN's preset adds onlyrequire/import/react-native. Adevelopmentcondition never matches under Metro.withRozenitereturns the config untouched whenenabled === falseor whenisBundling()(packages/metro/src/index.ts:37-49). Production bundling is exactly the case where Rozenite's Metro config does not run.Goal
Uniform across official and third-party plugins, requiring no cooperation from plugin authors beyond the manifest they already ship.
This is a declaration model, not a security model — the manifest is an explicit statement of intent, in the same category as
sideEffects: falseor"type": "module". A wrong declaration is a bug you report, not an attack we defend against.Architecture
1. App-side seam package
A new package exposing one component, with
reactas its only peer dependency:It must be a new package.
@rozenite/runtimeis the DevTools-frontend host (exports['.'] → ./dist/host.js, containingrn-devtools/,plugin-loader.ts,create-panel.ts, noreactpeer dep) — putting an app-side component there would drag the DevTools host into every production app bundle.This is the one Rozenite package that ships to production, so it should be the trivial case of its own declaration: renders the noop, imports nothing else.
2. Noop by default, redirected by the resolver
The seam statically imports a real noop that it ships:
In development, the Metro resolver redirects that specifier to
<projectRoot>/rozenite.dev, resolved throughcontext.resolveRequestso the user's configuredsourceExtsand platform extensions apply (rozenite.dev.ios.tsx,rozenite.dev.web.tsxwork for free). There is precedent for the resolution surgery atpackages/metro/src/index.ts:90.No user-side
__DEV__guard. That was the weakest link in every earlier design — forget it and the whole plugin tree ships while every other check passes. Here there is nothing to forget.No internal
__DEV__guard either.__DEV__ ? require('…') : nullinside the seam is a barerequirein atype: modulepackage — fatal under rspack, which treats it as a harmony module. Static import plus resolver decision works in both bundlers.Shipping a real noop rather than relying on the redirect keeps the failure modes graceful: no resolver installed, or no
rozenite.devfile, means a no-op plus a dev warning — not an unresolvable specifier and a broken build.3. All plugin wiring lives in
rozenite.dev.tsxAn ordinary project file, so Fast Refresh works on it:
It may span as many files as the user wants — none of them are reachable in production, so none of them need special handling.
4. The guarantee: plugin import in a production build throws in the resolver
Installed unconditionally in
withRozenite:In production the seam resolves to the noop, so no legitimate plugin resolution can occur. Any plugin resolution in a production build is by definition a bypass. No origin rule, no path convention, no resolution-chain tracking needed.
This requires a behavior change:
enabled: falsemust stop meaning "do nothing" and start meaning "no dev server, guard still active." Today bothenabled === falseandisBundling()return the config untouched — the exact production path the guard needs.5.
productionEntriesdeclarationSome plugins genuinely need a touchpoint in code that runs in production — a per-form hook (
rhf-plugin), a store enhancer (redux-devtools-plugin), an override lookup the app consults at flag-eval time (feature-flags-plugin). Those declare it:{ "productionEntries": ["./register"] }The resolver permits those subpaths and nothing else. Third-party authors get the same mechanism.
Deliberately not verified. We will not traverse the declared entry's import graph to check it is "really" safe. Safety is not a property of the import graph —
./format-bytes.jsand./bridge.jsare both just relative imports, and the difference is semantic. Any rule we write is either loose enough to prove nothing or tight enough to block legitimate code, and both teach people to ignore the check. The declaration is the author's explicit statement; the framework holds them to it and makes it attributable.The one check worth keeping is that a declared entry actually resolves, so a typo doesn't silently read as "declared nothing."
One marker, not two.
productionEntrieslives in the same file as the plugin marker (dist/rozenite.jsontoday, read byauto-discovery.ts:172). Two markers can disagree, and discovery and the guard having different ideas about what counts as a plugin is a worse failure than any I/O cost — which is negligible anyway, since the check memoizes per package root.6. Dev-time advisory warning
The production throw fires at release, which is late. A dev-time warning surfaces the same mistake while it's being made:
Path-heuristic based, warning only. A layout that doesn't match the heuristic costs a spurious warning, never a broken build — so the convention never becomes load-bearing. Heuristics warn, structure enforces.
It also covers the fact that a resolver throw stops at the first offending import, so someone with five bad imports would otherwise fix them one build at a time.
Relationship to #402
This subsumes the elimination half of that RFC. Once inclusion is a build error, "make inclusion safe" stops being a requirement — so the following are no longer needed:
*.stub.tssibling modules and the auto-stub return-type tableNODE_ENVfolding as the elimination mechanismStill wanted from #402, unaffected by this issue:
react-native/metro/sdktargets, Vite for panels only (stands on its own merits — three of four Vite runs bundle nothing)src/sharedfix that removessqlite-plugin's six incorrect stubsTo verify before implementing
resolveRequestsurfaces with its message intact, rather than being wrapped into a generic "unable to resolve module". The entire value of the guard is an actionable error naming the offending file.@rozenite/repackneeds the same resolver, also unconditional, or rspack users get the convention without the enforcement.require-profiler-plugin,redux-devtools-pluginboth ship ametro.ts) must be dev-gated, or they will trip the guard in production — correctly, but it needs checking.rhf-plugin,redux-devtools-pluginandfeature-flags-pluginare the complete set needingproductionEntries.Migration
App-author-facing, which is a wider blast radius than the plugin-author changes in #402.
rozenite initshould scaffoldrozenite.dev.tsxand the<Rozenite />mount.Escape hatch
withRozenite(config, { allowInProduction: ['some-plugin'] })— printed loudly on every build. Without one, the first person the guard blocks incorrectly will fork the config and lose the guarantee entirely.