You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Sub-issue of #415. That issue and its React Native / Re.Pack implementation (#445) establish the guarantee; this issue brings the same DX and the same enforcement to Lynx. It depends on #445 landing first.
Problem
Lynx today is where React Native was before #415: plugin device halves are imported straight from app code (apps/playground-lynx/src/plugins/* import useRozeniteControlsPlugin, useTanStackQueryDevTools, …) and the only thing keeping that code out of a release build is each plugin's hand-written react-native.ts shim folding on __DEV__. Inclusion is survivable, not impossible, and a third-party plugin exporting a hook from its index defeats it entirely.
The rspeedy plugin does not help here. rozeniteLynxPlugin is apply: 'serve' and additionally gated on NODE_ENV, so it never runs during rspeedy build. That is correct for the dev server and the runtime injection, but it means nothing can turn a stray plugin import into a build error.
Nothing in packages/test-utils can prove a Lynx release bundle is clean either — bundleForRelease() drives Metro only.
Goal
Same as #415, on Lynx: nothing reaches production except what its author explicitly declared for production, enforced in the resolver, with the same app-author DX as React Native — <Rozenite /> mounted once, all plugin wiring in rozenite.dev.tsx.
Architecture
1. <Rozenite /> is exported from @rozenite/lynx
The seam is needed on Lynx too: some plugins mount components at the root, so a bundler-injected entry is not enough.
@rozenite/lynx's root export currently is the injected device runtime and calls setupRozenite() at import time. A seam exported from that entry would make every app import the runtime unconditionally and ship the dispatcher install to production. So the package splits into two side-effect-free-by-construction entries:
@rozenite/lynx/runtime — the injected device runtime (today's root). rozeniteLynxPlugin points source.preEntry at this subpath. The __BACKGROUND__ gate stays here.
@rozenite/lynx/rspeedy — unchanged.
The README's manual fallback for non-rspeedy pipelines (if (__DEV__) require('@rozenite/lynx')) changes to the /runtime subpath. This is the one user-visible break and deserves its own changeset entry.
To verify first: the seam ships prebuilt JSX. Confirm that its emitted react/jsx-runtime import resolves under rspeedy / pluginReactLynx (the playground app declares no react dependency yet uses plugin hooks that import it, so an alias appears to exist — but confirm before choosing between a shared seam implementation and a ReactLynx-specific build).
2. Thread model
ReactLynx runs effects on the background thread only, so hooks inside rozenite.dev.tsx are naturally background-only. Nothing beyond what the seam already does is needed. The __BACKGROUND__ gate remains in the runtime entry, not in the seam.
3. The rspeedy plugin installs the guard in both modes
The dev server, middleware, DebugRouter transport and preEntry runtime injection stay serve-only and enabled-gated, exactly as today.
The resolver guard is installed unconditionally, in serve and build, via api.modifyRspackConfig, by appending the shared RozeniteResolverPlugin (relocated to @rozenite/middleware in feat(metro): guarantee Rozenite plugins never enter production bundles #445). isDev comes from the Rsbuild mode, not NODE_ENV. installDevEntryRedirect is true only when Rozenite is enabled.
Same semantics as Metro and Re.Pack: enabled: false means "no dev server, guard still active". A production build that resolves into a Rozenite plugin package through anything other than a declared productionEntries subpath fails, naming the importing file. The same mistake warns in development. allowInProduction is the escape hatch, logged loudly.
No new rspack mechanics are needed: beforeResolve for the dev-entry redirect and afterResolve + compilation.errors.push(new WebpackError(...)) for the guard are already verified against rspack in #445. Rsbuild leaves normalModuleFactory hooks intact.
4. rozenite.dev.tsx is identical
Project root, resolved through resolve.extensions, may be a flat file or a rozenite.dev/ directory. rozenite init scaffolds it for Lynx projects and prints the mount snippet.
5. Integration gating
Main now carries an integrations field in dist/rozenite.json (#456, #457). The Lynx guard should also refuse a plugin that does not declare lynx (or lynx-web for web targets): warning in development, error in production, same message shape as the production guard. A React Native-only plugin resolving into a Lynx bundle is a mistake the resolver can name just as well.
6. A rspeedy release-bundle bench
Add a rspeedy counterpart to bundleForRelease() in @rozenite/test-utils that builds a throwaway ReactLynx app in production mode and reports rozeniteModules / panelModules from the emitted module paths, so the Lynx guard gets the same non-vacuous tests documented in docs/agents/release-bundle-testing.md:
a deliberate plugin import in app code fails the build with the message naming the file;
a declared productionEntries import succeeds;
enabled: false still guards;
a clean app with rozenite.dev.tsx produces zero rozenite.dev modules and zero plugin src/** in production.
Bundler-injected dev entries as an alternative to the seam. Not viable on Metro (no way to add artificial dependencies to an entry point) and unnecessary on Lynx once the seam exists.
Problem
Lynx today is where React Native was before #415: plugin device halves are imported straight from app code (
apps/playground-lynx/src/plugins/*importuseRozeniteControlsPlugin,useTanStackQueryDevTools, …) and the only thing keeping that code out of a release build is each plugin's hand-writtenreact-native.tsshim folding on__DEV__. Inclusion is survivable, not impossible, and a third-party plugin exporting a hook from its index defeats it entirely.The rspeedy plugin does not help here.
rozeniteLynxPluginisapply: 'serve'and additionally gated onNODE_ENV, so it never runs duringrspeedy build. That is correct for the dev server and the runtime injection, but it means nothing can turn a stray plugin import into a build error.Nothing in
packages/test-utilscan prove a Lynx release bundle is clean either —bundleForRelease()drives Metro only.Goal
Same as #415, on Lynx: nothing reaches production except what its author explicitly declared for production, enforced in the resolver, with the same app-author DX as React Native —
<Rozenite />mounted once, all plugin wiring inrozenite.dev.tsx.Architecture
1.
<Rozenite />is exported from@rozenite/lynxThe seam is needed on Lynx too: some plugins mount components at the root, so a bundler-injected entry is not enough.
@rozenite/lynx's root export currently is the injected device runtime and callssetupRozenite()at import time. A seam exported from that entry would make every app import the runtime unconditionally and ship the dispatcher install to production. So the package splits into two side-effect-free-by-construction entries:@rozenite/lynx(root) — the pure seam:<Rozenite />rendering a statically imported./dev-entry.jsnoop, exactly as@rozenite/react-nativedoes in feat(metro): guarantee Rozenite plugins never enter production bundles #445.react(via ReactLynx) is its only peer.@rozenite/lynx/runtime— the injected device runtime (today's root).rozeniteLynxPluginpointssource.preEntryat this subpath. The__BACKGROUND__gate stays here.@rozenite/lynx/rspeedy— unchanged.The README's manual fallback for non-rspeedy pipelines (
if (__DEV__) require('@rozenite/lynx')) changes to the/runtimesubpath. This is the one user-visible break and deserves its own changeset entry.To verify first: the seam ships prebuilt JSX. Confirm that its emitted
react/jsx-runtimeimport resolves under rspeedy /pluginReactLynx(the playground app declares noreactdependency yet uses plugin hooks that import it, so an alias appears to exist — but confirm before choosing between a shared seam implementation and a ReactLynx-specific build).2. Thread model
ReactLynx runs effects on the background thread only, so hooks inside
rozenite.dev.tsxare naturally background-only. Nothing beyond what the seam already does is needed. The__BACKGROUND__gate remains in the runtime entry, not in the seam.3. The rspeedy plugin installs the guard in both modes
rozeniteLynxPlugindropsapply: 'serve'. Insidesetup:preEntryruntime injection stay serve-only andenabled-gated, exactly as today.serveandbuild, viaapi.modifyRspackConfig, by appending the sharedRozeniteResolverPlugin(relocated to@rozenite/middlewarein feat(metro): guarantee Rozenite plugins never enter production bundles #445).isDevcomes from the Rsbuild mode, notNODE_ENV.installDevEntryRedirectis true only when Rozenite is enabled.Same semantics as Metro and Re.Pack:
enabled: falsemeans "no dev server, guard still active". A production build that resolves into a Rozenite plugin package through anything other than a declaredproductionEntriessubpath fails, naming the importing file. The same mistake warns in development.allowInProductionis the escape hatch, logged loudly.No new rspack mechanics are needed:
beforeResolvefor the dev-entry redirect andafterResolve+compilation.errors.push(new WebpackError(...))for the guard are already verified against rspack in #445. Rsbuild leavesnormalModuleFactoryhooks intact.4.
rozenite.dev.tsxis identicalProject root, resolved through
resolve.extensions, may be a flat file or arozenite.dev/directory.rozenite initscaffolds it for Lynx projects and prints the mount snippet.5. Integration gating
Main now carries an
integrationsfield indist/rozenite.json(#456, #457). The Lynx guard should also refuse a plugin that does not declarelynx(orlynx-webfor web targets): warning in development, error in production, same message shape as the production guard. A React Native-only plugin resolving into a Lynx bundle is a mistake the resolver can name just as well.6. A rspeedy release-bundle bench
Add a rspeedy counterpart to
bundleForRelease()in@rozenite/test-utilsthat builds a throwaway ReactLynx app in production mode and reportsrozeniteModules/panelModulesfrom the emitted module paths, so the Lynx guard gets the same non-vacuous tests documented indocs/agents/release-bundle-testing.md:productionEntriesimport succeeds;enabled: falsestill guards;rozenite.dev.tsxproduces zerorozenite.devmodules and zero pluginsrc/**in production.Scope
packages/lynx: export split, seam, rspeedy plugin changes, README, changeset.packages/cli:rozenite initscaffolding for Lynx projects.packages/test-utils: rspeedy bench.apps/playground-lynx: move all plugin wiring intorozenite.dev.tsx, mount<Rozenite />at the root.website/: Lynx getting-started and plugin-authoring docs.docs/adr/recording the export split and the build-mode guard decision, per the repository's ADR convention.Out of scope
react-native.tsshims (RFC: declarative plugin authoring — generated dev/production entries, transpile-only targets, enforced UI/RN boundary #402).