feat(metro): guarantee Rozenite plugins never enter production bundles - #445
Open
V3RON wants to merge 9 commits into
Open
feat(metro): guarantee Rozenite plugins never enter production bundles#445V3RON wants to merge 9 commits into
V3RON wants to merge 9 commits into
Conversation
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
force-pushed
the
claude/rozenite-feature-orchestration-yjm9pz
branch
2 times, most recently
from
September 3, 2026 13:22
de93dba to
5401998
Compare
6 tasks
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
force-pushed
the
claude/rozenite-feature-orchestration-yjm9pz
branch
from
September 4, 2026 08:43
3402c81 to
9919179
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Makes plugin inclusion in a production bundle a build error rather than something each plugin has to survive on its own.
@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;reactis its only peer dependency.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 arozenite.dev/directory, and platform extensions (rozenite.dev.ios.tsx,rozenite.dev/index.web.tsx) work for free.rozenite initscaffolds it and prints the mount snippet.withRozenitein 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 rootregister.tsand declaresproductionEntries: ['./register']; the build exposes it as./registerand the resolver permits that and nothing else.redux-devtools-plugin,feature-flags-plugin,rhf-pluginandnetwork-activity-pluginship one.allowInProduction: ['some-plugin']as the escape hatch, logged loudly on every build.@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/playgroundfor release, not by tests or typechecking: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 firstpackage.jsonabove a resolved plugin entry. The walk stopped two directories short ofdist/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 whosepackage.jsonnames a package. This also fixes the dev-entry redirect silently never engaging when the seam resolves through its CommonJS build.productionEntriesresolved 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./registeras a literal relative path lands on the plugin's sourceregister.ts, while the consumer's import goes throughexportstodist/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.tsexports is what runs in a shipped app. Exporting straight fromsrc/**would have installed a live Redux enhancer retaining 150 actions, patched globalfetchwith 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 throughreact-native.ts, which already resolves each symbol to a no-op onceNODE_ENVfolds, 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:
resolveRequestsurfaces intact: Metro catches only its ownFailedToResolve*classes and rethrows everything else. A plainErroris correct.context.devis the right switch, notisBundling(), which is a coarseprocess.argvheuristic that only decides whether to start the dev server.Re.Pack needed different mechanics.
resolve.pluginsis silently a no-op in rspack (resolution is native), andNormalModuleReplacementPlugin's request mutation does not take effect in the pinned alpha — both verified with real builds. The redirect usesnormalModuleFactory.hooks.beforeResolveand the guard usesafterResolvepluscompilation.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.
withRozeniteRequireProfileradds its instrumentation throughserializer.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: falseis 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.tsshims stay untouched; deleting them belongs to #402. The set needingproductionEntriesturned out to be four, not the three named in the issue:network-activity-plugin'swithOnBootNetworkActivityRecordingis documented as being called fromindex.js, which always ships.Rebased onto
mainafter both branches moved. Main landedproductionEntries-adjacent work in the interim: require-profiler's ownenabledoption and itsisBundling()-based release-bundle fix (#453/#454), theintegrationsmanifest field (#456/#457), and@rozenite/test-utilsplus per-pluginrelease-bundle.test.tssuites (#429). Both sides survive:withRozeniteRequireProfilernow checksisBundling()and respects itsenabledoption;rozenite.json(andRozeniteConfig) carry bothintegrationsandproductionEntries.packages/tools/src/is-bundling.ts/packages.ts(this PR's original location) and main'spackages/metro/src/is-bundling.tswere the same file moved to two different homes — git's rename detection resolved this to thetoolslocation during the rebase, so there is exactly oneisBundlingimplementation, shared by@rozenite/metroand@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/repackto@rozenite/middleware.@rozenite/lynx(#492, deferred) needs the identical dev-entry redirect and production guard installed on an rspack compiler via Rsbuild'smodifyRspackConfig, and must not depend on@rozenite/repackto 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/repacknow imports it from@rozenite/middlewareinstead of owning it.Production-guard cache now survives
rozenite devrebuilding a plugin.@rozenite/middleware's plugin lookup memoizeddist/rozenite.jsoncontents 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 owndeclaredEntriesCache(resolvedproductionEntriespaths, keyed per plugin root) is invalidated the same way, by comparing against the plugin's currentproductionEntrieson 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;runBeforeMainModuleonly 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 cleanpnpm test:affected— 62/62 taskspnpm release:plan— version plan presentVerified against real Metro 0.86.0 in
apps/playground, perdocs/agents/metro-testing.md:CI=1 npx expo startreachesWaiting on http://localhost:8081with all 14 plugins discovered and no require-time errors.CI=1 npx expo export --platform ios --clearbundles 1028 modules, exit 0.import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin'inHomeScreen.tsx, actually called, fails the export, naming the importing file: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 throughwithRozenite: an app importing@rozenite/storage-pluginfails, naming the importing file; importing the declared@rozenite/rhf-plugin/registerproduction entry succeeds; the violating import still fails withenabled: false;allowInProduction: ['@rozenite/storage-plugin']lets it through.packages/middleware/src/__tests__/production-guard.test.ts— the manifest cache serves a cached lookup whiledist/rozenite.jsonis unchanged, and invalidates on both a rebuilt manifest and a removed one.packages/metro/src/__tests__/resolver.test.ts—resolveDeclaredEntries's cache re-resolves once the plugin'sproductionEntrieschanges underneath it.Re.Pack verified with real rspack 2.0.0-alpha.1 builds: undeclared import fails naming the importer; declared
./registerthrough a realexportsmap succeeds; a plugin-internal import succeeds; the dev redirect loads the project file; a missingrozenite.devfalls 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.