You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Guarantee Rozenite plugins never enter production bundles #415 — Guarantee Rozenite plugins never enter production bundles. Supersedes the generated dev/production entry design below (items 1 and 6, and the .stub.ts machinery): a resolver-level guard makes plugin inclusion a build error, so making inclusion safe is no longer a requirement. The transpile-only targets (item 3) and the src/shared fix are unaffected and still wanted.
Summary
Authoring a Rozenite plugin is more complex than it should be. The build runs four separate Vite invocations, three of which aren't really bundling anything, and every plugin author hand-writes a dev/production entry shim that re-declares the whole export surface by hand.
This RFC proposes making plugin authoring declarative: the filesystem becomes the manifest, the dev/production split is generated, the UI↔RN boundary becomes a build error instead of a convention, and three of the four bundler configs go away.
The design below has been checked against all 15 plugins in this repo. Results are in Fit test.
Findings
The build is four Vite runs, three of which barely bundle
rozenite build spawns four vite build processes dispatched off VITE_ROZENITE_TARGET (packages/vite-plugin/src/index.ts:88), each with its own vite-plugin-dts pass plus an API Extractor rollup. @rozenite/vite-plugin is ~1746 LOC.
For the react-native and sdk targets, every bare specifier is externalized:
The metro target uses build.ssr = true. So there's no cross-dependency tree-shaking and no vendoring on any of the three — it's transpile relative files, concatenate, emit ESM+CJS. Metro re-bundles the RN output downstream anyway.
Panels are the exception and genuinely need Vite. They're served as static files by the middleware (packages/middleware/src/middleware.ts) and mounted as iframes (packages/runtime/src/rn-devtools/plugin-view.ts), so they must be self-contained.
The manifest is already mostly the filesystem
detectPluginTargets keys purely on the existence of react-native.ts / metro.ts / sdk.ts, and syncPluginPackageJSON rewrites main / module / types / exports on every build. The only remaining declarative config is rozenite.config.ts, whose loader evals via new Function with no require in scope — so it cannot import anything.
The real pain is the hand-written shims
468 lines across 15 react-native.ts files. Each one re-declares export let per symbol, hand-types typeof import(…), re-sniffs the environment, and hand-writes a no-op twin for every function:
packages/sqlite-plugin/react-native.ts is 69 lines of this. packages/feature-flags-plugin/react-native.ts is 88.
Nothing enforces the UI/RN boundary
One tsconfig per plugin with lib: ["ES2020", "DOM", "DOM.Iterable"] applied to src/react-native/** too, no lint rules, and @typescript-eslint/no-require-imports disabled repo-wide specifically to permit the shim trick. Separation is entry-point reachability plus directory naming as convention.
Metro mechanics (verified, metro 0.84.4)
Two facts that constrain the design:
development / production export conditions do not work under Metro.metro-config/src/defaults/index.js:65 has unstable_conditionNames: [], and RN's preset adds only require / import / react-native. A development condition never matches, so Metro consumers silently fall through to default. (The SDK's existing { development: './sdk.ts', … } works because it's consumed by Node and Vite tooling, not Metro.)
__DEV__ / NODE_ENV folding does work, and is what the shims already rely on. The transform order in metro-transform-worker is inlinePlugin (177) → constantFoldingPlugin (208) → collectDependencies (246). Dead branches are folded before the graph is built, so a require() inside one never enters the bundle.
Why elimination matters
Production never creates a CDP target, so nothing is exfiltrated. The real risks, in order:
Import-time crashes on optional peer deps.expo-sqlite is optional: true in packages/sqlite-plugin/package.json. A real adapter surviving into a production bundle in an app that doesn't install it is a hard failure at import.
Capture buffers with no consumer to drain them. Needs verifying per plugin — see Open questions.
1 + 3. Generate the dev/production entry, and drop Rollup for the RN/Metro/SDK targets
These are one unit, not two steps — see the storage-plugin finding below.
The author writes src/react-native/useMMKVDevTools.ts. The build derives export names from the TS program already being run for dts, and generates the switch. Fallbacks live in a sibling module (*.stub.ts) so the module boundary does the elimination — no import-pruning transform needed, exactly as require-plugin.ts does today.
Auto-stub only where it is unambiguously safe:
Return type
Generated
void, undefined, null, T | null
auto-stubbed
type predicates (x is T)
never auto-stubbed
anything else
build error naming the .stub.ts to add
The build error is the point. Today a wrong stub is silent and a missing one crashes in someone else's production app; this moves the one decision that needs a human to the one moment a human is present.
Once the entry is generated, packages/vite-plugin/src/require-plugin.ts — which exists only to chunk the hand-written lazy requires — becomes dead. The RN, Metro and SDK targets then become transpile-to-ESM+CJS, removing three Vite runs, three dts passes, and three API Extractor rollups. Vite stays for panels.
src/shared exports never enter the split. They are always real. See the sqlite finding below.
Platform extensions (*.web.ts) become the mechanism for per-environment implementations. This only works once the RN target stops bundling — bundling resolves platform extensions at build time and flattens them away.
2. Enforce the boundary as a build error
A resolver hook per environment: src/ui/** may not reach src/react-native/** or vice versa. src/shared/** may reach neither, but may freely import React and other isomorphic deps. Failure prints the offending import chain. ~50 lines.
Note this is directory reachability, not package identity — panels import react-native deliberately, through vite-plugin-react-native-web, and the CLI scaffold (packages/cli/template/src/hello-world.tsx) is written entirely in View/Text.
4. Filesystem as manifest
definePanel({ name, icon }) co-located with the panel, filename as id. Deletes rozenite.config.ts and the new Function loader with it.
5. Per-directory tsconfigs
So src/react-native/** stops typechecking against document.
6. Assert the elimination in CI
Run each plugin's RN entry through Metro in production mode; fail if any non-stub module under src/react-native/** landed in the graph. Catches a regression in the generated switch at build time rather than in a release.
7. Migration
rozenite migrate for the mechanical parts; keep the explicit-config path working for one minor.
Fit test
The design was checked against all 15 plugins that have a rozenite.config.ts. Eleven fit unchanged.
One genuine gap: storage-plugin has three branches
// packages/storage-plugin/react-native.tsif(!isDev||isServer){/* all stubs */}elseif(isWeb){createAsyncStorageAdapter=require('./src/react-native/adapters/async-storage')…// realcreateExpoSecureStorageAdapter=require('./src/react-native/adapters/secure-storage')…// realcreateMMKVStorageAdapter=(options)=>createNoopStorageAdapter(options,'mmkv','MMKV');// stubbeduseRozeniteStoragePlugin=require(…)// real}else{/* all real */}
Web gets a partial implementation, because MMKV has no web backing. A binary impl.ts / impl.stub.ts split cannot express this.
Platform extensions solve it — and this is precisely why steps 1 and 3 are one unit. Bundling the RN target is why this plugin hand-rolls isWeb branching in the first place.
expo-atlas-plugin/react-native.ts is a single line — export * from './src/with-expo-atlas';. "No split" must be a legal first-class state, not something the design forces a stub onto.
Latent bug found: sqlite stubs pure functions
// packages/sqlite-plugin/react-native.tsclassifySqlStatement=()=>'other';normalizeSingleStatementSql=(sql)=>sql;splitSqlStatements=()=>[];formatSqliteError=()=>'Unknown SQLite error.';statementReturnsRows=(_type): _type is never=>false;
These live in src/shared and are pure string utilities — no instrumentation, no peer deps, no side effects, nothing to eliminate. Any consumer calling splitSqlStatements in a production build silently receives [].
controls-plugin handles the identical situation correctly: createSection is a plain re-export from src/shared/types and is never stubbed.
The src/shared rule above fixes this and removes six stubs from sqlite before the migration starts.
The directory convention is aspirational
Four of fifteen deviate, so step 2 needs file moves:
redux-devtools-plugin has no src/react-native/ at all.runtime.ts, runtime-bridge.ts, redux-devtools-agent.ts, useReduxDevToolsAgentTools.ts sit at src/ root beside src/ui/. Real restructure.
expo-atlas-plugin has none of the three directories — flat src/expo-atlas.tsx + src/with-expo-atlas.ts.
react-navigation-plugin uses src/devtools-ui/, not src/ui/.
file-system-plugin has its panel at src/file-system.tsx and loose formatters.ts / utils.ts / use-file-system-*.ts at root. A codemod can move files but cannot decide whether utils.ts is shared or UI-only.
Good news: zero boundary violations today
A scan for src/ui ↔ src/react-native imports returned two hits, both false positives — a string literal in a test fixture path (network-activity-plugin/src/ui/utils/__tests__/symbolication.test.ts:35) and tanstack-query-plugin/src/shared/useSyncOnlineStatus.ts importing useEffect from react, which is legitimate.
Step 2 turns on green.
rozenite.config.ts is not a panels array
405 lines total, of which feature-flags-plugin is 219 and storage-plugin is 82. Those are dev: { presets, flows } fixtures, including an inline RPC handler written inline specifically because the loader cannot require. Step 4 relocates ~300 lines of dev fixtures, not a config object.
The upside: moving them into a module that can actually import is a straight improvement.
One decision generation forces
The environment guards are inconsistent across the fifteen — four distinct shapes:
In a React-Native-Web context, five plugins stub themselves out and five run for real. Generation makes this uniform, which necessarily changes runtime behavior for one of those groups. This should be a deliberate call, not a side effect of the refactor.
Expected outcome
One bundler config instead of four.
~468 lines of the most error-prone code in the repo deleted.
The UI/RN separation becomes structurally impossible to get wrong rather than a discipline.
One latent production bug in sqlite-plugin fixed on the way.
Open questions
Stub granularity — per module, or per directory when fallbacks cluster (e.g. one stub for all of src/react-native/adapters)?
Do capture buffers self-bound? Unverified whether network-activity-plugin and storage-plugin cap their buffers independently or rely on the bridge draining them. If the latter, that is a live production bug today, independent of this work.
Which environment-guard behavior wins for the web/RNW case (see table above)?
rozenite dev starts no SDK watcher — dev-command.ts covers react-native and server only, so SDK edits need a manual rebuild. Small separate bug, noted here so it isn't lost.
Summary
Authoring a Rozenite plugin is more complex than it should be. The build runs four separate Vite invocations, three of which aren't really bundling anything, and every plugin author hand-writes a dev/production entry shim that re-declares the whole export surface by hand.
This RFC proposes making plugin authoring declarative: the filesystem becomes the manifest, the dev/production split is generated, the UI↔RN boundary becomes a build error instead of a convention, and three of the four bundler configs go away.
The design below has been checked against all 15 plugins in this repo. Results are in Fit test.
Findings
The build is four Vite runs, three of which barely bundle
rozenite buildspawns fourvite buildprocesses dispatched offVITE_ROZENITE_TARGET(packages/vite-plugin/src/index.ts:88), each with its ownvite-plugin-dtspass plus an API Extractor rollup.@rozenite/vite-pluginis ~1746 LOC.For the
react-nativeandsdktargets, every bare specifier is externalized:The
metrotarget usesbuild.ssr = true. So there's no cross-dependency tree-shaking and no vendoring on any of the three — it's transpile relative files, concatenate, emit ESM+CJS. Metro re-bundles the RN output downstream anyway.Panels are the exception and genuinely need Vite. They're served as static files by the middleware (
packages/middleware/src/middleware.ts) and mounted as iframes (packages/runtime/src/rn-devtools/plugin-view.ts), so they must be self-contained.The manifest is already mostly the filesystem
detectPluginTargetskeys purely on the existence ofreact-native.ts/metro.ts/sdk.ts, andsyncPluginPackageJSONrewritesmain/module/types/exportson every build. The only remaining declarative config isrozenite.config.ts, whose loader evals vianew Functionwith norequirein scope — so it cannot import anything.The real pain is the hand-written shims
468 lines across 15
react-native.tsfiles. Each one re-declaresexport letper symbol, hand-typestypeof import(…), re-sniffs the environment, and hand-writes a no-op twin for every function:packages/sqlite-plugin/react-native.tsis 69 lines of this.packages/feature-flags-plugin/react-native.tsis 88.Nothing enforces the UI/RN boundary
One tsconfig per plugin with
lib: ["ES2020", "DOM", "DOM.Iterable"]applied tosrc/react-native/**too, no lint rules, and@typescript-eslint/no-require-importsdisabled repo-wide specifically to permit the shim trick. Separation is entry-point reachability plus directory naming as convention.Metro mechanics (verified, metro 0.84.4)
Two facts that constrain the design:
development/productionexport conditions do not work under Metro.metro-config/src/defaults/index.js:65hasunstable_conditionNames: [], and RN's preset adds onlyrequire/import/react-native. Adevelopmentcondition never matches, so Metro consumers silently fall through todefault. (The SDK's existing{ development: './sdk.ts', … }works because it's consumed by Node and Vite tooling, not Metro.)__DEV__/NODE_ENVfolding does work, and is what the shims already rely on. The transform order inmetro-transform-workerisinlinePlugin(177) →constantFoldingPlugin(208) →collectDependencies(246). Dead branches are folded before the graph is built, so arequire()inside one never enters the bundle.Why elimination matters
Production never creates a CDP target, so nothing is exfiltrated. The real risks, in order:
expo-sqliteisoptional: trueinpackages/sqlite-plugin/package.json. A real adapter surviving into a production bundle in an app that doesn't install it is a hard failure at import.fetch/XHR, storage wrappers).Proposal
1 + 3. Generate the dev/production entry, and drop Rollup for the RN/Metro/SDK targets
These are one unit, not two steps — see the storage-plugin finding below.
The author writes
src/react-native/useMMKVDevTools.ts. The build derives export names from the TS program already being run for dts, and generates the switch. Fallbacks live in a sibling module (*.stub.ts) so the module boundary does the elimination — no import-pruning transform needed, exactly asrequire-plugin.tsdoes today.Auto-stub only where it is unambiguously safe:
void,undefined,null,T | nullx is T).stub.tsto addThe build error is the point. Today a wrong stub is silent and a missing one crashes in someone else's production app; this moves the one decision that needs a human to the one moment a human is present.
Once the entry is generated,
packages/vite-plugin/src/require-plugin.ts— which exists only to chunk the hand-written lazy requires — becomes dead. The RN, Metro and SDK targets then become transpile-to-ESM+CJS, removing three Vite runs, three dts passes, and three API Extractor rollups. Vite stays for panels.src/sharedexports never enter the split. They are always real. See the sqlite finding below.Platform extensions (
*.web.ts) become the mechanism for per-environment implementations. This only works once the RN target stops bundling — bundling resolves platform extensions at build time and flattens them away.2. Enforce the boundary as a build error
A resolver hook per environment:
src/ui/**may not reachsrc/react-native/**or vice versa.src/shared/**may reach neither, but may freely import React and other isomorphic deps. Failure prints the offending import chain. ~50 lines.Note this is directory reachability, not package identity — panels import
react-nativedeliberately, throughvite-plugin-react-native-web, and the CLI scaffold (packages/cli/template/src/hello-world.tsx) is written entirely inView/Text.4. Filesystem as manifest
definePanel({ name, icon })co-located with the panel, filename as id. Deletesrozenite.config.tsand thenew Functionloader with it.5. Per-directory tsconfigs
So
src/react-native/**stops typechecking againstdocument.6. Assert the elimination in CI
Run each plugin's RN entry through Metro in production mode; fail if any non-stub module under
src/react-native/**landed in the graph. Catches a regression in the generated switch at build time rather than in a release.7. Migration
rozenite migratefor the mechanical parts; keep the explicit-config path working for one minor.Fit test
The design was checked against all 15 plugins that have a
rozenite.config.ts. Eleven fit unchanged.One genuine gap:
storage-pluginhas three branchesWeb gets a partial implementation, because MMKV has no web backing. A binary
impl.ts/impl.stub.tssplit cannot express this.Platform extensions solve it — and this is precisely why steps 1 and 3 are one unit. Bundling the RN target is why this plugin hand-rolls
isWebbranching in the first place.Stub-file distribution
.stub.tsexpo-atlas-plugin/react-native.tsis a single line —export * from './src/with-expo-atlas';. "No split" must be a legal first-class state, not something the design forces a stub onto.Latent bug found: sqlite stubs pure functions
These live in
src/sharedand are pure string utilities — no instrumentation, no peer deps, no side effects, nothing to eliminate. Any consumer callingsplitSqlStatementsin a production build silently receives[].controls-pluginhandles the identical situation correctly:createSectionis a plain re-export fromsrc/shared/typesand is never stubbed.The
src/sharedrule above fixes this and removes six stubs from sqlite before the migration starts.The directory convention is aspirational
Four of fifteen deviate, so step 2 needs file moves:
redux-devtools-pluginhas nosrc/react-native/at all.runtime.ts,runtime-bridge.ts,redux-devtools-agent.ts,useReduxDevToolsAgentTools.tssit atsrc/root besidesrc/ui/. Real restructure.expo-atlas-pluginhas none of the three directories — flatsrc/expo-atlas.tsx+src/with-expo-atlas.ts.react-navigation-pluginusessrc/devtools-ui/, notsrc/ui/.file-system-pluginhas its panel atsrc/file-system.tsxand looseformatters.ts/utils.ts/use-file-system-*.tsat root. A codemod can move files but cannot decide whetherutils.tsis shared or UI-only.Good news: zero boundary violations today
A scan for
src/ui↔src/react-nativeimports returned two hits, both false positives — a string literal in a test fixture path (network-activity-plugin/src/ui/utils/__tests__/symbolication.test.ts:35) andtanstack-query-plugin/src/shared/useSyncOnlineStatus.tsimportinguseEffectfromreact, which is legitimate.Step 2 turns on green.
rozenite.config.tsis not a panels array405 lines total, of which
feature-flags-pluginis 219 andstorage-pluginis 82. Those aredev: { presets, flows }fixtures, including an inline RPC handler written inline specifically because the loader cannotrequire. Step 4 relocates ~300 lines of dev fixtures, not a config object.The upside: moving them into a module that can actually import is a straight improvement.
One decision generation forces
The environment guards are inconsistent across the fifteen — four distinct shapes:
isDev && !isWeb && !isServerisDev && !isServer!isDev || isServerNODE_ENVIn a React-Native-Web context, five plugins stub themselves out and five run for real. Generation makes this uniform, which necessarily changes runtime behavior for one of those groups. This should be a deliberate call, not a side effect of the refactor.
Expected outcome
sqlite-pluginfixed on the way.Open questions
src/react-native/adapters)?network-activity-pluginandstorage-plugincap their buffers independently or rely on the bridge draining them. If the latter, that is a live production bug today, independent of this work.rozenite devstarts no SDK watcher —dev-command.tscoversreact-nativeandserveronly, so SDK edits need a manual rebuild. Small separate bug, noted here so it isn't lost.