Skip to content

feat(lynx): guarantee Rozenite plugins never enter Lynx production bundles - #494

Open
V3RON wants to merge 5 commits into
claude/rozenite-feature-orchestration-yjm9pzfrom
claude/lynx-rozenite-seam-adr-1134d0
Open

feat(lynx): guarantee Rozenite plugins never enter Lynx production bundles#494
V3RON wants to merge 5 commits into
claude/rozenite-feature-orchestration-yjm9pzfrom
claude/lynx-rozenite-seam-adr-1134d0

Conversation

@V3RON

@V3RON V3RON commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements ADR 0002 (docs/adr/0002-lynx-plugins-never-enter-production-bundles.md), extending the production guarantee #445 gives Metro/Re.Pack to Lynx:

  • @rozenite/lynx gets an app-side seam, mirroring @rozenite/react-native's: a side-effect-free <Rozenite /> component (the new . export) that redirects to rozenite.dev.tsx in development and resolves to a shipped noop in production. The device runtime that used to be the root export moved to @rozenite/lynx/runtime (breaking change, covered in the changeset).
  • rozeniteLynxPlugin drops apply: 'serve' and installs the shared RozeniteResolverPlugin (from @rozenite/middleware) unconditionally, in both rspeedy dev and rspeedy build — a production build now fails, naming the importing file, if it resolves into a Rozenite plugin package through anything other than a declared production entry.
  • @rozenite/middleware grows an integration check: a plugin resolved into a Lynx bundle must declare "lynx" (or "lynx-web", derived from the Rsbuild environment name) in its manifest's integrations, or the build fails the same way. The seam-matching logic generalizes from a single hardcoded package name to cover both React Native and Lynx seams.
  • @rozenite/test-utils gains bundleLynxForRelease, the rspeedy/rspack counterpart to the existing Metro bundleForRelease bench, and packages/lynx gets a 7-case release-bundle suite proving the guarantee end-to-end against real rspeedy build runs — including the ADR's headline case: a clean app with a real rozenite.dev.tsx present, proving production never reaches it.
  • apps/playground-lynx migrates to the new convention: <Rozenite /> mounted once at the app root, every plugin panel moved under a rozenite.dev/ directory.

An adversarial review (Opus) of the first pass caught a real bug — the seam's CJS build required its dev entry with no interop, so a redirect to the app's rozenite.dev.tsx rendered the wrong thing — fixed and locked in with a regression test; see the last commit for details. The review also flagged that rozenite init Lynx scaffolding, mentioned in the ADR, is a substantially separate CLI feature (Lynx-project detection, an rspeedy config wrapper) that doesn't exist for Lynx at all today; that's called out explicitly in the ADR as deferred, tracked follow-up work rather than folded into this change.

Test plan

  • pnpm checks:affected (typecheck, lint, format) — clean
  • pnpm test:affected — clean, including 109 lynx tests and 280 middleware tests
  • Manual rspeedy build on apps/playground-lynx: 88.7 kB bundle, zero plugin/rozenite.dev strings
  • Manual rspeedy dev: dev-entry redirect fires, devices connect, no spurious advisories
  • Manual repro proving the guard fires on a real violation (temporarily added a plugin import to app code, confirmed the build fails with the right message, reverted)
  • Manual + automated verification of the CJS interop fix against both dev-entry shapes

@rozenite/lynx splits into a side-effect-free `.` seam (<Rozenite />,
rendering a statically-imported dev-entry noop) and a `./runtime`
subpath carrying the device runtime that used to be the root export.
rozeniteLynxPlugin drops apply: 'serve' and installs the shared
RozeniteResolverPlugin (from @rozenite/middleware) unconditionally, in
both rspeedy dev and rspeedy build, so a production build now fails,
naming the importing file, if it resolves into a Rozenite plugin
package through anything other than a declared production entry --
the same guarantee @rozenite/repack gives React Native.

@rozenite/middleware grows an integration check alongside the
existing production-entry guard: a plugin resolved into a Lynx bundle
must declare "lynx" in its manifest's `integrations`, or the build
fails the same way. The seam's dev-entry-redirect matching is
generalized from a single hardcoded package name to a set covering
both @rozenite/react-native and @rozenite/lynx, and extended to match
the .cjs specifier Lynx's Rollup-bundled CJS build emits (its tsc-built
React Native counterpart never rewrites the extension, so this case
had no test coverage before).

apps/playground-lynx migrates to the new convention: <Rozenite />
mounted once at the app root, every plugin panel moved under a
rozenite.dev/ directory so RozeniteResolverPlugin's dev-time advisory
is correctly suppressed for them (any file under that directory counts
as the dev entry, not only the entry file itself).
… end-to-end

@rozenite/test-utils gains bundleLynxForRelease, the rspeedy/rspack
counterpart to bundleForRelease: it builds a throwaway Lynx app
through rspeedy's JavaScript API (createRspeedy + .build()) in
production mode and reports which modules rspack actually put in the
compilation, via a small rspack plugin tapping compiler.hooks.afterCompile
-- the same non-vacuous, module-graph-based signal the Metro bench
uses, not a grep over emitted source.

.build() rejects with a generic "Rspack build failed." on a compile
error rather than the real message, so the bench also captures
stats.errors through onAfterBuild and re-throws with it, letting a
caller assert on RozeniteResolverPlugin's actual message the way the
Metro bench's callers do.

packages/lynx/src/__tests__/release-bundle.test.ts exercises
rozeniteLynxPlugin through six real rspeedy builds: a direct plugin
import fails naming the file, a plugin that doesn't declare Lynx
support fails naming the integrations it does declare, a declared
production entry succeeds with zero panel code, the guard still fires
when the plugin is disabled, allowInProduction bypasses it, and a
clean app ships zero Rozenite modules.
…undle bench

Records the resolved JSX-runtime question (@lynx-js/react does not
depend on react at all, so the seam is built against its own
jsx-runtime rather than shared source with @rozenite/react-native) and
documents bundleLynxForRelease alongside the existing Metro bench in
docs/agents/release-bundle-testing.md.
An adversarial review of the previous two checkpoints found a real
bug: the seam's CJS build (dist/index.cjs) required its dev entry
without any interop, so once RozeniteResolverPlugin redirected the
request to the app's rozenite.dev.tsx (compiled independently by
rspack/webpack as { default, __esModule: true }), it rendered that
namespace object instead of the component inside it. Two follow-up
fixes were tried and reverted before landing on the real one -- see
vite.seam.config.ts's comment for why a same-build entry and a
namespace-import unwrap both made things worse. The fix: build
dev-entry.tsx as a genuinely separate Rollup output
(vite.dev-entry.config.ts) and mark it external to the seam build,
with a per-format output.paths remap (the flat dist/ layout needs
dev-entry.cjs in the CJS build, not the literal dev-entry.js source
specifier) and explicit interop: 'auto' so Rollup's own runtime
__esModule check handles both shapes. Verified against both shapes
directly through the built dist/index.cjs, and locked in with an
automated regression test.

Also, from the same review:
- The integration-mismatch error now names the allowInProduction
  escape hatch, matching the existing production-entry error's shape.
- rozeniteLynxPlugin now derives lynx vs lynx-web from the Rsbuild
  environment name instead of always targeting "lynx".
- The release-bundle suite gained the ADR's actual headline case: a
  clean app with a real rozenite.dev.tsx present, proving production
  never reaches it, including with enabled forced on.
- packages/lynx/README.md and apps/playground-lynx/README.md drift
  (still describing the pre-seam src/plugins/ layout) is fixed.
- ADR 0002 now honestly defers `rozenite init` Lynx scaffolding
  (packages/cli is entirely React-Native-shaped today) as tracked
  follow-up work rather than claiming it as done.
@V3RON
V3RON force-pushed the claude/lynx-rozenite-seam-adr-1134d0 branch from 060896a to 51d24fa 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.

1 participant