Skip to content

Guarantee Rozenite plugins never enter production bundles on Lynx #492

Description

@V3RON

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 (root) — the pure seam: <Rozenite /> rendering a statically imported ./dev-entry.js noop, exactly as @rozenite/react-native does 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). 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

rozeniteLynxPlugin drops apply: 'serve'. Inside setup:

  • 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.

Scope

  • packages/lynx: export split, seam, rspeedy plugin changes, README, changeset.
  • packages/cli: rozenite init scaffolding for Lynx projects.
  • packages/test-utils: rspeedy bench.
  • apps/playground-lynx: move all plugin wiring into rozenite.dev.tsx, mount <Rozenite /> at the root.
  • website/: Lynx getting-started and plugin-authoring docs.
  • An ADR under docs/adr/ recording the export split and the build-mode guard decision, per the repository's ADR convention.

Out of scope

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions