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-banner](https://www.rozenite.dev/rozenite-banner.jpg) + +### 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.