Skip to content

feat(metro): guarantee Rozenite plugins never enter production bundles - #445

Open
V3RON wants to merge 4 commits into
mainfrom
claude/rozenite-feature-orchestration-yjm9pz
Open

feat(metro): guarantee Rozenite plugins never enter production bundles#445
V3RON wants to merge 4 commits into
mainfrom
claude/rozenite-feature-orchestration-yjm9pz

Conversation

@V3RON

@V3RON V3RON commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Description

Makes plugin inclusion in a production bundle a build error rather than something each plugin has to survive on its own.

  • New @rozenite/react-native — the app-side seam. Apps render <Rozenite /> once at the root, unconditionally, with no __DEV__ guard to write or forget. It statically imports a real noop it ships; react is its only peer dependency.
  • All plugin wiring moves to rozenite.dev.tsx. In development the Metro and Re.Pack resolvers redirect the seam's dev entry there; in production it resolves to the shipped noop. The entry may be a flat file or a rozenite.dev/ directory, and platform extensions (rozenite.dev.ios.tsx, rozenite.dev/index.web.tsx) work for free. rozenite init scaffolds it and prints the mount snippet.
  • The guard, installed unconditionally by withRozenite in both bundlers: a production build that resolves into a Rozenite plugin package throws, naming the offending file. The same mistake warns in development, so it surfaces while it is being made.
  • productionEntries — a plugin that genuinely needs to run in production adds a root register.ts and declares productionEntries: ['./register']; the build exposes it as ./register and the resolver permits that and nothing else. redux-devtools-plugin, feature-flags-plugin, rhf-plugin and network-activity-plugin ship one.
  • allowInProduction: ['some-plugin'] as the escape hatch, logged loudly on every build.

Related Issue

Closes #415

Context

Two bugs that only a real bundle run could find. Both were caught by exporting apps/playground for release, not by tests or typechecking:

  1. The guard was completely inert. It identified a plugin by walking up from a resolved file to the first package.json. tsc cannot emit .cjs/.mjs, so the plugin build drops a bare {"type": "module"} marker into every output directory — and that marker is the first package.json above a resolved plugin entry. The walk stopped two directories short of dist/rozenite.json, so every plugin read as "not a plugin". A release export with a deliberate violating import succeeded. A package root is now a directory whose package.json names a package. This also fixes the dev-entry redirect silently never engaging when the seam resolves through its CommonJS build.

  2. productionEntries resolved to the wrong file. A declared entry is an export subpath, so it has to be resolved as the bare specifier a consumer writes (@acme/plugin/register). Resolving ./register as a literal relative path lands on the plugin's source register.ts, while the consumer's import goes through exports to dist/react-native/register.js. The two never match, so a correctly declared entry would have failed the guard — the false positive that teaches people to ignore the check.

A declared production entry must be inert in production. This is the one place the guarantee cannot reach: the resolver permits the import because the author declared it, so whatever register.ts exports is what runs in a shipped app. Exporting straight from src/** would have installed a live Redux enhancer retaining 150 actions, patched global fetch with nothing draining the buffer, and serialized form state on every keystroke — the exact harm this issue exists to prevent, shipped silently. All four re-export through react-native.ts, which already resolves each symbol to a no-op once NODE_ENV folds, so there is one definition of the production behaviour rather than a second copy that can drift. Each has a test pinning it.

Metro mechanics, verified against 0.84.4 rather than assumed — the issue's own checklist:

  • A throw from resolveRequest surfaces intact: Metro catches only its own FailedToResolve* classes (ModuleResolution.js:102-193) and rethrows everything else. A plain Error is correct.
  • Dev and production resolution caches are genuinely separate: _resolutionCache is keyed outermost by {customResolverOptions, dev} (DependencyGraph.js:245-259), confirmed against one long-lived Server serving both. No serializer-level backstop needed.
  • context.dev is the right switch, not isBundling(), which is a coarse process.argv heuristic that only decides whether to start the dev server.

Re.Pack needed different mechanics. resolve.plugins is silently a no-op in rspack (resolution is native), and NormalModuleReplacementPlugin's request mutation does not take effect in the pinned alpha — both verified with real builds. The redirect uses normalModuleFactory.hooks.beforeResolve and the guard uses afterResolve plus compilation.errors.push(new WebpackError(...)), which is what Re.Pack's bundle command turns into a non-zero exit. Both call into the same shared core as Metro so the two cannot drift.

A gap the resolver structurally cannot close, fixed separately. withRozeniteRequireProfiler adds its instrumentation through serializer.getPolyfills, which Metro puts in the graph by absolute path rather than through module resolution — so the guard never sees it and it was shipping to release bundles today. It is now skipped when Metro is bundling for release, with a regression test.

enabled: false is a behaviour change. It still starts no dev server and adds no middleware, but the guard stays active — turning Rozenite off is not a way to opt out of the guarantee. That path is exactly the production path the guard needs.

Scope. The 468 lines of hand-written react-native.ts shims stay untouched; deleting them belongs to #402. The set needing productionEntries turned out to be four, not the three named in the issue: network-activity-plugin's withOnBootNetworkActivityRecording is documented as being called from index.js, which always ships.

Two smaller things found along the way: @rozenite/redux-devtools-plugin had five test files and no test script, so none had ever run in CI (added; 19 tests now run); and on web, <RozeniteOverlay /> now mounts where <Rozenite /> sits rather than nested inside its own demo card — the one playground behaviour that could not be preserved exactly.

Testing

Automated, from the repository root after git fetch origin main:

  • pnpm checks:affected — 99/99 tasks, typecheck/lint/format clean
  • pnpm test:affected — 61/61 tasks
  • pnpm release:plan — version plan present

Verified against real Metro 0.84.4 in apps/playground, per docs/agents/metro-testing.md:

  • CI=1 npx expo start reaches Waiting on http://localhost:8081 with all 14 plugins discovered and no require-time errors.
  • Production export succeeds with the three legitimate /register imports in place — no false positive.
  • A deliberate import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin' in HomeScreen.tsx fails the export, exit 1, message intact alongside Metro's import stack:
    Error: @rozenite/storage-plugin is a Rozenite plugin and declares no production entry points.
    Imported from: src/app/screens/HomeScreen.tsx
    
  • Production bundle contents, from the source map: 1035 modules, of which 6 come from a plugin package — three declared register.js files and the react-native.js each re-exports through. Zero rozenite.dev modules; no plugin src/**, panel or agent code.
  • Dev bundle from the running server: the full rozenite.dev/ tree is present (index.tsx, agent-tools, controls-sections, network-controls, sqlite-adapters, storage-adapters) along with the plugin hooks and the overlay.

Re.Pack verified with real rspack 2.0.0-alpha.1 builds: undeclared import fails naming the importer; declared ./register through a real exports map succeeds; a plugin-internal import succeeds; the dev redirect loads the project file; a missing rozenite.dev falls back to the noop and warns once.

Not run here: the playground on a device/simulator, which is where the migrated wiring for all 12 plugins wants a human pass.


Generated by Claude Code

claude added 4 commits August 21, 2026 06:56
Adds the app-side seam package, the resolver-level guard, and the
`productionEntries` declaration that lets a plugin name the parts of
itself that are allowed to run in production.

Work in progress on this branch: the Re.Pack integration, the playground
migration and the docs are still landing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLGRno4KVGnNBqBqx6BR3u
…nd and docs

Re-export each plugin's `./register` surface through its `react-native.ts`
entry so a declared production touchpoint is reachable in a release build
without being active in one, and pin that with a test per plugin. Resolve a
declared entry as the export subpath a consumer actually writes, so a
correctly declared import is not rejected by the guard.

Moves the playground's plugin wiring into `rozenite.dev/`, and rewrites the
docs around `<Rozenite />` and `rozenite.dev.tsx`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLGRno4KVGnNBqBqx6BR3u
The section demonstrates that a screen's own Controls section appears and
disappears with it while the app-level ones stay, so registering it
unconditionally from the dev entry would have quietly dropped half of what
it tests. The screen keeps ownership of when it is registered.

Also corrects the plugin-authoring guide: a declared production entry has to
be inert in production, which is why the official plugins re-export through
their root entry rather than reaching into src/**.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLGRno4KVGnNBqBqx6BR3u
…kage root

The guard identified a plugin by walking up from a resolved file to the
first package.json. tsc cannot emit .cjs/.mjs, so the plugin build drops a
bare {"type": "module"} marker into every output directory - and that marker
is the first package.json above a resolved plugin entry. The walk stopped
two directories short of dist/rozenite.json, so every plugin read as "not a
plugin" and the guard permitted everything.

Caught by bundling apps/playground for release with a deliberate violation:
the export succeeded. A package root is now a directory whose package.json
names a package, which also fixes the dev-entry redirect silently not
engaging when the seam resolves through its CommonJS build.

Adds the Re.Pack side of the guard, which reaches the same conclusion
through beforeResolve and afterResolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLGRno4KVGnNBqBqx6BR3u
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Guarantee Rozenite plugins never enter production bundles

2 participants