feat(metro): guarantee Rozenite plugins never enter production bundles - #445
Open
V3RON wants to merge 4 commits into
Open
feat(metro): guarantee Rozenite plugins never enter production bundles#445V3RON wants to merge 4 commits into
V3RON wants to merge 4 commits into
Conversation
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
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.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.84.4 rather than assumed — the issue's own checklist:
resolveRequestsurfaces intact: Metro catches only its ownFailedToResolve*classes (ModuleResolution.js:102-193) and rethrows everything else. A plainErroris correct._resolutionCacheis keyed outermost by{customResolverOptions, dev}(DependencyGraph.js:245-259), confirmed against one long-livedServerserving both. No serializer-level backstop needed.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.Two smaller things found along the way:
@rozenite/redux-devtools-pluginhad five test files and notestscript, 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 cleanpnpm test:affected— 61/61 taskspnpm release:plan— version plan presentVerified against real Metro 0.84.4 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./registerimports in place — no false positive.import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin'inHomeScreen.tsxfails the export, exit 1, message intact alongside Metro's import stack:register.jsfiles and thereact-native.jseach re-exports through. Zerorozenite.devmodules; no pluginsrc/**, panel or agent code.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
./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.
Generated by Claude Code