Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/lynx-production-plugin-guarantee.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@rozenite/lynx': minor
'@rozenite/middleware': minor
'@rozenite/tools': minor
---

Extend the [production guarantee](https://github.com/callstackincubator/rozenite/issues/492) landed
for Metro and Re.Pack to Lynx: a `rspeedy build` now fails, naming the importing file, if it resolves
into a Rozenite plugin package through anything other than that plugin's declared production entry.

`@rozenite/lynx` gets the same app-side seam React Native has. Render `<Rozenite />` (this package's
`.` export) once, unconditionally, at your app root, and move plugin wiring into a `rozenite.dev.tsx`
next to your `lynx.config.ts`. `rozeniteLynxPlugin()` redirects the seam to that file in development
and to a shipped noop in production, using the same `RozeniteResolverPlugin` (from `@rozenite/middleware`)
that Re.Pack installs — Metro, Re.Pack and Lynx now share one implementation of both the dev-entry
redirect and the production guard.

The guard also checks that a resolved plugin declares Lynx support in its manifest's `integrations`
field: a plugin published only for React Native resolving into a Lynx bundle now fails the same way,
naming the integrations it does declare.

**Breaking:** `rozeniteLynxPlugin`'s device runtime moved from `@rozenite/lynx`'s root export to
`@rozenite/lynx/runtime`. The root export is now the seam (`<Rozenite />`) instead, which must be
side-effect-free so it can be rendered unconditionally in production. If you previously followed the
manual fallback (`if (__DEV__) { require('@rozenite/lynx'); }`) for a non-rspeedy build pipeline,
change it to `require('@rozenite/lynx/runtime')`. Apps that only ever used `rozeniteLynxPlugin()`'s
automatic injection are unaffected.

**Breaking:** `rozeniteLynxPlugin` no longer declares `apply: 'serve'`, so its resolver guard now runs
during `rspeedy build` as well as `rspeedy dev` — this is the point of the change, but it means a
plugin import that previously shipped silently into a Lynx release bundle now fails the build. Move
plugin wiring into `rozenite.dev.tsx`, declare a `productionEntries` entry in the plugin's
`rozenite.config.ts`, or pass `allowInProduction: ['some-plugin']` (logged loudly on every build) as an
escape hatch.
15 changes: 11 additions & 4 deletions apps/playground-lynx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,22 @@ Edit `src/App.tsx` to see updates — the page hot-reloads as you save.

- [`@rozenite/lynx/rspeedy`](../../packages/lynx) is added to
`lynx.config.ts`. It discovers installed plugins, bridges Lynx's
DebugRouter to the CDP dialect `@rozenite/app` speaks, and injects the
device-side dispatcher plugins talk to — there is nothing to import in
`src/index.tsx`.
DebugRouter to the CDP dialect `@rozenite/app` speaks, injects the
device-side dispatcher plugins talk to, and guards every build --
`rspeedy build` included -- against plugin code reaching a production
bundle.
- `src/App.tsx` renders `<Rozenite />` (`@rozenite/lynx`'s default export)
once, unconditionally, exactly where the plugin playgrounds below used to
render directly. In development this redirects to
[`rozenite.dev/`](./rozenite.dev); in production it resolves to a shipped
noop.

## Plugins

Every official plugin that declares `lynx` in its `rozenite.config.ts`
`integrations` is installed here, with a minimal playground under
`src/plugins/`:
[`rozenite.dev/`](./rozenite.dev) -- never in app source, so the resolver
guard above can prove none of it reaches a release build:

| Plugin | Playground |
| ---------------------------------- | -------------------------------------------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion apps/playground-lynx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@lynx-js/react": "^0.125.0",
"@rozenite/controls-plugin": "workspace:*",
"@rozenite/feature-flags-plugin": "workspace:*",
"@rozenite/lynx": "workspace:*",
"@rozenite/rhf-plugin": "workspace:*",
"@rozenite/tanstack-query-plugin": "workspace:*",
"@tanstack/react-query": "^5.81.5",
Expand All @@ -25,7 +26,6 @@
"@lynx-js/react-rsbuild-plugin": "^0.19.1",
"@lynx-js/rspeedy": "^0.16.5",
"@lynx-js/types": "4.1.0",
"@rozenite/lynx": "workspace:*",
"@rsbuild/plugin-type-check": "1.6.0",
"@types/react": "^19.2.18",
"eslint": "^9.25.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMemo, useState } from '@lynx-js/react';
import { createSection, useRozeniteControlsPlugin } from '@rozenite/controls-plugin';

import { Button, Group, Row } from '../ui.jsx';
import { Button, Group, Row } from '../src/ui.jsx';

/**
* Minimal Controls playground: one section the DevTools panel can read and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type FeatureFlagInput,
} from '@rozenite/feature-flags-plugin';

import { Group, Row } from '../ui.jsx';
import { Group, Row } from '../src/ui.jsx';

const declarations: FeatureFlagInput[] = [
{ key: 'new-splash', value: true, type: 'boolean' },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useController, useForm } from 'react-hook-form';
import { useRozeniteRHFPlugin } from '@rozenite/rhf-plugin';

import { Group, Row } from '../ui.jsx';
import { Group, Row } from '../src/ui.jsx';

type DemoForm = {
email: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';

import { Button, Group, Row } from '../ui.jsx';
import { Button, Group, Row } from '../src/ui.jsx';

const queryClient = new QueryClient();

Expand Down
33 changes: 33 additions & 0 deletions apps/playground-lynx/rozenite.dev/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ControlsPlayground } from './ControlsPlayground.jsx';
import { FeatureFlagsPlayground } from './FeatureFlagsPlayground.jsx';
import { RhfPlayground } from './RhfPlayground.jsx';
import { TanStackQueryPlayground } from './TanStackQueryPlayground.jsx';

/**
* The Lynx dev entry. `rozeniteLynxPlugin()` redirects
* `@rozenite/lynx`'s `<Rozenite />` here in development; none of this is
* reachable in a production bundle.
*
* Every playground panel below both wires a plugin's DevTools hook and
* renders the on-device UI that shows a remote change taking effect, so the
* whole showcase lives under this `rozenite.dev/` directory rather than
* only the hook calls -- `<Rozenite />` sits in `../src/App.tsx` exactly
* where these panels used to render directly. Keeping every panel as a
* sibling file *inside* `rozenite.dev/`, rather than importing them from
* `../src/plugins/`, matters beyond organisation: `RozeniteResolverPlugin`
* only skips its "move this into rozenite.dev.tsx" dev-time advisory for an
* importer whose own path has a `rozenite.dev` segment (see
* `isDevEntryOrigin` in `@rozenite/middleware`'s `production-guard.ts`) --
* an importer one directory outside it would warn on every plugin hook
* call below, even though production is unaffected either way.
*/
export default function RozeniteDevEntry() {
return (
<>
<ControlsPlayground />
<FeatureFlagsPlayground />
<RhfPlayground />
<TanStackQueryPlayground />
</>
);
}
10 changes: 2 additions & 8 deletions apps/playground-lynx/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import './App.css';
import Rozenite from '@rozenite/lynx';
import { RozeniteLogo } from './RozeniteLogo.jsx';
import { ControlsPlayground } from './plugins/ControlsPlayground.jsx';
import { FeatureFlagsPlayground } from './plugins/FeatureFlagsPlayground.jsx';
import { RhfPlayground } from './plugins/RhfPlayground.jsx';
import { TanStackQueryPlayground } from './plugins/TanStackQueryPlayground.jsx';

export function App() {
return (
Expand All @@ -19,10 +16,7 @@ export function App() {
</text>
</view>

<ControlsPlayground />
<FeatureFlagsPlayground />
<RhfPlayground />
<TanStackQueryPlayground />
<Rozenite />
</scroll-view>
);
}
2 changes: 1 addition & 1 deletion apps/playground-lynx/src/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@

"noEmit": true
},
"include": ["./**/*.ts", "./**/*.tsx"]
"include": ["./**/*.ts", "./**/*.tsx", "../rozenite.dev/**/*.ts", "../rozenite.dev/**/*.tsx"]
}
176 changes: 176 additions & 0 deletions docs/adr/0002-lynx-plugins-never-enter-production-bundles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# 0002 — Rozenite plugins never enter Lynx production bundles

**Status:** Accepted

**Related:** [callstackincubator/rozenite#492](https://github.com/callstackincubator/rozenite/issues/492),
[callstackincubator/rozenite#415](https://github.com/callstackincubator/rozenite/issues/415),
[0001](./0001-plugins-never-enter-production-bundles.md)

## Context

ADR 0001 establishes, for React Native, that nothing from a Rozenite plugin
reaches a production bundle unless its author declared it: apps mount
`<Rozenite />` from `@rozenite/react-native` once, all plugin wiring lives in
`rozenite.dev.tsx`, the bundler resolver redirects the seam to that file in
development and to a shipped noop in production, and a production build that
resolves into a plugin package through anything other than a declared
`productionEntries` subpath fails, naming the importing file. The rspack
implementation of that resolver (`RozeniteResolverPlugin`) lives in
`@rozenite/middleware` so that more than one bundler integration can install
it.

Lynx has none of this. 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 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 package index defeats it entirely.

The current rspeedy integration cannot close that gap:

- `rozeniteLynxPlugin` (`packages/lynx/src/rspeedy.ts`) is `apply: 'serve'`
and additionally gated on `NODE_ENV`, so it never runs during
`rspeedy build`. That is correct for the dev server, the DebugRouter
transport and the runtime injection, but it means nothing observes a
production build at all.
- The root export of `@rozenite/lynx` *is* the injected device runtime and
calls `setupRozenite()` at import time. The plugin injects it through
`source.preEntry`; the app never imports it.
- `packages/test-utils` drives Metro only. Nothing can prove a Lynx release
bundle is clean.

Two facts shape what the Lynx design can look like:

- **Some plugins mount components at the root.** A bundler-injected entry
can register hooks, but it has no React tree to mount into. The seam
component is therefore required on Lynx as well, not just a React Native
workaround.
- **ReactLynx runs effects on the background thread only.** Hooks inside a
dev entry rendered from the ReactLynx root are naturally background-only.
The main-thread inertness the runtime needs is already handled by the
`__BACKGROUND__` gate in `packages/lynx/src/install.ts` and does not need
to be repeated in a seam.

## Decision

Lynx gets the same DX and the same enforcement as React Native, through the
same shared resolver plugin, with one package-shape change.

### `<Rozenite />` is exported from `@rozenite/lynx`

`@rozenite/lynx` splits into side-effect-free-by-construction entries:

| Entry | Contents |
|---|---|
| `@rozenite/lynx` | The seam: `<Rozenite />` rendering a statically imported `./dev-entry.js` noop, mirroring `@rozenite/react-native`. React (via ReactLynx) is its only peer. Importing it does nothing. |
| `@rozenite/lynx/runtime` | The injected device runtime (today's root export). `setupRozenite()` and the `__BACKGROUND__` gate live here. `rozeniteLynxPlugin` points `source.preEntry` at this subpath. |
| `@rozenite/lynx/rspeedy` | Unchanged. |

The seam cannot share the root entry with the runtime: an app imports the
seam unconditionally, so a side-effectful root would install the dispatcher
in every production build — the exact leak this ADR exists to prevent.

The README's manual fallback for non-rspeedy pipelines
(`if (__DEV__) require('@rozenite/lynx')`) moves to the `/runtime` subpath.
This is the one user-visible break and gets its own changeset entry.

### 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 in
`build` — via `api.modifyRspackConfig`, by appending the shared
`RozeniteResolverPlugin` from `@rozenite/middleware`. `isDev` derives from
the Rsbuild mode, not `NODE_ENV`. `installDevEntryRedirect` is true only
when Rozenite is enabled.

Semantics match 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 but a declared `productionEntries` subpath fails,
naming the importing file; the same mistake warns in development;
`allowInProduction` is the escape hatch and is logged loudly.

No new rspack mechanics are needed. `beforeResolve` for the dev-entry
redirect and `afterResolve` plus `compilation.errors.push(new
WebpackError(...))` for the guard were verified against rspack for Re.Pack,
and Rsbuild leaves `normalModuleFactory` hooks intact.

### `rozenite.dev.tsx` is identical

Project root, resolved through `resolve.extensions`, flat file or
`rozenite.dev/` directory.

**Deferred:** `rozenite init` scaffolding this file and printing the mount
snippet for Lynx projects, as this section originally promised, has not
landed. `packages/cli`'s `init-command.ts` is entirely React-Native-shaped
today -- Lynx-project detection, an rspeedy config wrapper, and a
Lynx-flavoured mount snippet are all new work, not a small addition to the
existing flow, and tracked separately rather than folded into this change.
Everything else in this ADR -- the seam, the resolver guard, and the
integration check -- does not depend on it: a Lynx project can adopt
`rozeniteLynxPlugin()` and `rozenite.dev.tsx` today by hand, following this
package's README, exactly as a React Native project could before `rozenite
init` supported it.

### Integration gating rides the same resolver

`dist/rozenite.json` now carries `integrations`. The Lynx guard also refuses
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.

### A rspeedy release-bundle bench

`@rozenite/test-utils` gains a rspeedy counterpart to `bundleForRelease()`
that builds a throwaway ReactLynx app in production mode and reports
`rozeniteModules` / `panelModules` from emitted module paths, so the Lynx
guard gets the same non-vacuous tests `docs/agents/release-bundle-testing.md`
requires: a deliberate plugin import fails naming the file, a declared
production entry succeeds, `enabled: false` still guards, and a clean app
with `rozenite.dev.tsx` ships zero `rozenite.dev` modules and zero plugin
`src/**`.

## Consequences

- App authors get one convention across React Native and Lynx: mount
`<Rozenite />` once, wire plugins in `rozenite.dev.tsx`, never write a
`__DEV__` guard.
- Plugin authors get nothing new to do. `productionEntries`,
`allowInProduction` and the manifest are shared, and `integrations` is
already populated.
- `rozeniteLynxPlugin` runs (minimally) during `rspeedy build`. A clean
production build pays one memoized `package.json` walk per resolved
module; a dirty one fails instead of shipping.
- The `@rozenite/lynx` root export changes meaning. Anyone who followed the
manual `require('@rozenite/lynx')` fallback must move to
`@rozenite/lynx/runtime`.
- Verified during implementation: `@lynx-js/react` does not depend on
`react` at all -- it is its own implementation of the React runtime, not
an alias for it -- so the apparent `react` resolution in the playground
came from elsewhere in the workspace, not from `@lynx-js/react`. The Lynx
seam is therefore built against `@lynx-js/react`'s own `jsx-runtime`
(`jsxImportSource: '@lynx-js/react'` at build time, `@lynx-js/react`
external in `packages/lynx/vite.seam.config.ts`), not shared source with
`@rozenite/react-native`'s seam -- as anticipated above, this does not
change the decision, only which JSX runtime the built seam imports.

## Alternatives considered

- **Keep `rozeniteLynxPlugin` serve-only and ship a separate guard plugin.**
Rejected. A guard users can forget to install is the same weak link the
seam removes on the app side. The guard has value only if it is present in
the build the user did not think about.
- **Inject `rozenite.dev.tsx` through `preEntry` instead of a seam.** Rejected.
It cannot mount root components, and it would give Lynx a different
convention from React Native, where injection is impossible (Metro has no
way to add artificial dependencies to an entry point; run-before-main-module
only reorders modules already in the graph).
- **Export the seam from the existing root entry next to the runtime.**
Rejected: the root entry has import-time side effects, so the seam would
ship the dispatcher install to production.
- **A `__DEV__`-folded seam instead of a resolver redirect.** Rejected for
the same reason as in 0001: a bare `require` inside a strict ES module is
fatal under rspack, and folding rests on transform order nothing pins.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ Status values:
|---|---|---|
| [0000](./0000-single-target-discovery-endpoint.md) | One Rozenite endpoint for debug-target discovery | Accepted |
| [0001](./0001-plugins-never-enter-production-bundles.md) | Plugins never enter production bundles | Accepted |
| [0002](./0002-lynx-plugins-never-enter-production-bundles.md) | Rozenite plugins never enter Lynx production bundles | Accepted |
Loading