From 3516ba8915668488bcc98017dbcb96450aeec456 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 06:56:39 +0000 Subject: [PATCH 1/9] feat(metro): make plugin imports in production builds a build error Adds the app-side seam package, the resolver-level guard, and the `productionEntries` declaration that lets a plugin name the parts of itself that are allowed to run in production. Work in progress on this branch: the Re.Pack integration, the playground migration and the docs are still landing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TLGRno4KVGnNBqBqx6BR3u --- .../src/__tests__/dev-entry-scaffold.test.ts | 70 +++++ .../src/__tests__/plugin-package-json.test.ts | 76 ++++++ packages/cli/src/__tests__/tsc-build.test.ts | 41 +++ packages/cli/src/commands/build-command.ts | 15 +- packages/cli/src/commands/dev-command.ts | 11 +- packages/cli/src/commands/init-command.ts | 45 +++- packages/cli/src/utils/dev-entry-scaffold.ts | 96 +++++++ packages/cli/src/utils/packages.ts | 9 + packages/cli/src/utils/plugin-package-json.ts | 24 +- packages/cli/src/utils/tsc-build.ts | 43 ++- packages/feature-flags-plugin/package.json | 7 +- packages/feature-flags-plugin/register.ts | 34 +++ .../feature-flags-plugin/rozenite.config.ts | 4 + packages/metro/package.json | 1 + packages/metro/src/index.ts | 71 +++-- packages/metro/src/resolver.ts | 217 +++++++++++++++ .../src/__tests__/production-guard.test.ts | 250 +++++++++++++++++ packages/middleware/src/index.ts | 10 + packages/middleware/src/production-guard.ts | 251 ++++++++++++++++++ packages/network-activity-plugin/package.json | 5 + packages/network-activity-plugin/register.ts | 11 + .../rozenite.config.ts | 4 + packages/react-native/.npmignore | 1 + packages/react-native/README.md | 88 ++++++ packages/react-native/eslint.config.mjs | 12 + packages/react-native/package.json | 52 ++++ .../react-native/scripts/cjs-package.json | 1 + packages/react-native/src/dev-entry.tsx | 32 +++ packages/react-native/src/index.tsx | 19 ++ packages/react-native/tsconfig.cjs.json | 26 ++ packages/react-native/tsconfig.esm.json | 12 + packages/react-native/tsconfig.json | 8 + packages/react-native/tsconfig.lib.json | 11 + packages/react-native/tsconfig.types.json | 12 + packages/redux-devtools-plugin/package.json | 5 + packages/redux-devtools-plugin/register.ts | 10 + .../redux-devtools-plugin/rozenite.config.ts | 3 + .../src/__tests__/metro-polyfill.test.ts | 35 ++- .../src/metro/index.ts | 11 +- packages/rhf-plugin/package.json | 7 +- packages/rhf-plugin/register.ts | 13 + packages/rhf-plugin/rozenite.config.ts | 4 + packages/tools/src/index.ts | 2 + packages/{metro => tools}/src/is-bundling.ts | 0 packages/{metro => tools}/src/packages.ts | 0 packages/vite-plugin/src/client-plugin.ts | 27 ++ packages/vite-plugin/src/load-config.ts | 7 + pnpm-lock.yaml | 15 ++ 48 files changed, 1663 insertions(+), 45 deletions(-) create mode 100644 packages/cli/src/__tests__/dev-entry-scaffold.test.ts create mode 100644 packages/cli/src/utils/dev-entry-scaffold.ts create mode 100644 packages/feature-flags-plugin/register.ts create mode 100644 packages/metro/src/resolver.ts create mode 100644 packages/middleware/src/__tests__/production-guard.test.ts create mode 100644 packages/middleware/src/production-guard.ts create mode 100644 packages/network-activity-plugin/register.ts create mode 100644 packages/react-native/.npmignore create mode 100644 packages/react-native/README.md create mode 100644 packages/react-native/eslint.config.mjs create mode 100644 packages/react-native/package.json create mode 100644 packages/react-native/scripts/cjs-package.json create mode 100644 packages/react-native/src/dev-entry.tsx create mode 100644 packages/react-native/src/index.tsx create mode 100644 packages/react-native/tsconfig.cjs.json create mode 100644 packages/react-native/tsconfig.esm.json create mode 100644 packages/react-native/tsconfig.json create mode 100644 packages/react-native/tsconfig.lib.json create mode 100644 packages/react-native/tsconfig.types.json create mode 100644 packages/redux-devtools-plugin/register.ts create mode 100644 packages/rhf-plugin/register.ts rename packages/{metro => tools}/src/is-bundling.ts (100%) rename packages/{metro => tools}/src/packages.ts (100%) 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/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..2e9755e8 --- /dev/null +++ b/packages/feature-flags-plugin/register.ts @@ -0,0 +1,34 @@ +// 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`. Import from the underlying `src/**` modules +// directly, never from `./react-native.ts`: that shim pulls in the plugin's +// whole dev surface, which is exactly what this entry point exists to keep +// out of the production bundle. +// +// `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, +} from './src/react-native/adapters'; +export type { + CreateCustomFlagsAdapterOptions, + CreateLaunchDarklyFlagsAdapterOptions, + FeatureFlagInput, + LaunchDarklyFlagsAdapter, + LDClientLike, + LDEvaluationDetailLike, + LDEvaluationReason, + LDFlagSet, +} from './src/react-native/adapters'; + +export { createFlagOverrides } from './src/react-native/overrides'; +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/metro/package.json b/packages/metro/package.json index 3e851bd8..6636c07a 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -53,6 +53,7 @@ }, "devDependencies": { "@react-native/metro-config": "~0.86.0", + "metro-resolver": "*", "vitest": "^4.0.18" }, "engines": { diff --git a/packages/metro/src/index.ts b/packages/metro/src/index.ts index 259a1ec0..f13698d7 100644 --- a/packages/metro/src/index.ts +++ b/packages/metro/src/index.ts @@ -7,10 +7,10 @@ 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 & { /** @@ -24,6 +24,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 +46,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 +80,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 +120,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..b10e23b9 --- /dev/null +++ b/packages/metro/src/resolver.ts @@ -0,0 +1,217 @@ +import path from 'node:path'; +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). +const declaredEntriesCache = new Map>(); + +// 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) { + return cached; + } + + const resolvedPaths = new Set(); + + if (plugin.productionEntries.length > 0) { + isResolvingDeclaredEntries = true; + + try { + for (const entry of plugin.productionEntries) { + let resolution: Resolution; + + try { + resolution = context.resolveRequest( + { ...context, originModulePath: path.join(plugin.root, 'package.json') }, + 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, resolvedPaths); + 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/src/__tests__/production-guard.test.ts b/packages/middleware/src/__tests__/production-guard.test.ts new file mode 100644 index 00000000..f0d30f4c --- /dev/null +++ b/packages/middleware/src/__tests__/production-guard.test.ts @@ -0,0 +1,250 @@ +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', () => { + 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'); + let plugin: ReturnType = null; + + expect(() => { + plugin = findRozenitePluginForFile(filePath); + }).not.toThrow(); + // A malformed manifest still exists on disk, so the package is still a + // plugin -- just one that has declared nothing. + expect(plugin).not.toBeNull(); + expect(plugin?.productionEntries).toEqual([]); + }); + + it('memoizes per directory, including negative results', () => { + 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(); + + // Remove the manifest after the first (cached) lookup -- a second + // lookup for a file in the same directory must still hit the cache and + // return the original (memoized) result rather than re-reading disk. + fs.rmSync(path.join(packageRoot, 'dist', 'rozenite.json')); + const second = findRozenitePluginForFile(filePath); + + expect(second).toEqual(first); + }); +}); + +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..70be0522 100644 --- a/packages/middleware/src/index.ts +++ b/packages/middleware/src/index.ts @@ -20,6 +20,16 @@ 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 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..2a281a79 --- /dev/null +++ b/packages/middleware/src/production-guard.ts @@ -0,0 +1,251 @@ +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; + +// 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 cache is process-lifetime and never +// invalidated -- package.json/rozenite.json contents are not expected to +// change while a bundler process is running. +const pluginCache = new Map(); + +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; +}; + +const readPackageName = (packageJsonPath: string, fallback: string): string => { + return readPackageNameOrNull(packageJsonPath) ?? fallback; +}; + +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 = readPackageName(path.join(packageRoot, 'package.json'), packageRoot); + + return { + name, + root: realpathSafe(packageRoot), + productionEntries: readProductionEntries(manifestPath), + }; +}; + +const findPluginForDirectory = (dir: string): PluginLookupResult => { + const cached = pluginCache.get(dir); + + if (cached !== undefined) { + return cached; + } + + const packageJsonPath = path.join(dir, 'package.json'); + let result: PluginLookupResult; + + if (fs.existsSync(packageJsonPath)) { + // The first package.json found going up is the package root, whether or + // not it turns out to be a Rozenite plugin -- we never look past it. + result = readPluginAtPackageRoot(dir); + } else { + const parentDir = path.dirname(dir); + result = parentDir === dir ? null : findPluginForDirectory(parentDir); + } + + pluginCache.set(dir, result); + return result; +}; + +/** + * 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)); +}; + +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; + } + + const packageJsonPath = path.join(dir, 'package.json'); + let result: string | null; + + if (fs.existsSync(packageJsonPath)) { + result = readPackageNameOrNull(packageJsonPath); + } 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/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..c66d7153 --- /dev/null +++ b/packages/network-activity-plugin/register.ts @@ -0,0 +1,11 @@ +// 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`. Import from the underlying +// `src/**` modules directly, never from `./react-native.ts`: that shim pulls +// in the plugin's whole dev surface, which is exactly what this entry point +// exists to keep out of the production bundle. +export { withOnBootNetworkActivityRecording } from './src/react-native/boot-recording'; +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/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/redux-devtools-plugin/package.json b/packages/redux-devtools-plugin/package.json index 0f38a0ee..0e475547 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": { diff --git a/packages/redux-devtools-plugin/register.ts b/packages/redux-devtools-plugin/register.ts new file mode 100644 index 00000000..575a6207 --- /dev/null +++ b/packages/redux-devtools-plugin/register.ts @@ -0,0 +1,10 @@ +// 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`. Import from the underlying +// `src/**` modules directly, never from `./react-native.ts`: that shim pulls +// in the plugin's whole dev surface, which is exactly what this entry point +// exists to keep out of the production bundle. +export { rozeniteDevToolsEnhancer, composeWithRozeniteDevTools } from './src/runtime'; +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/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/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..1581441c --- /dev/null +++ b/packages/rhf-plugin/register.ts @@ -0,0 +1,13 @@ +// 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`. Import from the underlying `src/**` modules +// directly, never from `./react-native.ts`: that shim pulls in the plugin's +// whole dev surface, which is exactly what this entry point exists to keep +// out of the production bundle. +export { useRozeniteRHFPlugin } from './src/react-native/useRozeniteRHFPlugin'; +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/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..2d6a33da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -958,6 +958,9 @@ 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) + 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 +1233,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': From c25eb72b6e37a02c46b039266ac6642f25f0edef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 07:14:39 +0000 Subject: [PATCH 2/9] feat(metro): keep declared production entries inert, migrate playground and docs Re-export each plugin's `./register` surface through its `react-native.ts` entry so a declared production touchpoint is reachable in a release build without being active in one, and pin that with a test per plugin. Resolve a declared entry as the export subpath a consumer actually writes, so a correctly declared import is not rejected by the guard. Moves the playground's plugin wiring into `rozenite.dev/`, and rewrites the docs around `` and `rozenite.dev.tsx`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TLGRno4KVGnNBqBqx6BR3u --- .changeset/production-plugin-guarantee.md | 52 ++++ apps/playground/package.json | 3 +- .../agent-tools.ts} | 5 + .../controls-sections.ts} | 7 +- apps/playground/rozenite.dev/index.tsx | 84 +++++++ apps/playground/rozenite.dev/index.web.tsx | 85 +++++++ .../rozenite.dev/network-controls.ts | 54 +++++ .../rozenite.dev/sqlite-adapters.ts | 35 +++ .../rozenite.dev/storage-adapters.ts | 53 +++++ apps/playground/src/app/App.tsx | 70 +----- apps/playground/src/app/App.web.tsx | 2 + apps/playground/src/app/WebPluginSections.tsx | 83 +------ .../src/app/feature-flags-plugin-adapters.ts | 8 +- .../src/app/navigation/navigationRef.ts | 23 ++ apps/playground/src/app/query-client.ts | 6 + .../app/screens/FeatureFlagsPluginScreen.tsx | 8 +- .../src/app/screens/NetworkTestScreen.tsx | 45 +--- .../app/screens/ReactHookFormPluginScreen.tsx | 5 +- .../src/app/sqlite-plugin-databases.ts | 37 +-- .../src/app/storage-plugin-adapters.ts | 48 +--- apps/playground/src/app/store.ts | 5 +- .../src/app/stores/networkTestStore.ts | 16 ++ apps/playground/tsconfig.app.json | 5 +- apps/playground/webpack.config.js | 47 +++- packages/controls-plugin/README.md | 15 +- packages/expo-atlas-plugin/README.md | 5 + packages/feature-flags-plugin/README.md | 18 +- packages/feature-flags-plugin/register.ts | 19 +- .../src/__tests__/register-entry.test.ts | 52 ++++ packages/feature-flags-plugin/tsconfig.json | 2 +- packages/file-system-plugin/README.md | 18 +- packages/metro/README.md | 54 +++++ packages/metro/src/__tests__/resolver.test.ts | 222 ++++++++++++++++++ packages/metro/src/index.ts | 7 +- packages/metro/src/resolver.ts | 23 +- packages/middleware/README.md | 19 ++ .../src/__tests__/production-guard.test.ts | 7 +- packages/network-activity-plugin/README.md | 4 +- packages/network-activity-plugin/register.ts | 17 +- .../__tests__/register-entry.test.ts | 34 +++ .../network-activity-plugin/tsconfig.json | 2 +- packages/overlay-plugin/README.md | 22 +- packages/performance-monitor-plugin/README.md | 30 +-- packages/react-navigation-plugin/README.md | 61 +++-- packages/redux-devtools-plugin/README.md | 6 +- packages/redux-devtools-plugin/package.json | 3 +- packages/redux-devtools-plugin/register.ts | 17 +- .../src/__tests__/register-entry.test.ts | 50 ++++ packages/redux-devtools-plugin/tsconfig.json | 2 +- packages/repack/README.md | 53 +++++ packages/require-profiler-plugin/README.md | 19 +- packages/rhf-plugin/README.md | 4 +- packages/rhf-plugin/register.ts | 16 +- .../src/__tests__/register-entry.test.ts | 39 +++ packages/rhf-plugin/tsconfig.json | 2 +- packages/sqlite-plugin/README.md | 46 ++-- packages/storage-plugin/README.md | 9 +- packages/tanstack-query-plugin/README.md | 57 ++--- pnpm-lock.yaml | 6 + website/src/docs/_meta.json | 5 + website/src/docs/getting-started.mdx | 71 +++++- .../src/docs/official-plugins/controls.mdx | 8 +- .../src/docs/official-plugins/expo-atlas.mdx | 8 + .../docs/official-plugins/feature-flags.mdx | 158 ++++++++----- .../src/docs/official-plugins/file-system.mdx | 26 +- .../official-plugins/network-activity.mdx | 16 +- website/src/docs/official-plugins/overlay.mdx | 22 +- .../src/docs/official-plugins/overview.mdx | 2 +- .../official-plugins/performance-monitor.mdx | 6 +- .../docs/official-plugins/react-hook-form.mdx | 8 +- .../official-plugins/react-navigation.mdx | 55 ++++- .../docs/official-plugins/redux-devtools.mdx | 19 +- .../official-plugins/require-profiler.mdx | 14 +- website/src/docs/official-plugins/sqlite.mdx | 15 +- website/src/docs/official-plugins/storage.mdx | 14 +- .../docs/official-plugins/tanstack-query.mdx | 27 ++- .../plugin-development/plugin-development.md | 55 +++++ website/src/docs/production-guarantee.mdx | 184 +++++++++++++++ 78 files changed, 1861 insertions(+), 598 deletions(-) create mode 100644 .changeset/production-plugin-guarantee.md rename apps/playground/{src/app/useAgentPlaygroundTools.ts => rozenite.dev/agent-tools.ts} (89%) rename apps/playground/{src/app/hooks/usePlaygroundControlsSections.ts => rozenite.dev/controls-sections.ts} (95%) create mode 100644 apps/playground/rozenite.dev/index.tsx create mode 100644 apps/playground/rozenite.dev/index.web.tsx create mode 100644 apps/playground/rozenite.dev/network-controls.ts create mode 100644 apps/playground/rozenite.dev/sqlite-adapters.ts create mode 100644 apps/playground/rozenite.dev/storage-adapters.ts create mode 100644 apps/playground/src/app/navigation/navigationRef.ts create mode 100644 apps/playground/src/app/query-client.ts create mode 100644 apps/playground/src/app/stores/networkTestStore.ts create mode 100644 packages/feature-flags-plugin/src/__tests__/register-entry.test.ts create mode 100644 packages/metro/src/__tests__/resolver.test.ts create mode 100644 packages/network-activity-plugin/src/react-native/__tests__/register-entry.test.ts create mode 100644 packages/redux-devtools-plugin/src/__tests__/register-entry.test.ts create mode 100644 packages/rhf-plugin/src/__tests__/register-entry.test.ts create mode 100644 website/src/docs/production-guarantee.mdx diff --git a/.changeset/production-plugin-guarantee.md b/.changeset/production-plugin-guarantee.md new file mode 100644 index 00000000..e031a797 --- /dev/null +++ b/.changeset/production-plugin-guarantee.md @@ -0,0 +1,52 @@ +--- +'@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. 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..cc432347 --- /dev/null +++ b/apps/playground/rozenite.dev/index.tsx @@ -0,0 +1,84 @@ +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 { navigationRef } from '../src/app/navigation/navigationRef'; +import { queryClient } from '../src/app/query-client'; +import { useAgentPlaygroundTools } from './agent-tools'; +import { usePlaygroundControlsSections } from './controls-sections'; +import { useNetworkControlsSections } 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 networkControlsSections = useNetworkControlsSections(); + + useTanStackQueryDevTools(queryClient); + // Two independent calls, mirroring the two independent callers that + // existed before this migration (App-level Controls sections and the + // Network screen's local section) — useRozeniteControlsPlugin merges + // sections from every caller, so this keeps both mounted side by side. + useRozeniteControlsPlugin({ + sections: controlsSections, + }); + useRozeniteControlsPlugin({ + sections: networkControlsSections, + }); + 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 ; +} 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.ts b/apps/playground/rozenite.dev/network-controls.ts new file mode 100644 index 00000000..e3edba2d --- /dev/null +++ b/apps/playground/rozenite.dev/network-controls.ts @@ -0,0 +1,54 @@ +import { createSection } 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 from the dev entry +// (not merged into `usePlaygroundControlsSections`'s array) so it mounts +// alongside the app-level Controls sections, matching today's behaviour of +// two independent callers. +export 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], + ); +}; 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..8be7743c 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, 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); @@ -188,42 +187,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..ae7c2aac --- /dev/null +++ b/apps/playground/src/app/stores/networkTestStore.ts @@ -0,0 +1,16 @@ +import { create } from 'zustand'; + +export type Transport = 'fetch' | 'expo' | 'nitro'; + +type NetworkTestState = { + transport: Transport; + setTransport: (transport: Transport) => 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 }), +})); 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/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/register.ts b/packages/feature-flags-plugin/register.ts index 2e9755e8..b3d9b416 100644 --- a/packages/feature-flags-plugin/register.ts +++ b/packages/feature-flags-plugin/register.ts @@ -4,10 +4,16 @@ // 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`. Import from the underlying `src/**` modules -// directly, never from `./react-native.ts`: that shim pulls in the plugin's -// whole dev surface, which is exactly what this entry point exists to keep -// out of the production bundle. +// `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 @@ -18,7 +24,8 @@ export { createCustomFlagsAdapter, createLaunchDarklyFlagsAdapter, -} from './src/react-native/adapters'; + createFlagOverrides, +} from './react-native'; export type { CreateCustomFlagsAdapterOptions, CreateLaunchDarklyFlagsAdapterOptions, @@ -29,6 +36,4 @@ export type { LDEvaluationReason, LDFlagSet, } from './src/react-native/adapters'; - -export { createFlagOverrides } from './src/react-native/overrides'; export type { FlagOverrides, FlagOverridesOptions } from './src/react-native/overrides'; 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/src/__tests__/resolver.test.ts b/packages/metro/src/__tests__/resolver.test.ts new file mode 100644 index 00000000..58f14e8a --- /dev/null +++ b/packages/metro/src/__tests__/resolver.test.ts @@ -0,0 +1,222 @@ +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)); + }); +}); + +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 f13698d7..137bb4dc 100644 --- a/packages/metro/src/index.ts +++ b/packages/metro/src/index.ts @@ -15,7 +15,12 @@ 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; diff --git a/packages/metro/src/resolver.ts b/packages/metro/src/resolver.ts index b10e23b9..e73fa73e 100644 --- a/packages/metro/src/resolver.ts +++ b/packages/metro/src/resolver.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import type { CustomResolutionContext, CustomResolver, Resolution } from 'metro-resolver'; import { findRozenitePluginForFile, @@ -22,6 +21,19 @@ const WEB_SOCKET_INTERCEPTOR_MODULE = 'react-native/Libraries/WebSocket/WebSocke // (pluginRoot, platform). const declaredEntriesCache = new Map>(); +/** + * 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 @@ -52,9 +64,14 @@ const resolveDeclaredEntries = ( 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, originModulePath: path.join(plugin.root, 'package.json') }, - entry, + context, + getEntrySpecifier(plugin.name, entry), platform, ); } catch (error) { 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 index f0d30f4c..c87add81 100644 --- a/packages/middleware/src/__tests__/production-guard.test.ts +++ b/packages/middleware/src/__tests__/production-guard.test.ts @@ -99,13 +99,12 @@ describe('findRozenitePluginForFile', () => { }); const filePath = path.join(packageRoot, 'src', 'index.ts'); - let plugin: ReturnType = null; - expect(() => { - plugin = findRozenitePluginForFile(filePath); - }).not.toThrow(); + 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([]); }); 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/register.ts b/packages/network-activity-plugin/register.ts index c66d7153..05d9bd8c 100644 --- a/packages/network-activity-plugin/register.ts +++ b/packages/network-activity-plugin/register.ts @@ -3,9 +3,16 @@ // 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`. Import from the underlying -// `src/**` modules directly, never from `./react-native.ts`: that shim pulls -// in the plugin's whole dev surface, which is exactly what this entry point -// exists to keep out of the production bundle. -export { withOnBootNetworkActivityRecording } from './src/react-native/boot-recording'; +// `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/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-navigation-plugin/README.md b/packages/react-navigation-plugin/README.md index 71ed85ce..d48e3253 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,26 @@ 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: +```typescript title="navigation.ts" +import { createNavigationContainerRef } from '@react-navigation/native'; + +export const navigationRef = createNavigationContainerRef(); +``` ```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 +61,45 @@ function App() { } ``` +```typescript title="rozenite.dev.tsx" +import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin'; +import { navigationRef } from './navigation'; + +export default function RozeniteDevTools() { + useReactNavigationDevTools({ ref: navigationRef }); + 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 0e475547..50195e47 100644 --- a/packages/redux-devtools-plugin/package.json +++ b/packages/redux-devtools-plugin/package.json @@ -74,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 index 575a6207..1d3408d2 100644 --- a/packages/redux-devtools-plugin/register.ts +++ b/packages/redux-devtools-plugin/register.ts @@ -2,9 +2,16 @@ // // 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`. Import from the underlying -// `src/**` modules directly, never from `./react-native.ts`: that shim pulls -// in the plugin's whole dev surface, which is exactly what this entry point -// exists to keep out of the production bundle. -export { rozeniteDevToolsEnhancer, composeWithRozeniteDevTools } from './src/runtime'; +// `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/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/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/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/register.ts b/packages/rhf-plugin/register.ts index 1581441c..781da6d1 100644 --- a/packages/rhf-plugin/register.ts +++ b/packages/rhf-plugin/register.ts @@ -4,10 +4,16 @@ // `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`. Import from the underlying `src/**` modules -// directly, never from `./react-native.ts`: that shim pulls in the plugin's -// whole dev surface, which is exactly what this entry point exists to keep -// out of the production bundle. -export { useRozeniteRHFPlugin } from './src/react-native/useRozeniteRHFPlugin'; +// `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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 2d6a33da..7a8bdf0b 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 @@ -1390,6 +1393,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..fc5b60de 100644 --- a/website/src/docs/official-plugins/react-navigation.mdx +++ b/website/src/docs/official-plugins/react-navigation.mdx @@ -12,18 +12,23 @@ 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 +```typescript title="navigation.ts" +import { createNavigationContainerRef } from '@react-navigation/native'; + +export const navigationRef = createNavigationContainerRef(); +``` + ```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 +37,44 @@ function App() { } ``` +```typescript title="rozenite.dev.tsx" +import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin'; +import { navigationRef } from './navigation'; + +export default function RozeniteDevTools() { + useReactNavigationDevTools({ ref: navigationRef }); + 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..21b2a100 100644 --- a/website/src/docs/plugin-development/plugin-development.md +++ b/website/src/docs/plugin-development/plugin-development.md @@ -292,6 +292,61 @@ 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" +// Import from your own src/** modules directly -- never from ./react-native.ts. +// That shim pulls in your plugin's whole dev surface, which is exactly what +// this entry point exists to keep out of the production bundle. +export { useMyPluginRuntimeHook } from './src/react-native/useMyPluginRuntimeHook'; +``` + +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`. + +:::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..0bd8db76 --- /dev/null +++ b/website/src/docs/production-guarantee.mdx @@ -0,0 +1,184 @@ +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. + +## 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. From 6d1ab43f7d04d5910925156e8d200b4a1c201474 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 07:20:58 +0000 Subject: [PATCH 3/9] fix(playground): keep the Network section mounted with its screen The section demonstrates that a screen's own Controls section appears and disappears with it while the app-level ones stay, so registering it unconditionally from the dev entry would have quietly dropped half of what it tests. The screen keeps ownership of when it is registered. Also corrects the plugin-authoring guide: a declared production entry has to be inert in production, which is why the official plugins re-export through their root entry rather than reaching into src/**. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TLGRno4KVGnNBqBqx6BR3u --- apps/playground/rozenite.dev/index.tsx | 26 ++++++++++------- ...twork-controls.ts => network-controls.tsx} | 25 +++++++++++++---- .../src/app/screens/NetworkTestScreen.tsx | 13 ++++++++- .../src/app/stores/networkTestStore.ts | 10 +++++++ packages/react-navigation-plugin/README.md | 14 ++++++++-- .../official-plugins/react-navigation.mdx | 15 ++++++++-- .../plugin-development/plugin-development.md | 28 ++++++++++++++++--- 7 files changed, 104 insertions(+), 27 deletions(-) rename apps/playground/rozenite.dev/{network-controls.ts => network-controls.tsx} (66%) diff --git a/apps/playground/rozenite.dev/index.tsx b/apps/playground/rozenite.dev/index.tsx index cc432347..044d9605 100644 --- a/apps/playground/rozenite.dev/index.tsx +++ b/apps/playground/rozenite.dev/index.tsx @@ -12,11 +12,12 @@ 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 { useNetworkControlsSections } from './network-controls'; +import { NetworkPlaygroundControls } from './network-controls'; import { sqlitePluginAdapters } from './sqlite-adapters'; import { storagePluginAdapters } from './storage-adapters'; @@ -27,19 +28,12 @@ import { storagePluginAdapters } from './storage-adapters'; */ export default function RozeniteDevTools() { const controlsSections = usePlaygroundControlsSections(); - const networkControlsSections = useNetworkControlsSections(); + const isNetworkScreenMounted = useNetworkTestStore((state) => state.isScreenMounted); useTanStackQueryDevTools(queryClient); - // Two independent calls, mirroring the two independent callers that - // existed before this migration (App-level Controls sections and the - // Network screen's local section) — useRozeniteControlsPlugin merges - // sections from every caller, so this keeps both mounted side by side. useRozeniteControlsPlugin({ sections: controlsSections, }); - useRozeniteControlsPlugin({ - sections: networkControlsSections, - }); useNetworkActivityDevTools({ clientUISettings: { showUrlAsName: true, @@ -80,5 +74,17 @@ export default function RozeniteDevTools() { ref: navigationRef as any, }); - return ; + 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/network-controls.ts b/apps/playground/rozenite.dev/network-controls.tsx similarity index 66% rename from apps/playground/rozenite.dev/network-controls.ts rename to apps/playground/rozenite.dev/network-controls.tsx index e3edba2d..c875a796 100644 --- a/apps/playground/rozenite.dev/network-controls.ts +++ b/apps/playground/rozenite.dev/network-controls.tsx @@ -1,4 +1,4 @@ -import { createSection } from '@rozenite/controls-plugin'; +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'; @@ -10,11 +10,10 @@ import { useNetworkTestStore } from '../src/app/stores/networkTestStore'; // 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 from the dev entry -// (not merged into `usePlaygroundControlsSections`'s array) so it mounts -// alongside the app-level Controls sections, matching today's behaviour of -// two independent callers. -export const useNetworkControlsSections = () => { +// 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); @@ -52,3 +51,17 @@ export const useNetworkControlsSections = () => { [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/src/app/screens/NetworkTestScreen.tsx b/apps/playground/src/app/screens/NetworkTestScreen.tsx index 8be7743c..b54d58ab 100644 --- a/apps/playground/src/app/screens/NetworkTestScreen.tsx +++ b/apps/playground/src/app/screens/NetworkTestScreen.tsx @@ -1,4 +1,4 @@ -import { useCallback, 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'; @@ -136,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) => { diff --git a/apps/playground/src/app/stores/networkTestStore.ts b/apps/playground/src/app/stores/networkTestStore.ts index ae7c2aac..03f89383 100644 --- a/apps/playground/src/app/stores/networkTestStore.ts +++ b/apps/playground/src/app/stores/networkTestStore.ts @@ -5,6 +5,14 @@ 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 @@ -13,4 +21,6 @@ type NetworkTestState = { export const useNetworkTestStore = create((set) => ({ transport: 'fetch', setTransport: (transport) => set({ transport }), + isScreenMounted: false, + setScreenMounted: (isScreenMounted) => set({ isScreenMounted }), })); diff --git a/packages/react-navigation-plugin/README.md b/packages/react-navigation-plugin/README.md index d48e3253..0ca10cce 100644 --- a/packages/react-navigation-plugin/README.md +++ b/packages/react-navigation-plugin/README.md @@ -41,10 +41,15 @@ once, at module scope, in a file both sides 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 { createNavigationContainerRef } from '@react-navigation/native'; +import { createRef } from 'react'; +import type { NavigationContainerRef } from '@react-navigation/native'; -export const navigationRef = createNavigationContainerRef(); +export const navigationRef = createRef>(); ``` ```typescript @@ -66,7 +71,10 @@ import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin'; import { navigationRef } from './navigation'; export default function RozeniteDevTools() { - useReactNavigationDevTools({ ref: navigationRef }); + // 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; } ``` diff --git a/website/src/docs/official-plugins/react-navigation.mdx b/website/src/docs/official-plugins/react-navigation.mdx index fc5b60de..362c9cb1 100644 --- a/website/src/docs/official-plugins/react-navigation.mdx +++ b/website/src/docs/official-plugins/react-navigation.mdx @@ -18,10 +18,16 @@ 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 { createNavigationContainerRef } from '@react-navigation/native'; +import { createRef } from 'react'; +import type { NavigationContainerRef } from '@react-navigation/native'; -export const navigationRef = createNavigationContainerRef(); +export const navigationRef = createRef>(); ``` ```typescript title="App.tsx" @@ -42,7 +48,10 @@ import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin'; import { navigationRef } from './navigation'; export default function RozeniteDevTools() { - useReactNavigationDevTools({ ref: navigationRef }); + // 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; } ``` diff --git a/website/src/docs/plugin-development/plugin-development.md b/website/src/docs/plugin-development/plugin-development.md index 21b2a100..44c5b71f 100644 --- a/website/src/docs/plugin-development/plugin-development.md +++ b/website/src/docs/plugin-development/plugin-development.md @@ -316,10 +316,11 @@ export default { and add the corresponding file at your plugin's root: ```typescript title="register.ts" -// Import from your own src/** modules directly -- never from ./react-native.ts. -// That shim pulls in your plugin's whole dev surface, which is exactly what -// this entry point exists to keep out of the production bundle. -export { useMyPluginRuntimeHook } from './src/react-native/useMyPluginRuntimeHook'; +// 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 @@ -333,6 +334,25 @@ 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 From 743a6623aa89be8a1b098161baf45be0db543657 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 07:32:02 +0000 Subject: [PATCH 4/9] fix(middleware): walk past the build's module-type markers to the package root The guard identified a plugin by walking up from a resolved file to the first package.json. tsc cannot emit .cjs/.mjs, so the plugin build drops a bare {"type": "module"} marker into every output directory - and that marker is the first package.json above a resolved plugin entry. The walk stopped two directories short of dist/rozenite.json, so every plugin read as "not a plugin" and the guard permitted everything. Caught by bundling apps/playground for release with a deliberate violation: the export succeeded. A package root is now a directory whose package.json names a package, which also fixes the dev-entry redirect silently not engaging when the seam resolves through its CommonJS build. Adds the Re.Pack side of the guard, which reaches the same conclusion through beforeResolve and afterResolve. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TLGRno4KVGnNBqBqx6BR3u --- .../src/__tests__/production-guard.test.ts | 24 + packages/middleware/src/production-guard.ts | 41 +- packages/repack/src/index.ts | 66 ++- packages/repack/src/resolver-plugin.ts | 409 ++++++++++++++++++ 4 files changed, 524 insertions(+), 16 deletions(-) create mode 100644 packages/repack/src/resolver-plugin.ts diff --git a/packages/middleware/src/__tests__/production-guard.test.ts b/packages/middleware/src/__tests__/production-guard.test.ts index c87add81..6a94f828 100644 --- a/packages/middleware/src/__tests__/production-guard.test.ts +++ b/packages/middleware/src/__tests__/production-guard.test.ts @@ -57,6 +57,30 @@ afterEach(() => { }); 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 }); diff --git a/packages/middleware/src/production-guard.ts b/packages/middleware/src/production-guard.ts index 2a281a79..1e3f8648 100644 --- a/packages/middleware/src/production-guard.ts +++ b/packages/middleware/src/production-guard.ts @@ -62,8 +62,22 @@ const readPackageNameOrNull = (packageJsonPath: string): string | null => { return typeof name === 'string' ? name : null; }; -const readPackageName = (packageJsonPath: string, fallback: string): string => { - return readPackageNameOrNull(packageJsonPath) ?? fallback; +/** + * 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 => { @@ -82,7 +96,11 @@ const readPluginAtPackageRoot = (packageRoot: string): PluginLookupResult => { return null; } - const name = readPackageName(path.join(packageRoot, 'package.json'), packageRoot); + const name = readPackageNameOrNull(path.join(packageRoot, 'package.json')); + + if (name === null) { + return null; + } return { name, @@ -98,12 +116,11 @@ const findPluginForDirectory = (dir: string): PluginLookupResult => { return cached; } - const packageJsonPath = path.join(dir, 'package.json'); let result: PluginLookupResult; - if (fs.existsSync(packageJsonPath)) { - // The first package.json found going up is the package root, whether or - // not it turns out to be a Rozenite plugin -- we never look past it. + 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. result = readPluginAtPackageRoot(dir); } else { const parentDir = path.dirname(dir); @@ -220,11 +237,15 @@ const findPackageNameForDirectory = (dir: string): string | null => { return cached; } - const packageJsonPath = path.join(dir, 'package.json'); + // 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 (fs.existsSync(packageJsonPath)) { - result = readPackageNameOrNull(packageJsonPath); + if (name !== null) { + result = name; } else { const parentDir = path.dirname(dir); result = parentDir === dir ? null : findPackageNameForDirectory(parentDir); diff --git a/packages/repack/src/index.ts b/packages/repack/src/index.ts index 3b543c12..02e8dbc1 100644 --- a/packages/repack/src/index.ts +++ b/packages/repack/src/index.ts @@ -5,8 +5,10 @@ import { RozeniteConfig, RozeniteMiddleware, } from '@rozenite/middleware'; +import { logger } from '@rozenite/tools'; import { RepackRspackConfig, type RepackRspackConfigExport } from '@callstack/repack'; import { assertSupportedRePackVersion } from './version-check.js'; +import { RozeniteResolverPlugin } from './resolver-plugin.js'; // Plugin discovery is async, but `setupMiddlewares` is only invoked by the // dev server (never for a plain `bundle`/production build) and must return @@ -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 `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/repack/src/resolver-plugin.ts b/packages/repack/src/resolver-plugin.ts new file mode 100644 index 00000000..4f1560e5 --- /dev/null +++ b/packages/repack/src/resolver-plugin.ts @@ -0,0 +1,409 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + findRozenitePluginForFile, + isDevEntryOrigin, + isSeamDevEntryRequest, + formatProductionGuardError, + formatDevAdvisory, + warnOnceForImport, + getDevEntrySpecifier, + type RozenitePluginPackage, +} from '@rozenite/middleware'; +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. + */ +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, + }), + ); + } + } +} From 920c9ee4464d6806e14585090374316edff77719 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 3 Sep 2026 15:21:47 +0200 Subject: [PATCH 5/9] refactor(middleware): move rspack resolver plugin from @rozenite/repack RozeniteResolverPlugin needs to be shared with @rozenite/lynx (#492), which installs the same plugin through Rsbuild's modifyRspackConfig and must not depend on @rozenite/repack. Move it into @rozenite/middleware, which both already depend on for the guard's shared core, and have @rozenite/repack import it from there instead. The plugin keeps its hand-written structural types and imports nothing from @rspack/core, so the middleware gains no rspack dependency. --- packages/middleware/src/index.ts | 4 ++++ .../src/rspack-resolver-plugin.ts} | 9 ++++++++- packages/repack/src/index.ts | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) rename packages/{repack/src/resolver-plugin.ts => middleware/src/rspack-resolver-plugin.ts} (97%) diff --git a/packages/middleware/src/index.ts b/packages/middleware/src/index.ts index 70be0522..ad9ea0e3 100644 --- a/packages/middleware/src/index.ts +++ b/packages/middleware/src/index.ts @@ -30,6 +30,10 @@ export { 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/repack/src/resolver-plugin.ts b/packages/middleware/src/rspack-resolver-plugin.ts similarity index 97% rename from packages/repack/src/resolver-plugin.ts rename to packages/middleware/src/rspack-resolver-plugin.ts index 4f1560e5..3d49c0d3 100644 --- a/packages/repack/src/resolver-plugin.ts +++ b/packages/middleware/src/rspack-resolver-plugin.ts @@ -9,7 +9,7 @@ import { warnOnceForImport, getDevEntrySpecifier, type RozenitePluginPackage, -} from '@rozenite/middleware'; +} from './production-guard.js'; import { logger } from '@rozenite/tools'; // We intentionally do NOT import types (or values) from `@rspack/core` here. @@ -142,6 +142,13 @@ export type RozeniteResolverPluginOptions = { * 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; diff --git a/packages/repack/src/index.ts b/packages/repack/src/index.ts index 02e8dbc1..318aabe3 100644 --- a/packages/repack/src/index.ts +++ b/packages/repack/src/index.ts @@ -4,11 +4,11 @@ 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'; -import { RozeniteResolverPlugin } from './resolver-plugin.js'; // Plugin discovery is async, but `setupMiddlewares` is only invoked by the // dev server (never for a plain `bundle`/production build) and must return @@ -104,7 +104,7 @@ export const withRozenite = ( // `RepackRspackConfig` (via `@callstack/repack`) extends rspack's // `Configuration`, whose `plugins` field isn't visible here (see the - // note atop `resolver-plugin.ts`): `@rspack/core`'s own types aren't + // 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. From cb22f8c147fecbc6038ce3bad1d3e4be5e584a6c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 3 Sep 2026 15:21:59 +0200 Subject: [PATCH 6/9] fix(middleware): invalidate the production-guard cache when a manifest changes findRozenitePluginForFile memoized a plugin's dist/rozenite.json for the process lifetime, which is wrong for `rozenite dev`: a plugin's manifest is rebuilt by its own watcher while the bundler keeps running, and the cache kept answering with whatever the plugin looked like the first time it was resolved. Each cache entry now carries the manifest's mtime and is re-stat'd on every hit, invalidating on a rebuild, a removal, or the manifest appearing where it didn't exist before. Metro's own declaredEntriesCache (resolved productionEntries paths) has the same problem one layer up: it is invalidated the same way, by comparing the plugin's current productionEntries against what the cached entry was resolved from. --- packages/metro/src/__tests__/resolver.test.ts | 45 ++++++++++++++++ packages/metro/src/resolver.ts | 24 +++++++-- .../src/__tests__/production-guard.test.ts | 53 ++++++++++++++++-- packages/middleware/src/production-guard.ts | 54 ++++++++++++++----- 4 files changed, 154 insertions(+), 22 deletions(-) diff --git a/packages/metro/src/__tests__/resolver.test.ts b/packages/metro/src/__tests__/resolver.test.ts index 58f14e8a..04b75606 100644 --- a/packages/metro/src/__tests__/resolver.test.ts +++ b/packages/metro/src/__tests__/resolver.test.ts @@ -170,6 +170,51 @@ describe('applyProductionGuard decision table', () => { 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', () => { diff --git a/packages/metro/src/resolver.ts b/packages/metro/src/resolver.ts index e73fa73e..8ea52456 100644 --- a/packages/metro/src/resolver.ts +++ b/packages/metro/src/resolver.ts @@ -18,8 +18,19 @@ const WEB_SOCKET_INTERCEPTOR_MODULE = 'react-native/Libraries/WebSocket/WebSocke // 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). -const declaredEntriesCache = new Map>(); +// (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 @@ -50,8 +61,8 @@ const resolveDeclaredEntries = ( const cacheKey = `${plugin.root}\0${platform ?? ''}`; const cached = declaredEntriesCache.get(cacheKey); - if (cached) { - return cached; + if (cached && sameProductionEntries(cached.productionEntries, plugin.productionEntries)) { + return cached.paths; } const resolvedPaths = new Set(); @@ -92,7 +103,10 @@ const resolveDeclaredEntries = ( } } - declaredEntriesCache.set(cacheKey, resolvedPaths); + declaredEntriesCache.set(cacheKey, { + paths: resolvedPaths, + productionEntries: [...plugin.productionEntries], + }); return resolvedPaths; }; diff --git a/packages/middleware/src/__tests__/production-guard.test.ts b/packages/middleware/src/__tests__/production-guard.test.ts index 6a94f828..3657599b 100644 --- a/packages/middleware/src/__tests__/production-guard.test.ts +++ b/packages/middleware/src/__tests__/production-guard.test.ts @@ -133,7 +133,7 @@ describe('findRozenitePluginForFile', () => { expect(plugin?.productionEntries).toEqual([]); }); - it('memoizes per directory, including negative results', () => { + it('memoizes per directory while the manifest is unchanged', () => { const packageRoot = createTempDir(); createPackage(packageRoot, '@acme/memoized', { hasManifest: true }); @@ -141,13 +141,56 @@ describe('findRozenitePluginForFile', () => { const first = findRozenitePluginForFile(filePath); expect(first).not.toBeNull(); - // Remove the manifest after the first (cached) lookup -- a second - // lookup for a file in the same directory must still hit the cache and - // return the original (memoized) result rather than re-reading disk. - fs.rmSync(path.join(packageRoot, 'dist', 'rozenite.json')); + // 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(); }); }); diff --git a/packages/middleware/src/production-guard.ts b/packages/middleware/src/production-guard.ts index 1e3f8648..48994fe5 100644 --- a/packages/middleware/src/production-guard.ts +++ b/packages/middleware/src/production-guard.ts @@ -18,12 +18,34 @@ export type RozenitePluginPackage = { 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 cache is process-lifetime and never -// invalidated -- package.json/rozenite.json contents are not expected to -// change while a bundler process is running. -const pluginCache = new Map(); +// 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 { @@ -109,26 +131,34 @@ const readPluginAtPackageRoot = (packageRoot: string): PluginLookupResult => { }; }; -const findPluginForDirectory = (dir: string): PluginLookupResult => { +const findPluginForDirectory = (dir: string): PluginCacheEntry => { const cached = pluginCache.get(dir); - if (cached !== undefined) { + if (cached && statMtimeMs(cached.manifestPath ?? '') === cached.manifestMtimeMs) { return cached; } - let result: PluginLookupResult; + 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. - result = readPluginAtPackageRoot(dir); + const manifestPath = path.join(dir, 'dist', ROZENITE_MANIFEST); + entry = { + result: readPluginAtPackageRoot(dir), + manifestPath, + manifestMtimeMs: statMtimeMs(manifestPath), + }; } else { const parentDir = path.dirname(dir); - result = parentDir === dir ? null : findPluginForDirectory(parentDir); + entry = + parentDir === dir + ? { result: null, manifestPath: null, manifestMtimeMs: null } + : findPluginForDirectory(parentDir); } - pluginCache.set(dir, result); - return result; + pluginCache.set(dir, entry); + return entry; }; /** @@ -137,7 +167,7 @@ const findPluginForDirectory = (dir: string): PluginLookupResult => { * that package is a Rozenite plugin iff `dist/rozenite.json` exists there. */ export const findRozenitePluginForFile = (filePath: string): RozenitePluginPackage | null => { - return findPluginForDirectory(path.dirname(filePath)); + return findPluginForDirectory(path.dirname(filePath)).result; }; const DEV_ENTRY_BASENAME = 'rozenite.dev'; From 33016fd86f712eff1b1216e592f81304bf5effc3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 3 Sep 2026 15:22:10 +0200 Subject: [PATCH 7/9] test(metro): add release-bundle tests for the production guard Bundler-integration guard for withRozenite itself, per docs/agents/release-bundle-testing.md, exercised through real Metro release builds instead of unit tests of applyProductionGuard alone: - an app importing @rozenite/storage-plugin directly fails, naming the importing file; - importing the declared @rozenite/rhf-plugin/register production entry succeeds; - the violating import still fails with enabled: false; - allowInProduction: ['@rozenite/storage-plugin'] lets it through. Adds @rozenite/storage-plugin, @rozenite/rhf-plugin and @rozenite/test-utils as devDependencies so Turborepo builds them before this suite runs. --- packages/metro/package.json | 3 + .../src/__tests__/release-bundle.test.ts | 98 +++++++++++++++++++ pnpm-lock.yaml | 9 ++ 3 files changed, 110 insertions(+) create mode 100644 packages/metro/src/__tests__/release-bundle.test.ts diff --git a/packages/metro/package.json b/packages/metro/package.json index 6636c07a..743b5670 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -53,6 +53,9 @@ }, "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" }, 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..2cbfc856 --- /dev/null +++ b/packages/metro/src/__tests__/release-bundle.test.ts @@ -0,0 +1,98 @@ +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", + }); + + expect(result.code).toBeTruthy(); + }, + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 7a8bdf0b..dad3bb2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -961,6 +961,15 @@ 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 From ee70f11b7706faf7d42a53ff9b2d621adc0fc8bd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 3 Sep 2026 15:22:21 +0200 Subject: [PATCH 8/9] docs(middleware): add ADR and scope the production guarantee to Metro/Re.Pack Record the production-guarantee decision as docs/adr/0001-plugins-never-enter-production-bundles.md, following 0000's format: the seam package, why Metro cannot inject a dev entry (no way to add artificial dependencies to an entry point; runBeforeMainModule only reorders modules already in the graph), the resolver-level guard installed unconditionally, productionEntries and allowInProduction as escape hatches, and the rspack plugin living in the middleware so Re.Pack and Lynx can share it. Also make the changeset and the production-guarantee doc explicit that this covers Metro and Re.Pack only; Lynx is tracked separately in #492. --- .changeset/production-plugin-guarantee.md | 8 +- ...-plugins-never-enter-production-bundles.md | 184 ++++++++++++++++++ docs/adr/README.md | 1 + website/src/docs/production-guarantee.mdx | 3 + 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0001-plugins-never-enter-production-bundles.md diff --git a/.changeset/production-plugin-guarantee.md b/.changeset/production-plugin-guarantee.md index e031a797..3769d230 100644 --- a/.changeset/production-plugin-guarantee.md +++ b/.changeset/production-plugin-guarantee.md @@ -13,7 +13,13 @@ 'rozenite': minor --- -Guarantee that Rozenite plugins never reach a production bundle. Until now the +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. 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/website/src/docs/production-guarantee.mdx b/website/src/docs/production-guarantee.mdx index 0bd8db76..1a1a4a26 100644 --- a/website/src/docs/production-guarantee.mdx +++ b/website/src/docs/production-guarantee.mdx @@ -6,6 +6,9 @@ Rozenite plugins add real weight to your app: DevTools UI, bridge wiring, someti 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: From 9919179acf056085746088516d61131024a66176 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 3 Sep 2026 15:38:54 +0200 Subject: [PATCH 9/9] test(metro): fix stale doc path and make the production-entry test non-vacuous docs/agents/release-bundle-testing.md still pointed at packages/metro/src/is-bundling.ts; the rebase moved isBundling into packages/tools. "succeeds when an app imports a declared production entry" only asserted result.code was truthy, which passes for any bundle at all. Assert instead that the rhf-plugin's built register module is actually in rozeniteModules (matching either the ESM or the CJS build, whichever Metro's resolver conditions pick) and that no panel code came along with it. --- docs/agents/release-bundle-testing.md | 2 +- packages/metro/src/__tests__/release-bundle.test.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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/metro/src/__tests__/release-bundle.test.ts b/packages/metro/src/__tests__/release-bundle.test.ts index 2cbfc856..d5f18b01 100644 --- a/packages/metro/src/__tests__/release-bundle.test.ts +++ b/packages/metro/src/__tests__/release-bundle.test.ts @@ -53,7 +53,15 @@ describe('withRozenite in a release bundle', () => { "require('@rozenite/rhf-plugin/register');\nconsole.log('rozenite release bundle fixture');\n", }); - expect(result.code).toBeTruthy(); + // 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, );