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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/production-plugin-guarantee.md
Original file line number Diff line number Diff line change
@@ -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 `<Rozenite />` 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 `<plugin>/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.
3 changes: 2 additions & 1 deletion apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
90 changes: 90 additions & 0 deletions apps/playground/rozenite.dev/index.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Rozenite />` 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<any>`-typed ref: `useReactNavigationDevTools`'s
// `ref: React.RefObject<TNavigationContainerRef | null>` doesn't infer
// `TNavigationContainerRef` from a route-specific ref, so it always falls
// back to comparing against the default `NavigationContainerRef<any>` 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 && <NetworkPlaygroundControls />}
<RozeniteOverlay />
</>
);
}
85 changes: 85 additions & 0 deletions apps/playground/rozenite.dev/index.web.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Rozenite />` 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<any>(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 <RozeniteOverlay />;
}
67 changes: 67 additions & 0 deletions apps/playground/rozenite.dev/network-controls.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
35 changes: 35 additions & 0 deletions apps/playground/rozenite.dev/sqlite-adapters.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
}),
];
Loading