Skip to content

RFC: declarative plugin authoring — generated dev/production entries, transpile-only targets, enforced UI/RN boundary #402

Description

@V3RON

Umbrella issue. Sub-issues:

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

// packages/vite-plugin/src/react-native-plugin.ts:22
config.build.rollupOptions.external = (id) => {
  if (id.startsWith('node:')) return true;
  return !id.startsWith('.') && !path.isAbsolute(id);
};

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/mmkv-plugin/react-native.ts
export let useMMKVDevTools: typeof import('./src/react-native/useMMKVDevTools').useMMKVDevTools;

const isWeb = typeof window !== 'undefined' && window.navigator.product !== 'ReactNative';
const isDev = process.env.NODE_ENV !== 'production';
const isServer = typeof window === 'undefined';

if (isDev && !isWeb && !isServer) {
  useMMKVDevTools = require('./src/react-native/useMMKVDevTools').useMMKVDevTools;
} else {
  useMMKVDevTools = () => null;
}

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:

  1. 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.
  2. Capture buffers with no consumer to drain them. Needs verifying per plugin — see Open questions.
  3. Hot-path overhead (patched fetch/XHR, storage wrappers).
  4. Bundle size — minor.

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 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.ts
if (!isDev || isServer) { /* all stubs */ }
else if (isWeb) {
  createAsyncStorageAdapter = require('./src/react-native/adapters/async-storage')      // real
  createExpoSecureStorageAdapter = require('./src/react-native/adapters/secure-storage') // real
  createMMKVStorageAdapter = (options) => createNoopStorageAdapter(options, 'mmkv', 'MMKV'); // stubbed
  useRozeniteStoragePlugin = 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.

Stub-file distribution

Plugins
Zero stub files needed mmkv, controls, overlay, performance-monitor, react-navigation, require-profiler, rhf, network-activity (8)
One .stub.ts sqlite, storage, feature-flags, file-system, redux-devtools, tanstack-query (6)
Opts out of the split entirely expo-atlas (1)

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.ts
classifySqlStatement = () => '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/uisrc/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:

Guard Plugins
isDev && !isWeb && !isServer file-system, mmkv, network-activity, require-profiler, sqlite
isDev && !isServer controls, overlay, react-navigation, redux-devtools, tanstack-query
!isDev || isServer feature-flags, rhf, storage
bare NODE_ENV performance-monitor

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

  1. Stub granularity — per module, or per directory when fallbacks cluster (e.g. one stub for all of src/react-native/adapters)?
  2. 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.
  3. Which environment-guard behavior wins for the web/RNW case (see table above)?
  4. rozenite dev starts no SDK watcherdev-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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions