diff --git a/.changeset/production-plugin-guarantee.md b/.changeset/production-plugin-guarantee.md
new file mode 100644
index 00000000..3769d230
--- /dev/null
+++ b/.changeset/production-plugin-guarantee.md
@@ -0,0 +1,58 @@
+---
+'@rozenite/react-native': minor
+'@rozenite/metro': minor
+'@rozenite/repack': minor
+'@rozenite/middleware': minor
+'@rozenite/tools': minor
+'@rozenite/vite-plugin': minor
+'@rozenite/redux-devtools-plugin': minor
+'@rozenite/feature-flags-plugin': minor
+'@rozenite/rhf-plugin': minor
+'@rozenite/network-activity-plugin': minor
+'@rozenite/require-profiler-plugin': minor
+'rozenite': minor
+---
+
+Guarantee that Rozenite plugins never reach a production bundle, for **Metro and Re.Pack**
+(Lynx support is tracked separately in
+[#492](https://github.com/callstackincubator/rozenite/issues/492)). The rspack resolver plugin that
+enforces this for Re.Pack now lives in `@rozenite/middleware`, so it can be shared with Lynx without
+`@rozenite/repack` becoming a dependency of it.
+
+Until now the
+only thing keeping plugin code out of a release was a shim each plugin wrote by
+hand, which made inclusion survivable rather than impossible and did nothing at
+all for a third-party plugin that exported a hook from its package index.
+
+Apps now install `@rozenite/react-native` and render `` once at the
+app root — unconditionally, with no `__DEV__` guard to write or forget — and
+move every plugin hook call into a `rozenite.dev.tsx` next to their bundler
+config. In development the Metro and Re.Pack resolvers redirect the seam to that
+file; in production it resolves to a shipped noop, so nothing reachable from it
+can enter the bundle. The dev entry may be a single file or a `rozenite.dev/`
+directory, and platform extensions (`rozenite.dev.ios.tsx`,
+`rozenite.dev/index.web.tsx`) work for free. `rozenite init` scaffolds it.
+
+Importing a plugin package from ordinary app code is now a **production build
+error** naming the file that did it, enforced in the resolver rather than by
+convention. The same mistake prints a warning during development, so it surfaces
+while it is being made rather than at release.
+
+A plugin that genuinely needs to run in production declares it: a root
+`register.ts` plus `productionEntries: ['./register']` in its
+`rozenite.config.ts` gets a `./register` export the resolver permits, and nothing
+else in the package. `@rozenite/redux-devtools-plugin` (store enhancer),
+`@rozenite/feature-flags-plugin` (flag evaluation), `@rozenite/rhf-plugin`
+(per-form hook) and `@rozenite/network-activity-plugin` (on-boot recording) now
+ship one — import those symbols from `/register`.
+
+Breaking: `withRozenite(config, { enabled: false })` no longer means "do
+nothing". It still starts no dev server and adds no middleware, but the guard
+stays active, so turning Rozenite off is not a way to opt out of the guarantee.
+Use `allowInProduction: ['some-plugin']` for that, which is logged loudly on
+every build.
+
+Also fixes `withRozeniteRequireProfiler` shipping its instrumentation polyfill
+into release bundles. Metro adds `serializer.getPolyfills` entries to the graph
+by absolute path rather than through module resolution, so the resolver guard
+could never have seen it; it is now skipped when Metro is bundling for release.
diff --git a/apps/playground/package.json b/apps/playground/package.json
index 29e4fcbd..401ea2a2 100644
--- a/apps/playground/package.json
+++ b/apps/playground/package.json
@@ -12,7 +12,7 @@
"web:webpack": "webpack serve --config webpack.config.js --mode development",
"web:webpack:build": "webpack --config webpack.config.js --mode production",
"typecheck": "tsc -p tsconfig.app.json --noEmit",
- "lint": "expo lint --no-cache"
+ "lint": "expo lint --no-cache src rozenite.dev"
},
"dependencies": {
"@dr.pogodin/react-native-fs": "^2.36.2",
@@ -35,6 +35,7 @@
"@rozenite/overlay-plugin": "workspace:*",
"@rozenite/performance-monitor-plugin": "workspace:*",
"@rozenite/plugin-bridge": "workspace:*",
+ "@rozenite/react-native": "workspace:*",
"@rozenite/react-navigation-plugin": "workspace:*",
"@rozenite/redux-devtools-plugin": "workspace:*",
"@rozenite/require-profiler-plugin": "workspace:*",
diff --git a/apps/playground/src/app/useAgentPlaygroundTools.ts b/apps/playground/rozenite.dev/agent-tools.ts
similarity index 89%
rename from apps/playground/src/app/useAgentPlaygroundTools.ts
rename to apps/playground/rozenite.dev/agent-tools.ts
index a26e3d9f..7ba5ae44 100644
--- a/apps/playground/src/app/useAgentPlaygroundTools.ts
+++ b/apps/playground/rozenite.dev/agent-tools.ts
@@ -1,6 +1,11 @@
import { Alert } from 'react-native';
import { useRozeniteInAppAgentTool, type AgentTool } from '@rozenite/agent-bridge';
+// Moved out of src/app/useAgentPlaygroundTools.ts. @rozenite/agent-bridge is
+// not a Rozenite plugin package, so the production guard does not block it —
+// but it is dev-only in exactly the same way as the plugin hooks, so it
+// lives here with the rest of the dev-only wiring.
+
type ShowAlertInput = {
title?: string;
message?: string;
diff --git a/apps/playground/src/app/hooks/usePlaygroundControlsSections.ts b/apps/playground/rozenite.dev/controls-sections.ts
similarity index 95%
rename from apps/playground/src/app/hooks/usePlaygroundControlsSections.ts
rename to apps/playground/rozenite.dev/controls-sections.ts
index 473fab9c..5fe64ba4 100644
--- a/apps/playground/src/app/hooks/usePlaygroundControlsSections.ts
+++ b/apps/playground/rozenite.dev/controls-sections.ts
@@ -1,7 +1,12 @@
import { createSection } from '@rozenite/controls-plugin';
import { useMemo } from 'react';
-import { useControlsPluginStore } from '../stores/controlsPluginStore';
+import { useControlsPluginStore } from '../src/app/stores/controlsPluginStore';
+// Moved out of app code wholesale (was
+// src/app/hooks/usePlaygroundControlsSections.ts): it only builds a
+// dev-tools section descriptor from app state, it reads (never writes)
+// `useControlsPluginStore`, which stays in app code. Registered from both
+// rozenite.dev/index.tsx (native) and rozenite.dev/index.web.tsx.
export const usePlaygroundControlsSections = () => {
const counter = useControlsPluginStore((state) => state.counter);
const releaseLabel = useControlsPluginStore((state) => state.releaseLabel);
diff --git a/apps/playground/rozenite.dev/index.tsx b/apps/playground/rozenite.dev/index.tsx
new file mode 100644
index 00000000..044d9605
--- /dev/null
+++ b/apps/playground/rozenite.dev/index.tsx
@@ -0,0 +1,90 @@
+import * as RNFS from '@dr.pogodin/react-native-fs';
+import { useRozeniteControlsPlugin } from '@rozenite/controls-plugin';
+import { useFileSystemDevTools } from '@rozenite/file-system-plugin';
+import { useRozeniteFeatureFlagsPlugin } from '@rozenite/feature-flags-plugin';
+import { useNetworkActivityDevTools } from '@rozenite/network-activity-plugin';
+import { RozeniteOverlay } from '@rozenite/overlay-plugin';
+import { usePerformanceMonitorDevTools } from '@rozenite/performance-monitor-plugin';
+import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
+import { useReduxDevToolsAgentTools } from '@rozenite/redux-devtools-plugin';
+import { useRequireProfilerDevTools } from '@rozenite/require-profiler-plugin';
+import { useRozeniteSqlitePlugin } from '@rozenite/sqlite-plugin';
+import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin';
+import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
+import { featureFlagsPluginAdapters } from '../src/app/feature-flags-plugin-adapters';
+import { useNetworkTestStore } from '../src/app/stores/networkTestStore';
+import { navigationRef } from '../src/app/navigation/navigationRef';
+import { queryClient } from '../src/app/query-client';
+import { useAgentPlaygroundTools } from './agent-tools';
+import { usePlaygroundControlsSections } from './controls-sections';
+import { NetworkPlaygroundControls } from './network-controls';
+import { sqlitePluginAdapters } from './sqlite-adapters';
+import { storagePluginAdapters } from './storage-adapters';
+
+/**
+ * The native/shared dev entry. `withRozenite()` redirects
+ * `@rozenite/react-native`'s `` here in development; none of
+ * this is reachable in a production bundle.
+ */
+export default function RozeniteDevTools() {
+ const controlsSections = usePlaygroundControlsSections();
+ const isNetworkScreenMounted = useNetworkTestStore((state) => state.isScreenMounted);
+
+ useTanStackQueryDevTools(queryClient);
+ useRozeniteControlsPlugin({
+ sections: controlsSections,
+ });
+ useNetworkActivityDevTools({
+ clientUISettings: {
+ showUrlAsName: true,
+ },
+ });
+ useRozeniteStoragePlugin({
+ storages: storagePluginAdapters,
+ });
+ useRozeniteFeatureFlagsPlugin({
+ providers: featureFlagsPluginAdapters,
+ });
+ useRozeniteSqlitePlugin({
+ adapters: sqlitePluginAdapters,
+ });
+ useReduxDevToolsAgentTools();
+ usePerformanceMonitorDevTools();
+ useRequireProfilerDevTools();
+ useAgentPlaygroundTools();
+ useFileSystemDevTools({
+ rnfs: RNFS,
+ fileTransfer: {
+ import: true,
+ export: true,
+ agent: {
+ import: true,
+ export: true,
+ },
+ },
+ });
+ // The pre-migration code cast this the same way (`ref: navigationRef as
+ // any`) even with a `NavigationContainerRef`-typed ref: `useReactNavigationDevTools`'s
+ // `ref: React.RefObject` doesn't infer
+ // `TNavigationContainerRef` from a route-specific ref, so it always falls
+ // back to comparing against the default `NavigationContainerRef` and
+ // fails the stricter `preload`/`navigate` overloads. Not specific to this
+ // migration's `navigationRef`.
+ useReactNavigationDevTools({
+ ref: navigationRef as any,
+ });
+
+ return (
+ <>
+ {/*
+ The Network screen's own Controls section, registered as a second,
+ independent `useRozeniteControlsPlugin` caller and mounted only while
+ that screen is — exactly as it behaved when the screen called the
+ hook itself. `controlsRegistry` merges every caller's sections, so
+ this appears alongside the app-level ones rather than replacing them.
+ */}
+ {isNetworkScreenMounted && }
+
+ >
+ );
+}
diff --git a/apps/playground/rozenite.dev/index.web.tsx b/apps/playground/rozenite.dev/index.web.tsx
new file mode 100644
index 00000000..4908e31b
--- /dev/null
+++ b/apps/playground/rozenite.dev/index.web.tsx
@@ -0,0 +1,85 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { QueryClient } from '@tanstack/react-query';
+import { useRozeniteControlsPlugin } from '@rozenite/controls-plugin';
+import { useRozeniteFeatureFlagsPlugin } from '@rozenite/feature-flags-plugin';
+import { RozeniteOverlay } from '@rozenite/overlay-plugin';
+import { usePerformanceMonitorDevTools } from '@rozenite/performance-monitor-plugin';
+import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
+import {
+ rozeniteDevToolsEnhancer,
+ useReduxDevToolsAgentTools,
+} from '@rozenite/redux-devtools-plugin';
+import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin';
+import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
+import { useEffect, useRef } from 'react';
+import { featureFlagsPluginAdapters } from '../src/app/feature-flags-plugin-adapters';
+import { usePlaygroundControlsSections } from './controls-sections';
+import { storagePluginAdapters } from './storage-adapters';
+
+// Demo-only: gives the web plugin cards something to show without an app
+// bundle behind them. Moved out of src/app/WebPluginSections.tsx, which is
+// now purely presentational.
+const tanstackQueryClient = new QueryClient();
+
+const reduxStore = configureStore({
+ reducer: (state = { count: 0 }, action: { type: string }) => {
+ if (action.type === 'web/increment') {
+ return { count: state.count + 1 };
+ }
+
+ return state;
+ },
+ enhancers: (getDefaultEnhancers) =>
+ getDefaultEnhancers().concat(
+ rozeniteDevToolsEnhancer({
+ name: 'playground-web-counter',
+ maxAge: 100,
+ }),
+ ),
+});
+
+/**
+ * The web dev entry. `withRozeniteWeb`'s webpack redirect (see
+ * webpack.config.js) sends `@rozenite/react-native`'s `` here in
+ * development for the plain-webpack (`web:webpack`) target; Metro's own
+ * platform resolution does the same for `expo start --web`.
+ *
+ * SQLite is intentionally not wired here: expo-sqlite has upstream issues on
+ * web, same as before this migration (see the SQLite card's copy in
+ * WebPluginSections.tsx).
+ */
+export default function RozeniteDevTools() {
+ // Decorative only, same as before this migration: this web entry has no
+ // real NavigationContainer to attach to, so the ref never resolves.
+ const navigationRef = useRef(null);
+ const controlsSections = usePlaygroundControlsSections();
+
+ useRozeniteStoragePlugin({
+ storages: storagePluginAdapters,
+ });
+ useRozeniteFeatureFlagsPlugin({
+ providers: featureFlagsPluginAdapters,
+ });
+ useReactNavigationDevTools({
+ ref: navigationRef,
+ });
+ useRozeniteControlsPlugin({
+ sections: controlsSections,
+ });
+ usePerformanceMonitorDevTools();
+ useReduxDevToolsAgentTools();
+ useTanStackQueryDevTools(tanstackQueryClient);
+
+ useEffect(() => {
+ reduxStore.dispatch({ type: 'web/increment' });
+ }, []);
+
+ useEffect(() => {
+ tanstackQueryClient.setQueryData(['web-plugin-section', 'demo'], {
+ initializedAt: new Date().toISOString(),
+ status: 'ready',
+ });
+ }, []);
+
+ return ;
+}
diff --git a/apps/playground/rozenite.dev/network-controls.tsx b/apps/playground/rozenite.dev/network-controls.tsx
new file mode 100644
index 00000000..c875a796
--- /dev/null
+++ b/apps/playground/rozenite.dev/network-controls.tsx
@@ -0,0 +1,67 @@
+import { createSection, useRozeniteControlsPlugin } from '@rozenite/controls-plugin';
+import { useMemo } from 'react';
+import { navigationRef } from '../src/app/navigation/navigationRef';
+import { useNetworkTestStore } from '../src/app/stores/networkTestStore';
+
+// Split out of src/app/screens/NetworkTestScreen.tsx: the screen used to
+// build this section from local `transport` state and call
+// `useRozeniteControlsPlugin` (and `navigation.navigate`) directly. The
+// transport state now lives in `useNetworkTestStore` so both the screen and
+// this dev-only section can read/write it, and navigation goes through the
+// module-level `navigationRef` instead of a navigation prop.
+//
+// Registered as its own `useRozeniteControlsPlugin` call, not merged into
+// `usePlaygroundControlsSections`'s array, so it mounts alongside the
+// app-level Controls sections the way two independent callers used to.
+const useNetworkControlsSections = () => {
+ const transport = useNetworkTestStore((state) => state.transport);
+ const setTransport = useNetworkTestStore((state) => state.setTransport);
+
+ return useMemo(
+ () => [
+ createSection({
+ id: 'network-playground',
+ title: 'Network Playground',
+ description:
+ 'Local controls registered from the Network screen, mounted alongside the app-level Controls sections.',
+ items: [
+ {
+ id: 'active-transport',
+ type: 'text' as const,
+ title: 'Active Transport',
+ value: transport,
+ },
+ {
+ id: 'reset-transport',
+ type: 'button' as const,
+ title: 'Reset to fetch',
+ actionLabel: 'Reset',
+ onPress: () => setTransport('fetch'),
+ },
+ {
+ id: 'request-body-test',
+ type: 'button' as const,
+ title: 'Open Request Body Test',
+ actionLabel: 'Open',
+ onPress: () => navigationRef.current?.navigate('RequestBodyTest'),
+ },
+ ],
+ }),
+ ],
+ [setTransport, transport],
+ );
+};
+
+/**
+ * Rendered by the dev entry only while NetworkTestScreen is on screen, which
+ * is the behaviour this section demonstrates: a section registered by a
+ * screen appears and disappears with it, while the app-level sections stay.
+ * It has to be its own component because that mount/unmount is what
+ * registers and unregisters the section, and a hook cannot be called
+ * conditionally.
+ */
+export const NetworkPlaygroundControls = () => {
+ useRozeniteControlsPlugin({ sections: useNetworkControlsSections() });
+
+ return null;
+};
diff --git a/apps/playground/rozenite.dev/sqlite-adapters.ts b/apps/playground/rozenite.dev/sqlite-adapters.ts
new file mode 100644
index 00000000..abb0820e
--- /dev/null
+++ b/apps/playground/rozenite.dev/sqlite-adapters.ts
@@ -0,0 +1,35 @@
+import { createExpoSqliteAdapter } from '@rozenite/sqlite-plugin';
+import {
+ analyticsDatabase,
+ appDatabase,
+ binaryDatabase,
+ testingDatabase,
+} from '../src/app/sqlite-plugin-databases';
+
+// Split out of src/app/sqlite-plugin-databases.ts: the database handles and
+// their seed data are real app resources and stay in app code; building the
+// Rozenite adapter on top of them is dev-only.
+export const sqlitePluginAdapters = [
+ createExpoSqliteAdapter({
+ adapterId: 'expo-sqlite',
+ adapterName: 'Expo SQLite',
+ databases: {
+ app: {
+ name: 'rozenite-app.db',
+ database: appDatabase,
+ },
+ analytics: {
+ name: 'rozenite-analytics.db',
+ database: analyticsDatabase,
+ },
+ testing: {
+ name: 'rozenite-testing.db',
+ database: testingDatabase,
+ },
+ binary: {
+ name: 'rozenite-binary.db',
+ database: binaryDatabase,
+ },
+ },
+ }),
+];
diff --git a/apps/playground/rozenite.dev/storage-adapters.ts b/apps/playground/rozenite.dev/storage-adapters.ts
new file mode 100644
index 00000000..8a362c14
--- /dev/null
+++ b/apps/playground/rozenite.dev/storage-adapters.ts
@@ -0,0 +1,53 @@
+import * as SecureStore from 'expo-secure-store';
+import {
+ createAsyncStorageAdapter,
+ createExpoSecureStorageAdapter,
+ createMMKVStorageAdapter,
+} from '@rozenite/storage-plugin';
+import { mmkvStorages } from '../src/app/mmkv-storages';
+import {
+ asyncStorageV2,
+ asyncStorageV3Instances,
+ getKnownSecureStoreKeys,
+} from '../src/app/storage-plugin-adapters';
+
+// Split out of src/app/storage-plugin-adapters.ts: the app-owned storage
+// instances and secure-store key registry stay in app code (screens use
+// them directly); building the Rozenite adapters on top of them is
+// dev-only.
+export const storagePluginAdapters = [
+ createMMKVStorageAdapter({
+ adapterId: 'mmkv',
+ adapterName: 'MMKV',
+ storages: mmkvStorages,
+ blacklist: {
+ 'user-storage': /sensitiveToken/,
+ },
+ }),
+ createAsyncStorageAdapter({
+ storages: {
+ 'v2-default': {
+ storage: asyncStorageV2,
+ name: 'AsyncStorage v2 (default)',
+ },
+ 'v3-auth': {
+ storage: asyncStorageV3Instances.auth,
+ name: 'AsyncStorage v3 (auth)',
+ },
+ 'v3-cache': {
+ storage: asyncStorageV3Instances.cache,
+ name: 'AsyncStorage v3 (cache)',
+ },
+ },
+ adapterId: 'async-storage',
+ adapterName: 'AsyncStorage',
+ }),
+ createExpoSecureStorageAdapter({
+ storage: SecureStore,
+ keys: async () => getKnownSecureStoreKeys(),
+ adapterId: 'secure-store',
+ adapterName: 'Expo SecureStore',
+ storageId: 'secure-default',
+ storageName: 'Default SecureStore',
+ }),
+];
diff --git a/apps/playground/src/app/App.tsx b/apps/playground/src/app/App.tsx
index 21915008..409b004a 100644
--- a/apps/playground/src/app/App.tsx
+++ b/apps/playground/src/app/App.tsx
@@ -1,18 +1,10 @@
-import { NavigationContainer, NavigationContainerRef } from '@react-navigation/native';
+import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
-import { useRozeniteControlsPlugin } from '@rozenite/controls-plugin';
-import { useRozeniteFeatureFlagsPlugin } from '@rozenite/feature-flags-plugin';
-import { usePerformanceMonitorDevTools } from '@rozenite/performance-monitor-plugin';
-import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
-import { useReduxDevToolsAgentTools } from '@rozenite/redux-devtools-plugin';
-import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin';
-import { useRozeniteSqlitePlugin } from '@rozenite/sqlite-plugin';
-import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { useRef } from 'react';
+import Rozenite from '@rozenite/react-native';
+import { QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Provider } from 'react-redux';
-import { usePlaygroundControlsSections } from './hooks/usePlaygroundControlsSections';
+import { navigationRef } from './navigation/navigationRef';
import { BottomTabNavigator } from './navigation/BottomTabNavigator';
import { SuccessiveScreensNavigator } from './navigation/SuccessiveScreensNavigator';
import { routes } from './navigation/routes';
@@ -30,60 +22,16 @@ import { RequireProfilerTestScreen } from './screens/RequireProfilerTestScreen';
import { FileSystemTestScreen } from './screens/FileSystemTestScreen';
import { ReactHookFormPluginScreen } from './screens/ReactHookFormPluginScreen';
import { StoragePluginScreen } from './screens/StoragePluginScreen';
-import { storagePluginAdapters } from './storage-plugin-adapters';
import { FeatureFlagsPluginScreen } from './screens/FeatureFlagsPluginScreen';
-import { featureFlagsPluginAdapters } from './feature-flags-plugin-adapters';
-import { sqlitePluginAdapters } from './sqlite-plugin-databases';
import { primaryStore } from './store';
-import { useRequireProfilerDevTools } from '@rozenite/require-profiler-plugin';
-import { RozeniteOverlay } from '@rozenite/overlay-plugin';
-import { useAgentPlaygroundTools } from './useAgentPlaygroundTools';
-import { useNetworkActivityDevTools } from '@rozenite/network-activity-plugin';
-import { useFileSystemDevTools } from '@rozenite/file-system-plugin';
-import * as RNFS from '@dr.pogodin/react-native-fs';
+import { queryClient } from './query-client';
import { ThemeProvider } from './theme/ThemeContext';
import { useTheme } from './theme/useTheme';
-const queryClient = new QueryClient();
const Stack = createNativeStackNavigator();
const Wrapper = () => {
const { theme } = useTheme();
- const controlsSections = usePlaygroundControlsSections();
-
- useTanStackQueryDevTools(queryClient);
- useRozeniteControlsPlugin({
- sections: controlsSections,
- });
- useNetworkActivityDevTools({
- clientUISettings: {
- showUrlAsName: true,
- },
- });
- useRozeniteStoragePlugin({
- storages: storagePluginAdapters,
- });
- useRozeniteFeatureFlagsPlugin({
- providers: featureFlagsPluginAdapters,
- });
- useRozeniteSqlitePlugin({
- adapters: sqlitePluginAdapters,
- });
- useReduxDevToolsAgentTools();
- usePerformanceMonitorDevTools();
- useRequireProfilerDevTools();
- useAgentPlaygroundTools();
- useFileSystemDevTools({
- rnfs: RNFS,
- fileTransfer: {
- import: true,
- export: true,
- agent: {
- import: true,
- export: true,
- },
- },
- });
return (
{
- const navigationRef = useRef>(null);
-
- useReactNavigationDevTools({
- ref: navigationRef as any,
- });
-
return (
@@ -186,7 +128,7 @@ export const App = () => {
-
+
diff --git a/apps/playground/src/app/App.web.tsx b/apps/playground/src/app/App.web.tsx
index a4b619a2..cf257dfb 100644
--- a/apps/playground/src/app/App.web.tsx
+++ b/apps/playground/src/app/App.web.tsx
@@ -1,3 +1,4 @@
+import Rozenite from '@rozenite/react-native';
import { SafeAreaView, ScrollView, StyleSheet, Text } from 'react-native';
import { ThemeProvider } from './theme/ThemeContext';
import { useTheme } from './theme/useTheme';
@@ -36,6 +37,7 @@ const AppContent = () => {
+
);
};
diff --git a/apps/playground/src/app/WebPluginSections.tsx b/apps/playground/src/app/WebPluginSections.tsx
index 33618072..35a59e36 100644
--- a/apps/playground/src/app/WebPluginSections.tsx
+++ b/apps/playground/src/app/WebPluginSections.tsx
@@ -1,42 +1,12 @@
-import { configureStore } from '@reduxjs/toolkit';
-import { QueryClient } from '@tanstack/react-query';
-import { useRozeniteControlsPlugin } from '@rozenite/controls-plugin';
-import { useRozeniteFeatureFlagsPlugin } from '@rozenite/feature-flags-plugin';
-import { RozeniteOverlay } from '@rozenite/overlay-plugin';
-import { usePerformanceMonitorDevTools } from '@rozenite/performance-monitor-plugin';
-import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
-import {
- rozeniteDevToolsEnhancer,
- useReduxDevToolsAgentTools,
-} from '@rozenite/redux-devtools-plugin';
-import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin';
-import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
-import { useEffect, useRef, type ReactNode } from 'react';
+import type { ReactNode } from 'react';
import { StyleSheet, Text, View } from 'react-native';
-import { usePlaygroundControlsSections } from './hooks/usePlaygroundControlsSections';
-import { storagePluginAdapters } from './storage-plugin-adapters';
-import { featureFlagsPluginAdapters } from './feature-flags-plugin-adapters';
import { useTheme } from './theme/useTheme';
-const tanstackQueryClient = new QueryClient();
-
-const reduxStore = configureStore({
- reducer: (state = { count: 0 }, action: { type: string }) => {
- if (action.type === 'web/increment') {
- return { count: state.count + 1 };
- }
-
- return state;
- },
- enhancers: (getDefaultEnhancers) =>
- getDefaultEnhancers().concat(
- rozeniteDevToolsEnhancer({
- name: 'playground-web-counter',
- maxAge: 100,
- }),
- ),
-});
-
+// Purely presentational — the plugin hooks that used to live in these
+// components (and the demo Redux store they used) moved to
+// rozenite.dev/index.web.tsx, so this file has no `@rozenite/*` import of
+// any kind. This page is documentation: every card and its copy reads
+// exactly as it did before the split.
type PluginCardProps = {
title: string;
packageName: string;
@@ -69,10 +39,6 @@ const PluginCard = ({ title, packageName, description, notes, children }: Plugin
};
export const StoragePluginSection = () => {
- useRozeniteStoragePlugin({
- storages: storagePluginAdapters,
- });
-
return (
{
};
export const FeatureFlagsPluginSection = () => {
- useRozeniteFeatureFlagsPlugin({
- providers: featureFlagsPluginAdapters,
- });
-
return (
{
};
export const ReactNavigationPluginSection = () => {
- const navigationRef = useRef(null);
-
- useReactNavigationDevTools({
- ref: navigationRef,
- });
-
return (
{
};
export const ControlsPluginSection = () => {
- const sections = usePlaygroundControlsSections();
-
- useRozeniteControlsPlugin({
- sections,
- });
-
return (
{
packageName="@rozenite/overlay-plugin"
description="Alignment grids and image comparison overlays driven from DevTools; works with React Native Web views in development."
notes={['Mounting RozeniteOverlay enables the plugin runtime bridge.']}
- >
-
-
+ />
);
};
export const PerformanceMonitorPluginSection = () => {
- usePerformanceMonitorDevTools();
-
return (
{
};
export const ReduxDevToolsPluginSection = () => {
- useReduxDevToolsAgentTools();
-
- useEffect(() => {
- reduxStore.dispatch({ type: 'web/increment' });
- }, []);
-
return (
{
};
export const TanStackQueryPluginSection = () => {
- useTanStackQueryDevTools(tanstackQueryClient);
-
- useEffect(() => {
- tanstackQueryClient.setQueryData(['web-plugin-section', 'demo'], {
- initializedAt: new Date().toISOString(),
- status: 'ready',
- });
- }, []);
-
return (
) and rozenite.dev's dev entry (passed to
+// useReactNavigationDevTools({ ref }) and used by the Network Playground
+// controls section to navigate without a navigation prop).
+//
+// React Navigation's own `createNavigationContainerRef()`
+// was tried first, but `NavigationContainerRefWithCurrent`
+// (its return type) is not assignable to `useReactNavigationDevTools`'s
+// `ref: React.RefObject`, so this falls back
+// to a plain `createRef`, per the migration brief. Even so,
+// `useReactNavigationDevTools` never actually infers `TNavigationContainerRef`
+// from a route-specific ref — it always compares against the default
+// `NavigationContainerRef` and fails the stricter, route-specific
+// `preload`/`navigate` overloads. This isn't new: the pre-migration code hit
+// the same thing with a `NavigationContainerRef`-typed ref and cast with
+// `ref: navigationRef as any` at the call site (see rozenite.dev/index.tsx) —
+// this ref stays properly typed for its other consumer, network-controls.ts's
+// `navigationRef.current?.navigate(...)`.
+export const navigationRef = createRef>();
diff --git a/apps/playground/src/app/query-client.ts b/apps/playground/src/app/query-client.ts
new file mode 100644
index 00000000..08fe0887
--- /dev/null
+++ b/apps/playground/src/app/query-client.ts
@@ -0,0 +1,6 @@
+import { QueryClient } from '@tanstack/react-query';
+
+// Shared between App.tsx (QueryClientProvider) and rozenite.dev's dev entry
+// (useTanStackQueryDevTools), which is why it lives at module scope in its
+// own file rather than inside either of them.
+export const queryClient = new QueryClient();
diff --git a/apps/playground/src/app/screens/FeatureFlagsPluginScreen.tsx b/apps/playground/src/app/screens/FeatureFlagsPluginScreen.tsx
index 80269d5d..e2cbbf5c 100644
--- a/apps/playground/src/app/screens/FeatureFlagsPluginScreen.tsx
+++ b/apps/playground/src/app/screens/FeatureFlagsPluginScreen.tsx
@@ -1,10 +1,16 @@
import { useCallback, useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
-import type { FeatureFlag } from '@rozenite/feature-flags-plugin';
import { Button, KeyValueList, PluginHeader, Row, Screen } from '../components/ui';
import { featureFlagsOverrides, featureFlagsPluginAdapter } from '../feature-flags-plugin-adapters';
import { useTheme } from '../theme/useTheme';
+// `/register` (feature-flags-plugin-adapters.ts's import) deliberately
+// doesn't export the general-purpose `FeatureFlag` result type — only the
+// adapter/override constructors it needs to declare production-safe. Derive
+// the same type from the adapter instance instead of reaching for the
+// plugin's dev-only entry point just for a type.
+type FeatureFlag = Awaited>[number];
+
type ThemeConfig = {
accentColor?: string;
roundedCorners?: boolean;
diff --git a/apps/playground/src/app/screens/NetworkTestScreen.tsx b/apps/playground/src/app/screens/NetworkTestScreen.tsx
index 2f6cd831..b54d58ab 100644
--- a/apps/playground/src/app/screens/NetworkTestScreen.tsx
+++ b/apps/playground/src/app/screens/NetworkTestScreen.tsx
@@ -1,5 +1,4 @@
-import { createSection, useRozeniteControlsPlugin } from '@rozenite/controls-plugin';
-import { useCallback, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Text } from 'react-native';
import EventSource from 'react-native-sse';
import { useNavigation } from '@react-navigation/native';
@@ -13,13 +12,12 @@ import {
SegmentedTabs,
} from '../components/ui';
import { NavigationProp } from '../navigation/types';
+import { type Transport, useNetworkTestStore } from '../stores/networkTestStore';
import { useTheme } from '../theme/useTheme';
import { api } from '../utils/network-activity/api';
import { expoFetchApi } from '../utils/network-activity/expo';
import { nitroApi } from '../utils/network-activity/nitro';
-type Transport = 'fetch' | 'expo' | 'nitro';
-
type ActionResult = {
title: string;
status: number;
@@ -127,7 +125,8 @@ const SSE_URL = 'https://stream.wikimedia.org/v2/stream/recentchange';
export const NetworkTestScreen = () => {
const { theme } = useTheme();
const navigation = useNavigation();
- const [transport, setTransport] = useState('fetch');
+ const transport = useNetworkTestStore((state) => state.transport);
+ const setTransport = useNetworkTestStore((state) => state.setTransport);
const [result, setResult] = useState(null);
const [pending, setPending] = useState(false);
const [wsConnected, setWsConnected] = useState(false);
@@ -137,6 +136,17 @@ export const NetworkTestScreen = () => {
const wsRef = useRef(null);
const sseRef = useRef(null);
+ // Registering the Network Playground controls section is dev-only work and
+ // lives in rozenite.dev, but *when* it is registered is this screen's
+ // behaviour to define, so the screen still owns the mounted flag.
+ const setScreenMounted = useNetworkTestStore((state) => state.setScreenMounted);
+
+ useEffect(() => {
+ setScreenMounted(true);
+
+ return () => setScreenMounted(false);
+ }, [setScreenMounted]);
+
const actions = TRANSPORT_ACTIONS[transport];
const run = useCallback((action?: () => Promise) => {
@@ -188,42 +198,6 @@ export const NetworkTestScreen = () => {
sseRef.current = es;
}, []);
- const networkControlsSections = useMemo(
- () => [
- createSection({
- id: 'network-playground',
- title: 'Network Playground',
- description:
- 'Local controls registered from the Network screen, mounted alongside the app-level Controls sections.',
- items: [
- {
- id: 'active-transport',
- type: 'text' as const,
- title: 'Active Transport',
- value: transport,
- },
- {
- id: 'reset-transport',
- type: 'button' as const,
- title: 'Reset to fetch',
- actionLabel: 'Reset',
- onPress: () => setTransport('fetch'),
- },
- {
- id: 'request-body-test',
- type: 'button' as const,
- title: 'Open Request Body Test',
- actionLabel: 'Open',
- onPress: () => navigation.navigate('RequestBodyTest'),
- },
- ],
- }),
- ],
- [navigation, transport],
- );
-
- useRozeniteControlsPlugin({ sections: networkControlsSections });
-
return (
{
};
export const getKnownSecureStoreKeys = () => [...secureStoreKnownKeys.values()];
-
-export const storagePluginAdapters = [
- createMMKVStorageAdapter({
- adapterId: 'mmkv',
- adapterName: 'MMKV',
- storages: mmkvStorages,
- blacklist: {
- 'user-storage': /sensitiveToken/,
- },
- }),
- createAsyncStorageAdapter({
- storages: {
- 'v2-default': {
- storage: asyncStorageV2,
- name: 'AsyncStorage v2 (default)',
- },
- 'v3-auth': {
- storage: asyncStorageV3Instances.auth,
- name: 'AsyncStorage v3 (auth)',
- },
- 'v3-cache': {
- storage: asyncStorageV3Instances.cache,
- name: 'AsyncStorage v3 (cache)',
- },
- },
- adapterId: 'async-storage',
- adapterName: 'AsyncStorage',
- }),
- createExpoSecureStorageAdapter({
- storage: SecureStore,
- keys: async () => getKnownSecureStoreKeys(),
- adapterId: 'secure-store',
- adapterName: 'Expo SecureStore',
- storageId: 'secure-default',
- storageName: 'Default SecureStore',
- }),
-];
diff --git a/apps/playground/src/app/store.ts b/apps/playground/src/app/store.ts
index 20764de7..7d02f6e4 100644
--- a/apps/playground/src/app/store.ts
+++ b/apps/playground/src/app/store.ts
@@ -2,7 +2,10 @@ import 'react-native-get-random-values';
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './store/counterSlice';
-import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin';
+// This store is real app state created at module scope, so it needs the
+// enhancer at production runtime — `/register` is the declared production
+// entry that the build-time guard permits from ordinary app code.
+import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin/register';
const createCounterStore = (name: string) =>
configureStore({
diff --git a/apps/playground/src/app/stores/networkTestStore.ts b/apps/playground/src/app/stores/networkTestStore.ts
new file mode 100644
index 00000000..03f89383
--- /dev/null
+++ b/apps/playground/src/app/stores/networkTestStore.ts
@@ -0,0 +1,26 @@
+import { create } from 'zustand';
+
+export type Transport = 'fetch' | 'expo' | 'nitro';
+
+type NetworkTestState = {
+ transport: Transport;
+ setTransport: (transport: Transport) => void;
+ /**
+ * Whether NetworkTestScreen is on screen. The Network Playground controls
+ * section is registered only while it is, which is what the section is
+ * there to demonstrate: sections registered by a screen appear and
+ * disappear with it, alongside the app-level ones that never do.
+ */
+ isScreenMounted: boolean;
+ setScreenMounted: (isScreenMounted: boolean) => void;
+};
+
+// Holds NetworkTestScreen's active-transport selection so it can be read and
+// reset from rozenite.dev's Network Playground controls section as well as
+// from the screen itself.
+export const useNetworkTestStore = create((set) => ({
+ transport: 'fetch',
+ setTransport: (transport) => set({ transport }),
+ isScreenMounted: false,
+ setScreenMounted: (isScreenMounted) => set({ isScreenMounted }),
+}));
diff --git a/apps/playground/tsconfig.app.json b/apps/playground/tsconfig.app.json
index 0b9ffb6a..7ff8b564 100644
--- a/apps/playground/tsconfig.app.json
+++ b/apps/playground/tsconfig.app.json
@@ -7,7 +7,6 @@
"emitDeclarationOnly": false,
"noEmit": true,
"outDir": "dist",
- "rootDir": "src",
"tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo",
"jsx": "react-jsx",
"module": "esnext",
@@ -26,7 +25,9 @@
"src/**/*.js",
"src/**/*.jsx",
"app/**/*.ts",
- "app/**/*.tsx"
+ "app/**/*.tsx",
+ "rozenite.dev/**/*.ts",
+ "rozenite.dev/**/*.tsx"
],
"exclude": [
"out-tsc",
diff --git a/apps/playground/webpack.config.js b/apps/playground/webpack.config.js
index bf0db6a3..50a5caa2 100644
--- a/apps/playground/webpack.config.js
+++ b/apps/playground/webpack.config.js
@@ -6,7 +6,36 @@ const { withRozeniteWeb } = require('@rozenite/web/webpack');
const appDirectory = __dirname;
const workspaceRoot = path.resolve(appDirectory, '../..');
const entryFile = path.resolve(appDirectory, 'src/main.tsx');
+
+// `web:webpack` is plain webpack — neither @rozenite/metro nor @rozenite/repack
+// covers it, so would silently resolve to the seam's shipped noop
+// here and every web plugin demo would go dead. Redirect its internal dev-entry
+// request to this project's rozenite.dev, the same way withRozenite() does for
+// Metro and Re.Pack.
+//
+// The naive check is `resource.context.includes('@rozenite/react-native')`,
+// on the assumption that pnpm's `nodeLinker: hoisted` puts a real directory at
+// `node_modules/@rozenite/react-native`. That assumption doesn't hold here:
+// even in hoisted mode, pnpm still symlinks workspace-local packages
+// (`node_modules/@rozenite/react-native -> ../../../../packages/react-native`),
+// and webpack's default `resolve.symlinks: true` resolves the *real* path
+// before this hook ever sees it — verified empirically with a scratch webpack
+// build using a symlinked workspace-style package: `resource.context` came back
+// as the symlink target (`.../packages/`), which does not contain the
+// substring `@rozenite/react-native` at all. So instead of a substring check,
+// resolve the seam package's real root once (mirroring how the Metro/Re.Pack
+// guard identifies its own seam) and compare directories directly.
+const seamPackageDir = (() => {
+ try {
+ return path.dirname(
+ require.resolve('@rozenite/react-native/package.json', { paths: [appDirectory] }),
+ );
+ } catch {
+ return null;
+ }
+})();
const srcDirectory = path.resolve(appDirectory, 'src');
+const rozeniteDevDirectory = path.resolve(appDirectory, 'rozenite.dev');
const distDirectory = path.resolve(appDirectory, 'dist');
const reactNativeDirectory = path.resolve(workspaceRoot, 'node_modules/react-native');
const localReactNativeDirectory = path.resolve(appDirectory, 'node_modules/react-native');
@@ -36,7 +65,13 @@ const htmlTemplate = ({ htmlWebpackPlugin }) => `
const babelLoaderConfiguration = {
test: /\.[jt]sx?$/,
- include: [entryFile, srcDirectory, reactNativeDirectory, localReactNativeDirectory],
+ include: [
+ entryFile,
+ srcDirectory,
+ rozeniteDevDirectory,
+ reactNativeDirectory,
+ localReactNativeDirectory,
+ ],
use: {
loader: 'babel-loader',
options: {
@@ -99,6 +134,16 @@ module.exports = (_, argv = {}) => {
__DEV__: JSON.stringify(!isProduction),
'process.env.NODE_ENV': JSON.stringify(mode),
}),
+ new webpack.NormalModuleReplacementPlugin(/^\.\/dev-entry(\.js)?$/, (resource) => {
+ if (
+ seamPackageDir &&
+ resource.context &&
+ (resource.context === seamPackageDir ||
+ resource.context.startsWith(seamPackageDir + path.sep))
+ ) {
+ resource.request = path.resolve(appDirectory, 'rozenite.dev');
+ }
+ }),
],
devServer: {
historyApiFallback: true,
diff --git a/docs/adr/0001-plugins-never-enter-production-bundles.md b/docs/adr/0001-plugins-never-enter-production-bundles.md
new file mode 100644
index 00000000..2f8c2295
--- /dev/null
+++ b/docs/adr/0001-plugins-never-enter-production-bundles.md
@@ -0,0 +1,184 @@
+# 0001 — Plugins never enter production bundles
+
+**Status:** Accepted
+
+**Related:** [callstackincubator/rozenite#415](https://github.com/callstackincubator/rozenite/issues/415), [callstackincubator/rozenite#445](https://github.com/callstackincubator/rozenite/pull/445)
+
+## Context
+
+Every Rozenite plugin hand-wrote a `react-native.ts` shim that re-declared its
+export surface, re-sniffed the environment, and hand-wrote a no-op twin per
+function — the only thing keeping plugin code out of production bundles. That
+made inclusion *survivable* rather than *impossible*:
+
+- It rested on transform-order luck: elimination depended on
+ `process.env.NODE_ENV` inlining plus Metro's transform ordering, which
+ nothing pinned.
+- A wrong stub was silent — a stub returning the wrong shape broke a
+ production build in a way nothing caught.
+- It only worked for plugins that opted in. A third-party plugin exporting a
+ hook straight from its package index defeated the whole design, and there
+ was no framework-level guarantee against that.
+
+The goal (#415): nothing reaches production except what its author
+explicitly declared for production, uniform across official and third-party
+plugins, requiring no cooperation from a plugin author beyond the manifest
+they already ship.
+
+Two mechanisms that look like they could fix this cannot:
+
+- **A `development` export condition.** `metro-config` defaults
+ `unstable_conditionNames: []`, and React Native's preset adds only
+ `require`/`import`/`react-native`. A `development` condition never matches
+ under Metro.
+- **A resolver that redirects real → stub in production.** `withRozenite`
+ historically returned the config untouched when `enabled === false` or
+ when bundling for release — production bundling was exactly the case where
+ Rozenite's Metro config did not run, so there was nothing to redirect with.
+
+**Why Metro cannot inject a dev entry.** An earlier design considered having
+the bundler itself inject plugin wiring as an extra entry point in
+development. Metro has no way to add artificial dependencies to an entry
+point — a config transformer can shape *how* the graph resolves, not add
+edges into it that the entry file didn't ask for. Metro's
+`runBeforeMainModule` looks like it could serve this purpose, but it only
+reorders modules already reachable from the graph; it cannot pull in a file
+nothing imports. Concretely, there is no hook that turns
+`config.transformer.someOption = 'rozenite.dev'` into "and also require this
+file before running the app". This is why the app-side seam package (below)
+exists at all: the only place code can be added to a bundle is a real import
+somewhere in the graph, so Rozenite ships one.
+
+## Decision
+
+1. **A new app-side seam package, `@rozenite/react-native`.** Apps render
+ `` once at the root, unconditionally — there is no `__DEV__`
+ guard for a user to write or forget. It statically imports a real noop it
+ ships (`./dev-entry.js`); `react` is its only peer dependency. No
+ `__DEV__` guard exists on the seam's own side either: `__DEV__ ?
+ require('…') : null` is a bare `require` in a `"type": "module"` package,
+ which is fatal under rspack's harmony-module handling. A static import
+ plus a resolver decision works in both bundlers, and shipping a real noop
+ (rather than relying purely on the redirect) keeps failure modes
+ graceful — no resolver installed, or no `rozenite.dev` file, degrades to
+ "renders nothing" instead of an unresolvable specifier or a broken build.
+
+2. **All plugin wiring lives in `rozenite.dev.tsx`**, an ordinary project
+ file (or a `rozenite.dev/` directory, with platform extensions working
+ for free — `rozenite.dev.ios.tsx`, `rozenite.dev/index.web.tsx`). In
+ development, the bundler's resolver redirects the seam's `./dev-entry.js`
+ request to this file, resolved through the host resolver so the project's
+ own `sourceExts`/`resolve.extensions` and platform extensions apply. All
+ wiring living in one project-owned file — rather than scattered across
+ whatever component happens to need a plugin's hook — is what makes the
+ redirect a single resolver decision instead of a search.
+
+3. **The production guard is installed unconditionally by `withRozenite`
+ (Metro) and its Re.Pack equivalent, in both `enabled: true` and
+ `enabled: false`.** A production build that resolves into a Rozenite
+ plugin package throws, naming the offending file. This is a behavior
+ change: `enabled: false` used to mean "do nothing"; it now means "no dev
+ server, guard still active". Turning Rozenite off is not a way to opt out
+ of the guarantee — that path is exactly the production path the guard
+ exists for. The same mistake warns (not throws) in development, so it
+ surfaces while it is being made rather than at release time.
+
+4. **`productionEntries`** is the escape hatch for a plugin that genuinely
+ needs a touchpoint running in production — a hook called once per form
+ instance (`rhf-plugin`), a store enhancer (`redux-devtools-plugin`), an
+ override lookup a running app consults (`feature-flags-plugin`,
+ `network-activity-plugin`). A plugin declares
+ `productionEntries: ['./register']` in `rozenite.config.ts`; the build
+ exposes that export subpath and the resolver permits it — and only it —
+ to resolve in production.
+
+ This is **declared, not verified**: the resolver does not traverse a
+ declared entry's import graph to confirm it is "really" safe. Any such
+ rule is either loose enough to prove nothing or tight enough to block
+ legitimate code, and both teach people to ignore the check. The
+ declaration is the author's explicit statement, in the same category as
+ `sideEffects: false` or `"type": "module"` — a wrong declaration is a bug
+ to report, not an attack to defend against. The one thing the resolver
+ does verify is that a declared entry actually resolves, so a typo reads
+ as a build error instead of silently meaning "declared nothing".
+
+5. **`allowInProduction: ['some-plugin']`** is the outer escape hatch,
+ logged loudly on every build it applies to. Without one, the first
+ person the guard blocks incorrectly would fork the config and lose the
+ guarantee entirely; with one, defeating the guarantee for a package is
+ visible in every build log rather than silent.
+
+6. **The rspack resolver plugin lives in `@rozenite/middleware`, not
+ `@rozenite/repack`.** Re.Pack and (per #492) Lynx both need the identical
+ dev-entry redirect and production guard installed on an rspack
+ compiler, and neither should have to depend on the other to get it.
+ Putting the plugin in the middleware — which both already depend on for
+ the guard's shared core (`findRozenitePluginForFile`,
+ `formatProductionGuardError`, etc.) — means `@rozenite/lynx` (#492) can
+ install it through Rsbuild's `modifyRspackConfig` directly. The plugin
+ keeps hand-written structural types for the slice of the
+ `NormalModuleFactory`-hooks surface it touches and imports nothing from
+ `@rspack/core`, so pulling it into the middleware adds no rspack
+ dependency there.
+
+7. **Metro and Re.Pack cannot drift.** Both implement the same decision
+ table (importer inside the plugin → allow; resolved file outside any
+ plugin → allow; plugin in `allowInProduction` → allow; resolved file is a
+ declared entry → allow; otherwise throw in production / warn in
+ development), and both call into the same shared core in
+ `@rozenite/middleware` for the plugin lookup and the two user-facing
+ messages, so the message and the rule read identically regardless of
+ bundler.
+
+## Consequences
+
+- Any plugin resolution in a production build is by definition a bypass —
+ there is no origin rule, path convention, or resolution-chain tracking to
+ keep in sync, because in a correct production build the seam already
+ resolves to the noop and no legitimate resolution into a plugin package
+ can occur at all.
+- This applies uniformly to third-party plugins with no cooperation beyond
+ the manifest (`dist/rozenite.json`) every plugin already ships one of.
+- `enabled: false` is a breaking behavior change for any project relying on
+ it to fully disable Rozenite, including the guard.
+- `withRozeniteRequireProfiler`'s Metro polyfill injection
+ (`serializer.getPolyfills`) reaches the bundle by absolute path rather
+ than through module resolution, so the resolver guard structurally cannot
+ see it. That gap is closed separately, by having the transformer itself
+ skip when Metro is bundling for release.
+- A plugin's declared `productionEntries` must themselves be inert in
+ production — the resolver permits the import because the author declared
+ it, so whatever the entry file exports is what runs in a shipped app. Each
+ plugin needing one re-exports from its own `react-native.ts` (which
+ already folds to a no-op once `NODE_ENV` is inlined) rather than from
+ `src/**` directly, so there remains one definition of the production
+ behavior instead of a second copy that can drift.
+- `@rozenite/middleware` gains one more export surface
+ (`RozeniteResolverPlugin`) consumed by both `@rozenite/repack` today and
+ `@rozenite/lynx` later, without gaining an rspack dependency itself.
+- Lynx is explicitly out of scope here — see
+ [callstackincubator/rozenite#492](https://github.com/callstackincubator/rozenite/issues/492),
+ which depends on this ADR's decisions landing first and adds its own ADR
+ for the seam/runtime export split and the build-mode guard specific to
+ Rsbuild.
+
+## Alternatives considered
+
+- **Generated dev/production entry points, `*.stub.ts` siblings, and a
+ type-level stub/implementation compatibility check** (the original #402
+ RFC). Once inclusion is a build error, "make inclusion safe" stops being a
+ requirement, so the generated-stub machinery, its return-type table, and
+ the type-level compatibility check it needed are no longer necessary.
+- **`NODE_ENV` folding as the sole elimination mechanism.** Still true at
+ the language level (`__DEV__`/`NODE_ENV` are what actually deletes code
+ from a bundle), but it cannot be the *guarantee* — it depends on
+ transform ordering nothing pins, and it does nothing for a plugin that
+ never bothered to write a shim in the first place.
+- **A CI assertion that Metro's production graph contains no real plugin
+ modules.** Superseded by the resolver guard itself: a build-time throw
+ during every build, not a separate check that could be skipped or run out
+ of date with the code it audits.
+- **Bundler-injected dev entries instead of an app-side seam.** Ruled out
+ for Metro (see Context: no way to add artificial dependencies to an entry
+ point) and, per #492, unnecessary for Lynx once the seam pattern already
+ exists.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 08c6c69a..fdc4ac84 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -21,3 +21,4 @@ Status values:
| ADR | Title | Status |
|---|---|---|
| [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 |
diff --git a/docs/agents/release-bundle-testing.md b/docs/agents/release-bundle-testing.md
index 430c7b53..5844171e 100644
--- a/docs/agents/release-bundle-testing.md
+++ b/docs/agents/release-bundle-testing.md
@@ -148,7 +148,7 @@ as the Vitest timeout.
## What it does not cover
-`isBundling()` in `packages/metro/src/is-bundling.ts` sniffs `process.argv`
+`isBundling()` in `packages/tools/src/is-bundling.ts` sniffs `process.argv`
to detect `react-native bundle` / `expo export`. The bench drives Metro
directly, so it cannot exercise that path; it is covered by unit tests
instead.
diff --git a/packages/cli/src/__tests__/dev-entry-scaffold.test.ts b/packages/cli/src/__tests__/dev-entry-scaffold.test.ts
new file mode 100644
index 00000000..9fa47ca7
--- /dev/null
+++ b/packages/cli/src/__tests__/dev-entry-scaffold.test.ts
@@ -0,0 +1,70 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { DEV_ENTRY_TEMPLATE, scaffoldDevEntryFile } from '../utils/dev-entry-scaffold.js';
+
+const tempDirs: string[] = [];
+
+const createTempDir = async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rozenite-dev-entry-'));
+ tempDirs.push(dir);
+ return dir;
+};
+
+afterEach(async () => {
+ await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
+});
+
+describe('scaffoldDevEntryFile', () => {
+ it('creates rozenite.dev.tsx with the expected default export when nothing exists', async () => {
+ const projectRoot = await createTempDir();
+
+ const result = await scaffoldDevEntryFile(projectRoot);
+
+ const expectedPath = path.join(projectRoot, 'rozenite.dev.tsx');
+ expect(result).toEqual({ status: 'created', filePath: expectedPath });
+
+ const contents = await fs.readFile(expectedPath, 'utf8');
+ expect(contents).toBe(DEV_ENTRY_TEMPLATE);
+ expect(contents).toContain('export default function RozeniteDevTools()');
+ });
+
+ it('leaves an existing rozenite.dev.tsx byte-for-byte untouched', async () => {
+ const projectRoot = await createTempDir();
+ const existingPath = path.join(projectRoot, 'rozenite.dev.tsx');
+ const existingContents =
+ '// custom dev entry\nexport default function RozeniteDevTools() {\n return null;\n}\n';
+ await fs.writeFile(existingPath, existingContents, 'utf8');
+
+ const result = await scaffoldDevEntryFile(projectRoot);
+
+ expect(result).toEqual({ status: 'skipped', filePath: existingPath });
+ const contentsAfter = await fs.readFile(existingPath, 'utf8');
+ expect(contentsAfter).toBe(existingContents);
+ });
+
+ it('skips when only rozenite.dev.js exists', async () => {
+ const projectRoot = await createTempDir();
+ const existingPath = path.join(projectRoot, 'rozenite.dev.js');
+ const existingContents = 'module.exports = function RozeniteDevTools() { return null; };\n';
+ await fs.writeFile(existingPath, existingContents, 'utf8');
+
+ const result = await scaffoldDevEntryFile(projectRoot);
+
+ expect(result).toEqual({ status: 'skipped', filePath: existingPath });
+ expect(await fs.readdir(projectRoot)).toEqual(['rozenite.dev.js']);
+ });
+
+ it('skips when only a platform-suffixed rozenite.dev.ios.tsx exists', async () => {
+ const projectRoot = await createTempDir();
+ const existingPath = path.join(projectRoot, 'rozenite.dev.ios.tsx');
+ const existingContents = 'export default function RozeniteDevTools() {\n return null;\n}\n';
+ await fs.writeFile(existingPath, existingContents, 'utf8');
+
+ const result = await scaffoldDevEntryFile(projectRoot);
+
+ expect(result).toEqual({ status: 'skipped', filePath: existingPath });
+ expect(await fs.readdir(projectRoot)).toEqual(['rozenite.dev.ios.tsx']);
+ });
+});
diff --git a/packages/cli/src/__tests__/plugin-package-json.test.ts b/packages/cli/src/__tests__/plugin-package-json.test.ts
index f073b332..d79e4a18 100644
--- a/packages/cli/src/__tests__/plugin-package-json.test.ts
+++ b/packages/cli/src/__tests__/plugin-package-json.test.ts
@@ -84,6 +84,82 @@ describe('syncPluginPackageJSON', () => {
});
});
+ it('adds the managed register export when register.ts exists', async () => {
+ const projectRoot = await createTempDir();
+
+ await writeJson(path.join(projectRoot, 'package.json'), {
+ name: 'demo-plugin',
+ type: 'module',
+ exports: {
+ './custom': './src/custom.ts',
+ },
+ });
+
+ await fs.writeFile(path.join(projectRoot, 'react-native.ts'), 'export {}\n');
+ await fs.writeFile(path.join(projectRoot, 'register.ts'), 'export {}\n');
+
+ const result = await syncPluginPackageJSON(projectRoot);
+ const packageJson = JSON.parse(
+ await fs.readFile(path.join(projectRoot, 'package.json'), 'utf8'),
+ );
+
+ expect(result.targets.hasRegisterEntryPoint).toBe(true);
+ expect(packageJson.exports).toEqual({
+ '.': {
+ types: './dist/react-native/react-native.d.ts',
+ import: './dist/react-native/react-native.js',
+ require: './dist/react-native/cjs/react-native.js',
+ },
+ './register': {
+ types: './dist/react-native/register.d.ts',
+ import: './dist/react-native/register.js',
+ require: './dist/react-native/cjs/register.js',
+ },
+ './custom': './src/custom.ts',
+ './package.json': './package.json',
+ });
+ });
+
+ it('removes the managed register export when register.ts no longer exists', async () => {
+ const projectRoot = await createTempDir();
+
+ await writeJson(path.join(projectRoot, 'package.json'), {
+ name: 'demo-plugin',
+ type: 'module',
+ exports: {
+ '.': {
+ types: './dist/react-native/react-native.d.ts',
+ import: './dist/react-native/react-native.js',
+ require: './dist/react-native/cjs/react-native.js',
+ },
+ './register': {
+ types: './dist/react-native/register.d.ts',
+ import: './dist/react-native/register.js',
+ require: './dist/react-native/cjs/register.js',
+ },
+ './custom': './src/custom.ts',
+ },
+ });
+
+ await fs.writeFile(path.join(projectRoot, 'react-native.ts'), 'export {}\n');
+
+ const result = await syncPluginPackageJSON(projectRoot);
+ const packageJson = JSON.parse(
+ await fs.readFile(path.join(projectRoot, 'package.json'), 'utf8'),
+ );
+
+ expect(result.targets.hasRegisterEntryPoint).toBe(false);
+ expect(packageJson.exports).toEqual({
+ '.': {
+ types: './dist/react-native/react-native.d.ts',
+ import: './dist/react-native/react-native.js',
+ require: './dist/react-native/cjs/react-native.js',
+ },
+ './custom': './src/custom.ts',
+ './package.json': './package.json',
+ });
+ });
+
it('removes only the managed metro export when no metro target exists', async () => {
const projectRoot = await createTempDir();
diff --git a/packages/cli/src/__tests__/tsc-build.test.ts b/packages/cli/src/__tests__/tsc-build.test.ts
index 9b25f180..b095c23c 100644
--- a/packages/cli/src/__tests__/tsc-build.test.ts
+++ b/packages/cli/src/__tests__/tsc-build.test.ts
@@ -46,6 +46,34 @@ describe('getTscEmits', () => {
expect(getTscEmits(target).filter((emit) => emit.declaration)).toHaveLength(1);
}
});
+
+ it('adds register.ts to both react-native emits, not a target of its own', () => {
+ // `register.ts` must share the react-native target's single output tree
+ // rather than emit a second copy of `dist/react-native/src/**` under a
+ // separate outDir.
+ expect(getTscEmits('react-native', { extraEntryFiles: ['register.ts'] })).toEqual([
+ {
+ target: 'react-native',
+ format: 'esm',
+ outDir: 'dist/react-native',
+ declaration: true,
+ extraEntryFiles: ['register.ts'],
+ },
+ {
+ target: 'react-native',
+ format: 'cjs',
+ outDir: 'dist/react-native/cjs',
+ declaration: false,
+ extraEntryFiles: ['register.ts'],
+ },
+ ]);
+ });
+
+ it('ignores extraEntryFiles for targets other than react-native', () => {
+ expect(getTscEmits('metro', { extraEntryFiles: ['register.ts'] })).toEqual([
+ { target: 'metro', format: 'cjs', outDir: 'dist/metro', declaration: true },
+ ]);
+ });
});
describe('writeModuleTypeMarker', () => {
@@ -107,4 +135,17 @@ describe('prepareEmit', () => {
outDir: '../dist/metro',
});
});
+
+ it('compiles register.ts into the same react-native emit as react-native.ts', async () => {
+ const projectRoot = await createTempDir();
+ const [esm, cjs] = getTscEmits('react-native', { extraEntryFiles: ['register.ts'] });
+
+ const esmConfig = await readJson(await prepareEmit(projectRoot, esm));
+ const cjsConfig = await readJson(await prepareEmit(projectRoot, cjs));
+
+ expect(esmConfig.files).toEqual(['../react-native.ts', '../register.ts']);
+ expect(esmConfig.compilerOptions.outDir).toBe('../dist/react-native');
+ expect(cjsConfig.files).toEqual(['../react-native.ts', '../register.ts']);
+ expect(cjsConfig.compilerOptions.outDir).toBe('../dist/react-native/cjs');
+ });
});
diff --git a/packages/cli/src/commands/build-command.ts b/packages/cli/src/commands/build-command.ts
index 441668bb..92d7f018 100644
--- a/packages/cli/src/commands/build-command.ts
+++ b/packages/cli/src/commands/build-command.ts
@@ -11,6 +11,7 @@ import {
assertTsconfigExists,
buildTarget,
PluginTarget,
+ REGISTER_ENTRY_FILE,
TARGET_LABEL,
} from '../utils/tsc-build.js';
@@ -30,7 +31,8 @@ export const buildCommand = async (targetDir: string) => {
logger.warn(`Updated package.json builder-managed fields: ${updatedFields.join(', ')}`);
}
- const { hasReactNativeEntryPoint, hasMetroEntryPoint, hasSdkEntryPoint } = targets;
+ const { hasReactNativeEntryPoint, hasMetroEntryPoint, hasSdkEntryPoint, hasRegisterEntryPoint } =
+ targets;
const tscTargets: PluginTarget[] = [
...(hasMetroEntryPoint ? (['metro'] as const) : []),
@@ -84,7 +86,16 @@ export const buildCommand = async (targetDir: string) => {
start: `Building ${TARGET_LABEL[target]} entry point`,
stop: `${TARGET_LABEL[target]} entry point built`,
error: failureLabel(`the ${TARGET_LABEL[target]} entry point`),
- run: (signal: AbortSignal) => buildTarget(targetDir, target, { signal }),
+ // `register.ts`, when present, compiles as an extra file inside the
+ // `react-native` target's emits rather than as a target of its own -
+ // see the `extraEntryFiles` doc on `TscEmit`.
+ run: (signal: AbortSignal) =>
+ buildTarget(targetDir, target, {
+ signal,
+ ...(target === 'react-native' && hasRegisterEntryPoint
+ ? { extraEntryFiles: [REGISTER_ENTRY_FILE] }
+ : {}),
+ }),
})),
];
diff --git a/packages/cli/src/commands/dev-command.ts b/packages/cli/src/commands/dev-command.ts
index 9773b8d5..8a9a0e09 100644
--- a/packages/cli/src/commands/dev-command.ts
+++ b/packages/cli/src/commands/dev-command.ts
@@ -8,6 +8,7 @@ import {
getTscEmits,
PluginTarget,
prepareEmit,
+ REGISTER_ENTRY_FILE,
spawnTscWatch,
} from '../utils/tsc-build.js';
@@ -72,7 +73,7 @@ export const devCommand = async (targetDir: string) => {
logger.warn(`Updated package.json builder-managed fields: ${updatedFields.join(', ')}`);
}
- const { hasReactNativeEntryPoint, hasMetroEntryPoint } = targets;
+ const { hasReactNativeEntryPoint, hasMetroEntryPoint, hasRegisterEntryPoint } = targets;
const watchTargets: PluginTarget[] = [
...(hasReactNativeEntryPoint ? (['react-native'] as const) : []),
@@ -87,7 +88,13 @@ export const devCommand = async (targetDir: string) => {
const processes: Subprocess[] = [];
for (const target of watchTargets) {
- for (const emit of getTscEmits(target)) {
+ // `register.ts`, when present, compiles as an extra file inside the
+ // `react-native` target's emits rather than as a target of its own -
+ // see the `extraEntryFiles` doc on `TscEmit`.
+ const extraEntryFiles =
+ target === 'react-native' && hasRegisterEntryPoint ? [REGISTER_ENTRY_FILE] : undefined;
+
+ for (const emit of getTscEmits(target, { extraEntryFiles })) {
const configPath = await prepareEmit(targetDir, emit);
processes.push(spawnTscWatch(targetDir, configPath));
}
diff --git a/packages/cli/src/commands/init-command.ts b/packages/cli/src/commands/init-command.ts
index 3528fffd..2f45e17b 100644
--- a/packages/cli/src/commands/init-command.ts
+++ b/packages/cli/src/commands/init-command.ts
@@ -1,10 +1,17 @@
+import path from 'node:path';
import { getProjectType, type BundlerType } from '@rozenite/tools';
import { getAvailableBundlerTypes } from '@rozenite/tools';
import { wrapConfigFile } from '../utils/config-wrapper.js';
+import { getMountInstructions, scaffoldDevEntryFile } from '../utils/dev-entry-scaffold.js';
import { isGitRepositoryClean } from '../utils/git.js';
import { logger } from '../utils/logger.js';
-import { getExecForPackageManager, installDevDependency, isProject } from '../utils/packages.js';
-import { intro, outro, promptConfirm } from '../utils/prompts.js';
+import {
+ getExecForPackageManager,
+ installDependency,
+ installDevDependency,
+ isProject,
+} from '../utils/packages.js';
+import { intro, note, outro, promptConfirm } from '../utils/prompts.js';
import { spawn } from '../utils/spawn.js';
import { step } from '../utils/steps.js';
@@ -87,5 +94,39 @@ export const initCommand = async (projectRoot: string) => {
);
}
+ // Install the app-side seam. Unlike @rozenite/metro / @rozenite/repack,
+ // this is the one Rozenite package that ships to production, so it is a
+ // normal dependency rather than a dev one.
+ await step(
+ {
+ start: 'Installing @rozenite/react-native...',
+ stop: '@rozenite/react-native installed',
+ error: 'Failed to install @rozenite/react-native',
+ },
+ async () => {
+ await installDependency(projectRoot, '@rozenite/react-native');
+ },
+ );
+
+ // Scaffold the dev entry. This is best-effort: the bundler config wrapped
+ // above is what actually matters, so a scaffold failure is reported and
+ // swallowed rather than aborting a mostly-successful init.
+ try {
+ const result = await scaffoldDevEntryFile(projectRoot);
+ const relativePath = path.relative(projectRoot, result.filePath);
+
+ if (result.status === 'created') {
+ logger.success(`Created ${relativePath}`);
+ } else {
+ logger.info(`Found existing ${relativePath}, leaving it untouched`);
+ }
+ } catch (err) {
+ logger.warn(
+ `Could not create rozenite.dev.tsx: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+
+ note(getMountInstructions());
+
outro('You are now ready to use Rozenite!');
};
diff --git a/packages/cli/src/utils/dev-entry-scaffold.ts b/packages/cli/src/utils/dev-entry-scaffold.ts
new file mode 100644
index 00000000..7c935d51
--- /dev/null
+++ b/packages/cli/src/utils/dev-entry-scaffold.ts
@@ -0,0 +1,96 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+/**
+ * The basename `@rozenite/react-native`'s dev entry redirects to, resolved
+ * through the host bundler's resolver — so any extension the project's
+ * `sourceExts`/platform extensions would produce counts as "already exists".
+ */
+export const DEV_ENTRY_BASENAME = 'rozenite.dev';
+
+const DEV_ENTRY_DEFAULT_EXTENSION = '.tsx';
+
+// Matches `rozenite.dev.tsx`, `rozenite.dev.js`, and platform-suffixed
+// variants like `rozenite.dev.ios.tsx` or `rozenite.dev.web.js` — anything
+// the bundler's resolver could land on for the extensionless specifier.
+const DEV_ENTRY_FILE_PATTERN = /^rozenite\.dev(\.[^./]+)?\.(tsx|ts|jsx|js)$/;
+
+export const DEV_ENTRY_TEMPLATE = `// Everything you wire up here is development-only. Rozenite redirects its
+// dev entry to this file in development, and to a noop in production, so
+// nothing imported from here can reach a production bundle.
+//
+// Import your plugins and call their hooks, for example:
+//
+// import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin';
+//
+// export default function RozeniteDevTools() {
+// useRozeniteStoragePlugin({ ... });
+// return null;
+// }
+
+export default function RozeniteDevTools() {
+ return null;
+}
+`;
+
+/**
+ * Looks for any `rozenite.dev.*` file (including platform-suffixed variants)
+ * directly inside `projectRoot`. Returns its absolute path, or `null` when
+ * none exists.
+ */
+export const findExistingDevEntryFile = async (projectRoot: string): Promise => {
+ let entries: string[];
+
+ try {
+ entries = await fs.readdir(projectRoot);
+ } catch {
+ return null;
+ }
+
+ const match = entries.find((entry) => DEV_ENTRY_FILE_PATTERN.test(entry));
+ return match ? path.join(projectRoot, match) : null;
+};
+
+export type ScaffoldDevEntryResult =
+ | { status: 'created'; filePath: string }
+ | { status: 'skipped'; filePath: string };
+
+/**
+ * Creates `/rozenite.dev.tsx` with a default-export placeholder,
+ * unless a `rozenite.dev.*` file already exists — in which case it is left
+ * byte-for-byte untouched and `status: 'skipped'` is returned. Never throws
+ * because a dev entry already exists; that is the expected, idempotent case.
+ */
+export const scaffoldDevEntryFile = async (
+ projectRoot: string,
+): Promise => {
+ const existing = await findExistingDevEntryFile(projectRoot);
+
+ if (existing) {
+ return { status: 'skipped', filePath: existing };
+ }
+
+ const filePath = path.join(projectRoot, `${DEV_ENTRY_BASENAME}${DEV_ENTRY_DEFAULT_EXTENSION}`);
+ await fs.writeFile(filePath, DEV_ENTRY_TEMPLATE, 'utf8');
+
+ return { status: 'created', filePath };
+};
+
+export const getMountInstructions = (): string => {
+ return [
+ 'Add to your app root:',
+ '',
+ " import Rozenite from '@rozenite/react-native';",
+ '',
+ ' export default function App() {',
+ ' return (',
+ ' <>',
+ ' ',
+ ' {/* your app */}',
+ ' >',
+ ' );',
+ ' }',
+ '',
+ 'Then wire your plugins up in rozenite.dev.tsx.',
+ ].join('\n');
+};
diff --git a/packages/cli/src/utils/packages.ts b/packages/cli/src/utils/packages.ts
index 87607369..1d2cf0ae 100644
--- a/packages/cli/src/utils/packages.ts
+++ b/packages/cli/src/utils/packages.ts
@@ -78,6 +78,15 @@ export const installDevDependency = async (
await spawn(packageManager, args, { cwd: projectRoot });
};
+export const installDependency = async (
+ projectRoot: string,
+ packageName: string,
+): Promise => {
+ const packageManager = getPackageManager(projectRoot);
+ const args = ['add', packageName];
+ await spawn(packageManager, args, { cwd: projectRoot });
+};
+
export const isPackageInstalled = async (
projectRoot: string,
packageName: string,
diff --git a/packages/cli/src/utils/plugin-package-json.ts b/packages/cli/src/utils/plugin-package-json.ts
index 324af0be..e9b99998 100644
--- a/packages/cli/src/utils/plugin-package-json.ts
+++ b/packages/cli/src/utils/plugin-package-json.ts
@@ -31,6 +31,7 @@ type PluginTargets = {
hasReactNativeEntryPoint: boolean;
hasMetroEntryPoint: boolean;
hasSdkEntryPoint: boolean;
+ hasRegisterEntryPoint: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -79,6 +80,17 @@ const buildPackageContract = (targets: PluginTargets): PluginPackageContract | n
};
}
+ // `register.ts` compiles as an extra file inside the `react-native` target
+ // (see `getTscEmits`'s `extraEntryFiles`), so its output sits alongside
+ // `react-native.js` in the same emitted tree - same shape as the `.` entry.
+ if (targets.hasRegisterEntryPoint) {
+ contract.exports['./register'] = {
+ types: './dist/react-native/register.d.ts',
+ import: './dist/react-native/register.js',
+ require: './dist/react-native/cjs/register.js',
+ };
+ }
+
return contract;
};
@@ -116,6 +128,12 @@ const mergeManagedExports = (
delete mergedExports['./sdk'];
}
+ if (targets.hasRegisterEntryPoint) {
+ mergedExports['./register'] = contract.exports['./register'];
+ } else {
+ delete mergedExports['./register'];
+ }
+
return mergedExports;
};
@@ -133,6 +151,7 @@ export const detectPluginTargets = async (projectRoot: string): Promise = {
@@ -27,6 +35,14 @@ export const TARGET_LABEL: Record = {
sdk: 'SDK',
};
+/**
+ * The conventional production entry point. Unlike `TARGET_ENTRY_FILE`, this
+ * is not a `PluginTarget` of its own - it is compiled as an extra file inside
+ * the `react-native` target's emits (see `TscEmit['extraEntryFiles']`), so it
+ * shares that target's single output tree instead of duplicating it.
+ */
+export const REGISTER_ENTRY_FILE = 'register.ts';
+
// Generated tsconfig files live in the project so `extends` and the relative
// entry paths stay simple, and so a failing build leaves the exact config
// behind for inspection.
@@ -42,11 +58,21 @@ const GENERATED_CONFIG_DIR = '.rozenite';
* the `default` condition serves `require()` and `import` alike. Emitting only
* CommonJS also keeps `__dirname` and friends available to config authors.
*/
-export const getTscEmits = (target: PluginTarget): TscEmit[] => {
+export type GetTscEmitsOptions = {
+ /** See `TscEmit['extraEntryFiles']`. Only meaningful for the `react-native` target. */
+ extraEntryFiles?: string[];
+};
+
+export const getTscEmits = (
+ target: PluginTarget,
+ { extraEntryFiles }: GetTscEmitsOptions = {},
+): TscEmit[] => {
+ const extra = extraEntryFiles?.length ? { extraEntryFiles } : {};
+
if (target === 'react-native') {
return [
- { target, format: 'esm', outDir: 'dist/react-native', declaration: true },
- { target, format: 'cjs', outDir: 'dist/react-native/cjs', declaration: false },
+ { target, format: 'esm', outDir: 'dist/react-native', declaration: true, ...extra },
+ { target, format: 'cjs', outDir: 'dist/react-native/cjs', declaration: false, ...extra },
];
}
@@ -88,7 +114,10 @@ const createTsconfig = (emit: TscEmit) => {
rootDir: '..',
outDir: path.posix.join('..', emit.outDir),
},
- files: [path.posix.join('..', TARGET_ENTRY_FILE[emit.target])],
+ files: [
+ path.posix.join('..', TARGET_ENTRY_FILE[emit.target]),
+ ...(emit.extraEntryFiles ?? []).map((file) => path.posix.join('..', file)),
+ ],
// The entry graph alone would miss ambient declarations - global
// augmentations and module shims are never imported, only declared.
include: ['../*.d.ts', '../src/**/*.d.ts'],
@@ -186,13 +215,15 @@ export const prepareEmit = async (projectRoot: string, emit: TscEmit): Promise => {
await Promise.all(
- getTscEmits(target).map(async (emit) => {
+ getTscEmits(target, { extraEntryFiles }).map(async (emit) => {
const configPath = await prepareEmit(projectRoot, emit);
await runTsc(projectRoot, configPath, { signal });
}),
diff --git a/packages/controls-plugin/README.md b/packages/controls-plugin/README.md
index ebb51c02..34a72cf7 100644
--- a/packages/controls-plugin/README.md
+++ b/packages/controls-plugin/README.md
@@ -14,11 +14,14 @@ npm install @rozenite/controls-plugin
## Usage
-```ts
+Wire the plugin up in `rozenite.dev.tsx`, next to your Metro or Re.Pack config — see the
+[Production Guarantee](https://www.rozenite.dev/docs/production-guarantee) docs for why:
+
+```ts title="rozenite.dev.tsx"
import { createSection, useRozeniteControlsPlugin } from '@rozenite/controls-plugin';
import { useMemo, useState } from 'react';
-function App() {
+export default function RozeniteDevTools() {
const [verboseLogging, setVerboseLogging] = useState(false);
const [environment, setEnvironment] = useState('local');
const [releaseLabel, setReleaseLabel] = useState('build-001');
@@ -82,13 +85,13 @@ function App() {
useRozeniteControlsPlugin({ sections });
- return ;
+ return null;
}
```
-You can also call the hook from multiple components. Each active hook instance contributes sections to the same panel:
+You can also call the hook from multiple components. Each active hook instance contributes sections to the same panel — for example, from a second component rendered alongside the first inside your `rozenite.dev.tsx` default export:
-```ts
+```ts title="rozenite.dev.tsx"
function LocaleControls() {
useRozeniteControlsPlugin((previousOptions) => ({
sections: [
@@ -148,4 +151,4 @@ Controls can guide users toward safe actions:
- The panel appears in React Native DevTools as `Controls`.
- Updates flow both ways: local state changes are reflected in DevTools, and DevTools actions update the device.
-- The hook is disabled in production builds.
+- Call the hook from `rozenite.dev.tsx`, not from your app's own components — importing it anywhere else is a production build error. See the [Production Guarantee](https://www.rozenite.dev/docs/production-guarantee) docs.
diff --git a/packages/expo-atlas-plugin/README.md b/packages/expo-atlas-plugin/README.md
index 4798fca5..2025c1d8 100644
--- a/packages/expo-atlas-plugin/README.md
+++ b/packages/expo-atlas-plugin/README.md
@@ -51,6 +51,11 @@ export default withRozenite(
Start your development server and open React Native DevTools. You'll find the "Expo Atlas" panel in the DevTools interface.
+This plugin's public surface is a Metro config transformer, not app code, so there's nothing to wire
+up in `rozenite.dev.tsx` — the `metro.config.js` setup above is everything it needs. See the
+[Production Guarantee](https://www.rozenite.dev/docs/production-guarantee) docs for why other plugins'
+app-facing code lives there instead.
+
## Made with ❤️ at Callstack
`rozenite` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟.
diff --git a/packages/feature-flags-plugin/README.md b/packages/feature-flags-plugin/README.md
index f14e6bfb..c7916802 100644
--- a/packages/feature-flags-plugin/README.md
+++ b/packages/feature-flags-plugin/README.md
@@ -34,11 +34,11 @@ npm install @statsig/js-client @statsig/react-native-bindings @statsig/js-local-
For a homegrown flag store, or as a placeholder before wiring a real provider. **No call-site change** beyond registering the adapter — flags are read straight from your own `listFlags()`.
+The app consults the override store at flag-evaluation time, which is ordinary app code that ships in production - so `createCustomFlagsAdapter` is imported from `@rozenite/feature-flags-plugin/register`, the plugin's declared production entry point. `useRozeniteFeatureFlagsPlugin` stays dev-only and is imported from the package root as before.
+
```ts
-import {
- createCustomFlagsAdapter,
- useRozeniteFeatureFlagsPlugin,
-} from '@rozenite/feature-flags-plugin';
+import { createCustomFlagsAdapter } from '@rozenite/feature-flags-plugin/register';
+import { useRozeniteFeatureFlagsPlugin } from '@rozenite/feature-flags-plugin';
// Module-level, like `storagePluginAdapters` in the playground app. The hook
// tracks `providers` by content, so a fresh array literal on every render
@@ -59,7 +59,7 @@ useRozeniteFeatureFlagsPlugin({ providers: featureFlagsProviders });
Overrides are an in-memory `Map`, gone on app restart by default. Wire persistence with `createFlagOverrides`:
```ts
-import { createCustomFlagsAdapter, createFlagOverrides } from '@rozenite/feature-flags-plugin';
+import { createCustomFlagsAdapter, createFlagOverrides } from '@rozenite/feature-flags-plugin/register';
const overrides = createFlagOverrides({
initial: JSON.parse(storage.getString('flag-overrides') ?? '{}'),
@@ -73,12 +73,12 @@ createCustomFlagsAdapter({ id: 'app', name: 'App flags', listFlags, overrides })
`createLaunchDarklyFlagsAdapter` returns a `provider` for the hook and a `client` you must pass to `` in place of the raw SDK client — **the one changed line**. Every LD hook (`useBoolVariation`, `useLDClient`, ...) then reads through it automatically, since LD's hooks are a thin read off the context client.
+Passing the wrapped `client` to `` is ordinary app code that ships in production, so `createLaunchDarklyFlagsAdapter` is imported from `@rozenite/feature-flags-plugin/register`, the plugin's declared production entry point. `useRozeniteFeatureFlagsPlugin` stays dev-only and is imported from the package root as before.
+
```ts
import { ReactNativeLDClient, AutoEnvAttributes, LDProvider } from '@launchdarkly/react-native-client-sdk';
-import {
- createLaunchDarklyFlagsAdapter,
- useRozeniteFeatureFlagsPlugin,
-} from '@rozenite/feature-flags-plugin';
+import { createLaunchDarklyFlagsAdapter } from '@rozenite/feature-flags-plugin/register';
+import { useRozeniteFeatureFlagsPlugin } from '@rozenite/feature-flags-plugin';
const rawClient = new ReactNativeLDClient(LD_MOBILE_KEY, AutoEnvAttributes.Enabled);
const { provider, client } = createLaunchDarklyFlagsAdapter({ client: rawClient });
diff --git a/packages/feature-flags-plugin/package.json b/packages/feature-flags-plugin/package.json
index 3333d5a5..1e071008 100644
--- a/packages/feature-flags-plugin/package.json
+++ b/packages/feature-flags-plugin/package.json
@@ -26,7 +26,12 @@
"types": "./dist/sdk/sdk.d.ts",
"default": "./dist/sdk/sdk.js"
},
- "./package.json": "./package.json"
+ "./package.json": "./package.json",
+ "./register": {
+ "types": "./dist/react-native/register.d.ts",
+ "import": "./dist/react-native/register.js",
+ "require": "./dist/react-native/cjs/register.js"
+ }
},
"publishConfig": {
"access": "public"
diff --git a/packages/feature-flags-plugin/register.ts b/packages/feature-flags-plugin/register.ts
new file mode 100644
index 00000000..b3d9b416
--- /dev/null
+++ b/packages/feature-flags-plugin/register.ts
@@ -0,0 +1,39 @@
+// Production entry point (`@rozenite/feature-flags-plugin/register`).
+//
+// The app consults the override store at flag-evaluation time, and the
+// LaunchDarkly adapter's wrapped `client` must be threaded into a real
+// `` - both are ordinary app code that runs in production, so
+// these touchpoints are declared safe via `productionEntries` in
+// `rozenite.config.ts`.
+//
+// Re-exported from `./react-native` rather than from `./src/**` directly.
+// Being reachable in production is not the same as being active in it: the
+// root entry already resolves each of these to a noop once
+// `process.env.NODE_ENV` is folded, so a shipped app reads its flags with no
+// overrides applied and no evaluation interception. Re-exporting keeps one
+// definition of that production behaviour instead of a second copy here that
+// could drift from it, and `register.js` is emitted into the same tree as
+// `react-native.js`, so both entry points share one module instance.
+//
+// `createStatsigFlagsAdapter` is intentionally left out: unlike the
+// LaunchDarkly adapter, it does not return a wrapped client for you to pass
+// to a provider - you construct `StatsigClient`/`LocalOverrideAdapter`
+// yourself and hand them straight to Statsig's own provider. The adapter's
+// only consumer is `useRozeniteFeatureFlagsPlugin`, which stays dev-only, so
+// there is no production call site for it.
+export {
+ createCustomFlagsAdapter,
+ createLaunchDarklyFlagsAdapter,
+ createFlagOverrides,
+} from './react-native';
+export type {
+ CreateCustomFlagsAdapterOptions,
+ CreateLaunchDarklyFlagsAdapterOptions,
+ FeatureFlagInput,
+ LaunchDarklyFlagsAdapter,
+ LDClientLike,
+ LDEvaluationDetailLike,
+ LDEvaluationReason,
+ LDFlagSet,
+} from './src/react-native/adapters';
+export type { FlagOverrides, FlagOverridesOptions } from './src/react-native/overrides';
diff --git a/packages/feature-flags-plugin/rozenite.config.ts b/packages/feature-flags-plugin/rozenite.config.ts
index ec0c42af..c250321b 100644
--- a/packages/feature-flags-plugin/rozenite.config.ts
+++ b/packages/feature-flags-plugin/rozenite.config.ts
@@ -173,6 +173,10 @@ export default {
source: './src/ui/panel.tsx',
},
],
+ // Flag evaluation and the LaunchDarkly wrapped-client call site run in
+ // ordinary app code, so they need a touchpoint that survives a production
+ // build. See `register.ts`.
+ productionEntries: ['./register'],
dev: {
flows: [
{
diff --git a/packages/feature-flags-plugin/src/__tests__/register-entry.test.ts b/packages/feature-flags-plugin/src/__tests__/register-entry.test.ts
new file mode 100644
index 00000000..7b3f6d74
--- /dev/null
+++ b/packages/feature-flags-plugin/src/__tests__/register-entry.test.ts
@@ -0,0 +1,52 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+/**
+ * `register.ts` is the part of this plugin an app is allowed to import from
+ * code that ships, because flags are evaluated in the running app. Being
+ * *reachable* in production is not the same as being *active* in it: a
+ * shipped app must read its flags with no overrides applied and no
+ * evaluation interception.
+ *
+ * This is the failure the resolver guard cannot catch, because the import is
+ * declared and therefore permitted. Re-exporting through `react-native.ts`
+ * is what keeps these inert; exporting straight from `src/**` would silently
+ * ship the real implementations. The root entry's own production behaviour
+ * is covered by `react-native-entry.test.ts`; this pins that `/register`
+ * resolves to the same stubs rather than to a second, divergent copy.
+ */
+const originalNodeEnv = process.env.NODE_ENV;
+
+beforeAll(() => {
+ process.env.NODE_ENV = 'production';
+});
+
+afterAll(() => {
+ process.env.NODE_ENV = originalNodeEnv;
+});
+
+describe('register entry, production build', () => {
+ it('exports the same inert implementations as the root entry', async () => {
+ const register = await import('../../register');
+ const rootEntry = await import('../../react-native');
+
+ expect(register.createCustomFlagsAdapter).toBe(rootEntry.createCustomFlagsAdapter);
+ expect(register.createLaunchDarklyFlagsAdapter).toBe(rootEntry.createLaunchDarklyFlagsAdapter);
+ expect(register.createFlagOverrides).toBe(rootEntry.createFlagOverrides);
+ });
+
+ it('reports no overrides and no flags', async () => {
+ const { createCustomFlagsAdapter, createFlagOverrides } = await import('../../register');
+
+ const overrides = createFlagOverrides();
+ await overrides.set('dark-mode', true);
+
+ expect(overrides.get('dark-mode')).toBeUndefined();
+ expect(
+ await createCustomFlagsAdapter({
+ id: 'app',
+ name: 'App flags',
+ listFlags: () => [{ key: 'dark-mode', value: true }],
+ }).listFlags(),
+ ).toEqual([]);
+ });
+});
diff --git a/packages/feature-flags-plugin/tsconfig.json b/packages/feature-flags-plugin/tsconfig.json
index 09ec9ddd..0694832c 100644
--- a/packages/feature-flags-plugin/tsconfig.json
+++ b/packages/feature-flags-plugin/tsconfig.json
@@ -17,7 +17,7 @@
"noEmit": true,
"jsx": "react-jsx"
},
- "include": ["src/**/*", "react-native.ts", "sdk.ts", "rozenite.config.ts"],
+ "include": ["src/**/*", "react-native.ts", "register.ts", "sdk.ts", "rozenite.config.ts"],
"exclude": ["node_modules", "dist", "build"],
"references": [
{
diff --git a/packages/file-system-plugin/README.md b/packages/file-system-plugin/README.md
index abef8587..01ad29da 100644
--- a/packages/file-system-plugin/README.md
+++ b/packages/file-system-plugin/README.md
@@ -42,41 +42,41 @@ npm install @dr.pogodin/react-native-fs
npm install @rozenite/file-system-plugin
```
-### 2. Integrate with Your App
+### 2. Wire It Up in `rozenite.dev.tsx`
#### With Expo FileSystem
-```typescript
+```typescript title="rozenite.dev.tsx"
import * as FileSystem from 'expo-file-system';
import {
createExpoFileSystemAdapter,
useFileSystemDevTools,
} from '@rozenite/file-system-plugin';
-function App() {
+export default function RozeniteDevTools() {
useFileSystemDevTools({
adapter: createExpoFileSystemAdapter(FileSystem),
});
- return ;
+ return null;
}
```
#### With RNFS
-```typescript
+```typescript title="rozenite.dev.tsx"
import RNFS from '@dr.pogodin/react-native-fs';
import {
createRNFSAdapter,
useFileSystemDevTools,
} from '@rozenite/file-system-plugin';
-function App() {
+export default function RozeniteDevTools() {
useFileSystemDevTools({
adapter: createRNFSAdapter(RNFS),
});
- return ;
+ return null;
}
```
@@ -99,7 +99,7 @@ Start your development server and open React Native DevTools. You’ll find the
To enable file transfer in the DevTools panel, opt in explicitly:
-```typescript
+```typescript title="rozenite.dev.tsx"
useFileSystemDevTools({
adapter: createRNFSAdapter(RNFS),
fileTransfer: {
@@ -111,7 +111,7 @@ useFileSystemDevTools({
To enable agent-triggered file transfer, opt in separately:
-```typescript
+```typescript title="rozenite.dev.tsx"
useFileSystemDevTools({
adapter: createRNFSAdapter(RNFS),
fileTransfer: {
diff --git a/packages/metro/README.md b/packages/metro/README.md
index eff59c0b..c43f00a3 100644
--- a/packages/metro/README.md
+++ b/packages/metro/README.md
@@ -67,19 +67,73 @@ The configuration object for the Metro plugin:
```typescript
type RozeniteMetroConfig = {
+ enabled?: boolean; // Whether to enable Rozenite. The production guard is active either way.
include?: string[]; // Only load these specific plugins
exclude?: string[]; // Exclude these plugins from loading
destroyOnDetachPlugins?: string[]; // Plugins that should be destroyed when switching panels
pluginDisplay?: 'sidebar' | 'tabs'; // How plugins are displayed in DevTools
+ allowInProduction?: string[]; // Plugin packages exempted from the production guard
};
```
**Options:**
+- `enabled` - Whether Rozenite's dev server and plugin discovery are active. See
+ [The production guarantee](#the-production-guarantee) below — `false` no longer disables the
+ production guard itself (optional)
- `include` - Array of package names to explicitly include (optional)
- `exclude` - Array of package names to exclude from loading (optional)
- `destroyOnDetachPlugins` - Array of package names that should be destroyed when switching panels instead of maintaining their state (optional, by default all plugins persist their state)
- `pluginDisplay` - Use `'sidebar'` (default) to show all plugin panels in one Rozenite tab, or `'tabs'` to retain a separate DevTools tab for every plugin panel
+- `allowInProduction` - Array of Rozenite plugin package names exempted from the production guard (optional, last resort — see [The production guarantee](#the-production-guarantee))
+
+## The production guarantee
+
+`withRozenite()` installs a guard on Metro's resolver, unconditionally, that keeps Rozenite plugin
+code out of production bundles. It runs whether or not `enabled` is `true`. See the
+[Production Guarantee](https://www.rozenite.dev/docs/production-guarantee) docs for the full picture;
+the parts that affect this package specifically are below.
+
+### The dev-entry redirect
+
+`@rozenite/react-native`'s `` component asks for a dev-entry module that, in a plain
+resolution, would resolve to a shipped noop. When `enabled` is `true` and Metro is resolving a
+development bundle, `withRozenite()` intercepts that specific request and redirects it — through
+Metro's own resolver, so your project's `sourceExts` and platform extensions apply — to
+`/rozenite.dev`. If no matching file exists, resolution falls back to the shipped noop
+and logs once; a missing `rozenite.dev` file is never a build failure.
+
+### The build error
+
+Independent of that redirect, every resolution Metro performs is checked against a simple rule: a
+production build must not resolve into a Rozenite plugin package except through that plugin's declared
+`productionEntries`. A violation throws, naming the plugin and the importing file. In a development
+build the same violation only warns, since Fast Refresh would otherwise force you to hunt down a
+whole batch of stray imports one build at a time.
+
+### `enabled: false` no longer means "do nothing"
+
+**This is a behavior change.** Previously, `enabled: false` (or omitting `enabled`) short-circuited
+`withRozenite()` entirely and returned your config untouched. Now, `enabled: false` still returns a
+config without the dev server or plugin discovery, but the production guard above stays installed. If
+you used `enabled: false` to keep a particular build free of Rozenite altogether, audit that build for
+plugin imports living outside `rozenite.dev.tsx` — they'll now fail it.
+
+### `allowInProduction`
+
+An escape hatch for when you need to unblock a build immediately, before restructuring an import or
+waiting on a plugin author to add a `productionEntries` declaration:
+
+```javascript
+// metro.config.js
+module.exports = withRozenite(config, {
+ allowInProduction: ['@acme/some-plugin'],
+});
+```
+
+Every package listed here is exempted from the guard entirely, through any import path. This is
+printed loudly once per build, since it defeats the production guarantee for the listed package(s) —
+treat it as a last resort, not a fix.
## Plugin Discovery
diff --git a/packages/metro/package.json b/packages/metro/package.json
index 3e851bd8..743b5670 100644
--- a/packages/metro/package.json
+++ b/packages/metro/package.json
@@ -53,6 +53,10 @@
},
"devDependencies": {
"@react-native/metro-config": "~0.86.0",
+ "@rozenite/rhf-plugin": "workspace:*",
+ "@rozenite/storage-plugin": "workspace:*",
+ "@rozenite/test-utils": "workspace:*",
+ "metro-resolver": "*",
"vitest": "^4.0.18"
},
"engines": {
diff --git a/packages/metro/src/__tests__/release-bundle.test.ts b/packages/metro/src/__tests__/release-bundle.test.ts
new file mode 100644
index 00000000..d5f18b01
--- /dev/null
+++ b/packages/metro/src/__tests__/release-bundle.test.ts
@@ -0,0 +1,106 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { bundleForRelease, RELEASE_BUNDLE_TIMEOUT, type MetroConfig } from '@rozenite/test-utils';
+import { describe, expect, it } from 'vitest';
+import { withRozenite } from '../index.js';
+
+// This is the bundler-integration guard for `withRozenite` itself, per
+// docs/agents/release-bundle-testing.md. Plugin suites guard their own
+// `enabled` handling directly, without `withRozenite` (importing
+// `@rozenite/metro` from a plugin's `development`-conditioned tsconfig pulls
+// `@rozenite/middleware`'s sources into that package's TypeScript program).
+// What belongs here is the resolver's own decision table, exercised through
+// a real Metro release build rather than through unit tests of
+// `applyProductionGuard` alone.
+const packageRoot = path.resolve(fileURLToPath(import.meta.url), '../../..');
+
+const bundle = (files: Record, options?: Parameters[1]) =>
+ bundleForRelease({
+ resolveFrom: packageRoot,
+ files,
+ // `withRozenite` returns a thunk (`() => Promise`); calling it here
+ // yields a plain `Promise`. The cast below is only needed
+ // because `T` is inferred from `@rozenite/test-utils`'s own `MetroConfig`
+ // type alias, which structurally differs just enough (optional vs.
+ // required `cacheVersion`) from `withRozenite`'s generic bound to trip
+ // TypeScript -- both describe the same real Metro config at runtime.
+ configureMetro: async (config): Promise =>
+ (await withRozenite(config, options)()) as unknown as MetroConfig,
+ });
+
+describe('withRozenite in a release bundle', () => {
+ it(
+ 'fails when an app imports a Rozenite plugin directly, naming the importing file',
+ async () => {
+ const importingFile = path.join('src', 'app', 'screens', 'HomeScreen.tsx');
+
+ await expect(
+ bundle({
+ 'index.js': "require('./src/app/screens/HomeScreen.tsx');\n",
+ [importingFile]:
+ "import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin';\nuseRozeniteStoragePlugin;\n",
+ }),
+ ).rejects.toThrow(new RegExp(importingFile.replace(/[/\\]/g, '.')));
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+
+ it(
+ 'succeeds when an app imports a declared production entry',
+ async () => {
+ const result = await bundle({
+ 'index.js':
+ "require('@rozenite/rhf-plugin/register');\nconsole.log('rozenite release bundle fixture');\n",
+ });
+
+ // Non-vacuous: the declared entry really did get bundled (either the
+ // ESM or the CJS build, whichever Metro's resolver conditions pick),
+ // and nothing beyond it -- no panel code -- came along with it.
+ expect(
+ result.rozeniteModules.some((modulePath) =>
+ /rhf-plugin\/dist\/react-native\/(cjs\/)?register\.js$/.test(modulePath),
+ ),
+ ).toBe(true);
+ expect(result.panelModules).toEqual([]);
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+
+ it(
+ 'still fails the violating import when withRozenite is disabled',
+ async () => {
+ const importingFile = path.join('src', 'app', 'screens', 'HomeScreen.tsx');
+
+ await expect(
+ bundle(
+ {
+ 'index.js': "require('./src/app/screens/HomeScreen.tsx');\n",
+ [importingFile]:
+ "import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin';\nuseRozeniteStoragePlugin;\n",
+ },
+ { enabled: false },
+ ),
+ ).rejects.toThrow(new RegExp(importingFile.replace(/[/\\]/g, '.')));
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+
+ it(
+ 'lets an undeclared import through when the plugin is listed in allowInProduction',
+ async () => {
+ const importingFile = path.join('src', 'app', 'screens', 'HomeScreen.tsx');
+
+ const result = await bundle(
+ {
+ 'index.js': "require('./src/app/screens/HomeScreen.tsx');\n",
+ [importingFile]:
+ "import { useRozeniteStoragePlugin } from '@rozenite/storage-plugin';\nuseRozeniteStoragePlugin;\n",
+ },
+ { allowInProduction: ['@rozenite/storage-plugin'] },
+ );
+
+ expect(result.rozeniteModules.length).toBeGreaterThan(0);
+ },
+ RELEASE_BUNDLE_TIMEOUT,
+ );
+});
diff --git a/packages/metro/src/__tests__/resolver.test.ts b/packages/metro/src/__tests__/resolver.test.ts
new file mode 100644
index 00000000..04b75606
--- /dev/null
+++ b/packages/metro/src/__tests__/resolver.test.ts
@@ -0,0 +1,267 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import type { CustomResolutionContext, CustomResolver, Resolution } from 'metro-resolver';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { applyProductionGuard, createRozeniteResolveRequest } from '../resolver.js';
+
+const tempDirs: string[] = [];
+
+const createTempDir = (): string => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rozenite-metro-resolver-'));
+ tempDirs.push(tempDir);
+ return tempDir;
+};
+
+const writeJson = (filePath: string, value: unknown): void => {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
+};
+
+const writeFile = (filePath: string, contents = ''): void => {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, contents);
+};
+
+/** Creates an on-disk Rozenite plugin package (package.json + dist/rozenite.json). */
+const createPlugin = (
+ pluginRoot: string,
+ pluginName: string,
+ productionEntries: string[] = [],
+): void => {
+ writeJson(path.join(pluginRoot, 'package.json'), { name: pluginName, version: '1.0.0' });
+ writeJson(path.join(pluginRoot, 'dist', 'rozenite.json'), { productionEntries });
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+
+ while (tempDirs.length) {
+ fs.rmSync(tempDirs.pop()!, { recursive: true, force: true });
+ }
+});
+
+const sourceFile = (filePath: string): Resolution => ({ type: 'sourceFile', filePath });
+
+const createContext = (options: {
+ dev: boolean;
+ originModulePath: string;
+ resolveRequest?: CustomResolver;
+}): CustomResolutionContext =>
+ ({
+ dev: options.dev,
+ originModulePath: options.originModulePath,
+ resolveRequest:
+ options.resolveRequest ??
+ (() => {
+ throw new Error('resolveRequest should not be called in this test');
+ }),
+ }) as unknown as CustomResolutionContext;
+
+describe('applyProductionGuard decision table', () => {
+ it('allows when the importing file is itself inside the plugin', () => {
+ const pluginRoot = createTempDir();
+ createPlugin(pluginRoot, '@acme/some-plugin');
+
+ const originModulePath = path.join(pluginRoot, 'src', 'internal.ts');
+ const resolvedFilePath = path.join(pluginRoot, 'src', 'other.ts');
+ const context = createContext({ dev: false, originModulePath });
+
+ const result = applyProductionGuard(context, sourceFile(resolvedFilePath), null, {
+ projectRoot: '/project',
+ allowInProduction: [],
+ });
+
+ expect(result).toEqual(sourceFile(resolvedFilePath));
+ });
+
+ // A declared entry is an export subpath, so it must be resolved as the bare
+ // specifier a consumer writes. Resolving './register' as a literal relative
+ // path would land on the plugin's source `register.ts` at the package root,
+ // while the consumer's import goes through `exports` to
+ // `dist/react-native/register.js` - and a correctly declared entry would
+ // then fail the guard. This test fails if that regresses: the fake resolver
+ // only answers the bare specifier, and only ever returns the dist file.
+ it('allows a declared productionEntry, resolved as an export subpath', () => {
+ const pluginRoot = createTempDir();
+ createPlugin(pluginRoot, '@acme/some-plugin', ['./register']);
+
+ const builtRegisterPath = path.join(pluginRoot, 'dist', 'react-native', 'register.js');
+ const requestedSpecifiers: string[] = [];
+ const resolveRequest: CustomResolver = (_context, moduleName) => {
+ requestedSpecifiers.push(moduleName);
+
+ if (moduleName === '@acme/some-plugin/register') {
+ return sourceFile(builtRegisterPath);
+ }
+
+ throw new Error(`unexpected moduleName: ${moduleName}`);
+ };
+ const context = createContext({
+ dev: false,
+ originModulePath: '/project/src/App.tsx',
+ resolveRequest,
+ });
+
+ const result = applyProductionGuard(context, sourceFile(builtRegisterPath), null, {
+ projectRoot: '/project',
+ allowInProduction: [],
+ });
+
+ expect(result).toEqual(sourceFile(builtRegisterPath));
+ expect(requestedSpecifiers).toEqual(['@acme/some-plugin/register']);
+ });
+
+ it('throws in production for an undeclared import into the plugin', () => {
+ const pluginRoot = createTempDir();
+ createPlugin(pluginRoot, '@acme/some-plugin');
+
+ const resolvedFilePath = path.join(pluginRoot, 'src', 'index.ts');
+ const context = createContext({
+ dev: false,
+ originModulePath: '/project/src/screens/Settings.tsx',
+ });
+
+ expect(() =>
+ applyProductionGuard(context, sourceFile(resolvedFilePath), null, {
+ projectRoot: '/project',
+ allowInProduction: [],
+ }),
+ ).toThrowError(
+ /@acme\/some-plugin is a Rozenite plugin and declares no production entry points\./,
+ );
+ });
+
+ it('warns instead of throwing for the same undeclared import in development', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const pluginRoot = createTempDir();
+ createPlugin(pluginRoot, '@acme/some-plugin');
+
+ const resolvedFilePath = path.join(pluginRoot, 'src', 'index.ts');
+ const context = createContext({
+ dev: true,
+ originModulePath: '/project/src/screens/Settings.tsx',
+ });
+
+ const result = applyProductionGuard(context, sourceFile(resolvedFilePath), null, {
+ projectRoot: '/project',
+ allowInProduction: [],
+ });
+
+ expect(result).toEqual(sourceFile(resolvedFilePath));
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy.mock.calls[0]?.[0]).toContain('@acme/some-plugin imported from');
+ });
+
+ it('allows an undeclared import in production when the plugin is in allowInProduction', () => {
+ const pluginRoot = createTempDir();
+ createPlugin(pluginRoot, '@acme/some-plugin');
+
+ const resolvedFilePath = path.join(pluginRoot, 'src', 'index.ts');
+ const context = createContext({
+ dev: false,
+ originModulePath: '/project/src/screens/Settings.tsx',
+ });
+
+ const result = applyProductionGuard(context, sourceFile(resolvedFilePath), null, {
+ projectRoot: '/project',
+ allowInProduction: ['@acme/some-plugin'],
+ });
+
+ expect(result).toEqual(sourceFile(resolvedFilePath));
+ });
+
+ // `rozenite dev` rebuilds a plugin's `dist/rozenite.json` while Metro keeps
+ // running. `resolveDeclaredEntries`'s own cache must not keep answering
+ // with a `productionEntries` declaration the plugin no longer has once
+ // `findRozenitePluginForFile` (in `@rozenite/middleware`) picks up the
+ // rebuilt manifest.
+ it('re-resolves declared entries once the plugin manifest changes', () => {
+ const pluginRoot = createTempDir();
+ createPlugin(pluginRoot, '@acme/some-plugin', ['./register']);
+
+ const builtRegisterPath = path.join(pluginRoot, 'dist', 'react-native', 'register.js');
+ const resolveRequest: CustomResolver = (_context, moduleName) => {
+ if (moduleName === '@acme/some-plugin/register') {
+ return sourceFile(builtRegisterPath);
+ }
+ throw new Error(`unexpected moduleName: ${moduleName}`);
+ };
+ const context = createContext({
+ dev: false,
+ originModulePath: '/project/src/App.tsx',
+ resolveRequest,
+ });
+
+ const firstResult = applyProductionGuard(context, sourceFile(builtRegisterPath), null, {
+ projectRoot: '/project',
+ allowInProduction: [],
+ });
+ expect(firstResult).toEqual(sourceFile(builtRegisterPath));
+
+ // Rebuild the manifest with the declaration removed, bumping mtime past
+ // the original write so a fast filesystem can't land on the same tick.
+ createPlugin(pluginRoot, '@acme/some-plugin', []);
+ const manifestPath = path.join(pluginRoot, 'dist', 'rozenite.json');
+ const bumpedMtime = new Date(fs.statSync(manifestPath).mtimeMs + 1000);
+ fs.utimesSync(manifestPath, bumpedMtime, bumpedMtime);
+
+ expect(() =>
+ applyProductionGuard(context, sourceFile(builtRegisterPath), null, {
+ projectRoot: '/project',
+ allowInProduction: [],
+ }),
+ ).toThrowError(
+ /@acme\/some-plugin is a Rozenite plugin and declares no production entry points\./,
+ );
+ });
+});
+
+describe('createRozeniteResolveRequest', () => {
+ it('still returns {type: "empty"} for the web WebSocketInterceptor special case (regression guard)', () => {
+ const resolveRequest = createRozeniteResolveRequest({
+ projectRoot: '/project',
+ allowInProduction: [],
+ installDevEntryRedirect: true,
+ });
+ const context = createContext({ dev: true, originModulePath: '/project/src/App.tsx' });
+
+ const result = resolveRequest(
+ context,
+ 'react-native/Libraries/WebSocket/WebSocketInterceptor',
+ 'web',
+ );
+
+ expect(result).toEqual({ type: 'empty' });
+ });
+
+ it('falls through to normal resolution when the rozenite.dev redirect target fails to resolve', () => {
+ const seamRoot = createTempDir();
+ writeJson(path.join(seamRoot, 'package.json'), { name: '@rozenite/react-native' });
+ const noopFilePath = path.join(seamRoot, 'dist', 'cjs', 'dev-entry.js');
+ writeFile(noopFilePath);
+ const originModulePath = path.join(seamRoot, 'dist', 'cjs', 'index.js');
+ writeFile(originModulePath);
+
+ const projectRoot = createTempDir();
+ const resolveRequest: CustomResolver = (_context, moduleName) => {
+ if (moduleName === './dev-entry.js') {
+ return sourceFile(noopFilePath);
+ }
+ // Simulates the missing rozenite.dev file: no rozenite.dev(.*) exists
+ // in projectRoot, so the redirect target fails to resolve.
+ throw new Error('Unable to resolve module rozenite.dev');
+ };
+ const context = createContext({ dev: true, originModulePath, resolveRequest });
+
+ const guardResolveRequest = createRozeniteResolveRequest({
+ projectRoot,
+ allowInProduction: [],
+ installDevEntryRedirect: true,
+ });
+
+ const result = guardResolveRequest(context, './dev-entry.js', null);
+
+ expect(result).toEqual(sourceFile(noopFilePath));
+ });
+});
diff --git a/packages/metro/src/index.ts b/packages/metro/src/index.ts
index 259a1ec0..137bb4dc 100644
--- a/packages/metro/src/index.ts
+++ b/packages/metro/src/index.ts
@@ -7,15 +7,20 @@ import {
type MiddlewareRequest,
type RozeniteConfig,
} from '@rozenite/middleware';
-import { logger } from '@rozenite/tools';
+import { isBundling, logger } from '@rozenite/tools';
import runtimePackage from '@rozenite/runtime/package.json' with { type: 'json' };
import path from 'node:path';
-import { isBundling } from './is-bundling.js';
+import { createRozeniteResolveRequest } from './resolver.js';
export type RozeniteMetroConfig = Omit & {
/**
* Whether to enable Rozenite.
- * If false, Rozenite will not be initialized and the config will be returned as is.
+ *
+ * If false, Rozenite starts no dev server and adds no middleware -- but the
+ * production guard stays installed, so importing a Rozenite plugin from app
+ * code still fails a production build. Turning Rozenite off is not a way to
+ * opt out of the guarantee.
+ *
* @default false
*/
enabled?: boolean;
@@ -24,6 +29,19 @@ export type RozeniteMetroConfig = Omit Promise | TMetroConfig;
+ /**
+ * Rozenite plugin packages that are allowed to reach a production bundle.
+ *
+ * By default, Rozenite's Metro resolver throws when a production build
+ * resolves into a Rozenite plugin package through anything other than
+ * that plugin's declared `productionEntries`. This is an escape hatch,
+ * not a fix: listing a package here defeats that guarantee for it, and
+ * its code -- devtools UI, agent wiring, whatever it ships -- can end up
+ * in what you ship to users. Prefer declaring `productionEntries` in the
+ * plugin's `rozenite.config.ts` instead. Every package listed here is
+ * logged loudly once per build.
+ */
+ allowInProduction?: string[];
};
export const withRozenite = (
@@ -33,6 +51,33 @@ export const withRozenite = (
return async () => {
const resolvedConfig = await config;
const projectRoot = resolvedConfig.projectRoot ?? process.cwd();
+ const allowInProduction = options.allowInProduction ?? [];
+
+ if (allowInProduction.length > 0) {
+ logger.warn(
+ `allowInProduction is set for: ${allowInProduction.join(', ')}. ` +
+ 'Code from these Rozenite plugin package(s) may reach your production bundle -- ' +
+ 'this defeats the production guarantee for them. Prefer declaring productionEntries ' +
+ "in the plugin's rozenite.config.ts instead.",
+ );
+ }
+
+ // The guard-only config: no dev server, no middleware, no
+ // watchFolders/extraNodeModules, no dev-entry redirect. Everything the
+ // `enabled === false` and bundling paths need, and nothing more.
+ const withGuardOnly = (): T =>
+ ({
+ ...resolvedConfig,
+ resolver: {
+ ...resolvedConfig.resolver,
+ resolveRequest: createRozeniteResolveRequest({
+ projectRoot,
+ allowInProduction,
+ installDevEntryRedirect: false,
+ previousResolveRequest: resolvedConfig.resolver?.resolveRequest,
+ }),
+ },
+ }) satisfies MetroConfig as T;
if (options.enabled === undefined) {
logger.info('Rozenite will no longer be enabled by default in the next version.');
@@ -40,12 +85,12 @@ export const withRozenite = (
logger.info('Remember to make it conditional to avoid bundling issues.');
if (isBundling(projectRoot)) {
- return resolvedConfig;
+ return withGuardOnly();
}
}
if (options.enabled === false) {
- return resolvedConfig;
+ return withGuardOnly();
}
const { devModePackage, middleware: rozeniteMiddleware } = await initializeRozenite(
@@ -80,23 +125,12 @@ export const withRozenite = (
),
}
: resolvedConfig.resolver?.extraNodeModules,
- resolveRequest: (context, moduleName, platform) => {
- // Unfortunately, 'web' doesn't include certain internal modules like 'react-native/Libraries/WebSocket/WebSocketInterceptor'.
- // This is currently the only module that we need to mock, but it may change in the future.
- if (
- platform === 'web' &&
- moduleName === 'react-native/Libraries/WebSocket/WebSocketInterceptor'
- ) {
- return {
- type: 'empty',
- };
- }
-
- return (
- resolvedConfig.resolver?.resolveRequest?.(context, moduleName, platform) ??
- context.resolveRequest(context, moduleName, platform)
- );
- },
+ resolveRequest: createRozeniteResolveRequest({
+ projectRoot,
+ allowInProduction,
+ installDevEntryRedirect: true,
+ previousResolveRequest: resolvedConfig.resolver?.resolveRequest,
+ }),
},
server: {
...resolvedConfig.server,
diff --git a/packages/metro/src/resolver.ts b/packages/metro/src/resolver.ts
new file mode 100644
index 00000000..8ea52456
--- /dev/null
+++ b/packages/metro/src/resolver.ts
@@ -0,0 +1,248 @@
+import type { CustomResolutionContext, CustomResolver, Resolution } from 'metro-resolver';
+import {
+ findRozenitePluginForFile,
+ isDevEntryOrigin,
+ isSeamDevEntryRequest,
+ formatProductionGuardError,
+ formatDevAdvisory,
+ warnOnceForImport,
+ getDevEntrySpecifier,
+ type RozenitePluginPackage,
+} from '@rozenite/middleware';
+import { logger } from '@rozenite/tools';
+
+const WEB_SOCKET_INTERCEPTOR_MODULE = 'react-native/Libraries/WebSocket/WebSocketInterceptor';
+
+// Resolving a plugin's declared `productionEntries` must go through the host
+// resolver (Metro's own standard algorithm, via `context.resolveRequest`),
+// not Node's `require.resolve` -- Node applies different export conditions
+// than Metro does and the two can land on different files, which would turn
+// a legitimate import into a false build failure. Memoized per
+// (pluginRoot, platform), alongside the `productionEntries` it was resolved
+// from: `findRozenitePluginForFile` (in `@rozenite/middleware`) already
+// re-reads a plugin's manifest when it changes underneath `rozenite dev`, so
+// comparing against that fresh value is what tells this cache its resolved
+// paths are stale, without this module re-stat'ing the manifest itself.
+type DeclaredEntriesCacheEntry = {
+ paths: Set;
+ productionEntries: string[];
+};
+const declaredEntriesCache = new Map();
+
+const sameProductionEntries = (a: string[], b: string[]): boolean =>
+ a.length === b.length && a.every((entry, index) => entry === b[index]);
+
+/**
+ * A declared entry is an *export subpath*, so it has to be resolved as the
+ * bare specifier a consumer would actually write -- `./register` becomes
+ * `@acme/some-plugin/register`. Resolving `./register` as a literal relative
+ * path instead would walk the plugin's own directory and land on its source
+ * `register.ts`, while the consumer's import goes through the `exports` map
+ * to `dist/react-native/register.js`. The two never match, so a correctly
+ * declared entry would fail the guard.
+ */
+const getEntrySpecifier = (pluginName: string, entry: string): string => {
+ return entry === '.' ? pluginName : `${pluginName}/${entry.replace(/^\.\//, '')}`;
+};
+
+// Re-entrancy flag: resolving declared entries below re-enters
+// `context.resolveRequest`, which per Metro's design is the built-in
+// standard resolver (not this custom resolver) and so cannot actually loop
+// back here. This flag is kept anyway as a defensive backstop so that inner
+// resolution never re-triggers the guard, regardless of how a given Metro
+// version or a test double wires `resolveRequest`.
+let isResolvingDeclaredEntries = false;
+
+const resolveDeclaredEntries = (
+ plugin: RozenitePluginPackage,
+ context: CustomResolutionContext,
+ platform: string | null,
+): Set => {
+ const cacheKey = `${plugin.root}\0${platform ?? ''}`;
+ const cached = declaredEntriesCache.get(cacheKey);
+
+ if (cached && sameProductionEntries(cached.productionEntries, plugin.productionEntries)) {
+ return cached.paths;
+ }
+
+ const resolvedPaths = new Set();
+
+ if (plugin.productionEntries.length > 0) {
+ isResolvingDeclaredEntries = true;
+
+ try {
+ for (const entry of plugin.productionEntries) {
+ let resolution: Resolution;
+
+ try {
+ // Resolved from the importing module, not from the plugin root or
+ // the project root: that is the exact context the import being
+ // checked resolved in, so the two cannot disagree. The plugin is
+ // already known to be reachable from here -- we only got this far
+ // because a resolution landed inside it.
+ resolution = context.resolveRequest(
+ context,
+ getEntrySpecifier(plugin.name, entry),
+ platform,
+ );
+ } catch (error) {
+ const cause = error instanceof Error ? error.message : String(error);
+ throw new Error(
+ `${plugin.name} declares "${entry}" as a production entry point, but it could not be resolved: ${cause}`,
+ );
+ }
+
+ if (resolution.type === 'sourceFile') {
+ resolvedPaths.add(resolution.filePath);
+ } else if (resolution.type === 'assetFiles') {
+ resolution.filePaths.forEach((filePath) => resolvedPaths.add(filePath));
+ }
+ }
+ } finally {
+ isResolvingDeclaredEntries = false;
+ }
+ }
+
+ declaredEntriesCache.set(cacheKey, {
+ paths: resolvedPaths,
+ productionEntries: [...plugin.productionEntries],
+ });
+ return resolvedPaths;
+};
+
+export type ProductionGuardOptions = {
+ projectRoot: string;
+ allowInProduction: string[];
+};
+
+/**
+ * Deciding whether a resolution is allowed, in order:
+ * 1. importer is itself inside a Rozenite plugin package -> allow.
+ * 2. resolved file is not inside a Rozenite plugin package -> allow.
+ * 3. plugin is listed in allowInProduction -> allow.
+ * 4. resolved file IS one of the plugin's declared productionEntries -> allow.
+ * 5. otherwise: production -> throw; development -> warn (suppressed for
+ * the dev entry itself).
+ */
+export const applyProductionGuard = (
+ context: CustomResolutionContext,
+ resolution: Resolution,
+ platform: string | null,
+ options: ProductionGuardOptions,
+): Resolution => {
+ if (isResolvingDeclaredEntries) {
+ return resolution;
+ }
+
+ if (resolution.type !== 'sourceFile') {
+ return resolution;
+ }
+
+ const originModulePath = context.originModulePath;
+
+ if (findRozenitePluginForFile(originModulePath)) {
+ return resolution;
+ }
+
+ const plugin = findRozenitePluginForFile(resolution.filePath);
+
+ if (!plugin) {
+ return resolution;
+ }
+
+ if (options.allowInProduction.includes(plugin.name)) {
+ return resolution;
+ }
+
+ const declaredEntryPaths = resolveDeclaredEntries(plugin, context, platform);
+
+ if (declaredEntryPaths.has(resolution.filePath)) {
+ return resolution;
+ }
+
+ if (!context.dev) {
+ throw new Error(
+ formatProductionGuardError({
+ plugin,
+ importedFrom: originModulePath,
+ projectRoot: options.projectRoot,
+ }),
+ );
+ }
+
+ if (!isDevEntryOrigin(originModulePath)) {
+ warnOnceForImport(
+ `${originModulePath}\0${plugin.name}`,
+ formatDevAdvisory({
+ plugin,
+ importedFrom: originModulePath,
+ projectRoot: options.projectRoot,
+ }),
+ );
+ }
+
+ return resolution;
+};
+
+let hasWarnedMissingDevEntry = false;
+
+export type RozeniteResolverOptions = {
+ projectRoot: string;
+ allowInProduction: string[];
+ /**
+ * Only true when Rozenite is actually enabled (`enabled === true`): the
+ * dev-entry redirect has no reason to run when Rozenite isn't wired up,
+ * and must never run when the guard-only config is installed
+ * (`enabled === false`, or the undefined-default bundling path).
+ */
+ installDevEntryRedirect: boolean;
+ previousResolveRequest?: CustomResolver | null;
+};
+
+/**
+ * Builds the `resolveRequest` Rozenite installs on the Metro config. Always
+ * preserves the pre-existing `WebSocketInterceptor` web special case and
+ * delegation to a user-supplied `resolveRequest` (falling back to Metro's
+ * own `context.resolveRequest`). Optionally redirects the seam package's
+ * dev-entry request to the project's `rozenite.dev` file. Always applies the
+ * production guard to the resulting resolution.
+ */
+export const createRozeniteResolveRequest = (options: RozeniteResolverOptions): CustomResolver => {
+ return (context, moduleName, platform) => {
+ if (platform === 'web' && moduleName === WEB_SOCKET_INTERCEPTOR_MODULE) {
+ return { type: 'empty' };
+ }
+
+ if (
+ options.installDevEntryRedirect &&
+ context.dev &&
+ isSeamDevEntryRequest(context.originModulePath, moduleName)
+ ) {
+ const devEntrySpecifier = getDevEntrySpecifier(options.projectRoot);
+
+ try {
+ return context.resolveRequest(context, devEntrySpecifier, platform);
+ } catch {
+ // A missing rozenite.dev file must never break the build: fall
+ // through to normal resolution below, which resolves the literal
+ // './dev-entry.js' request to the seam's shipped noop.
+ if (!hasWarnedMissingDevEntry) {
+ hasWarnedMissingDevEntry = true;
+ logger.warn(
+ `No rozenite.dev file found at ${devEntrySpecifier} (checked with your configured sourceExts ` +
+ 'and platform extensions). will render nothing until you add one.',
+ );
+ }
+ }
+ }
+
+ const resolution =
+ options.previousResolveRequest?.(context, moduleName, platform) ??
+ context.resolveRequest(context, moduleName, platform);
+
+ return applyProductionGuard(context, resolution, platform, {
+ projectRoot: options.projectRoot,
+ allowInProduction: options.allowInProduction,
+ });
+ };
+};
diff --git a/packages/middleware/README.md b/packages/middleware/README.md
index 4695cdba..70371786 100644
--- a/packages/middleware/README.md
+++ b/packages/middleware/README.md
@@ -17,6 +17,25 @@ This package is primarily used internally by Metro and Re.pack integrations. You
- **Express Middleware**: Provides custom Express middleware for plugin routing and serving
- **Configuration Options**: Flexible configuration for including/excluding specific plugins
+## Production guard core
+
+This package also owns the bundler-agnostic logic behind Rozenite's
+[production guarantee](https://www.rozenite.dev/docs/production-guarantee) — the check that keeps
+plugin code out of production bundles — so `@rozenite/metro` and `@rozenite/repack` share one
+implementation instead of two that could drift. It's exported for those integrations to build their
+resolver hooks on top of; you won't need it directly unless you're writing a new bundler integration:
+
+- `findRozenitePluginForFile` — resolves a file path to the Rozenite plugin package containing it (by
+ walking up to the nearest `package.json` with a `dist/rozenite.json` marker), or `null`. Memoized per
+ directory, including negative results, since bundler resolvers call this synchronously and
+ constantly.
+- `formatProductionGuardError` / `formatDevAdvisory` — the exact user-facing messages for the
+ production build error and the development warning, respectively, so both integrations print
+ identical wording.
+- `isDevEntryOrigin`, `getDevEntrySpecifier`, `isSeamDevEntryRequest`, `warnOnceForImport` — supporting
+ helpers for locating a project's `rozenite.dev` file and recognizing `@rozenite/react-native`'s own
+ dev-entry request.
+
## Plugin Discovery
The middleware automatically discovers Rozenite plugins by:
diff --git a/packages/middleware/src/__tests__/production-guard.test.ts b/packages/middleware/src/__tests__/production-guard.test.ts
new file mode 100644
index 00000000..3657599b
--- /dev/null
+++ b/packages/middleware/src/__tests__/production-guard.test.ts
@@ -0,0 +1,316 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ findRozenitePluginForFile,
+ isDevEntryOrigin,
+ isSeamDevEntryRequest,
+ formatProductionGuardError,
+ formatDevAdvisory,
+ warnOnceForImport,
+ getDevEntrySpecifier,
+} from '../production-guard.js';
+
+const tempDirs: string[] = [];
+
+const createTempDir = (): string => {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rozenite-production-guard-'));
+ tempDirs.push(tempDir);
+ return tempDir;
+};
+
+const writeJson = (filePath: string, value: unknown): void => {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
+};
+
+const writeFile = (filePath: string, contents = ''): void => {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, contents);
+};
+
+const createPackage = (
+ packageRoot: string,
+ packageName: string,
+ options?: { hasManifest?: boolean; manifestContents?: unknown | string },
+): void => {
+ writeJson(path.join(packageRoot, 'package.json'), { name: packageName, version: '1.0.0' });
+
+ if (options?.hasManifest) {
+ const manifestPath = path.join(packageRoot, 'dist', 'rozenite.json');
+
+ if (typeof options.manifestContents === 'string') {
+ writeFile(manifestPath, options.manifestContents);
+ } else {
+ writeJson(manifestPath, options.manifestContents ?? {});
+ }
+ }
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+
+ while (tempDirs.length) {
+ fs.rmSync(tempDirs.pop()!, { recursive: true, force: true });
+ }
+});
+
+describe('findRozenitePluginForFile', () => {
+ // Regression: tsc cannot emit .cjs/.mjs, so the plugin build drops a bare
+ // `{"type": "module"}` marker into every output directory. That marker is
+ // the first package.json above a resolved plugin entry, and treating it as
+ // the package root stops the walk two directories short of
+ // dist/rozenite.json -- which made the guard read every plugin as "not a
+ // plugin" and permit everything, in a real production bundle, silently.
+ it('walks past the nameless module-type markers the build emits', () => {
+ const packageRoot = createTempDir();
+ writeJson(path.join(packageRoot, 'package.json'), { name: '@acme/some-plugin' });
+ writeJson(path.join(packageRoot, 'dist', 'rozenite.json'), {});
+ writeJson(path.join(packageRoot, 'dist', 'react-native', 'package.json'), {
+ type: 'module',
+ });
+ writeJson(path.join(packageRoot, 'dist', 'react-native', 'cjs', 'package.json'), {
+ type: 'commonjs',
+ });
+
+ const esmEntry = path.join(packageRoot, 'dist', 'react-native', 'react-native.js');
+ const cjsEntry = path.join(packageRoot, 'dist', 'react-native', 'cjs', 'react-native.js');
+
+ expect(findRozenitePluginForFile(esmEntry)?.name).toBe('@acme/some-plugin');
+ expect(findRozenitePluginForFile(cjsEntry)?.name).toBe('@acme/some-plugin');
+ });
+
+ it('detects a package with dist/rozenite.json', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/some-plugin', { hasManifest: true });
+
+ const filePath = path.join(packageRoot, 'src', 'index.ts');
+ const plugin = findRozenitePluginForFile(filePath);
+
+ expect(plugin).not.toBeNull();
+ expect(plugin?.name).toBe('@acme/some-plugin');
+ expect(plugin?.root).toBe(fs.realpathSync(packageRoot));
+ });
+
+ it('ignores a package without dist/rozenite.json', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/not-a-plugin');
+
+ const filePath = path.join(packageRoot, 'src', 'index.ts');
+
+ expect(findRozenitePluginForFile(filePath)).toBeNull();
+ });
+
+ it('reads productionEntries out of the manifest', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/with-entries', {
+ hasManifest: true,
+ manifestContents: { productionEntries: ['./register', './other'] },
+ });
+
+ const filePath = path.join(packageRoot, 'src', 'nested', 'file.ts');
+ const plugin = findRozenitePluginForFile(filePath);
+
+ expect(plugin?.productionEntries).toEqual(['./register', './other']);
+ });
+
+ it('degrades a malformed manifest to no declared entries, without crashing', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/malformed', {
+ hasManifest: true,
+ manifestContents: '{ this is not json',
+ });
+
+ const filePath = path.join(packageRoot, 'src', 'index.ts');
+
+ expect(() => findRozenitePluginForFile(filePath)).not.toThrow();
+
+ // A malformed manifest still exists on disk, so the package is still a
+ // plugin -- just one that has declared nothing.
+ const plugin = findRozenitePluginForFile(filePath);
+ expect(plugin).not.toBeNull();
+ expect(plugin?.productionEntries).toEqual([]);
+ });
+
+ it('memoizes per directory while the manifest is unchanged', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/memoized', { hasManifest: true });
+
+ const filePath = path.join(packageRoot, 'src', 'index.ts');
+ const first = findRozenitePluginForFile(filePath);
+ expect(first).not.toBeNull();
+
+ // Same manifest, same mtime -- must hit the cache rather than re-reading
+ // disk on every resolution (`resolveRequest` is synchronous and called
+ // on every module resolution).
+ const readFileSpy = vi.spyOn(fs, 'readFileSync');
+ const second = findRozenitePluginForFile(filePath);
+
+ expect(second).toEqual(first);
+ expect(readFileSpy).not.toHaveBeenCalled();
+ });
+
+ // `rozenite dev` rebuilds a plugin's `dist/rozenite.json` while the
+ // bundler keeps running (its Vite watcher reacts to source changes). A
+ // cache that never invalidated would keep answering with whatever the
+ // plugin looked like the first time it was resolved for the rest of the
+ // session.
+ it('invalidates the cache when the manifest is rebuilt with different content', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/rebuilt', {
+ hasManifest: true,
+ manifestContents: { productionEntries: ['./register'] },
+ });
+
+ const filePath = path.join(packageRoot, 'src', 'index.ts');
+ const first = findRozenitePluginForFile(filePath);
+ expect(first?.productionEntries).toEqual(['./register']);
+
+ // Rewriting in place can land on the same mtime tick as the original
+ // write on a fast filesystem; bump it explicitly to simulate a rebuild a
+ // moment later, exactly like a real filesystem would report one.
+ const manifestPath = path.join(packageRoot, 'dist', 'rozenite.json');
+ writeJson(manifestPath, { productionEntries: [] });
+ const bumpedMtime = new Date(fs.statSync(manifestPath).mtimeMs + 1000);
+ fs.utimesSync(manifestPath, bumpedMtime, bumpedMtime);
+
+ const second = findRozenitePluginForFile(filePath);
+ expect(second?.productionEntries).toEqual([]);
+ });
+
+ it('invalidates the cache when the manifest is removed', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/removed', { hasManifest: true });
+
+ const filePath = path.join(packageRoot, 'src', 'index.ts');
+ const first = findRozenitePluginForFile(filePath);
+ expect(first).not.toBeNull();
+
+ fs.rmSync(path.join(packageRoot, 'dist', 'rozenite.json'));
+ const second = findRozenitePluginForFile(filePath);
+
+ expect(second).toBeNull();
+ });
+});
+
+describe('isDevEntryOrigin', () => {
+ it('is true for rozenite.dev.tsx', () => {
+ expect(isDevEntryOrigin('/project/rozenite.dev.tsx')).toBe(true);
+ });
+
+ it('is true for a platform-suffixed rozenite.dev.ios.tsx', () => {
+ expect(isDevEntryOrigin('/project/rozenite.dev.ios.tsx')).toBe(true);
+ });
+
+ it('is true for a file inside a rozenite.dev/ directory', () => {
+ expect(isDevEntryOrigin('/project/rozenite.dev/index.tsx')).toBe(true);
+ });
+
+ it('is false for an ordinary project file', () => {
+ expect(isDevEntryOrigin('/project/src/screens/Settings.tsx')).toBe(false);
+ });
+});
+
+describe('isSeamDevEntryRequest', () => {
+ it('matches the dev-entry specifier requested from inside the seam package', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@rozenite/react-native');
+
+ const originModulePath = path.join(packageRoot, 'dist', 'cjs', 'index.js');
+
+ expect(isSeamDevEntryRequest(originModulePath, './dev-entry.js')).toBe(true);
+ expect(isSeamDevEntryRequest(originModulePath, './dev-entry')).toBe(true);
+ });
+
+ it('does not match an unrelated request from inside the seam package', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@rozenite/react-native');
+
+ const originModulePath = path.join(packageRoot, 'dist', 'cjs', 'index.js');
+
+ expect(isSeamDevEntryRequest(originModulePath, './something-else.js')).toBe(false);
+ });
+
+ it('does not match when the seam package is not installed (origin outside it)', () => {
+ const packageRoot = createTempDir();
+ createPackage(packageRoot, '@acme/some-other-package');
+
+ const originModulePath = path.join(packageRoot, 'src', 'index.ts');
+
+ expect(isSeamDevEntryRequest(originModulePath, './dev-entry.js')).toBe(false);
+ });
+});
+
+describe('formatProductionGuardError', () => {
+ it('matches the documented shape when the plugin declares no production entries', () => {
+ const message = formatProductionGuardError({
+ plugin: {
+ name: '@acme/some-plugin',
+ root: '/node_modules/@acme/some-plugin',
+ productionEntries: [],
+ },
+ importedFrom: '/project/src/screens/Settings.tsx',
+ projectRoot: '/project',
+ });
+
+ const lines = message.split('\n');
+ expect(lines[0]).toBe(
+ '@acme/some-plugin is a Rozenite plugin and declares no production entry points.',
+ );
+ expect(lines[1]).toBe('Imported from: src/screens/Settings.tsx');
+ expect(lines[2]).toMatch(/rozenite\.dev\.tsx/);
+ expect(lines[2]).toMatch(/allowInProduction/);
+ });
+
+ it('formats an absolute path when the importer is outside projectRoot', () => {
+ const message = formatProductionGuardError({
+ plugin: {
+ name: '@acme/some-plugin',
+ root: '/node_modules/@acme/some-plugin',
+ productionEntries: [],
+ },
+ importedFrom: '/elsewhere/Settings.tsx',
+ projectRoot: '/project',
+ });
+
+ expect(message.split('\n')[1]).toBe('Imported from: /elsewhere/Settings.tsx');
+ });
+});
+
+describe('formatDevAdvisory', () => {
+ it('matches the documented shape', () => {
+ const message = formatDevAdvisory({
+ plugin: {
+ name: '@rozenite/mmkv-plugin',
+ root: '/node_modules/@rozenite/mmkv-plugin',
+ productionEntries: [],
+ },
+ importedFrom: '/project/src/screens/Settings.tsx',
+ projectRoot: '/project',
+ });
+
+ expect(message).toBe(
+ 'warning: @rozenite/mmkv-plugin imported from src/screens/Settings.tsx.\n' +
+ ' Plugin imports belong in rozenite.dev.tsx. This will fail your production build.',
+ );
+ });
+});
+
+describe('warnOnceForImport', () => {
+ it('warns only once per key', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ warnOnceForImport('a\0b', 'first message');
+ warnOnceForImport('a\0b', 'first message');
+ warnOnceForImport('c\0d', 'second message');
+
+ expect(warnSpy).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('getDevEntrySpecifier', () => {
+ it('joins the project root with the extensionless rozenite.dev specifier', () => {
+ expect(getDevEntrySpecifier('/project')).toBe(path.join('/project', 'rozenite.dev'));
+ });
+});
diff --git a/packages/middleware/src/index.ts b/packages/middleware/src/index.ts
index 25ef8d72..ad9ea0e3 100644
--- a/packages/middleware/src/index.ts
+++ b/packages/middleware/src/index.ts
@@ -20,6 +20,20 @@ export type RozeniteInstance = {
export { createScopedMiddleware };
export type { MiddlewareHandler, MiddlewareNext, MiddlewareRequest } from './scoped-middleware.js';
+export {
+ findRozenitePluginForFile,
+ isDevEntryOrigin,
+ formatProductionGuardError,
+ formatDevAdvisory,
+ warnOnceForImport,
+ getDevEntrySpecifier,
+ isSeamDevEntryRequest,
+ type RozenitePluginPackage,
+} from './production-guard.js';
+export {
+ RozeniteResolverPlugin,
+ type RozeniteResolverPluginOptions,
+} from './rspack-resolver-plugin.js';
export const initializeRozenite = async (
options: RozeniteConfig,
diff --git a/packages/middleware/src/production-guard.ts b/packages/middleware/src/production-guard.ts
new file mode 100644
index 00000000..48994fe5
--- /dev/null
+++ b/packages/middleware/src/production-guard.ts
@@ -0,0 +1,302 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { ROZENITE_MANIFEST } from './constants.js';
+import { logger } from './logger.js';
+
+/**
+ * A Rozenite plugin package as discovered on disk by walking up from a
+ * resolved file to its nearest `package.json`.
+ */
+export type RozenitePluginPackage = {
+ /** package.json `name` field. */
+ name: string;
+ /** realpath of the package root (the directory containing package.json). */
+ root: string;
+ /** `productionEntries` as declared in `dist/rozenite.json`; `[]` when absent. */
+ productionEntries: string[];
+};
+
+type PluginLookupResult = RozenitePluginPackage | null;
+
+type PluginCacheEntry = {
+ result: PluginLookupResult;
+ /** `dist/rozenite.json` this entry's `result` was computed from, or null
+ * when the walk never reached a package root (e.g. filesystem root). */
+ manifestPath: string | null;
+ /** mtime of `manifestPath` at computation time, or null when it did not
+ * exist yet. Re-stat'd on every cache hit below. */
+ manifestMtimeMs: number | null;
+};
+
+// Memoized per directory (both hits and misses), so repeated resolutions in
+// a hot directory cost nothing. `resolveRequest` is synchronous and called
+// on every module resolution, so this cannot afford to re-walk the
+// filesystem per request -- but `rozenite dev` rebuilds a plugin's
+// `dist/rozenite.json` while the bundler keeps running (its Vite watcher
+// reacts to source changes), so a plain process-lifetime cache would keep
+// answering with whatever the plugin looked like the first time it was
+// resolved. Each entry instead carries the manifest's mtime and is re-stat'd
+// on every hit, so a rebuild invalidates it on the next resolution.
+const pluginCache = new Map();
+
+const statMtimeMs = (filePath: string): number | null => {
+ try {
+ return fs.statSync(filePath).mtimeMs;
+ } catch {
+ return null;
+ }
+};
+
+const readJsonSafe = (filePath: string): unknown => {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+ } catch {
+ return null;
+ }
+};
+
+const readProductionEntries = (manifestPath: string): string[] => {
+ const manifest = readJsonSafe(manifestPath);
+
+ if (
+ manifest === null ||
+ typeof manifest !== 'object' ||
+ !Array.isArray((manifest as Record).productionEntries)
+ ) {
+ // Covers both "no such field" and "the manifest failed to parse" -- a
+ // malformed manifest must degrade to "no declared entries", never crash
+ // a build.
+ return [];
+ }
+
+ return (manifest as { productionEntries: unknown[] }).productionEntries.filter(
+ (entry): entry is string => typeof entry === 'string',
+ );
+};
+
+const readPackageNameOrNull = (packageJsonPath: string): string | null => {
+ const packageJson = readJsonSafe(packageJsonPath);
+ const name =
+ packageJson !== null && typeof packageJson === 'object'
+ ? (packageJson as Record).name
+ : undefined;
+
+ return typeof name === 'string' ? name : null;
+};
+
+/**
+ * A package root is a directory whose `package.json` names a package.
+ *
+ * The `name` check is load-bearing, not defensive. tsc cannot emit `.cjs`/
+ * `.mjs`, so the plugin build drops a bare `{"type": "module"}` /
+ * `{"type": "commonjs"}` marker into each output directory to tell Node how
+ * to read the plain `.js` files next to it. Those markers sit between a
+ * resolved file and its real package root - a plugin entry resolves to
+ * `/dist/react-native/react-native.js`, and
+ * `dist/react-native/package.json` is the first `package.json` above it.
+ * Treating one as a package root stops the walk two directories short of
+ * `/dist/rozenite.json`, so every plugin reads as "not a Rozenite
+ * plugin" and the guard silently permits everything.
+ */
+const isPackageRoot = (dir: string): boolean => {
+ return readPackageNameOrNull(path.join(dir, 'package.json')) !== null;
+};
+
+const realpathSafe = (dir: string): string => {
+ try {
+ return fs.realpathSync(dir);
+ } catch {
+ return dir;
+ }
+};
+
+/** The package at `packageRoot` is a Rozenite plugin iff this manifest exists. */
+const readPluginAtPackageRoot = (packageRoot: string): PluginLookupResult => {
+ const manifestPath = path.join(packageRoot, 'dist', ROZENITE_MANIFEST);
+
+ if (!fs.existsSync(manifestPath)) {
+ return null;
+ }
+
+ const name = readPackageNameOrNull(path.join(packageRoot, 'package.json'));
+
+ if (name === null) {
+ return null;
+ }
+
+ return {
+ name,
+ root: realpathSafe(packageRoot),
+ productionEntries: readProductionEntries(manifestPath),
+ };
+};
+
+const findPluginForDirectory = (dir: string): PluginCacheEntry => {
+ const cached = pluginCache.get(dir);
+
+ if (cached && statMtimeMs(cached.manifestPath ?? '') === cached.manifestMtimeMs) {
+ return cached;
+ }
+
+ let entry: PluginCacheEntry;
+
+ if (isPackageRoot(dir)) {
+ // The first *named* package.json going up is the package root, whether
+ // or not it turns out to be a Rozenite plugin -- we never look past it.
+ const manifestPath = path.join(dir, 'dist', ROZENITE_MANIFEST);
+ entry = {
+ result: readPluginAtPackageRoot(dir),
+ manifestPath,
+ manifestMtimeMs: statMtimeMs(manifestPath),
+ };
+ } else {
+ const parentDir = path.dirname(dir);
+ entry =
+ parentDir === dir
+ ? { result: null, manifestPath: null, manifestMtimeMs: null }
+ : findPluginForDirectory(parentDir);
+ }
+
+ pluginCache.set(dir, entry);
+ return entry;
+};
+
+/**
+ * Realpath'd file path -> the Rozenite plugin package containing it, or
+ * null. Walks up from the file's directory to the first `package.json`;
+ * that package is a Rozenite plugin iff `dist/rozenite.json` exists there.
+ */
+export const findRozenitePluginForFile = (filePath: string): RozenitePluginPackage | null => {
+ return findPluginForDirectory(path.dirname(filePath)).result;
+};
+
+const DEV_ENTRY_BASENAME = 'rozenite.dev';
+
+/**
+ * True when the importing file's own layout says it is (part of) the dev
+ * entry: its basename starts with `rozenite.dev` (matches `rozenite.dev.tsx`,
+ * `rozenite.dev.ios.tsx`, ...), or any path segment of it is a `rozenite.dev`
+ * directory.
+ */
+export const isDevEntryOrigin = (originModulePath: string): boolean => {
+ const segments = originModulePath.split(path.sep);
+ const basename = segments[segments.length - 1] ?? '';
+
+ if (basename.startsWith(DEV_ENTRY_BASENAME)) {
+ return true;
+ }
+
+ return segments.includes(DEV_ENTRY_BASENAME);
+};
+
+const formatImportedFrom = (importedFrom: string, projectRoot: string): string => {
+ const relative = path.relative(projectRoot, importedFrom);
+ const isInsideProjectRoot =
+ relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
+
+ return isInsideProjectRoot ? relative : importedFrom;
+};
+
+/** The two user-facing messages, so Metro and Re.Pack cannot drift. */
+export const formatProductionGuardError = (args: {
+ plugin: RozenitePluginPackage;
+ importedFrom: string;
+ projectRoot: string;
+}): string => {
+ const { plugin, importedFrom, projectRoot } = args;
+ const declaration =
+ plugin.productionEntries.length === 0
+ ? 'declares no production entry points'
+ : 'does not declare this file as a production entry point';
+
+ return [
+ `${plugin.name} is a Rozenite plugin and ${declaration}.`,
+ `Imported from: ${formatImportedFrom(importedFrom, projectRoot)}`,
+ `Move plugin wiring into rozenite.dev.tsx, or declare this file in productionEntries in rozenite.config.ts. To bypass this check for ${plugin.name} only, pass allowInProduction: ['${plugin.name}'] to withRozenite().`,
+ ].join('\n');
+};
+
+export const formatDevAdvisory = (args: {
+ plugin: RozenitePluginPackage;
+ importedFrom: string;
+ projectRoot: string;
+}): string => {
+ const { plugin, importedFrom, projectRoot } = args;
+
+ return [
+ `warning: ${plugin.name} imported from ${formatImportedFrom(importedFrom, projectRoot)}.`,
+ ` Plugin imports belong in rozenite.dev.tsx. This will fail your production build.`,
+ ].join('\n');
+};
+
+// Warn-once bookkeeping, keyed by the caller-supplied key (per the
+// documented `${importedFrom}\0${plugin.name}` shape).
+const warnedKeys = new Set();
+
+/** Warn-once bookkeeping keyed by `${importedFrom}\0${plugin.name}`. */
+export const warnOnceForImport = (key: string, message: string): void => {
+ if (warnedKeys.has(key)) {
+ return;
+ }
+
+ warnedKeys.add(key);
+ logger.warn(message);
+};
+
+/** Locate `/rozenite.dev` (extensionless) — the bundler resolves the extension. */
+export const getDevEntrySpecifier = (projectRoot: string): string => {
+ return path.join(projectRoot, 'rozenite.dev');
+};
+
+const SEAM_PACKAGE_NAME = '@rozenite/react-native';
+
+// The relative specifier `@rozenite/react-native`'s `src/index.tsx` emits for
+// its dev-entry seam (`import DevEntry from './dev-entry.js'`). Matched with
+// and without the extension since the CJS/ESM emit may differ.
+const SEAM_DEV_ENTRY_REQUESTS = new Set(['./dev-entry.js', './dev-entry']);
+
+// Separate cache from `pluginCache`: this walk answers "what package is this
+// file inside", not "is this file inside a Rozenite plugin", and the seam
+// package itself is not a Rozenite plugin (it ships no dist/rozenite.json).
+const packageNameCache = new Map();
+
+const findPackageNameForDirectory = (dir: string): string | null => {
+ const cached = packageNameCache.get(dir);
+
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ // Same module-type-marker hazard as `findPluginForDirectory`: the seam's
+ // own CommonJS output carries a nameless `{"type": "commonjs"}` marker, so
+ // stopping at the first package.json would fail to recognise the seam and
+ // silently skip the dev-entry redirect for CJS consumers.
+ const name = readPackageNameOrNull(path.join(dir, 'package.json'));
+ let result: string | null;
+
+ if (name !== null) {
+ result = name;
+ } else {
+ const parentDir = path.dirname(dir);
+ result = parentDir === dir ? null : findPackageNameForDirectory(parentDir);
+ }
+
+ packageNameCache.set(dir, result);
+ return result;
+};
+
+/**
+ * True when this request is the seam package (`@rozenite/react-native`)
+ * asking for its shipped noop -- i.e. `originModulePath` resolves (by
+ * walking up to its nearest package.json) to that package, and `request` is
+ * its dev-entry specifier. "Seam not installed" (no such package.json found)
+ * is "no match", never an error -- an app that does not use ``
+ * must still build.
+ */
+export const isSeamDevEntryRequest = (originModulePath: string, request: string): boolean => {
+ if (!SEAM_DEV_ENTRY_REQUESTS.has(request)) {
+ return false;
+ }
+
+ return findPackageNameForDirectory(path.dirname(originModulePath)) === SEAM_PACKAGE_NAME;
+};
diff --git a/packages/middleware/src/rspack-resolver-plugin.ts b/packages/middleware/src/rspack-resolver-plugin.ts
new file mode 100644
index 00000000..3d49c0d3
--- /dev/null
+++ b/packages/middleware/src/rspack-resolver-plugin.ts
@@ -0,0 +1,416 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import {
+ findRozenitePluginForFile,
+ isDevEntryOrigin,
+ isSeamDevEntryRequest,
+ formatProductionGuardError,
+ formatDevAdvisory,
+ warnOnceForImport,
+ getDevEntrySpecifier,
+ type RozenitePluginPackage,
+} from './production-guard.js';
+import { logger } from '@rozenite/tools';
+
+// We intentionally do NOT import types (or values) from `@rspack/core` here.
+// It's an optional peer dependency of `@callstack/repack` -- not guaranteed
+// to be resolvable wherever `@rozenite/repack` itself is type-checked, built,
+// or consumed -- and `@callstack/repack` does not re-export its types for us
+// to borrow. The shapes below describe exactly the slice of the
+// NormalModuleFactory-hooks surface we touch, which rspack implements
+// identically to webpack.
+type ContextInfo = { issuer: string };
+
+type ResolveData = {
+ request: string;
+ context: string;
+ contextInfo: ContextInfo;
+ createData?: { resource?: string };
+};
+
+type Tappable = { tap: (name: string, fn: (arg: T) => void) => void };
+
+type Resolver = {
+ resolveSync: (context: object, path: string, request: string) => string | false;
+};
+
+type ResolverFactory = {
+ get: (type: string, resolveOptions?: unknown) => Resolver;
+};
+
+type NormalModuleFactory = {
+ hooks: {
+ beforeResolve: Tappable;
+ afterResolve: Tappable;
+ };
+ resolverFactory: ResolverFactory;
+};
+
+type Compilation = {
+ errors: Error[];
+ resolverFactory: ResolverFactory;
+};
+
+// Both webpack and rspack compilers expose the module namespace on
+// `compiler.webpack`/`compiler.rspack` precisely so plugins never have to
+// import the bundler package themselves just to reach a constructor like
+// `WebpackError`.
+type BundlerNamespace = { WebpackError: new (message: string) => Error };
+
+type Compiler = {
+ webpack?: BundlerNamespace;
+ rspack?: BundlerNamespace;
+ options: {
+ resolve?: {
+ extensions?: string[];
+ };
+ };
+ hooks: {
+ normalModuleFactory: Tappable;
+ compilation: {
+ tap: (
+ name: string,
+ fn: (
+ compilation: Compilation,
+ params: { normalModuleFactory: NormalModuleFactory },
+ ) => void,
+ ) => void;
+ };
+ };
+};
+
+const PLUGIN_NAME = 'RozeniteResolverPlugin';
+
+/**
+ * A declared entry is an *export subpath*, so it has to be resolved as the
+ * bare specifier a consumer would actually write -- `./register` becomes
+ * `@acme/some-plugin/register`. Resolving `./register` as a literal relative
+ * path instead would walk the plugin's own directory and land on its source
+ * `register.ts`, while the consumer's import goes through the `exports` map
+ * to `dist/react-native/register.js`. The two never match, so a correctly
+ * declared entry would fail the guard. Mirrors
+ * `packages/metro/src/resolver.ts`'s `getEntrySpecifier` exactly.
+ */
+const getEntrySpecifier = (pluginName: string, entry: string): string => {
+ return entry === '.' ? pluginName : `${pluginName}/${entry.replace(/^\.\//, '')}`;
+};
+
+const getWebpackErrorConstructor = (compiler: Compiler): new (message: string) => Error => {
+ return compiler.webpack?.WebpackError ?? compiler.rspack?.WebpackError ?? Error;
+};
+
+/**
+ * `resolveData.contextInfo.issuer` is the importing file's absolute path,
+ * exactly what `originModulePath` means throughout `@rozenite/middleware`'s
+ * shared core -- present at every stage of resolution (`beforeResolve`
+ * through `afterResolve`), since it's the same `ResolveData` object mutated
+ * in place as resolution proceeds. It's only empty for the handful of
+ * modules that have no issuer (e.g. the bundle entry point itself); in that
+ * case we fall back to a path built from `resolveData.context` (the
+ * importing module's directory), because every shared-core helper that takes
+ * an "origin module path" immediately does `path.dirname(originModulePath)`
+ * -- passing a directory directly would make that dirname() call walk one
+ * level too high.
+ */
+const getOriginModulePath = (resolveData: ResolveData): string => {
+ return resolveData.contextInfo.issuer || path.join(resolveData.context, '');
+};
+
+let hasWarnedMissingDevEntry = false;
+
+export type RozeniteResolverPluginOptions = {
+ projectRoot: string;
+ allowInProduction: string[];
+ /** Whether this compiler is bundling for production (`env.mode === 'production'`). */
+ isDev: boolean;
+ /**
+ * Only true when Rozenite is actually enabled (`enabled === true`): the
+ * dev-entry redirect has no reason to run when Rozenite isn't wired up,
+ * and must never run when the guard-only config is installed
+ * (`enabled === false`).
+ */
+ installDevEntryRedirect: boolean;
+};
+
+/**
+ * A single rspack plugin implementing both Rozenite behaviours documented in
+ * `packages/metro/src/resolver.ts`, mirrored here so Metro and Re.Pack cannot
+ * drift:
+ *
+ * 1. In development, redirects the `@rozenite/react-native` seam's
+ * `./dev-entry.js` request to the project's `rozenite.dev` file, falling
+ * back (with a once-only warning) to the shipped noop when absent.
+ * 2. Unconditionally guards production bundles against importing Rozenite
+ * plugin code that was never declared reachable in production.
+ *
+ * Lives in `@rozenite/middleware` rather than `@rozenite/repack` so it can be
+ * shared with `@rozenite/lynx` (issue #492), which installs the same plugin
+ * through Rsbuild's `modifyRspackConfig` and must not depend on
+ * `@rozenite/repack`. It stays free of any `@rspack/core` dependency (see the
+ * hand-written structural types above) so pulling it in adds no rspack
+ * dependency to the middleware.
+ */
+export class RozeniteResolverPlugin {
+ private readonly options: RozeniteResolverPluginOptions;
+
+ // Memoized per plugin package root. One plugin instance is created per
+ // `withRozenite` config-function invocation, which Re.Pack calls once per
+ // platform/compiler -- so this is equivalent to Metro's
+ // per-(pluginRoot, platform) memoization without needing platform in the
+ // key.
+ private readonly declaredEntriesCache = new Map>();
+
+ // Defensive backstop: `Resolver#resolveSync` below is enhanced-resolve's
+ // own direct resolution, not the NormalModuleFactory pipeline, so it
+ // cannot actually re-trigger `afterResolve`. Kept anyway so resolving a
+ // declared entry never recurses back into the guard, regardless of rspack
+ // version.
+ private isResolvingDeclaredEntries = false;
+
+ constructor(options: RozeniteResolverPluginOptions) {
+ this.options = options;
+ }
+
+ apply(compiler: Compiler): void {
+ if (this.options.installDevEntryRedirect) {
+ compiler.hooks.normalModuleFactory.tap(PLUGIN_NAME, (normalModuleFactory) => {
+ normalModuleFactory.hooks.beforeResolve.tap(PLUGIN_NAME, (resolveData) => {
+ this.redirectDevEntry(resolveData, compiler);
+ });
+ });
+ }
+
+ compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation, { normalModuleFactory }) => {
+ normalModuleFactory.hooks.afterResolve.tap(PLUGIN_NAME, (resolveData) => {
+ this.applyProductionGuard(resolveData, compiler, compilation, normalModuleFactory);
+ });
+ });
+ }
+
+ private redirectDevEntry(resolveData: ResolveData, compiler: Compiler): void {
+ if (!this.options.isDev) {
+ return;
+ }
+
+ const originModulePath = getOriginModulePath(resolveData);
+
+ if (!isSeamDevEntryRequest(originModulePath, resolveData.request)) {
+ return;
+ }
+
+ const devEntrySpecifier = getDevEntrySpecifier(this.options.projectRoot);
+
+ // A missing `rozenite.dev` file must never fail the build: rewriting
+ // `resolveData.request` unconditionally would turn a missing file into
+ // an unresolvable specifier (rspack's `NormalModuleReplacementPlugin`
+ // approach doesn't even take effect here -- verified). So we check for
+ // it ourselves, synchronously, honouring the same extensions the
+ // bundler would, and resolve it to a concrete, fully-specified file.
+ //
+ // Rewriting to the EXTENSIONLESS specifier and letting `resolve.extensions`
+ // pick the file (as an initial reading of this problem suggested) does
+ // NOT work for this seam in practice: `@rozenite/react-native` ships as
+ // a strict ES module (`"type": "module"`), and Node/webpack ESM
+ // resolution requires import specifiers from a strict ESM importer to be
+ // "fully specified" (extension included) -- an extensionless rewrite
+ // fails there with "the request ... failed to resolve only because it
+ // was resolved as fully specified". Resolving the concrete file
+ // ourselves and rewriting straight to it sidesteps that rule entirely
+ // and works for both ESM and CommonJS importers.
+ const devEntryFile = this.findDevEntryFile(devEntrySpecifier, compiler);
+
+ if (!devEntryFile) {
+ if (!hasWarnedMissingDevEntry) {
+ hasWarnedMissingDevEntry = true;
+ logger.warn(
+ `No rozenite.dev file found at ${devEntrySpecifier} (checked with your configured resolve.extensions). ` +
+ ' will render nothing until you add one.',
+ );
+ }
+ return;
+ }
+
+ resolveData.request = devEntryFile;
+ }
+
+ /**
+ * Resolves `/rozenite.dev` to a concrete file, honouring the
+ * same `resolve.extensions` the bundler would (already carrying this
+ * project's platform variants, e.g. `.ios.tsx`) and the same priority a
+ * real resolve would use: a flat file (`rozenite.dev.tsx`,
+ * `rozenite.dev.ios.tsx`, ...) before a directory's index
+ * (`rozenite.dev/index.tsx`, `rozenite.dev/index.web.tsx`, ...). Returns
+ * `null` when neither form exists -- the caller falls back to the seam's
+ * shipped noop rather than failing the build.
+ */
+ private findDevEntryFile(devEntrySpecifier: string, compiler: Compiler): string | null {
+ const extensions = compiler.options.resolve?.extensions ?? [];
+
+ for (const ext of extensions) {
+ const candidate = devEntrySpecifier + ext;
+ if (fs.existsSync(candidate)) {
+ return candidate;
+ }
+ }
+
+ for (const ext of extensions) {
+ const candidate = path.join(devEntrySpecifier, `index${ext}`);
+ if (fs.existsSync(candidate)) {
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Resolving a plugin's declared `productionEntries` must go through
+ * rspack's own resolver -- not Node's `require.resolve` -- so export
+ * conditions match what the build actually used (a literal relative
+ * resolve of `./register` lands on the plugin's source `register.ts`
+ * instead of the `exports`-mapped `dist/react-native/register.js` a real
+ * consumer import resolves to).
+ */
+ private resolveDeclaredEntries(
+ plugin: RozenitePluginPackage,
+ resolveData: ResolveData,
+ resolverFactory: ResolverFactory,
+ ): Set {
+ const cached = this.declaredEntriesCache.get(plugin.root);
+
+ if (cached) {
+ return cached;
+ }
+
+ const resolvedPaths = new Set();
+
+ if (plugin.productionEntries.length > 0) {
+ this.isResolvingDeclaredEntries = true;
+
+ try {
+ const resolver = resolverFactory.get('normal');
+
+ for (const entry of plugin.productionEntries) {
+ const specifier = getEntrySpecifier(plugin.name, entry);
+ // Resolved from the importing module's directory, not from the
+ // plugin root or the project root: that is the exact context the
+ // import being checked resolved in, so the two cannot disagree.
+ //
+ // `resolveSync` is typed as returning `false` on failure, but in
+ // practice (rspack 2.0.0-alpha.1) it THROWS instead -- verified
+ // with a deliberately unresolvable declared entry, which raised a
+ // raw `RspackResolver(NotFound(...))` error rather than returning
+ // `false`. Catch both shapes so a typo always reads as our own
+ // clearly-worded error, not the resolver's raw one.
+ let resolved: string | false;
+
+ try {
+ resolved = resolver.resolveSync({}, resolveData.context, specifier);
+ } catch {
+ resolved = false;
+ }
+
+ if (!resolved) {
+ throw new Error(
+ `${plugin.name} declares "${entry}" as a production entry point, but it could not be resolved.`,
+ );
+ }
+
+ resolvedPaths.add(resolved);
+ }
+ } finally {
+ this.isResolvingDeclaredEntries = false;
+ }
+ }
+
+ this.declaredEntriesCache.set(plugin.root, resolvedPaths);
+ return resolvedPaths;
+ }
+
+ /**
+ * Deciding whether a resolution is allowed, in the same order as
+ * `applyProductionGuard` in `packages/metro/src/resolver.ts`:
+ * 1. importer is itself inside a Rozenite plugin package -> allow.
+ * 2. resolved file is not inside a Rozenite plugin package -> allow.
+ * 3. plugin is listed in allowInProduction -> allow.
+ * 4. resolved file IS one of the plugin's declared productionEntries -> allow.
+ * 5. otherwise: production -> fail the build; development -> warn once
+ * (suppressed for the dev entry itself).
+ */
+ private applyProductionGuard(
+ resolveData: ResolveData,
+ compiler: Compiler,
+ compilation: Compilation,
+ normalModuleFactory: NormalModuleFactory,
+ ): void {
+ if (this.isResolvingDeclaredEntries) {
+ return;
+ }
+
+ const resolvedFile = resolveData.createData?.resource;
+
+ if (!resolvedFile) {
+ return;
+ }
+
+ const originModulePath = getOriginModulePath(resolveData);
+
+ if (findRozenitePluginForFile(originModulePath)) {
+ return;
+ }
+
+ const plugin = findRozenitePluginForFile(resolvedFile);
+
+ if (!plugin) {
+ return;
+ }
+
+ if (this.options.allowInProduction.includes(plugin.name)) {
+ return;
+ }
+
+ let declaredEntryPaths: Set;
+
+ try {
+ declaredEntryPaths = this.resolveDeclaredEntries(
+ plugin,
+ resolveData,
+ normalModuleFactory.resolverFactory,
+ );
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ const WebpackError = getWebpackErrorConstructor(compiler);
+ compilation.errors.push(new WebpackError(message));
+ return;
+ }
+
+ if (declaredEntryPaths.has(resolvedFile)) {
+ return;
+ }
+
+ if (!this.options.isDev) {
+ const WebpackError = getWebpackErrorConstructor(compiler);
+ compilation.errors.push(
+ new WebpackError(
+ formatProductionGuardError({
+ plugin,
+ importedFrom: originModulePath,
+ projectRoot: this.options.projectRoot,
+ }),
+ ),
+ );
+ return;
+ }
+
+ if (!isDevEntryOrigin(originModulePath)) {
+ warnOnceForImport(
+ `${originModulePath}\0${plugin.name}`,
+ formatDevAdvisory({
+ plugin,
+ importedFrom: originModulePath,
+ projectRoot: this.options.projectRoot,
+ }),
+ );
+ }
+ }
+}
diff --git a/packages/network-activity-plugin/README.md b/packages/network-activity-plugin/README.md
index 192d744d..289694e1 100644
--- a/packages/network-activity-plugin/README.md
+++ b/packages/network-activity-plugin/README.md
@@ -80,11 +80,11 @@ function App() {
}
```
-Optional: To capture network requests before your React Native app initialization, add this to your entrypoint:
+Optional: To capture network requests before your React Native app initialization, add this to your entrypoint. `index.js` always ships in production, so this is imported from `@rozenite/network-activity-plugin/register`, the plugin's declared production entry point:
```ts
// index.js
-import { withOnBootNetworkActivityRecording } from '@rozenite/network-activity-plugin';
+import { withOnBootNetworkActivityRecording } from '@rozenite/network-activity-plugin/register';
withOnBootNetworkActivityRecording();
```
diff --git a/packages/network-activity-plugin/package.json b/packages/network-activity-plugin/package.json
index 7ec82fb2..28678ab8 100644
--- a/packages/network-activity-plugin/package.json
+++ b/packages/network-activity-plugin/package.json
@@ -26,6 +26,11 @@
"development": "./sdk.ts",
"types": "./dist/sdk/sdk.d.ts",
"default": "./dist/sdk/sdk.js"
+ },
+ "./register": {
+ "types": "./dist/react-native/register.d.ts",
+ "import": "./dist/react-native/register.js",
+ "require": "./dist/react-native/cjs/register.js"
}
},
"publishConfig": {
diff --git a/packages/network-activity-plugin/register.ts b/packages/network-activity-plugin/register.ts
new file mode 100644
index 00000000..05d9bd8c
--- /dev/null
+++ b/packages/network-activity-plugin/register.ts
@@ -0,0 +1,18 @@
+// Production entry point (`@rozenite/network-activity-plugin/register`).
+//
+// The README documents calling `withOnBootNetworkActivityRecording` "at the
+// root of your app, before any other imports", i.e. from `index.js` - a file
+// that always ships in production - so this touchpoint is declared safe via
+// `productionEntries` in `rozenite.config.ts`.
+//
+// Re-exported from `./react-native` rather than from `./src/**` directly.
+// Being reachable in production is not the same as being active in it: the
+// root entry already resolves this to a noop once `process.env.NODE_ENV` is
+// folded, and going straight to the implementation would patch `fetch`/XHR
+// and buffer every request in a shipped app, with nothing draining the
+// buffer. Re-exporting keeps one definition of that production behaviour
+// instead of a second copy here that could drift from it, and `register.js`
+// is emitted into the same tree as `react-native.js`, so both entry points
+// share one module instance.
+export { withOnBootNetworkActivityRecording } from './react-native';
+export type { BootRecordingOptions } from './src/react-native/boot-recording';
diff --git a/packages/network-activity-plugin/rozenite.config.ts b/packages/network-activity-plugin/rozenite.config.ts
index 863b0460..c5f92025 100644
--- a/packages/network-activity-plugin/rozenite.config.ts
+++ b/packages/network-activity-plugin/rozenite.config.ts
@@ -6,4 +6,8 @@ export default {
source: './src/ui/App.tsx',
},
],
+ // `withOnBootNetworkActivityRecording` is documented to be called from
+ // `index.js`, before any other imports, so it needs a touchpoint that
+ // survives a production build. See `register.ts`.
+ productionEntries: ['./register'],
};
diff --git a/packages/network-activity-plugin/src/react-native/__tests__/register-entry.test.ts b/packages/network-activity-plugin/src/react-native/__tests__/register-entry.test.ts
new file mode 100644
index 00000000..34a0d859
--- /dev/null
+++ b/packages/network-activity-plugin/src/react-native/__tests__/register-entry.test.ts
@@ -0,0 +1,34 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+/**
+ * `register.ts` is the one part of this plugin an app is allowed to import
+ * from code that ships, because the README documents calling it from
+ * `index.js`. Being *reachable* in production is not the same as being
+ * *active* in it: the real implementation patches `fetch`/XHR and buffers
+ * every request, with nothing draining the buffer in a release build.
+ *
+ * This is the failure the resolver guard cannot catch, because the import is
+ * declared and therefore permitted. Re-exporting through `react-native.ts`
+ * is what keeps it inert; exporting straight from `src/**` would silently
+ * ship the real implementation.
+ */
+const originalNodeEnv = process.env.NODE_ENV;
+
+beforeAll(() => {
+ process.env.NODE_ENV = 'production';
+});
+
+afterAll(() => {
+ process.env.NODE_ENV = originalNodeEnv;
+});
+
+describe('register entry, production build', () => {
+ it('is inert, and leaves the global fetch alone', async () => {
+ const { withOnBootNetworkActivityRecording } = await import('../../../register');
+
+ const originalFetch = globalThis.fetch;
+
+ expect(withOnBootNetworkActivityRecording({})).toBeNull();
+ expect(globalThis.fetch).toBe(originalFetch);
+ });
+});
diff --git a/packages/network-activity-plugin/tsconfig.json b/packages/network-activity-plugin/tsconfig.json
index 859aebf5..8338c906 100644
--- a/packages/network-activity-plugin/tsconfig.json
+++ b/packages/network-activity-plugin/tsconfig.json
@@ -17,7 +17,7 @@
"noEmit": true,
"jsx": "react-jsx"
},
- "include": ["src/**/*", "react-native.ts", "sdk.ts", "rozenite.config.ts"],
+ "include": ["src/**/*", "react-native.ts", "register.ts", "sdk.ts", "rozenite.config.ts"],
"exclude": ["node_modules", "dist", "build"],
"references": [
{
diff --git a/packages/overlay-plugin/README.md b/packages/overlay-plugin/README.md
index bb142cf3..01b7eedb 100644
--- a/packages/overlay-plugin/README.md
+++ b/packages/overlay-plugin/README.md
@@ -18,7 +18,7 @@ This plugin was inspired by [RocketSim](https://www.rocketsim.app/) - an enhance
- **Real-time Configuration**: Adjust grid size, color, opacity, and image settings in real-time
- **Multiple Resize Modes**: Support for contain, cover, stretch, and center image positioning
- **Clipboard Integration**: Paste images directly from clipboard for quick reference
-- **Production Safety**: Automatically disabled in production builds
+- **Production Safety**: Wired up only in `rozenite.dev.tsx`, so its code never reaches a production bundle -- importing it anywhere else is a build error
## Installation
@@ -36,19 +36,31 @@ npm install @rozenite/overlay-plugin react-native-svg
npm install @rozenite/overlay-plugin react-native-svg
```
-### 2. Integrate with Your App
+### 2. Return It From `rozenite.dev.tsx`
-Add the `RozeniteOverlay` component to your React Native app:
+Unlike most plugins, this one's public surface is a rendered component, not a hook, so the dev entry
+returns it instead of `null`:
+
+```typescript title="rozenite.dev.tsx"
+import { RozeniteOverlay } from '@rozenite/overlay-plugin';
+
+export default function RozeniteDevTools() {
+ return ;
+}
+```
+
+Wherever `` sits in your app tree is where the overlay renders, so place it after
+everything else:
```typescript
// App.tsx
-import { RozeniteOverlay } from '@rozenite/overlay-plugin';
+import Rozenite from '@rozenite/react-native';
function App() {
return (
<>
-
+
>
);
}
diff --git a/packages/performance-monitor-plugin/README.md b/packages/performance-monitor-plugin/README.md
index a2978caf..7f153b51 100644
--- a/packages/performance-monitor-plugin/README.md
+++ b/packages/performance-monitor-plugin/README.md
@@ -16,7 +16,7 @@ The Rozenite Performance Monitor Plugin offers comprehensive real-time monitorin
- **Performance Marks**: Monitor key performance milestones and events
- **Performance Metrics**: Real-time metrics with values and details
- **Data Export**: Export performance data for analysis
-- **Production Safety**: Automatically disabled in production builds
+- **Production Safety**: Wired up only in `rozenite.dev.tsx`, so its code never reaches a production bundle -- importing it anywhere else is a build error
## Installation
@@ -34,21 +34,14 @@ npm install @rozenite/performance-monitor-plugin react-native-performance
npm install @rozenite/performance-monitor-plugin react-native-performance
```
-### 2. Integrate with Your React Native App
+### 2. Wire It Up in `rozenite.dev.tsx`
-Add the DevTools hook to your React Native app:
-
-```typescript
-// App.tsx
+```typescript title="rozenite.dev.tsx"
import { usePerformanceMonitorDevTools } from '@rozenite/performance-monitor-plugin';
-function App() {
- // Enable Performance Monitor DevTools in development
+export default function RozeniteDevTools() {
usePerformanceMonitorDevTools();
-
- return (
- // Your app components
- );
+ return null;
}
```
@@ -60,18 +53,7 @@ Start your development server and open React Native DevTools. You'll find the "P
### Basic Integration
-The plugin automatically integrates with your existing React Native setup:
-
-```typescript
-import { usePerformanceMonitorDevTools } from '@rozenite/performance-monitor-plugin';
-
-function App() {
- // DevTools are automatically enabled in development
- usePerformanceMonitorDevTools();
-
- return ;
-}
-```
+The plugin automatically integrates with your existing React Native setup once wired up in `rozenite.dev.tsx` as shown above.
### Using Performance API
diff --git a/packages/react-native/.npmignore b/packages/react-native/.npmignore
new file mode 100644
index 00000000..c72a4fc7
--- /dev/null
+++ b/packages/react-native/.npmignore
@@ -0,0 +1 @@
+.turbo
diff --git a/packages/react-native/README.md b/packages/react-native/README.md
new file mode 100644
index 00000000..1aaafe6f
--- /dev/null
+++ b/packages/react-native/README.md
@@ -0,0 +1,88 @@
+
+
+### Rozenite for React Native
+
+[![mit licence][license-badge]][license] [![npm downloads][npm-downloads-badge]][npm-downloads] [![Chat][chat-badge]][chat] [![PRs Welcome][prs-welcome-badge]][prs-welcome]
+
+**`@rozenite/react-native`** is the app-side seam for Rozenite. It is the only Rozenite package that
+ships to a production bundle, so it is deliberately trivial: it renders a noop and imports nothing
+besides `react`.
+
+## Why this exists
+
+Wiring every plugin by hand into your app entry point, then remembering to guard each import so it
+never reaches production, is error-prone. This package removes the guesswork: render ``
+unconditionally, and let `withRozenite()` (from `@rozenite/metro` or `@rozenite/repack`) decide what it
+resolves to.
+
+- In **development**, `withRozenite()` redirects `` to your project's `rozenite.dev` file,
+ where all of your plugin wiring lives.
+- In **production**, `` resolves to a shipped noop. No plugin code is ever included.
+
+## Install
+
+```bash
+pnpm add @rozenite/react-native
+```
+
+## Usage
+
+Render `` once, near the root of your app, with nothing to guard:
+
+```tsx
+import Rozenite from '@rozenite/react-native';
+
+export default function App() {
+ return (
+ <>
+
+ {/* your app */}
+ >
+ );
+}
+```
+
+Then create a `rozenite.dev.tsx` file next to your Metro or Re.Pack config, and wire up your plugins
+there — it's an ordinary project file, so Fast Refresh works on it, and it may span as many files as you
+want:
+
+```tsx
+// rozenite.dev.tsx
+import { useRozeniteStoragePlugin, createMMKVStorageAdapter } from '@rozenite/storage-plugin';
+import { storage } from './src/storage';
+
+export default function RozeniteDevTools() {
+ useRozeniteStoragePlugin({ adapters: [createMMKVStorageAdapter({ mmkv: storage })] });
+ return null;
+}
+```
+
+In production, `` renders nothing and pulls in no plugin code — there is nothing to remove
+before shipping.
+
+## Documentation
+
+The documentation is available at [rozenite.dev](https://rozenite.dev). You can also use the following
+links to jump to specific topics:
+
+- [Quick Start](https://rozenite.dev/docs/getting-started)
+- [Plugin Directory](https://rozenite.dev/plugin-directory)
+- [Plugin Development](https://rozenite.dev/docs/plugin-development/overview)
+
+## Made with ❤️ at Callstack
+
+`rozenite` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟.
+
+[Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi!
+
+Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥
+
+[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=rozenite&utm_term=readme-with-love
+[license-badge]: https://img.shields.io/npm/l/rozenite?style=for-the-badge
+[license]: https://github.com/callstackincubator/rozenite/blob/main/LICENSE
+[npm-downloads-badge]: https://img.shields.io/npm/dm/@rozenite/react-native?style=for-the-badge
+[npm-downloads]: https://www.npmjs.com/package/@rozenite/react-native
+[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge
+[prs-welcome]: ./CONTRIBUTING.md
+[chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge
+[chat]: https://discord.gg/xgGt7KAjxv
diff --git a/packages/react-native/eslint.config.mjs b/packages/react-native/eslint.config.mjs
new file mode 100644
index 00000000..f81d0270
--- /dev/null
+++ b/packages/react-native/eslint.config.mjs
@@ -0,0 +1,12 @@
+import baseConfig from '../../eslint.config.mjs';
+
+export default [
+ ...baseConfig,
+ {
+ files: ['**/*.json'],
+ rules: {},
+ languageOptions: {
+ parser: await import('jsonc-eslint-parser'),
+ },
+ },
+];
diff --git a/packages/react-native/package.json b/packages/react-native/package.json
new file mode 100644
index 00000000..3008afc3
--- /dev/null
+++ b/packages/react-native/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@rozenite/react-native",
+ "version": "2.2.0",
+ "description": "React Native entry point for Rozenite.",
+ "homepage": "https://github.com/callstackincubator/rozenite#readme",
+ "bugs": {
+ "url": "https://github.com/callstackincubator/rozenite/issues"
+ },
+ "license": "MIT",
+ "author": "Szymon Chmal ",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/callstackincubator/rozenite.git"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "main": "./dist/cjs/index.js",
+ "module": "./dist/esm/index.js",
+ "types": "./dist/types/index.d.ts",
+ "exports": {
+ "./package.json": "./package.json",
+ ".": {
+ "types": "./dist/types/index.d.ts",
+ "import": "./dist/esm/index.js",
+ "require": "./dist/cjs/index.js"
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "pnpm run build:esm & pnpm run build:cjs & pnpm run build:types & wait",
+ "build:esm": "tsc -p tsconfig.esm.json",
+ "build:cjs": "tsc -p tsconfig.cjs.json && cp scripts/cjs-package.json dist/cjs/package.json",
+ "build:types": "tsc -p tsconfig.types.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "lint": "eslint ."
+ },
+ "devDependencies": {
+ "@types/react": "catalog:",
+ "react": "catalog:",
+ "typescript": "~5.9.3"
+ },
+ "peerDependencies": {
+ "react": "*"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/packages/react-native/scripts/cjs-package.json b/packages/react-native/scripts/cjs-package.json
new file mode 100644
index 00000000..a3c15a7a
--- /dev/null
+++ b/packages/react-native/scripts/cjs-package.json
@@ -0,0 +1 @@
+{ "type": "commonjs" }
diff --git a/packages/react-native/src/dev-entry.tsx b/packages/react-native/src/dev-entry.tsx
new file mode 100644
index 00000000..c593043e
--- /dev/null
+++ b/packages/react-native/src/dev-entry.tsx
@@ -0,0 +1,32 @@
+/**
+ * The shipped noop. This is what `` renders when nothing redirects
+ * the `./dev-entry.js` request made from `./index.tsx`.
+ *
+ * In development, `@rozenite/metro` and `@rozenite/repack` redirect that
+ * request to the app's `rozenite.dev` file, resolved through the host resolver
+ * so the project's `sourceExts` and platform extensions apply. In production
+ * nothing redirects it, so this module is what ships — which is why it must
+ * stay a plain `() => null` after `process.env.NODE_ENV` is folded, with no
+ * hooks, no imports and no plugin code behind it.
+ */
+
+let hasWarned = false;
+
+const RozeniteDevEntry = () => {
+ // Warning from the render body rather than an effect keeps the whole block
+ // foldable: in a production bundle this collapses to `() => null`, with no
+ // `react` import and no hook call left behind. `hasWarned` keeps a double
+ // render under StrictMode from logging twice.
+ if (process.env.NODE_ENV !== 'production' && !hasWarned) {
+ hasWarned = true;
+ console.warn(
+ '[Rozenite] rendered but no dev entry was found, so nothing was loaded.\n' +
+ ' Check that withRozenite() wraps your Metro or Re.Pack config, and that\n' +
+ ' rozenite.dev.tsx exists next to it.',
+ );
+ }
+
+ return null;
+};
+
+export default RozeniteDevEntry;
diff --git a/packages/react-native/src/index.tsx b/packages/react-native/src/index.tsx
new file mode 100644
index 00000000..c0efcf47
--- /dev/null
+++ b/packages/react-native/src/index.tsx
@@ -0,0 +1,19 @@
+import type { ReactElement } from 'react';
+import DevEntry from './dev-entry.js';
+
+/**
+ * The Rozenite app-side seam. Render it unconditionally from your app root:
+ *
+ * ```tsx
+ * import Rozenite from '@rozenite/react-native';
+ *
+ *
+ * ```
+ *
+ * In development, `withRozenite()` redirects the import below to your
+ * project's `rozenite.dev` file. In production it resolves to a shipped
+ * noop, and no plugin code is ever included in the bundle.
+ */
+const Rozenite = (): ReactElement => ;
+
+export default Rozenite;
diff --git a/packages/react-native/tsconfig.cjs.json b/packages/react-native/tsconfig.cjs.json
new file mode 100644
index 00000000..71b8a6c4
--- /dev/null
+++ b/packages/react-native/tsconfig.cjs.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "lib": ["es2022"],
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmitOnError": true,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noUnusedLocals": true,
+ "isolatedModules": true,
+ "importHelpers": true,
+ "declaration": false,
+ "declarationMap": false,
+ "baseUrl": ".",
+ "rootDir": "src",
+ "outDir": "dist/cjs",
+ "tsBuildInfoFile": "dist/cjs/tsconfig.cjs.tsbuildinfo",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/packages/react-native/tsconfig.esm.json b/packages/react-native/tsconfig.esm.json
new file mode 100644
index 00000000..ac5e5d1d
--- /dev/null
+++ b/packages/react-native/tsconfig.esm.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.lib.json",
+ "compilerOptions": {
+ "composite": false,
+ "declaration": true,
+ "declarationMap": true,
+ "emitDeclarationOnly": false,
+ "outDir": "dist/esm",
+ "tsBuildInfoFile": "dist/esm/tsconfig.esm.tsbuildinfo"
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/packages/react-native/tsconfig.json b/packages/react-native/tsconfig.json
new file mode 100644
index 00000000..592c031b
--- /dev/null
+++ b/packages/react-native/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.lib.json",
+ "compilerOptions": {
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/react-native/tsconfig.lib.json b/packages/react-native/tsconfig.lib.json
new file mode 100644
index 00000000..8d552038
--- /dev/null
+++ b/packages/react-native/tsconfig.lib.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "rootDir": "src",
+ "lib": ["es2022"],
+ "jsx": "react-jsx",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/packages/react-native/tsconfig.types.json b/packages/react-native/tsconfig.types.json
new file mode 100644
index 00000000..6549f471
--- /dev/null
+++ b/packages/react-native/tsconfig.types.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.lib.json",
+ "compilerOptions": {
+ "composite": false,
+ "declaration": true,
+ "declarationMap": true,
+ "emitDeclarationOnly": true,
+ "outDir": "dist/types",
+ "tsBuildInfoFile": "dist/types/tsconfig.types.tsbuildinfo"
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/packages/react-navigation-plugin/README.md b/packages/react-navigation-plugin/README.md
index 71ed85ce..0ca10cce 100644
--- a/packages/react-navigation-plugin/README.md
+++ b/packages/react-navigation-plugin/README.md
@@ -15,7 +15,7 @@ The Rozenite React Navigation Plugin provides real-time navigation state monitor
- **Time Travel Debugging**: Jump back to any previous navigation state
- **Deep Link Testing**: Test and validate deep links directly from DevTools
- **Real-time Updates**: See navigation changes as they happen in your app
-- **Production Safety**: Automatically disabled in production builds
+- **Production Safety**: Wired up only in `rozenite.dev.tsx`, so its code never reaches a production bundle -- importing it anywhere else is a build error
## Installation
@@ -33,24 +33,31 @@ npm install @rozenite/react-navigation-plugin
npm install @rozenite/react-navigation-plugin
```
-### 2. Integrate with Your App
+### 2. Wire It Up in `rozenite.dev.tsx`
+
+The DevTools hook needs the exact same ref instance that's attached to your navigator, but it now runs
+from `rozenite.dev.tsx` — a different component than the one rendering your navigator. Create the ref
+once, at module scope, in a file both sides import.
#### With react-navigation
-Add the DevTools hook to your React Native app with a reference to your NavigationContainer:
+Create the ref with plain `createRef` from `react`, not React Navigation's own
+`createNavigationContainerRef` — its return type doesn't satisfy `useReactNavigationDevTools`'s `ref`
+parameter, so `NavigationContainer` would accept it but the hook wouldn't:
+
+```typescript title="navigation.ts"
+import { createRef } from 'react';
+import type { NavigationContainerRef } from '@react-navigation/native';
+
+export const navigationRef = createRef>();
+```
```typescript
// App.tsx
-import React, { useRef } from 'react';
import { NavigationContainer } from '@react-navigation/native';
-import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
+import { navigationRef } from './navigation';
function App() {
- const navigationRef = useRef(null);
-
- // Enable React Navigation DevTools in development
- useReactNavigationDevTools({ ref: navigationRef });
-
return (
@@ -59,22 +66,48 @@ function App() {
}
```
+```typescript title="rozenite.dev.tsx"
+import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
+import { navigationRef } from './navigation';
+
+export default function RozeniteDevTools() {
+ // useReactNavigationDevTools's `ref` type doesn't infer from a
+ // route-typed ref -- it always checks against the untyped default, so
+ // even a correctly-typed ref needs this cast at the call site.
+ useReactNavigationDevTools({ ref: navigationRef as any });
+ return null;
+}
+```
+
#### With expo-router
-Add the DevTools hook to your root \_layout file with a reference to your NavigationContainer:
+`expo-router`'s `useNavigationContainerRef` reads from the router's own context instead of creating a
+new ref, so it works from `rozenite.dev.tsx` directly, as long as `` is mounted inside your
+root layout:
```typescript
-// _layout.tsx
-import { Stack, useNavigationContainerRef } from 'expo-router';
+// app/_layout.tsx
+import { Stack } from 'expo-router';
+import Rozenite from '@rozenite/react-native';
+
+export default function RootLayout() {
+ return (
+ <>
+
+
+ >
+ );
+}
+```
+
+```typescript title="rozenite.dev.tsx"
+import { useNavigationContainerRef } from 'expo-router';
import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
-function App() {
+export default function RozeniteDevTools() {
const navigationRef = useNavigationContainerRef();
-
- // Enable React Navigation DevTools in development
useReactNavigationDevTools({ ref: navigationRef });
-
- return ;
+ return null;
}
```
diff --git a/packages/redux-devtools-plugin/README.md b/packages/redux-devtools-plugin/README.md
index a6a64b7f..00832704 100644
--- a/packages/redux-devtools-plugin/README.md
+++ b/packages/redux-devtools-plugin/README.md
@@ -30,12 +30,14 @@ npm install -D @rozenite/redux-devtools-plugin
Add the Redux DevTools enhancer to your Redux store:
+A store enhancer is set up where the store is created, which is ordinary app code that ships in production - so it's imported from `@rozenite/redux-devtools-plugin/register`, the plugin's declared production entry point, rather than from the package root.
+
#### For Redux Toolkit (Recommended)
```typescript
// store.ts
import { configureStore } from '@reduxjs/toolkit';
-import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin';
+import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin/register';
import rootReducer from './reducers';
const store = configureStore({
@@ -52,7 +54,7 @@ export default store;
```typescript
// store.ts
import { createStore, applyMiddleware } from 'redux';
-import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin';
+import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin/register';
import rootReducer from './reducers';
const store = createStore(
diff --git a/packages/redux-devtools-plugin/package.json b/packages/redux-devtools-plugin/package.json
index 0f38a0ee..50195e47 100644
--- a/packages/redux-devtools-plugin/package.json
+++ b/packages/redux-devtools-plugin/package.json
@@ -30,6 +30,11 @@
"development": "./sdk.ts",
"types": "./dist/sdk/sdk.d.ts",
"default": "./dist/sdk/sdk.js"
+ },
+ "./register": {
+ "types": "./dist/react-native/register.d.ts",
+ "import": "./dist/react-native/register.js",
+ "require": "./dist/react-native/cjs/register.js"
}
},
"publishConfig": {
@@ -69,7 +74,8 @@
"rozenite": "workspace:*",
"styled-components": "^5.3.11",
"typescript": "~5.9.3",
- "vite": "catalog:"
+ "vite": "catalog:",
+ "vitest": "^4.0.18"
},
"peerDependencies": {
"react": "*",
diff --git a/packages/redux-devtools-plugin/register.ts b/packages/redux-devtools-plugin/register.ts
new file mode 100644
index 00000000..1d3408d2
--- /dev/null
+++ b/packages/redux-devtools-plugin/register.ts
@@ -0,0 +1,17 @@
+// Production entry point (`@rozenite/redux-devtools-plugin/register`).
+//
+// A store enhancer is applied where the store is created, which is ordinary
+// app code that runs in production - so this touchpoint is declared safe via
+// `productionEntries` in `rozenite.config.ts`.
+//
+// Re-exported from `./react-native` rather than from `./src/**` directly.
+// Being reachable in production is not the same as being active in it: the
+// root entry already resolves each of these to a pass-through noop once
+// `process.env.NODE_ENV` is folded, and going straight to the implementation
+// would install a live enhancer - retaining `maxAge` actions and serializing
+// every dispatch - in a shipped app. Re-exporting keeps one definition of
+// that production behaviour instead of a second copy here that could drift
+// from it, and `register.js` is emitted into the same tree as
+// `react-native.js`, so both entry points share one module instance.
+export { rozeniteDevToolsEnhancer, composeWithRozeniteDevTools } from './react-native';
+export type { RozeniteDevToolsOptions } from './src/runtime';
diff --git a/packages/redux-devtools-plugin/rozenite.config.ts b/packages/redux-devtools-plugin/rozenite.config.ts
index fa011176..1ce498a3 100644
--- a/packages/redux-devtools-plugin/rozenite.config.ts
+++ b/packages/redux-devtools-plugin/rozenite.config.ts
@@ -6,4 +6,7 @@ export default {
source: './src/ui/panel.tsx',
},
],
+ // The store enhancer runs in ordinary app code, so it needs a touchpoint
+ // that survives a production build. See `register.ts`.
+ productionEntries: ['./register'],
};
diff --git a/packages/redux-devtools-plugin/src/__tests__/register-entry.test.ts b/packages/redux-devtools-plugin/src/__tests__/register-entry.test.ts
new file mode 100644
index 00000000..9a6e5f8a
--- /dev/null
+++ b/packages/redux-devtools-plugin/src/__tests__/register-entry.test.ts
@@ -0,0 +1,50 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+/**
+ * `register.ts` is the one part of this plugin an app is allowed to import
+ * from code that ships. Being *reachable* in production is not the same as
+ * being *active* in it: a live enhancer retains `maxAge` actions and
+ * serializes every dispatch, and nothing drains it in a release build.
+ *
+ * This is the failure the resolver guard cannot catch, because the import is
+ * declared and therefore permitted. Re-exporting through `react-native.ts`
+ * is what keeps it inert; exporting straight from `src/**` would silently
+ * ship the real implementation.
+ */
+const originalNodeEnv = process.env.NODE_ENV;
+
+beforeAll(() => {
+ process.env.NODE_ENV = 'production';
+});
+
+afterAll(() => {
+ process.env.NODE_ENV = originalNodeEnv;
+});
+
+describe('register entry, production build', () => {
+ it('hands back a pass-through enhancer that touches nothing', async () => {
+ const { rozeniteDevToolsEnhancer } = await import('../../register');
+
+ const createStore = (reducer: unknown, preloadedState: unknown) => ({
+ reducer,
+ preloadedState,
+ });
+ const created = rozeniteDevToolsEnhancer({ name: 'app', maxAge: 150 })(createStore as never)(
+ 'reducer' as never,
+ 'preloaded' as never,
+ );
+
+ expect(created).toEqual({ reducer: 'reducer', preloadedState: 'preloaded' });
+ });
+
+ it('composes enhancers without inserting one of its own', async () => {
+ const { composeWithRozeniteDevTools } = await import('../../register');
+
+ const createStore = (() => 'store') as never;
+ const marker = (next: unknown) => next;
+
+ expect(composeWithRozeniteDevTools({ name: 'app' })(marker as never)(createStore)).toBe(
+ createStore,
+ );
+ });
+});
diff --git a/packages/redux-devtools-plugin/tsconfig.json b/packages/redux-devtools-plugin/tsconfig.json
index 71e8cc72..fb22593f 100644
--- a/packages/redux-devtools-plugin/tsconfig.json
+++ b/packages/redux-devtools-plugin/tsconfig.json
@@ -17,7 +17,7 @@
"noEmit": true,
"jsx": "react-jsx"
},
- "include": ["src/**/*", "react-native.ts", "sdk.ts", "metro.ts"],
+ "include": ["src/**/*", "react-native.ts", "register.ts", "sdk.ts", "metro.ts"],
"exclude": ["node_modules", "dist", "build"],
"references": [
{
diff --git a/packages/repack/README.md b/packages/repack/README.md
index 47200768..063deef0 100644
--- a/packages/repack/README.md
+++ b/packages/repack/README.md
@@ -77,17 +77,70 @@ The configuration object for the Re.Pack plugin:
```typescript
type RozeniteRepackConfig = {
+ enabled?: boolean; // Whether to enable Rozenite. The production guard is active either way.
include?: string[]; // Only load these specific plugins
exclude?: string[]; // Exclude these plugins from loading
destroyOnDetachPlugins?: string[]; // Plugins that should be destroyed when switching panels
+ allowInProduction?: string[]; // Plugin packages exempted from the production guard
};
```
**Options:**
+- `enabled` - Whether Rozenite's dev server and plugin discovery are active. See
+ [The production guarantee](#the-production-guarantee) below — `false` no longer disables the
+ production guard itself (optional)
- `include` - Array of package names to explicitly include (optional)
- `exclude` - Array of package names to exclude from loading (optional)
- `destroyOnDetachPlugins` - Array of package names that should be destroyed when switching panels instead of maintaining their state (optional, by default all plugins persist their state)
+- `allowInProduction` - Array of Rozenite plugin package names exempted from the production guard (optional, last resort — see [The production guarantee](#the-production-guarantee))
+
+## The production guarantee
+
+`withRozenite()` installs a guard, unconditionally, that keeps Rozenite plugin code out of production
+bundles. It runs whether or not `enabled` is `true`. See the
+[Production Guarantee](https://www.rozenite.dev/docs/production-guarantee) docs for the full picture;
+the parts that affect this package specifically are below.
+
+### The dev-entry redirect
+
+`@rozenite/react-native`'s `` component asks for a dev-entry module that, in a plain
+resolution, would resolve to a shipped noop. When `enabled` is `true` and `env.mode` is
+`'development'`, `withRozenite()` redirects that specific request to
+`/rozenite.dev`, letting Re.Pack's own `resolve.extensions` pick the right file. If no
+matching file exists, resolution falls back to the shipped noop and logs once; a missing
+`rozenite.dev` file is never a build failure.
+
+### The build error
+
+Independent of that redirect, every module Re.Pack resolves is checked against a simple rule: a
+production build (`env.mode === 'production'`) must not resolve into a Rozenite plugin package except
+through that plugin's declared `productionEntries`. A violation fails the build with a compilation
+error naming the plugin and the importing file. In a development build the same violation only warns.
+
+### `enabled: false` no longer means "do nothing"
+
+**This is a behavior change.** Previously, `enabled: false` (or omitting `enabled`) short-circuited
+`withRozenite()` entirely and returned your config untouched. Now, `enabled: false` still returns a
+config without the dev server or plugin discovery, but the production guard above stays installed. If
+you used `enabled: false` to keep a particular build free of Rozenite altogether, audit that build for
+plugin imports living outside `rozenite.dev.tsx` — they'll now fail it.
+
+### `allowInProduction`
+
+An escape hatch for when you need to unblock a build immediately, before restructuring an import or
+waiting on a plugin author to add a `productionEntries` declaration:
+
+```javascript
+// rspack.config.mjs
+export default withRozenite(config, {
+ allowInProduction: ['@acme/some-plugin'],
+});
+```
+
+Every package listed here is exempted from the guard entirely, through any import path. This is
+printed loudly once per build, since it defeats the production guarantee for the listed package(s) —
+treat it as a last resort, not a fix.
## Plugin Discovery
diff --git a/packages/repack/src/index.ts b/packages/repack/src/index.ts
index 3b543c12..318aabe3 100644
--- a/packages/repack/src/index.ts
+++ b/packages/repack/src/index.ts
@@ -4,7 +4,9 @@ import {
initializeRozenite,
RozeniteConfig,
RozeniteMiddleware,
+ RozeniteResolverPlugin,
} from '@rozenite/middleware';
+import { logger } from '@rozenite/tools';
import { RepackRspackConfig, type RepackRspackConfigExport } from '@callstack/repack';
import { assertSupportedRePackVersion } from './version-check.js';
@@ -56,6 +58,19 @@ export type RozeniteRePackConfig = {
* @default false
*/
enabled?: boolean;
+ /**
+ * Rozenite plugin packages that are allowed to reach a production bundle.
+ *
+ * By default, Rozenite's Metro resolver throws when a production build
+ * resolves into a Rozenite plugin package through anything other than
+ * that plugin's declared `productionEntries`. This is an escape hatch,
+ * not a fix: listing a package here defeats that guarantee for it, and
+ * its code -- devtools UI, agent wiring, whatever it ships -- can end up
+ * in what you ship to users. Prefer declaring `productionEntries` in the
+ * plugin's `rozenite.config.ts` instead. Every package listed here is
+ * logged loudly once per build.
+ */
+ allowInProduction?: string[];
} & Omit;
export const withRozenite = (
@@ -64,11 +79,18 @@ export const withRozenite = (
): RepackRspackConfigExport => {
assertSupportedRePackVersion(process.cwd());
- if (!rozeniteConfig.enabled) {
- return config;
- }
-
return async (env) => {
+ const allowInProduction = rozeniteConfig.allowInProduction ?? [];
+
+ if (allowInProduction.length > 0) {
+ logger.warn(
+ `allowInProduction is set for: ${allowInProduction.join(', ')}. ` +
+ 'Code from these Rozenite plugin package(s) may reach your production bundle -- ' +
+ 'this defeats the production guarantee for them. Prefer declaring productionEntries ' +
+ "in the plugin's rozenite.config.ts instead.",
+ );
+ }
+
let resolvedConfig: RepackRspackConfig;
if (typeof config === 'function') {
@@ -77,8 +99,40 @@ export const withRozenite = (
resolvedConfig = config;
}
- return patchConfig(resolvedConfig, {
- projectRoot: env.context ?? process.cwd(),
+ const projectRoot = env.context ?? process.cwd();
+ const isDev = env.mode !== 'production';
+
+ // `RepackRspackConfig` (via `@callstack/repack`) extends rspack's
+ // `Configuration`, whose `plugins` field isn't visible here (see the
+ // note atop @rozenite/middleware's `rspack-resolver-plugin.ts`): `@rspack/core`'s own types aren't
+ // resolvable in every context that type-checks/builds this package, and
+ // `@callstack/repack` doesn't re-export them. `unknown[]` is enough to
+ // append our plugin without needing that type.
+ type ConfigWithPlugins = RepackRspackConfig & { plugins?: unknown[] };
+ const resolvedConfigWithPlugins = resolvedConfig as ConfigWithPlugins;
+
+ // The guard is installed unconditionally -- `enabled: false` means "no
+ // dev server, guard still active", not "do nothing". Only the
+ // middleware/dev-server wiring below is gated on `enabled`.
+ const guardedConfig: ConfigWithPlugins = {
+ ...resolvedConfigWithPlugins,
+ plugins: [
+ ...(resolvedConfigWithPlugins.plugins ?? []),
+ new RozeniteResolverPlugin({
+ projectRoot,
+ allowInProduction,
+ isDev,
+ installDevEntryRedirect: rozeniteConfig.enabled === true,
+ }),
+ ],
+ };
+
+ if (!rozeniteConfig.enabled) {
+ return guardedConfig;
+ }
+
+ return patchConfig(guardedConfig, {
+ projectRoot,
...rozeniteConfig,
});
};
diff --git a/packages/require-profiler-plugin/README.md b/packages/require-profiler-plugin/README.md
index 71465a10..e87a7e7c 100644
--- a/packages/require-profiler-plugin/README.md
+++ b/packages/require-profiler-plugin/README.md
@@ -65,27 +65,22 @@ module.exports = withRozenite(
Keep `withRozenite`'s `enabled` option conditional as above — when it is false,
`enhanceMetroConfig` never runs and nothing is instrumented. The profiler also
defends itself for the cases that sit outside that gate: it skips instrumentation
-when `process.env.NODE_ENV` is `production`, and the polyfill it injects is wrapped
-in `__DEV__`, which Metro strips from release bundles. Pass `enabled` to decide for
-yourself:
+when `process.env.NODE_ENV` is `production` or when Metro is bundling for
+release, and the polyfill it injects is wrapped in `__DEV__`, which Metro strips
+from release bundles. Pass `enabled` to decide for yourself:
```javascript
withRozeniteRequireProfiler(config, { enabled: process.env.PROFILE_REQUIRES === 'true' });
```
-### 3. Integrate with Your App
+### 3. Wire It Up in `rozenite.dev.tsx`
-Add the DevTools hook to your React Native app:
-
-```typescript
-// App.tsx
+```typescript title="rozenite.dev.tsx"
import { useRequireProfilerDevTools } from '@rozenite/require-profiler-plugin';
-function App() {
- // Enable Require Profiler DevTools
+export default function RozeniteDevTools() {
useRequireProfilerDevTools();
-
- return ;
+ return null;
}
```
diff --git a/packages/require-profiler-plugin/src/__tests__/metro-polyfill.test.ts b/packages/require-profiler-plugin/src/__tests__/metro-polyfill.test.ts
index 9d164e79..c49e8c90 100644
--- a/packages/require-profiler-plugin/src/__tests__/metro-polyfill.test.ts
+++ b/packages/require-profiler-plugin/src/__tests__/metro-polyfill.test.ts
@@ -7,30 +7,51 @@ const require = createRequire(import.meta.url);
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
const SOURCE_POLYFILL = path.join(PACKAGE_ROOT, 'src', 'metro', 'setup.js');
+type MetroSerializer = { getPolyfills?: () => string[] };
+
type MetroEntry = {
- withRozeniteRequireProfiler: (config: { serializer?: { getPolyfills?: () => string[] } }) => {
- serializer: { getPolyfills: () => string[] };
+ withRozeniteRequireProfiler: (config: { serializer?: MetroSerializer }) => {
+ serializer?: MetroSerializer;
};
};
+const loadMetroEntry = (): MetroEntry =>
+ require(path.join(PACKAGE_ROOT, 'dist', 'metro', 'metro.js')) as MetroEntry;
+
// The polyfill is injected into the app bundle and executed verbatim by Metro,
// so it is referenced straight out of `src` instead of being compiled or
// copied. That makes the path depend on how deep tsc emits the Metro entry
// point, which only the built output can confirm.
describe('Metro polyfill', () => {
it('resolves to the untouched source file from the built Metro entry point', () => {
- const { withRozeniteRequireProfiler } = require(
- path.join(PACKAGE_ROOT, 'dist', 'metro', 'metro.js'),
- ) as MetroEntry;
+ const { withRozeniteRequireProfiler } = loadMetroEntry();
const config = withRozeniteRequireProfiler({ serializer: {} });
- const polyfills = config.serializer.getPolyfills();
- const injected = polyfills.at(-1);
+ const injected = config.serializer?.getPolyfills?.().at(-1);
expect(injected).toBe(SOURCE_POLYFILL);
expect(fs.existsSync(SOURCE_POLYFILL)).toBe(true);
});
+ it('injects nothing when Metro is bundling for release', () => {
+ // `getPolyfills` entries reach the graph by absolute path rather than
+ // through module resolution, so the production resolver guard cannot see
+ // them. This gate is what keeps the instrumentation out of a release
+ // bundle, and it is easy to remove by accident.
+ const originalArgv = process.argv;
+ process.argv = ['node', '/app/node_modules/.bin/react-native', 'bundle'];
+
+ try {
+ const { withRozeniteRequireProfiler } = loadMetroEntry();
+
+ const config = withRozeniteRequireProfiler({ serializer: {} });
+
+ expect(config.serializer?.getPolyfills).toBeUndefined();
+ } finally {
+ process.argv = originalArgv;
+ }
+ });
+
it('ships the polyfill in the published package', () => {
// No `files` field, so `src` is published; the entry point resolves the
// polyfill relative to the package root at runtime.
diff --git a/packages/require-profiler-plugin/src/metro/index.ts b/packages/require-profiler-plugin/src/metro/index.ts
index 5f924e11..af31d681 100644
--- a/packages/require-profiler-plugin/src/metro/index.ts
+++ b/packages/require-profiler-plugin/src/metro/index.ts
@@ -1,6 +1,6 @@
import type { ConfigT as MetroConfig } from 'metro-config';
import path from 'node:path';
-import { createMetroConfigTransformer } from '@rozenite/tools';
+import { createMetroConfigTransformer, isBundling } from '@rozenite/tools';
// `setup.js` is a Metro polyfill: Metro injects it into the app bundle and
// executes it verbatim, so it must never be compiled, bundled or otherwise
@@ -56,7 +56,14 @@ export const withRozeniteRequireProfiler =
(config: MetroConfig, options): MetroConfig => {
const enabled = options?.enabled ?? process.env.NODE_ENV !== 'production';
- if (!enabled) {
+ // Metro adds `getPolyfills` entries to the graph by absolute path, not
+ // through module resolution, so Rozenite's production resolver guard never
+ // sees this one. This check is the only thing keeping the instrumentation
+ // out of a release bundle when `enabled` folds to `true` regardless (e.g.
+ // `NODE_ENV` unset) -- the profiler reports over the DevTools bridge and
+ // does nothing without a dev server anyway, so a bundle run has no use for
+ // it either way.
+ if (!enabled || isBundling(config.projectRoot ?? process.cwd())) {
return config;
}
diff --git a/packages/rhf-plugin/README.md b/packages/rhf-plugin/README.md
index 709773de..ead2f0b5 100644
--- a/packages/rhf-plugin/README.md
+++ b/packages/rhf-plugin/README.md
@@ -24,9 +24,11 @@ npm install react-hook-form
Call `useRozeniteRHFPlugin` in any component that has access to your form `control` (typically next to `useForm`).
+It's called once per `useForm()` instance inside ordinary screen components, which is code that ships in production - so it's imported from `@rozenite/rhf-plugin/register`, the plugin's declared production entry point.
+
```ts
import { useForm } from 'react-hook-form';
-import { useRozeniteRHFPlugin } from '@rozenite/rhf-plugin';
+import { useRozeniteRHFPlugin } from '@rozenite/rhf-plugin/register';
type FormValues = {
email: string;
diff --git a/packages/rhf-plugin/package.json b/packages/rhf-plugin/package.json
index 33b5e80f..cf61186e 100644
--- a/packages/rhf-plugin/package.json
+++ b/packages/rhf-plugin/package.json
@@ -21,7 +21,12 @@
"import": "./dist/react-native/react-native.js",
"require": "./dist/react-native/cjs/react-native.js"
},
- "./package.json": "./package.json"
+ "./package.json": "./package.json",
+ "./register": {
+ "types": "./dist/react-native/register.d.ts",
+ "import": "./dist/react-native/register.js",
+ "require": "./dist/react-native/cjs/register.js"
+ }
},
"publishConfig": {
"access": "public"
diff --git a/packages/rhf-plugin/register.ts b/packages/rhf-plugin/register.ts
new file mode 100644
index 00000000..781da6d1
--- /dev/null
+++ b/packages/rhf-plugin/register.ts
@@ -0,0 +1,19 @@
+// Production entry point (`@rozenite/rhf-plugin/register`).
+//
+// `useRozeniteRHFPlugin` takes the `control`/`reset` of one specific
+// `useForm()` instance, so it is called once per form inside ordinary screen
+// components - it cannot be hoisted to a single dev-entry mount point, so
+// this touchpoint is declared safe via `productionEntries` in
+// `rozenite.config.ts`.
+//
+// Re-exported from `./react-native` rather than from `./src/**` directly.
+// Being reachable in production is not the same as being active in it: the
+// root entry already resolves this to a noop once `process.env.NODE_ENV` is
+// folded, and going straight to the implementation would subscribe to and
+// serialize form state on every change in a shipped app. Re-exporting keeps
+// one definition of that production behaviour instead of a second copy here
+// that could drift from it, and `register.js` is emitted into the same tree
+// as `react-native.js`, so both entry points share one module instance.
+export { useRozeniteRHFPlugin } from './react-native';
+export type { UseRozeniteRHFPluginOptions } from './src/react-native/useRozeniteRHFPlugin';
+export type { FieldError, FormSnapshot } from './src/shared/types';
diff --git a/packages/rhf-plugin/rozenite.config.ts b/packages/rhf-plugin/rozenite.config.ts
index 0931c077..c161f31a 100644
--- a/packages/rhf-plugin/rozenite.config.ts
+++ b/packages/rhf-plugin/rozenite.config.ts
@@ -87,6 +87,10 @@ export default {
source: './src/ui/panel.tsx',
},
],
+ // `useRozeniteRHFPlugin` is called once per form inside ordinary screen
+ // components, so it needs a touchpoint that survives a production build.
+ // See `register.ts`.
+ productionEntries: ['./register'],
dev: {
flows: [
{
diff --git a/packages/rhf-plugin/src/__tests__/register-entry.test.ts b/packages/rhf-plugin/src/__tests__/register-entry.test.ts
new file mode 100644
index 00000000..54179147
--- /dev/null
+++ b/packages/rhf-plugin/src/__tests__/register-entry.test.ts
@@ -0,0 +1,39 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+/**
+ * `register.ts` is the one part of this plugin an app is allowed to import
+ * from code that ships, because the hook needs one specific `useForm()`
+ * instance and cannot be hoisted to the dev entry. Being *reachable* in
+ * production is not the same as being *active* in it: the real hook
+ * subscribes to and serializes form state on every change.
+ *
+ * This is the failure the resolver guard cannot catch, because the import is
+ * declared and therefore permitted. Re-exporting through `react-native.ts`
+ * is what keeps it inert; exporting straight from `src/**` would silently
+ * ship the real implementation.
+ */
+const originalNodeEnv = process.env.NODE_ENV;
+
+beforeAll(() => {
+ process.env.NODE_ENV = 'production';
+});
+
+afterAll(() => {
+ process.env.NODE_ENV = originalNodeEnv;
+});
+
+describe('register entry, production build', () => {
+ it('is inert, and subscribes to nothing', async () => {
+ const { useRozeniteRHFPlugin } = await import('../../register');
+
+ // A real implementation would reach into `control` here. The stub must
+ // not, so it stays safe to call outside a React render too.
+ const control = {
+ get _subjects(): never {
+ throw new Error('production stub touched the form control');
+ },
+ };
+
+ expect(useRozeniteRHFPlugin({ control: control as never, id: 'profile-form' })).toBeUndefined();
+ });
+});
diff --git a/packages/rhf-plugin/tsconfig.json b/packages/rhf-plugin/tsconfig.json
index 3a6db650..a14f5dc2 100644
--- a/packages/rhf-plugin/tsconfig.json
+++ b/packages/rhf-plugin/tsconfig.json
@@ -17,7 +17,7 @@
"noEmit": true,
"jsx": "react-jsx"
},
- "include": ["src/**/*", "react-native.ts", "rozenite.config.ts"],
+ "include": ["src/**/*", "react-native.ts", "register.ts", "rozenite.config.ts"],
"exclude": ["node_modules", "dist", "build"],
"references": [
{
diff --git a/packages/sqlite-plugin/README.md b/packages/sqlite-plugin/README.md
index 156a0cdb..ecba7a79 100644
--- a/packages/sqlite-plugin/README.md
+++ b/packages/sqlite-plugin/README.md
@@ -20,35 +20,35 @@ npm install expo-sqlite
## Usage
-```ts
+Wire the plugin up in `rozenite.dev.tsx`. Nothing here is reachable in production, so there's no need
+for an `__DEV__` guard of your own:
+
+```ts title="rozenite.dev.tsx"
import * as SQLite from 'expo-sqlite';
import {
createExpoSqliteAdapter,
useRozeniteSqlitePlugin,
} from '@rozenite/sqlite-plugin';
-const adapters = __DEV__
- ? [
- createExpoSqliteAdapter({
- adapterName: 'Expo SQLite',
- databases: {
- app: {
- name: 'app.db',
- database: SQLite.openDatabaseSync('app.db'),
- },
- analytics: {
- name: 'analytics.db',
- database: SQLite.openDatabaseSync('analytics.db'),
- },
- },
- }),
- ]
- : [];
+const adapters = [
+ createExpoSqliteAdapter({
+ adapterName: 'Expo SQLite',
+ databases: {
+ app: {
+ name: 'app.db',
+ database: SQLite.openDatabaseSync('app.db'),
+ },
+ analytics: {
+ name: 'analytics.db',
+ database: SQLite.openDatabaseSync('analytics.db'),
+ },
+ },
+ }),
+];
-function App() {
+export default function RozeniteDevTools() {
useRozeniteSqlitePlugin({ adapters });
-
- return ;
+ return null;
}
```
@@ -56,7 +56,7 @@ function App() {
You can support any SQLite-like library by normalizing its statement execution API:
-```ts
+```ts title="rozenite.dev.tsx"
import { createSqliteAdapter } from '@rozenite/sqlite-plugin';
const adapters = [
@@ -94,7 +94,7 @@ const adapters = [
## Notes
-- Register adapters in development only. The hook no-ops in production, but your app-level database setup should still stay behind `__DEV__`.
+- Register adapters from `rozenite.dev.tsx`. Nothing imported from there reaches a production bundle, so there's no separate `__DEV__` guard to write.
- The SQL editor executes multi-statement scripts in order and stops on the first error.
- Custom adapters receive the full ordered statement array for scripts. To preserve per-statement failure details, throw an error enriched with `completedResults` and `failedStatementIndex`.
- Explicit `BEGIN`, `COMMIT`, and `ROLLBACK` statements are preserved as written. The plugin does not wrap scripts in an implicit transaction.
diff --git a/packages/storage-plugin/README.md b/packages/storage-plugin/README.md
index b78bda55..384aeaa3 100644
--- a/packages/storage-plugin/README.md
+++ b/packages/storage-plugin/README.md
@@ -18,7 +18,9 @@ npm install react-native-mmkv @react-native-async-storage/async-storage expo-sec
## Usage
-```ts
+Wire the plugin up in `rozenite.dev.tsx`:
+
+```ts title="rozenite.dev.tsx"
import {
createAsyncStorageAdapter,
createMMKVStorageAdapter,
@@ -43,7 +45,10 @@ const storages = [
}),
];
-useRozeniteStoragePlugin({ storages });
+export default function RozeniteDevTools() {
+ useRozeniteStoragePlugin({ storages });
+ return null;
+}
```
### MMKV v3 and v4
diff --git a/packages/tanstack-query-plugin/README.md b/packages/tanstack-query-plugin/README.md
index cc8dab7f..919ea6cc 100644
--- a/packages/tanstack-query-plugin/README.md
+++ b/packages/tanstack-query-plugin/README.md
@@ -19,7 +19,7 @@ This plugin was inspired by the excellent work of Austin Johnson and his [react-
- **Mutation Tracking**: Monitor mutation states and progress
- **Agent Tools**: Expose query and mutation inspection plus cache-management tools to coding agents
- **Bidirectional Communication**: Real-time sync between device and DevTools
-- **Production Safety**: Automatically disabled in production builds
+- **Production Safety**: Wired up only in `rozenite.dev.tsx`, so its code never reaches a production bundle -- importing it anywhere else is a build error
## Installation
@@ -39,19 +39,22 @@ npm install @rozenite/tanstack-query-plugin
### 2. Integrate with Your Query Client
-Add the DevTools hook to your React Native app:
+Your `queryClient` needs to reach both `` in your app and the DevTools hook in
+`rozenite.dev.tsx` — a module-level export shared between the two is the simplest way to do that:
```typescript
-// App.tsx
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
+// query-client.ts
+import { QueryClient } from '@tanstack/react-query';
-const queryClient = new QueryClient();
+export const queryClient = new QueryClient();
+```
-function App() {
- // Enable DevTools in development
- useTanStackQueryDevTools(queryClient);
+```typescript
+// App.tsx
+import { QueryClientProvider } from '@tanstack/react-query';
+import { queryClient } from './query-client';
+function App() {
return (
{/* Your app components */}
@@ -60,6 +63,16 @@ function App() {
}
```
+```typescript title="rozenite.dev.tsx"
+import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
+import { queryClient } from './query-client';
+
+export default function RozeniteDevTools() {
+ useTanStackQueryDevTools(queryClient);
+ return null;
+}
+```
+
### 3. Access DevTools
Start your development server and open React Native DevTools. You'll find the "TanStack Query" panel in the DevTools interface.
@@ -89,31 +102,7 @@ Available tools:
### Basic Integration
-The plugin automatically integrates with your existing TanStack Query setup:
-
-```typescript
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
-
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: 5 * 60 * 1000, // 5 minutes
- },
- },
-});
-
-function App() {
- // DevTools are automatically enabled in development
- useTanStackQueryDevTools(queryClient);
-
- return (
-
-
-
- );
-}
-```
+The plugin automatically integrates with your existing TanStack Query setup once wired up as shown above — no further configuration needed.
## Made with ❤️ at Callstack
diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts
index 0b1b4874..04714e17 100644
--- a/packages/tools/src/index.ts
+++ b/packages/tools/src/index.ts
@@ -21,3 +21,5 @@ export {
createMetroConfigTransformer,
composeMetroConfigTransformers,
} from './metro-transformers.js';
+export { isBundling } from './is-bundling.js';
+export { getBinaryRelativePath } from './packages.js';
diff --git a/packages/metro/src/is-bundling.ts b/packages/tools/src/is-bundling.ts
similarity index 100%
rename from packages/metro/src/is-bundling.ts
rename to packages/tools/src/is-bundling.ts
diff --git a/packages/metro/src/packages.ts b/packages/tools/src/packages.ts
similarity index 100%
rename from packages/metro/src/packages.ts
rename to packages/tools/src/packages.ts
diff --git a/packages/vite-plugin/src/client-plugin.ts b/packages/vite-plugin/src/client-plugin.ts
index 3f97d4cd..0f105faf 100644
--- a/packages/vite-plugin/src/client-plugin.ts
+++ b/packages/vite-plugin/src/client-plugin.ts
@@ -97,6 +97,28 @@ export const rozeniteClientPlugin = (): Plugin => {
return resolveIntegrations(getRozeniteConfig());
};
+ // Validated where it's read, not where it's declared: a bad entry should
+ // fail the plugin author's own build with a message naming the offender,
+ // rather than surface later as a confusing resolver error downstream.
+ const getProductionEntries = (): string[] => {
+ const productionEntries = getRozeniteConfig().productionEntries;
+
+ if (!productionEntries) {
+ return [];
+ }
+
+ for (const entry of productionEntries) {
+ if (typeof entry !== 'string' || !entry.startsWith('./')) {
+ throw new Error(
+ `Invalid "productionEntries" entry in rozenite.config.ts: ${JSON.stringify(entry)}. ` +
+ 'Each entry must be a string export subpath starting with "./" (e.g. "./register").',
+ );
+ }
+ }
+
+ return productionEntries;
+ };
+
const getDevHostPanels = (): DevHostPanelEntry[] => {
return getPanels().map((panel) => ({
label: panel.label,
@@ -310,6 +332,8 @@ export const rozeniteClientPlugin = (): Plugin => {
}
if (url === '/rozenite.json') {
+ const productionEntries = getProductionEntries();
+
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify(
@@ -319,6 +343,7 @@ export const rozeniteClientPlugin = (): Plugin => {
description: packageJSON.description,
panels: getManifestPanels(),
integrations: getManifestIntegrations(),
+ ...(productionEntries.length > 0 ? { productionEntries } : {}),
},
null,
2,
@@ -367,6 +392,7 @@ export const rozeniteClientPlugin = (): Plugin => {
async generateBundle() {
const packageJSON = await getPackageJSON(projectRoot);
+ const productionEntries = getProductionEntries();
this.emitFile({
type: 'asset',
@@ -377,6 +403,7 @@ export const rozeniteClientPlugin = (): Plugin => {
description: packageJSON.description,
panels: getManifestPanels(),
integrations: getManifestIntegrations(),
+ ...(productionEntries.length > 0 ? { productionEntries } : {}),
}),
});
},
diff --git a/packages/vite-plugin/src/load-config.ts b/packages/vite-plugin/src/load-config.ts
index cc2e330a..45ba0821 100644
--- a/packages/vite-plugin/src/load-config.ts
+++ b/packages/vite-plugin/src/load-config.ts
@@ -78,6 +78,13 @@ export type RozeniteConfig = {
* @default ['react-native']
*/
integrations?: RozeniteIntegration[];
+ /**
+ * Export subpaths of this plugin (e.g. `['./register']`) that the author
+ * declares safe to reach a production bundle. Everything else the plugin
+ * exports becomes a production build error for consumers, enforced by the
+ * bundler's resolver.
+ */
+ productionEntries?: string[];
};
/**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 29ece1ed..dad3bb2c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -178,6 +178,9 @@ importers:
'@rozenite/plugin-bridge':
specifier: workspace:*
version: link:../../packages/plugin-bridge
+ '@rozenite/react-native':
+ specifier: workspace:*
+ version: link:../../packages/react-native
'@rozenite/react-navigation-plugin':
specifier: workspace:*
version: link:../../packages/react-navigation-plugin
@@ -958,6 +961,18 @@ importers:
'@react-native/metro-config':
specifier: ~0.86.0
version: 0.86.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1)
+ '@rozenite/rhf-plugin':
+ specifier: workspace:*
+ version: link:../rhf-plugin
+ '@rozenite/storage-plugin':
+ specifier: workspace:*
+ version: link:../storage-plugin
+ '@rozenite/test-utils':
+ specifier: workspace:*
+ version: link:../test-utils
+ metro-resolver:
+ specifier: '*'
+ version: 0.84.4
vitest:
specifier: ^4.0.18
version: 4.1.0(@types/node@18.16.9)(@vitest/ui@3.2.4(vitest@3.2.4))(jsdom@22.1.0(supports-color@8.1.1))(vite@7.3.5(@types/node@18.16.9)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1))
@@ -1230,6 +1245,18 @@ importers:
specifier: ^2.3.0
version: 2.8.1
+ packages/react-native:
+ devDependencies:
+ '@types/react':
+ specifier: 'catalog:'
+ version: 19.2.18
+ react:
+ specifier: 'catalog:'
+ version: 19.2.3
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+
packages/react-navigation-plugin:
dependencies:
'@rozenite/agent-bridge':
@@ -1375,6 +1402,9 @@ importers:
vite:
specifier: ^7.3.1
version: 7.3.1(@types/node@18.16.9)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1)
+ vitest:
+ specifier: ^4.0.18
+ version: 4.1.0(@types/node@18.16.9)(@vitest/ui@3.2.4(vitest@3.2.4))(jsdom@22.1.0(supports-color@8.1.1))(vite@7.3.1(@types/node@18.16.9)(jiti@2.4.2)(lightningcss@1.32.0)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.1))
packages/repack:
dependencies:
diff --git a/website/src/docs/_meta.json b/website/src/docs/_meta.json
index 85dedbf3..2c7d72a7 100644
--- a/website/src/docs/_meta.json
+++ b/website/src/docs/_meta.json
@@ -2,6 +2,11 @@
{ "type": "file", "name": "introduction", "label": "Introduction" },
{ "type": "file", "name": "prior-art", "label": "Prior Art" },
{ "type": "file", "name": "getting-started", "label": "Getting started" },
+ {
+ "type": "file",
+ "name": "production-guarantee",
+ "label": "Production Guarantee"
+ },
{ "type": "file", "name": "compatibility", "label": "Compatibility" },
{ "type": "dir", "name": "targets", "label": "Targets" },
{ "type": "dir", "name": "agent", "label": "Rozenite for Agents" },
diff --git a/website/src/docs/getting-started.mdx b/website/src/docs/getting-started.mdx
index b2e212b7..89c085f3 100644
--- a/website/src/docs/getting-started.mdx
+++ b/website/src/docs/getting-started.mdx
@@ -21,7 +21,14 @@ Run the `rozenite init` command in your project. It detects your bundler, instal
}}
/>
-That's it — start your app as usual and open React Native DevTools. If everything worked, you'll see plugin panels for anything you've installed (see [Official Plugins](/docs/official-plugins/overview) to add some).
+`rozenite init` installs the bundler package for your project, wraps your bundler config with
+`withRozenite()`, installs `@rozenite/react-native` (the one Rozenite package that ships to
+production), and scaffolds a `rozenite.dev.tsx` file next to your bundler config. It also prints the
+one line you need to add yourself: mounting `` in your app.
+
+That's it — start your app as usual and open React Native DevTools. If everything worked, you'll see
+plugin panels for anything you've installed (see [Official Plugins](/docs/official-plugins/overview)
+to add some).
If the command fails, or you'd rather wire things up yourself, follow the manual steps below.
@@ -78,11 +85,60 @@ export default withRozenite(
);
```
-### 3. Start your app
+### 3. Install the app-side seam and mount it
+
+`@rozenite/react-native` is the one Rozenite package that ships to production — it renders a noop and
+imports nothing besides `react`. Install it as a regular dependency (not a dev dependency):
+
+
+
+Then render `` once, near the root of your app, with nothing to guard:
+
+```tsx title="App.tsx"
+import Rozenite from '@rozenite/react-native';
+
+export default function App() {
+ return (
+ <>
+
+ {/* your app */}
+ >
+ );
+}
+```
+
+There's no `__DEV__` check to write here and none to forget — what `` resolves to is
+entirely up to the bundler, and it's covered in full on the
+[Production Guarantee](./production-guarantee) page.
+
+### 4. Wire up your plugins in `rozenite.dev.tsx`
+
+Create a `rozenite.dev.tsx` file next to your bundler config. This is where all of your plugin
+wiring lives — it's an ordinary project file, so Fast Refresh works on it, and nothing reachable from
+it can end up in a production bundle:
+
+```tsx title="rozenite.dev.tsx"
+import { useRozeniteStoragePlugin, createMMKVStorageAdapter } from '@rozenite/storage-plugin';
+import { storage } from './src/storage';
+
+export default function RozeniteDevTools() {
+ useRozeniteStoragePlugin({ adapters: [createMMKVStorageAdapter({ mmkv: storage })] });
+ return null;
+}
+```
+
+It can grow into as many files as you need — see each plugin's page under
+[Official Plugins](/docs/official-plugins/overview) for what to add here. Importing a plugin from
+anywhere else in your app is a production build error; see the
+[Production Guarantee](./production-guarantee) page for what that looks like and the rare cases where
+a plugin needs a touchpoint outside this file.
+
+### 5. Start your app
-Open React Native DevTools — any Rozenite plugins you've installed will show up automatically, no extra wiring needed.
+Open React Native DevTools — any Rozenite plugins you've wired up in `rozenite.dev.tsx` will show up
+automatically.
## Choosing which plugins load
@@ -122,10 +178,15 @@ module.exports = withRozenite(mergeConfig(defaultConfig, customConfig), {
## Verifying it worked
- Your bundler's server logs should mention discovering Rozenite plugins.
-- React Native DevTools should show a panel for each plugin you've installed.
+- React Native DevTools should show a panel for each plugin you've wired up in `rozenite.dev.tsx`.
-If nothing shows up, double check that `enabled` evaluates to `true` and that you restarted the bundler after changing the config.
+If nothing shows up, double check that `enabled` evaluates to `true`, that `` is mounted
+in your app, and that you restarted the bundler after changing the config.
## Using Rozenite with AI coding agents
If you use AI or coding agents in your workflow, continue with the [Rozenite for Agents overview](/docs/agent/overview).
+
+## Next steps
+
+Read the [Production Guarantee](./production-guarantee) page to understand exactly what `` and `rozenite.dev.tsx` guarantee, what the production build error looks like, and how a plugin can declare a touchpoint that's allowed to run in production.
diff --git a/website/src/docs/official-plugins/controls.mdx b/website/src/docs/official-plugins/controls.mdx
index de364b82..3960aad8 100644
--- a/website/src/docs/official-plugins/controls.mdx
+++ b/website/src/docs/official-plugins/controls.mdx
@@ -16,11 +16,11 @@ Install the Controls plugin as a development dependency:
## Base Setup
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
import { createSection, useRozeniteControlsPlugin } from '@rozenite/controls-plugin';
import { useMemo, useState } from 'react';
-function App() {
+export default function RozeniteDevTools() {
const [verboseLogging, setVerboseLogging] = useState(false);
const [environment, setEnvironment] = useState('local');
const [releaseLabel, setReleaseLabel] = useState('build-001');
@@ -79,12 +79,12 @@ function App() {
],
}),
],
- [environment, releaseLabel, verboseLogging]
+ [environment, releaseLabel, verboseLogging],
);
useRozeniteControlsPlugin({ sections });
- return ;
+ return null;
}
```
diff --git a/website/src/docs/official-plugins/expo-atlas.mdx b/website/src/docs/official-plugins/expo-atlas.mdx
index 7742b726..51e4ed0f 100644
--- a/website/src/docs/official-plugins/expo-atlas.mdx
+++ b/website/src/docs/official-plugins/expo-atlas.mdx
@@ -28,6 +28,14 @@ module.exports = withRozenite(config, {
});
```
+:::info No app code, no `rozenite.dev.tsx`
+This plugin's public surface is a Metro config transformer, not app code — there's nothing to import
+from your app or wire up in `rozenite.dev.tsx`. The setup above, in `metro.config.js`, is everything
+it needs. See the [Production Guarantee](/docs/production-guarantee) page for why plugin app code
+belongs in `rozenite.dev.tsx` — it doesn't apply here since none of this plugin's code runs as part of
+your app.
+:::
+
## Usage
Once configured, "Expo Atlas" appears in your React Native DevTools sidebar. From there you can:
diff --git a/website/src/docs/official-plugins/feature-flags.mdx b/website/src/docs/official-plugins/feature-flags.mdx
index 1d5c1a4f..e43e7d82 100644
--- a/website/src/docs/official-plugins/feature-flags.mdx
+++ b/website/src/docs/official-plugins/feature-flags.mdx
@@ -27,67 +27,93 @@ Install the peer dependency for whichever adapter you use:
## Adapter: Custom / local (Tier B)
-For a homegrown flag store, or before wiring a real provider:
-
-```ts title="App.tsx"
-import {
- createCustomFlagsAdapter,
- useRozeniteFeatureFlagsPlugin,
-} from '@rozenite/feature-flags-plugin';
-
-// Module-level, like storage/sqlite adapters elsewhere in the docs. The
-// hook tracks `providers` by content, so a fresh array literal on every
-// render works too -- hoisting just avoids rebuilding provider state for
-// nothing.
-const featureFlagsProviders = [
- createCustomFlagsAdapter({
- id: 'app',
- name: 'App flags',
- listFlags: () => flagStore.getAll(),
- }),
-];
+For a homegrown flag store, Tier B's override map is what has to survive into production — it's the
+only thing a forced override lives in — so the adapter is constructed in a shared module, imported
+from [`@rozenite/feature-flags-plugin/register`](/docs/production-guarantee#productionentries-and-register).
+The DevTools connection itself has no reason to run in production, so it stays in `rozenite.dev.tsx`,
+imported from the plugin's main entry point:
+
+```ts title="flags.ts"
+import { createCustomFlagsAdapter } from '@rozenite/feature-flags-plugin/register';
+
+// Module-level, like storage/sqlite adapters elsewhere in the docs.
+export const appFlagsAdapter = createCustomFlagsAdapter({
+ id: 'app',
+ name: 'App flags',
+ listFlags: () => flagStore.getAll(),
+});
+```
-function App() {
- useRozeniteFeatureFlagsPlugin({ providers: featureFlagsProviders });
+```ts title="rozenite.dev.tsx"
+import { useRozeniteFeatureFlagsPlugin } from '@rozenite/feature-flags-plugin';
+import { appFlagsAdapter } from './flags';
- return ;
+export default function RozeniteDevTools() {
+ useRozeniteFeatureFlagsPlugin({ providers: [appFlagsAdapter] });
+ return null;
}
```
`setOverride` throws for a key not present in `listFlags()` — nothing is written for a typo'd or unknown key.
-Overrides default to an in-memory `Map`. Bring your own store to persist them across restarts:
+Overrides default to an in-memory `Map`. Bring your own store to persist them across restarts, and
+construct it alongside the adapter so both survive into production:
-```ts title="App.tsx"
-import { createFlagOverrides } from '@rozenite/feature-flags-plugin';
+```ts title="flags.ts"
+import {
+ createCustomFlagsAdapter,
+ createFlagOverrides,
+} from '@rozenite/feature-flags-plugin/register';
const overrides = createFlagOverrides({
initial: JSON.parse(storage.getString('flag-overrides') ?? '{}'),
onChange: (all) => storage.set('flag-overrides', JSON.stringify(all)),
});
-createCustomFlagsAdapter({ id: 'app', name: 'App flags', listFlags, overrides });
+export const appFlagsAdapter = createCustomFlagsAdapter({
+ id: 'app',
+ name: 'App flags',
+ listFlags: () => flagStore.getAll(),
+ overrides,
+});
+
+// Wherever your app actually reads this flag, check for a forced override
+// first -- this is ordinary production code, and `overrides` is the same
+// instance the adapter above reports to DevTools.
+export const isDarkModeEnabled = () =>
+ (overrides.get('dark-mode') as boolean | undefined) ?? flagStore.get('dark-mode');
```
## Adapter: LaunchDarkly (Tier B)
-`createLaunchDarklyFlagsAdapter` returns `{ provider, client }`. Pass `client` — not your raw `ReactNativeLDClient` — to ``. Every LD hook (`useBoolVariation`, `useLDClient`, ...) reads through it from there automatically, because LD's own hooks are a thin read off the context client.
+`createLaunchDarklyFlagsAdapter` returns `{ provider, client }`. Pass `client` — not your raw `ReactNativeLDClient` — to ``. Every LD hook (`useBoolVariation`, `useLDClient`, ...) reads through it from there automatically, because LD's own hooks are a thin read off the context client. `` is ordinary production code, so the wrapped client comes from
+[`@rozenite/feature-flags-plugin/register`](/docs/production-guarantee#productionentries-and-register):
-```ts title="App.tsx"
-import { ReactNativeLDClient, AutoEnvAttributes, LDProvider } from '@launchdarkly/react-native-client-sdk';
-import {
- createLaunchDarklyFlagsAdapter,
- useRozeniteFeatureFlagsPlugin,
-} from '@rozenite/feature-flags-plugin';
+```ts title="flags.ts"
+import { ReactNativeLDClient, AutoEnvAttributes } from '@launchdarkly/react-native-client-sdk';
+import { createLaunchDarklyFlagsAdapter } from '@rozenite/feature-flags-plugin/register';
const rawClient = new ReactNativeLDClient(LD_MOBILE_KEY, AutoEnvAttributes.Enabled);
-const { provider, client } = createLaunchDarklyFlagsAdapter({ client: rawClient });
-const featureFlagsProviders = [provider];
+export const { provider: launchDarklyProvider, client: launchDarklyClient } =
+ createLaunchDarklyFlagsAdapter({ client: rawClient });
+```
+
+```tsx title="App.tsx"
+import { LDProvider } from '@launchdarkly/react-native-client-sdk';
+import { launchDarklyClient } from './flags';
function App() {
- useRozeniteFeatureFlagsPlugin({ providers: featureFlagsProviders });
+ return {/* ... */};
+}
+```
+
+```ts title="rozenite.dev.tsx"
+import { useRozeniteFeatureFlagsPlugin } from '@rozenite/feature-flags-plugin';
+import { launchDarklyProvider } from './flags';
- return {/* ... */};
+export default function RozeniteDevTools() {
+ useRozeniteFeatureFlagsPlugin({ providers: [launchDarklyProvider] });
+ return null;
}
```
@@ -99,37 +125,51 @@ Notes:
## Adapter: Statsig (Tier A)
-You construct `StatsigClient` and `LocalOverrideAdapter` yourself; the adapter only takes references.
+You construct `StatsigClient` and `LocalOverrideAdapter` yourself, using Statsig's own SDK — that part
+is ordinary production code and has nothing to do with Rozenite, so it's unaffected by any of this.
+`createStatsigFlagsAdapter` only takes references to what you already built, and — unlike the custom
+and LaunchDarkly adapters — it has no production call site of its own: Tier A's override store lives in
+Statsig's `LocalOverrideAdapter`, not in anything Rozenite owns, so the adapter is only ever consumed
+by the DevTools connection. It stays in `rozenite.dev.tsx`, imported from the plugin's main entry
+point:
-```ts title="App.tsx"
+```ts title="flags.ts"
import { StatsigClient } from '@statsig/js-client';
import { LocalOverrideAdapter } from '@statsig/js-local-overrides';
+
+export const overrideAdapter = new LocalOverrideAdapter();
+export const statsigClient = new StatsigClient(
+ STATSIG_CLIENT_KEY,
+ { userID: 'user-123' },
+ { overrideAdapter },
+);
+await statsigClient.initializeAsync();
+```
+
+```ts title="rozenite.dev.tsx"
import {
createStatsigFlagsAdapter,
useRozeniteFeatureFlagsPlugin,
} from '@rozenite/feature-flags-plugin';
-
-const overrideAdapter = new LocalOverrideAdapter();
-const client = new StatsigClient(STATSIG_CLIENT_KEY, { userID: 'user-123' }, { overrideAdapter });
-await client.initializeAsync();
-
-const featureFlagsProviders = [
- createStatsigFlagsAdapter({
- client,
- overrideAdapter,
- flags: [
- { key: 'new-onboarding' }, // boolean gate (default type)
- { key: 'checkout-copy', type: 'string' },
- { key: 'max-items', type: 'number' },
- { key: 'layout-config', type: 'json' },
+import { statsigClient, overrideAdapter } from './flags';
+
+export default function RozeniteDevTools() {
+ useRozeniteFeatureFlagsPlugin({
+ providers: [
+ createStatsigFlagsAdapter({
+ client: statsigClient,
+ overrideAdapter,
+ flags: [
+ { key: 'new-onboarding' }, // boolean gate (default type)
+ { key: 'checkout-copy', type: 'string' },
+ { key: 'max-items', type: 'number' },
+ { key: 'layout-config', type: 'json' },
+ ],
+ }),
],
- }),
-];
-
-function App() {
- useRozeniteFeatureFlagsPlugin({ providers: featureFlagsProviders });
+ });
- return ;
+ return null;
}
```
diff --git a/website/src/docs/official-plugins/file-system.mdx b/website/src/docs/official-plugins/file-system.mdx
index f5c4af9a..a221ff02 100644
--- a/website/src/docs/official-plugins/file-system.mdx
+++ b/website/src/docs/official-plugins/file-system.mdx
@@ -20,37 +20,31 @@ Install whichever filesystem library your app already uses:
### With Expo FileSystem
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
import * as FileSystem from 'expo-file-system';
-import {
- createExpoFileSystemAdapter,
- useFileSystemDevTools,
-} from '@rozenite/file-system-plugin';
+import { createExpoFileSystemAdapter, useFileSystemDevTools } from '@rozenite/file-system-plugin';
-function App() {
+export default function RozeniteDevTools() {
useFileSystemDevTools({
adapter: createExpoFileSystemAdapter(FileSystem),
});
- return ;
+ return null;
}
```
### With RNFS
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
import RNFS from '@dr.pogodin/react-native-fs';
-import {
- createRNFSAdapter,
- useFileSystemDevTools,
-} from '@rozenite/file-system-plugin';
+import { createRNFSAdapter, useFileSystemDevTools } from '@rozenite/file-system-plugin';
-function App() {
+export default function RozeniteDevTools() {
useFileSystemDevTools({
adapter: createRNFSAdapter(RNFS),
});
- return ;
+ return null;
}
```
@@ -60,7 +54,7 @@ Once configured, the plugin appears in DevTools as "File System". You can jump b
Importing and exporting files is off by default. Turn it on with `fileTransfer` when you want the panel to move files in or out of your app:
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
useFileSystemDevTools({
adapter: createRNFSAdapter(RNFS),
fileTransfer: {
@@ -74,7 +68,7 @@ Imports keep the original filename and ask before overwriting an existing file.
If you also want coding agents to import or export files through Rozenite for Agents, opt in separately:
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
useFileSystemDevTools({
adapter: createRNFSAdapter(RNFS),
fileTransfer: {
diff --git a/website/src/docs/official-plugins/network-activity.mdx b/website/src/docs/official-plugins/network-activity.mdx
index 5972e87b..fbd01da8 100644
--- a/website/src/docs/official-plugins/network-activity.mdx
+++ b/website/src/docs/official-plugins/network-activity.mdx
@@ -16,20 +16,22 @@ Make sure to go through the [Getting Started guide](/docs/getting-started) befor
-```typescript title="App.tsx"
+```typescript title="rozenite.dev.tsx"
import { useNetworkActivityDevTools } from '@rozenite/network-activity-plugin';
-function App() {
+export default function RozeniteDevTools() {
useNetworkActivityDevTools();
-
- return ;
+ return null;
}
```
-To also capture requests made before your app finishes initializing, add this to your entry point:
+To also capture requests made before your app finishes initializing, add this to your entry point.
+This runs before any other code in your app, in a file that always ships in production, so it's
+declared as a [production entry point](/docs/production-guarantee#productionentries-and-register) —
+import it from `@rozenite/network-activity-plugin/register`, not the plugin's main entry point:
```typescript title="index.js"
-import { withOnBootNetworkActivityRecording } from '@rozenite/network-activity-plugin';
+import { withOnBootNetworkActivityRecording } from '@rozenite/network-activity-plugin/register';
withOnBootNetworkActivityRecording();
```
@@ -58,7 +60,7 @@ The response body view adapts to the content type, with a Preview / Raw toggle w
By default all traffic types are monitored. Disable ones you don't need — useful when a type is noisy or expensive to capture:
-```typescript title="App.tsx"
+```typescript title="rozenite.dev.tsx"
useNetworkActivityDevTools({
inspectors: {
http: true,
diff --git a/website/src/docs/official-plugins/overlay.mdx b/website/src/docs/official-plugins/overlay.mdx
index 5770a9c7..25514f2e 100644
--- a/website/src/docs/official-plugins/overlay.mdx
+++ b/website/src/docs/official-plugins/overlay.mdx
@@ -10,17 +10,29 @@ Make sure to go through the [Getting Started guide](/docs/getting-started) befor
-Add the overlay component at the root of your app:
+`@rozenite/overlay-plugin`'s public surface is a rendered component, not a hook, so its dev entry
+returns it instead of `null`:
-```typescript title="App.tsx"
+```typescript title="rozenite.dev.tsx"
import { RozeniteOverlay } from '@rozenite/overlay-plugin';
+export default function RozeniteDevTools() {
+ return ;
+}
+```
+
+Wherever `` sits in your app tree is where the overlay renders, so place it after
+everything else, the same way you would have placed `` directly:
+
+```typescript title="App.tsx"
+import Rozenite from '@rozenite/react-native';
+
function App() {
return (
<>
- {/* Add the overlay component at the root level */}
-
+ {/* Overlays render wherever sits in the tree */}
+
>
);
}
@@ -45,7 +57,7 @@ Overlay a reference image over your app to compare it against a design — eithe
Settings persist for your development session but aren't saved between app restarts.
:::warning Positioning
-Place `RozeniteOverlay` at the root of your app, after everything else, so overlays render on top.
+Place `` at the root of your app, after everything else, so overlays render on top.
:::
:::info Development only
diff --git a/website/src/docs/official-plugins/overview.mdx b/website/src/docs/official-plugins/overview.mdx
index 9985fcfa..8af263ff 100644
--- a/website/src/docs/official-plugins/overview.mdx
+++ b/website/src/docs/official-plugins/overview.mdx
@@ -31,7 +31,7 @@ Each plugin installs as a dev dependency, since it's only needed during developm
-Swap in the package name for the plugin you want — see its page for the exact setup steps, since most plugins also need a small hook added to your app.
+Swap in the package name for the plugin you want — see its page for the exact setup steps, since most plugins also need a small hook wired up in your [`rozenite.dev.tsx`](/docs/production-guarantee).
## Community plugins
diff --git a/website/src/docs/official-plugins/performance-monitor.mdx b/website/src/docs/official-plugins/performance-monitor.mdx
index 6bf1a449..1e4529cb 100644
--- a/website/src/docs/official-plugins/performance-monitor.mdx
+++ b/website/src/docs/official-plugins/performance-monitor.mdx
@@ -12,13 +12,13 @@ Make sure to go through the [Getting Started guide](/docs/getting-started) befor
-```typescript title="App.tsx"
+```typescript title="rozenite.dev.tsx"
import { usePerformanceMonitorDevTools } from '@rozenite/performance-monitor-plugin';
-function App() {
+export default function RozeniteDevTools() {
usePerformanceMonitorDevTools();
- return ;
+ return null;
}
```
diff --git a/website/src/docs/official-plugins/react-hook-form.mdx b/website/src/docs/official-plugins/react-hook-form.mdx
index 9627d422..4b47b7d9 100644
--- a/website/src/docs/official-plugins/react-hook-form.mdx
+++ b/website/src/docs/official-plugins/react-hook-form.mdx
@@ -14,11 +14,15 @@ Make sure to go through the [Getting Started guide](/docs/getting-started) befor
## Setup
-Call `useRozeniteRHFPlugin` inside any component that has access to a `react-hook-form` `control` object:
+Call `useRozeniteRHFPlugin` inside any component that has access to a `react-hook-form` `control`
+object. It's called once per form, from inside ordinary screen components — it can't be hoisted to a
+single `rozenite.dev.tsx` mount point — so this plugin ships it as a
+[production entry point](/docs/production-guarantee#productionentries-and-register): import it from
+`@rozenite/rhf-plugin/register`, not the plugin's main entry point:
```typescript title="MyForm.tsx"
import { useForm } from 'react-hook-form';
-import { useRozeniteRHFPlugin } from '@rozenite/rhf-plugin';
+import { useRozeniteRHFPlugin } from '@rozenite/rhf-plugin/register';
function MyForm() {
const { control, handleSubmit } = useForm();
diff --git a/website/src/docs/official-plugins/react-navigation.mdx b/website/src/docs/official-plugins/react-navigation.mdx
index 0c3950ca..362c9cb1 100644
--- a/website/src/docs/official-plugins/react-navigation.mdx
+++ b/website/src/docs/official-plugins/react-navigation.mdx
@@ -12,18 +12,29 @@ Make sure to go through the [Getting Started guide](/docs/getting-started) befor
+The DevTools hook needs the exact same ref instance that's attached to your navigator, but it's now
+called from `rozenite.dev.tsx` — a different component than the one that renders your navigator. Create
+the ref once, at module scope, in a file both sides can import:
+
### With react-navigation
+Create the ref with plain `createRef` from `react`, not React Navigation's own
+`createNavigationContainerRef` — its return type doesn't satisfy
+`useReactNavigationDevTools`'s `ref` parameter, so `NavigationContainer` would accept it but the hook
+wouldn't:
+
+```typescript title="navigation.ts"
+import { createRef } from 'react';
+import type { NavigationContainerRef } from '@react-navigation/native';
+
+export const navigationRef = createRef>();
+```
+
```typescript title="App.tsx"
-import React, { useRef } from 'react';
import { NavigationContainer } from '@react-navigation/native';
-import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
+import { navigationRef } from './navigation';
function App() {
- const navigationRef = useRef(null);
-
- useReactNavigationDevTools({ ref: navigationRef });
-
return (
@@ -32,18 +43,47 @@ function App() {
}
```
+```typescript title="rozenite.dev.tsx"
+import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
+import { navigationRef } from './navigation';
+
+export default function RozeniteDevTools() {
+ // useReactNavigationDevTools's `ref` type doesn't infer from a
+ // route-typed ref -- it always checks against the untyped default, so
+ // even a correctly-typed ref needs this cast at the call site.
+ useReactNavigationDevTools({ ref: navigationRef as any });
+ return null;
+}
+```
+
### With expo-router
-```typescript title="_layout.tsx"
-import { Stack, useNavigationContainerRef } from 'expo-router';
+`expo-router`'s `useNavigationContainerRef` reads from the router's own context rather than creating a
+new ref, so it works from `rozenite.dev.tsx` directly, as long as `` is mounted inside your
+root layout — which is already inside the router's tree:
+
+```typescript title="app/_layout.tsx"
+import { Stack } from 'expo-router';
+import Rozenite from '@rozenite/react-native';
+
+export default function RootLayout() {
+ return (
+ <>
+
+
+ >
+ );
+}
+```
+
+```typescript title="rozenite.dev.tsx"
+import { useNavigationContainerRef } from 'expo-router';
import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin';
-function App() {
+export default function RozeniteDevTools() {
const navigationRef = useNavigationContainerRef();
-
useReactNavigationDevTools({ ref: navigationRef });
-
- return ;
+ return null;
}
```
diff --git a/website/src/docs/official-plugins/redux-devtools.mdx b/website/src/docs/official-plugins/redux-devtools.mdx
index 090dcc47..e8de1b87 100644
--- a/website/src/docs/official-plugins/redux-devtools.mdx
+++ b/website/src/docs/official-plugins/redux-devtools.mdx
@@ -12,13 +12,15 @@ Make sure to go through the [Getting Started guide](/docs/getting-started) befor
-Add the enhancer to your store:
+Add the enhancer to your store. Your store is created in ordinary app code that runs in production, so
+this plugin ships the enhancer as a [production entry point](/docs/production-guarantee#productionentries-and-register) —
+import it from `@rozenite/redux-devtools-plugin/register`, not the plugin's main entry point:
#### Redux Toolkit (recommended)
```typescript title="store.ts"
import { configureStore } from '@reduxjs/toolkit';
-import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin';
+import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin/register';
import rootReducer from './reducers';
const store = configureStore({
@@ -33,7 +35,7 @@ export default store;
```typescript title="store.ts"
import { createStore, applyMiddleware } from 'redux';
-import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin';
+import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin/register';
import rootReducer from './reducers';
const store = createStore(
@@ -51,7 +53,7 @@ Follow the [Rematch documentation](https://rematchjs.org/docs/guides/devtools/)
```typescript title="store.ts"
import { init } from '@rematch/core';
-import { composeWithRozeniteDevTools } from '@rozenite/redux-devtools-plugin';
+import { composeWithRozeniteDevTools } from '@rozenite/redux-devtools-plugin/register';
export const store = init({
models: {
@@ -115,15 +117,14 @@ Pass `traceSymbolication: false` to keep raw stacks without the Metro round-trip
## Agent Integration
-Agent tools are a separate, manual step — instrumenting your store with the enhancer doesn't register them on its own. Mount this once near your app root:
+Agent tools are a separate, manual step — instrumenting your store with the enhancer doesn't register them on its own. Unlike the enhancer, this hook has no reason to run in production, so it belongs in `rozenite.dev.tsx`, imported from the plugin's main entry point:
-```tsx title="App.tsx"
+```tsx title="rozenite.dev.tsx"
import { useReduxDevToolsAgentTools } from '@rozenite/redux-devtools-plugin';
-function App() {
+export default function RozeniteDevTools() {
useReduxDevToolsAgentTools();
-
- return ;
+ return null;
}
```
diff --git a/website/src/docs/official-plugins/require-profiler.mdx b/website/src/docs/official-plugins/require-profiler.mdx
index cd6c287e..2511f5db 100644
--- a/website/src/docs/official-plugins/require-profiler.mdx
+++ b/website/src/docs/official-plugins/require-profiler.mdx
@@ -35,23 +35,23 @@ module.exports = withRozenite(
Keep `withRozenite`'s `enabled` option conditional as above — when it is false,
`enhanceMetroConfig` never runs and nothing is instrumented. The profiler also
defends itself for the cases outside that gate: it skips instrumentation when
-`process.env.NODE_ENV` is `production`, and the polyfill it injects is guarded by
-`__DEV__`, which Metro strips from release bundles. Pass `enabled` to override the
-default:
+`process.env.NODE_ENV` is `production` or when Metro is bundling for release,
+and the polyfill it injects is guarded by `__DEV__`, which Metro strips from
+release bundles. Pass `enabled` to override the default:
```javascript
withRozeniteRequireProfiler(config, { enabled: process.env.PROFILE_REQUIRES === 'true' });
```
-Add the DevTools hook to your app:
+Add the DevTools hook in `rozenite.dev.tsx`:
-```typescript title="App.tsx"
+```typescript title="rozenite.dev.tsx"
import { useRequireProfilerDevTools } from '@rozenite/require-profiler-plugin';
-function App() {
+export default function RozeniteDevTools() {
useRequireProfilerDevTools();
- return ;
+ return null;
}
```
diff --git a/website/src/docs/official-plugins/sqlite.mdx b/website/src/docs/official-plugins/sqlite.mdx
index 8c1d20a1..9b3e5629 100644
--- a/website/src/docs/official-plugins/sqlite.mdx
+++ b/website/src/docs/official-plugins/sqlite.mdx
@@ -20,12 +20,9 @@ Install the adapter peer dependency if you use Expo SQLite:
## Base Setup
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
import * as SQLite from 'expo-sqlite';
-import {
- createExpoSqliteAdapter,
- useRozeniteSqlitePlugin,
-} from '@rozenite/sqlite-plugin';
+import { createExpoSqliteAdapter, useRozeniteSqlitePlugin } from '@rozenite/sqlite-plugin';
const appDb = SQLite.openDatabaseSync('app.db');
const analyticsDb = SQLite.openDatabaseSync('analytics.db');
@@ -45,9 +42,9 @@ const adapters = [
}),
];
-function App() {
+export default function RozeniteDevTools() {
useRozeniteSqlitePlugin({ adapters });
- return ;
+ return null;
}
```
@@ -69,7 +66,7 @@ Use `list-databases` first to discover available database IDs, then pass the ID
## Adapter: Expo SQLite
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
createExpoSqliteAdapter({
adapterId: 'expo-sqlite',
adapterName: 'Expo SQLite',
@@ -97,7 +94,7 @@ createExpoSqliteAdapter({
You can support any sqlite-like runtime by creating a generic adapter with an `executeStatements()` function per database:
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
import { createSqliteAdapter } from '@rozenite/sqlite-plugin';
const adapters = [
diff --git a/website/src/docs/official-plugins/storage.mdx b/website/src/docs/official-plugins/storage.mdx
index 7db0e7a9..690715d2 100644
--- a/website/src/docs/official-plugins/storage.mdx
+++ b/website/src/docs/official-plugins/storage.mdx
@@ -16,7 +16,7 @@ Install the peer dependencies for the storages you use:
## Setup
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
import {
createAsyncStorageAdapter,
createExpoSecureStorageAdapter,
@@ -37,9 +37,9 @@ const storages = [
}),
];
-function App() {
+export default function RozeniteDevTools() {
useRozeniteStoragePlugin({ storages });
- return ;
+ return null;
}
```
@@ -51,7 +51,7 @@ With [Rozenite for Web](/docs/targets/rozenite-for-web), this plugin is also ava
### MMKV
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
createMMKVStorageAdapter({
storages: { 'user-storage': userStorage, 'settings-storage': settingsStorage },
blacklist: { 'user-storage': /token|secret|password/ },
@@ -62,7 +62,7 @@ MMKV v4 arrays aren't supported — pass a record (`{ id: instance }`) instead.
### AsyncStorage
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
// v2 style
createAsyncStorageAdapter({ storage: AsyncStorage });
@@ -77,7 +77,7 @@ createAsyncStorageAdapter({
### Expo SecureStore
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
createExpoSecureStorageAdapter({
storage: SecureStore,
keys: async () => ['token', 'session', 'refreshToken'],
@@ -95,7 +95,7 @@ MMKV storages that hold binary values render and edit them through a hex viewer
`blacklist` is configured per storage and matched against the key in that storage:
-```ts title="App.tsx"
+```ts title="rozenite.dev.tsx"
createAsyncStorageAdapter({
storages: {
cache: { storage: cacheStorageInstance, blacklist: /temp|debug|internal/ },
diff --git a/website/src/docs/official-plugins/tanstack-query.mdx b/website/src/docs/official-plugins/tanstack-query.mdx
index da94524a..908d7b43 100644
--- a/website/src/docs/official-plugins/tanstack-query.mdx
+++ b/website/src/docs/official-plugins/tanstack-query.mdx
@@ -14,11 +14,10 @@ Make sure to go through the [Getting Started guide](/docs/getting-started) befor
-```typescript title="App.tsx"
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
+```typescript title="query-client.ts"
+import { QueryClient } from '@tanstack/react-query';
-const queryClient = new QueryClient({
+export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
@@ -26,10 +25,16 @@ const queryClient = new QueryClient({
},
},
});
+```
-function App() {
- useTanStackQueryDevTools(queryClient);
+Your `queryClient` still needs to reach `` in your app, and the DevTools hook still
+needs the same instance — a module-level export shared between the two is the simplest way to do that:
+
+```typescript title="App.tsx"
+import { QueryClientProvider } from '@tanstack/react-query';
+import { queryClient } from './query-client';
+function App() {
return (
{/* Your app components */}
@@ -38,6 +43,16 @@ function App() {
}
```
+```typescript title="rozenite.dev.tsx"
+import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin';
+import { queryClient } from './query-client';
+
+export default function RozeniteDevTools() {
+ useTanStackQueryDevTools(queryClient);
+ return null;
+}
+```
+
## Web (React Native for Web)
With [Rozenite for Web](/docs/targets/rozenite-for-web), this plugin is also available when debugging your React Native web app.
diff --git a/website/src/docs/plugin-development/plugin-development.md b/website/src/docs/plugin-development/plugin-development.md
index a20a6eaa..44c5b71f 100644
--- a/website/src/docs/plugin-development/plugin-development.md
+++ b/website/src/docs/plugin-development/plugin-development.md
@@ -292,6 +292,81 @@ export default function setupPlugin(
}
```
+### Production entry points
+
+App code only ever calls into your plugin from `rozenite.dev.tsx`, so `react-native.ts` never reaches
+a production bundle — a build that resolves into your plugin package from anywhere else fails with a
+build error naming your package and the offending import (see the
+[Production Guarantee](../production-guarantee) page for the app-author side of this).
+
+Most plugins are done at that point. But if your plugin has a genuine touchpoint that has to run in
+production — a store enhancer applied where the app creates its store, a per-form hook called from
+inside app screens, an override lookup consulted at flag-evaluation time — declare it as a
+**production entry point** in `rozenite.config.ts`:
+
+```typescript title="rozenite.config.ts"
+export default {
+ panels: [
+ /* ... */
+ ],
+ productionEntries: ['./register'],
+};
+```
+
+and add the corresponding file at your plugin's root:
+
+```typescript title="register.ts"
+// Re-exported through ./react-native.ts, which already resolves this to a
+// noop once `process.env.NODE_ENV` is folded. `register.js` is emitted into
+// the same output tree as `react-native.js`, so both entry points share one
+// module instance.
+export { useMyPluginRuntimeHook } from './react-native';
+```
+
+The build picks up `productionEntries` automatically and exposes `register.ts` as your package's
+`./register` subpath export. App code that needs the production-safe piece imports it from there
+instead of your plugin's main entry point:
+
+```typescript title="store.ts"
+import { useMyPluginRuntimeHook } from '@acme/my-plugin/register';
+```
+
+Everything else your plugin exports keeps living behind the main entry point, dev-only, and is meant
+to be called from `rozenite.dev.tsx`.
+
+#### A production entry point must be inert in production
+
+This is the one part of your plugin the guarantee cannot cover for you. The resolver permits the
+import because you declared it, so whatever `register.ts` exports is what actually runs in someone's
+shipped app — and a hook that subscribes and serializes, an enhancer that retains an action history,
+or an interceptor that patches `fetch` with nothing draining its buffer is exactly the harm keeping
+plugins out of production is meant to prevent.
+
+Reachable is not the same as active. Export the same production behaviour your main entry point
+already defines — which is why the official plugins re-export through `react-native.ts` rather than
+reaching into `src/**` — so there is one definition of what your plugin does in a release build
+instead of a second copy that can silently drift from it. A wrong stub here fails the way the old
+hand-written shims did: quietly, in someone else's production app.
+
+Write a test that pins it. Set `process.env.NODE_ENV` to `'production'`, import your `register`
+entry, and assert the inert behaviour directly — that the enhancer passes `createStore` through
+untouched, that the interceptor leaves `globalThis.fetch` identical, that the hook returns without
+touching what it was handed.
+
+:::info The declaration is not verified
+Rozenite does not walk `register.ts`'s import graph to confirm it's "really" safe for production —
+safety isn't a property of an import graph. `productionEntries` is your explicit, attributable
+statement about what you intend to ship; Rozenite holds you to exactly that declaration and does not
+audit what it reaches. The one thing that is checked is that each declared entry actually resolves to
+a real file, so a typo surfaces as its own clear error rather than silently behaving as "nothing
+declared".
+:::
+
+Keep `register.ts` importing only from your plugin's own `src/**` modules, never from
+`react-native.ts` — that file is the dev-only shim your `rozenite.dev.tsx` consumers import, and
+re-exporting through it from `register.ts` would drag your whole dev surface (DevTools client
+connection, panel bridge, everything) into every app that uses your production entry.
+
## Step 5: Local Development Workflow
### Complete Development Setup
diff --git a/website/src/docs/production-guarantee.mdx b/website/src/docs/production-guarantee.mdx
new file mode 100644
index 00000000..1a1a4a26
--- /dev/null
+++ b/website/src/docs/production-guarantee.mdx
@@ -0,0 +1,187 @@
+import { PackageManagerTabs } from '@rspress/core/theme';
+
+# The Production Guarantee
+
+Rozenite plugins add real weight to your app: DevTools UI, bridge wiring, sometimes a native
+dependency. None of that should ever reach the app your users install. This page explains how
+Rozenite makes that a structural guarantee instead of a convention you have to remember.
+
+This guarantee currently covers **Metro** and **Re.Pack**. Lynx support is tracked in
+[#492](https://github.com/callstackincubator/rozenite/issues/492).
+
+## The model
+
+Your app has exactly one Rozenite import that is always there, unconditionally:
+
+```tsx title="App.tsx"
+import Rozenite from '@rozenite/react-native';
+
+export default function App() {
+ return (
+ <>
+
+ {/* your app */}
+ >
+ );
+}
+```
+
+There is nothing to guard here — no `__DEV__` check, no build flag. `` itself never
+imports anything besides `react`. What it renders depends on how the bundler resolves it:
+
+- In **development**, `withRozenite()` (from `@rozenite/metro` or `@rozenite/repack`) redirects it
+ to your project's `rozenite.dev` file — an ordinary project file, next to `metro.config.js`,
+ where all of your plugin wiring lives.
+- In **production**, it resolves to a shipped noop. No plugin code is reachable from it at all.
+
+```tsx title="rozenite.dev.tsx"
+import { useRozeniteStoragePlugin, createMMKVStorageAdapter } from '@rozenite/storage-plugin';
+import { storage } from './src/storage';
+
+export default function RozeniteDevTools() {
+ useRozeniteStoragePlugin({ adapters: [createMMKVStorageAdapter({ mmkv: storage })] });
+ return null;
+}
+```
+
+`rozenite.dev.tsx` can grow into as many files as you need — a `rozenite.dev/` directory with an
+`index.tsx` works too, and platform extensions (`rozenite.dev.ios.tsx`, `rozenite.dev/index.web.tsx`)
+apply for free, the same as anywhere else in your project. `rozenite init` scaffolds the flat file
+for you. Because it's an ordinary project file, Fast Refresh works on it like on anything else — and
+because nothing outside it ever imports it, nothing reachable from it can end up in a production
+bundle.
+
+If no `rozenite.dev` file exists yet, `` just renders nothing and logs once — a missing
+file is never a build failure.
+
+## Why this needed to be structural
+
+Before this, a plugin's `react-native.ts` was a hand-written shim that checked `__DEV__` (or
+`process.env.NODE_ENV`) and no-op'd itself outside development. That makes shipping a plugin's code
+_survivable_ — the hook does nothing at runtime — but the code itself, and everything it imports, is
+still sitting in your bundle. It only worked at all for plugins that bothered to write that shim, and
+it depended on every app author remembering to call the hook the right way.
+
+Rozenite now enforces this at the bundler's resolver instead, so it applies to every plugin, in every
+app, with no cooperation required beyond the plugin manifest a plugin already ships.
+
+## The build error
+
+If a production build resolves an import into a Rozenite plugin package, and that import is not one
+of the plugin's declared production entry points, the build fails with a message naming both the
+plugin and the file that imported it:
+
+```
+@acme/some-plugin is a Rozenite plugin and declares no production entry points.
+Imported from: src/screens/Settings.tsx
+Move plugin wiring into rozenite.dev.tsx, or declare this file in productionEntries in rozenite.config.ts. To bypass this check for @acme/some-plugin only, pass allowInProduction: ['@acme/some-plugin'] to withRozenite().
+```
+
+This is a **structural** check, not a heuristic: it looks at where the import actually resolves, not
+at where you wrote it from, so it catches every route into the plugin's code, including one buried a
+few modules deep. To fix it, do one of:
+
+- Move the offending import into `rozenite.dev.tsx` (or a file inside a `rozenite.dev/` directory) —
+ the right fix for the vast majority of plugin usage, which has no reason to run in production at
+ all.
+- If the plugin genuinely needs this touchpoint in production — see
+ [`productionEntries` / `./register`](#productionentries-and-register) below — check whether the
+ plugin already ships one and, if so, import from `/register` instead of the plugin's main
+ entry point.
+- As a last resort, [`allowInProduction`](#allowinproduction-the-escape-hatch).
+
+:::info `enabled: false` still enforces this
+`withRozenite(config, { enabled: false })` used to mean "do nothing" — no dev server, but also no
+guard, so a stray plugin import would silently ship. It now means "no dev server, guard still
+active": the build error above still fires. If you relied on `enabled: false` as a way to keep
+Rozenite out of a build entirely, audit that build for plugin imports outside `rozenite.dev.tsx`
+before you rely on this.
+:::
+
+## The dev-time warning
+
+Waiting for a release build to catch a stray import is late. In development, the same mistake prints
+a warning instead of failing anything:
+
+```
+warning: @acme/some-plugin imported from src/screens/Settings.tsx.
+ Plugin imports belong in rozenite.dev.tsx. This will fail your production build.
+```
+
+Unlike the production error, this warning is a **path heuristic**: it's suppressed for any file whose
+name starts with `rozenite.dev`, or that sits inside a `rozenite.dev` directory, on the assumption
+that those files are the dev entry itself. If your project has an unusual layout the heuristic doesn't
+recognize, you might see a spurious warning on a file that's actually fine — that's a false positive
+in a warning, never a broken build, so the convention it nudges you toward never becomes
+load-bearing. The production check above is what actually enforces the guarantee; this warning only
+tries to surface the same mistake earlier, while it's cheap to fix. It also fires at most once per
+`(file, plugin)` pair, and independently for every offending import — so five bad imports get five
+warnings, not one failure that stops at the first.
+
+## `productionEntries` and `./register`
+
+Most plugin code has no business running in production — a DevTools panel connection is only useful
+while DevTools is open. But a few plugins have a genuine touchpoint that has to survive into your
+shipped app: a store enhancer applied where you create your Redux store, a per-form hook called from
+inside your screens, a feature-flag override lookup consulted at evaluation time. For those, the
+plugin author declares a **production entry point** in `rozenite.config.ts`:
+
+```typescript title="rozenite.config.ts"
+export default {
+ panels: [/* ... */],
+ productionEntries: ['./register'],
+};
+```
+
+and ships the corresponding `register.ts` at the plugin's root. The build picks this up automatically
+and the plugin exposes it as a `./register` subpath export — so app code imports the production-safe
+pieces from `/register` instead of the plugin's main entry point:
+
+```typescript title="store.ts"
+import { rozeniteDevToolsEnhancer } from '@rozenite/redux-devtools-plugin/register';
+```
+
+Everything else that plugin exports — its DevTools connection hook, for instance — still only exists
+on the main entry point, and still belongs in `rozenite.dev.tsx`. Check the plugin's own docs for
+which pieces, if any, ship a `./register` entry; most official plugins don't need one at all.
+
+:::info The declaration is not verified
+Rozenite does not traverse a declared entry's import graph to confirm it's "really" safe — safety
+isn't a property of an import graph, and trying to prove it would be both expensive and wrong most of
+the time. `productionEntries` is the plugin author's explicit, attributable statement about what they
+intend to ship to production, and the framework holds them to exactly that declaration. The one thing
+Rozenite does check is that a declared entry actually resolves to a real file — a typo in
+`productionEntries` gets its own distinct error naming the plugin and the bad entry, instead of
+silently behaving as if nothing had been declared.
+:::
+
+If you're building a plugin and want to add a production entry point, see
+[Plugin Development](/docs/plugin-development/plugin-development#production-entry-points).
+
+## `allowInProduction`, the escape hatch
+
+Sometimes you need to unblock a build right now, before you've had a chance to restructure an import
+or wait on a plugin author to add a `productionEntries` declaration. `withRozenite` accepts
+`allowInProduction` for exactly that:
+
+```javascript title="metro.config.js"
+module.exports = withRozenite(config, {
+ allowInProduction: ['@acme/some-plugin'],
+});
+```
+
+Every package listed here is exempted from the guard entirely — its code can end up in your
+production bundle through any import path, not just a declared one. This is printed loudly once per
+build (not once per resolution) specifically so it can't sit forgotten in a config file:
+
+```
+allowInProduction is set for: @acme/some-plugin. Code from these Rozenite plugin package(s) may reach your production bundle -- this defeats the production guarantee for them. Prefer declaring productionEntries in the plugin's rozenite.config.ts instead.
+```
+
+Treat this as a last resort, not a fix. It exists so the first person the guard blocks incorrectly has
+a way out that doesn't mean forking `@rozenite/metro`/`@rozenite/repack` and losing the guarantee for
+every plugin, everywhere. If you reach for it, follow up with the plugin author about declaring a
+proper `productionEntries` entry.
+
+**Next**: back to [Getting Started](./getting-started), or the
+[Plugin Development guide](./plugin-development/plugin-development) if you're building a plugin.