Skip to content

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

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

feat(metro): guarantee Rozenite plugins never enter production bundles#445
V3RON wants to merge 9 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.
  • The rspack resolver plugin now lives in @rozenite/middleware, not @rozenite/repack, so it can be shared with @rozenite/lynx (Guarantee Rozenite plugins never enter production bundles on Lynx #492) without that package depending on @rozenite/repack.

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.86.0 rather than assumed — the issue's own checklist:

  • A throw from resolveRequest surfaces intact: Metro catches only its own FailedToResolve* classes and rethrows everything else. A plain Error is correct.
  • Dev and production resolution caches are genuinely separate, so no serializer-level backstop is 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.

Rebased onto main after both branches moved. Main landed productionEntries-adjacent work in the interim: require-profiler's own enabled option and its isBundling()-based release-bundle fix (#453/#454), the integrations manifest field (#456/#457), and @rozenite/test-utils plus per-plugin release-bundle.test.ts suites (#429). Both sides survive: withRozeniteRequireProfiler now checks isBundling() and respects its enabled option; rozenite.json (and RozeniteConfig) carry both integrations and productionEntries. packages/tools/src/is-bundling.ts/packages.ts (this PR's original location) and main's packages/metro/src/is-bundling.ts were the same file moved to two different homes — git's rename detection resolved this to the tools location during the rebase, so there is exactly one isBundling implementation, shared by @rozenite/metro and @rozenite/require-profiler-plugin. Main's own require-profiler release-bundle regression test (packages/require-profiler-plugin/src/metro/__tests__/release-bundle.test.ts) needed no changes and is kept as-is; it already exercises the merged fix.

The rspack resolver plugin moved from @rozenite/repack to @rozenite/middleware. @rozenite/lynx (#492, deferred) needs the identical dev-entry redirect and production guard installed on an rspack compiler via Rsbuild's modifyRspackConfig, and must not depend on @rozenite/repack to get it. RozeniteResolverPlugin (packages/middleware/src/rspack-resolver-plugin.ts) keeps its hand-written structural types and imports nothing from @rspack/core, so the middleware gains no rspack dependency; @rozenite/repack now imports it from @rozenite/middleware instead of owning it.

Production-guard cache now survives rozenite dev rebuilding a plugin. @rozenite/middleware's plugin lookup memoized dist/rozenite.json contents for the process lifetime, which is wrong once a plugin's Vite watcher rebuilds that manifest mid-session — the dev server would keep answering with whatever the plugin looked like the first time it was resolved. Each cache entry now carries the manifest's mtime and is re-stat'd on every hit, so a rebuild (new content, or the manifest appearing/disappearing) invalidates it on the next resolution. @rozenite/metro's own declaredEntriesCache (resolved productionEntries paths, keyed per plugin root) is invalidated the same way, by comparing against the plugin's current productionEntries on every lookup instead of trusting a value cached once.

A post-factum ADR records this decision: docs/adr/0001-plugins-never-enter-production-bundles.md, including why Metro cannot inject a dev entry (no way to add artificial dependencies to an entry point; runBeforeMainModule only reorders modules already in the graph) — which is why the app-side seam exists at all.

Scope note added to the docs and changeset. The production guarantee covers Metro and Re.Pack only; Lynx is tracked separately in #492, which depends on this PR landing first and adds its own ADR for the Rsbuild-specific pieces.

Testing

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

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

Verified against real Metro 0.86.0 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 as-is: CI=1 npx expo export --platform ios --clear bundles 1028 modules, exit 0.
  • A deliberate import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin' in HomeScreen.tsx, actually called, fails the export, naming the importing file:
    Error: @rozenite/storage-plugin is a Rozenite plugin and declares no production entry points.
    Imported from: src/app/screens/HomeScreen.tsx
    
    (the import was reverted afterwards; a clean re-export confirms 1028 modules again, matching the baseline).

New test coverage added on top of the merged release-bundle bench (docs/agents/release-bundle-testing.md):

  • packages/metro/src/__tests__/release-bundle.test.ts — real Metro release builds through withRozenite: an app importing @rozenite/storage-plugin fails, naming the importing file; importing the declared @rozenite/rhf-plugin/register production entry succeeds; the violating import still fails with enabled: false; allowInProduction: ['@rozenite/storage-plugin'] lets it through.
  • packages/middleware/src/__tests__/production-guard.test.ts — the manifest cache serves a cached lookup while dist/rozenite.json is unchanged, and invalidates on both a rebuilt manifest and a removed one.
  • packages/metro/src/__tests__/resolver.test.tsresolveDeclaredEntries's cache re-resolves once the plugin's productionEntries changes underneath it.

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.

V3RON added a commit that referenced this pull request Sep 3, 2026
Records the seam-plus-resolver-guard design from #415/#445 as ADR
0001: no __DEV__ guard anywhere, all wiring in rozenite.dev.tsx, the
guard installed unconditionally including enabled: false,
productionEntries and allowInProduction as escape hatches, why Metro
can't inject a dev entry on its own, and the shared rspack resolver
plugin now living in @rozenite/middleware so Re.Pack and the
upcoming Lynx integration (#492) don't drift.

Also updates the production-plugin-guarantee changeset and website
docs to say plainly that the guarantee covers Metro and Re.Pack today,
with Lynx tracked separately in #492.
@V3RON
V3RON force-pushed the claude/rozenite-feature-orchestration-yjm9pz branch 2 times, most recently from de93dba to 5401998 Compare September 3, 2026 13:22
claude and others added 9 commits September 4, 2026 10:43
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
RozeniteResolverPlugin needs to be shared with @rozenite/lynx (#492),
which installs the same plugin through Rsbuild's modifyRspackConfig
and must not depend on @rozenite/repack. Move it into
@rozenite/middleware, which both already depend on for the guard's
shared core, and have @rozenite/repack import it from there instead.

The plugin keeps its hand-written structural types and imports
nothing from @rspack/core, so the middleware gains no rspack
dependency.
…t changes

findRozenitePluginForFile memoized a plugin's dist/rozenite.json for
the process lifetime, which is wrong for `rozenite dev`: a plugin's
manifest is rebuilt by its own watcher while the bundler keeps
running, and the cache kept answering with whatever the plugin looked
like the first time it was resolved.

Each cache entry now carries the manifest's mtime and is re-stat'd on
every hit, invalidating on a rebuild, a removal, or the manifest
appearing where it didn't exist before.

Metro's own declaredEntriesCache (resolved productionEntries paths)
has the same problem one layer up: it is invalidated the same way, by
comparing the plugin's current productionEntries against what the
cached entry was resolved from.
Bundler-integration guard for withRozenite itself, per
docs/agents/release-bundle-testing.md, exercised through real Metro
release builds instead of unit tests of applyProductionGuard alone:

- an app importing @rozenite/storage-plugin directly fails, naming
  the importing file;
- importing the declared @rozenite/rhf-plugin/register production
  entry succeeds;
- the violating import still fails with enabled: false;
- allowInProduction: ['@rozenite/storage-plugin'] lets it through.

Adds @rozenite/storage-plugin, @rozenite/rhf-plugin and
@rozenite/test-utils as devDependencies so Turborepo builds them
before this suite runs.
…/Re.Pack

Record the production-guarantee decision as
docs/adr/0001-plugins-never-enter-production-bundles.md, following
0000's format: the seam package, why Metro cannot inject a dev entry
(no way to add artificial dependencies to an entry point;
runBeforeMainModule only reorders modules already in the graph),
the resolver-level guard installed unconditionally, productionEntries
and allowInProduction as escape hatches, and the rspack plugin living
in the middleware so Re.Pack and Lynx can share it.

Also make the changeset and the production-guarantee doc explicit
that this covers Metro and Re.Pack only; Lynx is tracked separately
in #492.
…n-vacuous

docs/agents/release-bundle-testing.md still pointed at
packages/metro/src/is-bundling.ts; the rebase moved isBundling into
packages/tools.

"succeeds when an app imports a declared production entry" only
asserted result.code was truthy, which passes for any bundle at all.
Assert instead that the rhf-plugin's built register module is
actually in rozeniteModules (matching either the ESM or the CJS
build, whichever Metro's resolver conditions pick) and that no panel
code came along with it.
@V3RON
V3RON force-pushed the claude/rozenite-feature-orchestration-yjm9pz branch from 3402c81 to 9919179 Compare September 4, 2026 08:43
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