From 7e66a84c479016eeed6f2b714a17f0a73604c0ee Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 25 Sep 2026 17:17:47 -0700 Subject: [PATCH] Warm the CLI-init app closure in warm-feed --- packages/@rnw-scripts/warm-feed/README.md | 12 + .../specialModules/createReactNativeApp.ts | 226 ++++++++++++++++++ .../createReactNativeLibrary.ts | 20 +- .../warm-feed/src/specialModules/index.ts | 2 + .../src/test/createReactNativeApp.test.ts | 130 ++++++++++ .../src/test/createReactNativeLibrary.test.ts | 20 ++ .../warm-feed/warm-feed.config.json | 11 + 7 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 packages/@rnw-scripts/warm-feed/src/specialModules/createReactNativeApp.ts create mode 100644 packages/@rnw-scripts/warm-feed/src/test/createReactNativeApp.test.ts diff --git a/packages/@rnw-scripts/warm-feed/README.md b/packages/@rnw-scripts/warm-feed/README.md index d4144763003..107c5a2f0d8 100644 --- a/packages/@rnw-scripts/warm-feed/README.md +++ b/packages/@rnw-scripts/warm-feed/README.md @@ -96,6 +96,18 @@ from the working-tree `vnext/package.json`; for `0.NN-stable`, the exact scaffolds, and reads the generated manifests. Mirrors `vnext/Scripts/creaternwlib.cmd`. +The second module, **`create-react-native-app`**, is the app counterpart: it +reproduces the CLI-init *app* test, which scaffolds a standalone app with +`@react-native-community/cli init` (pulling `@react-native-community/template`) +and installs *that generated app* — so its closure (e.g. +`react-native-safe-area-context`) is what the feed needs. It reuses the same +per-branch RN/CLI/RNW version resolution and mirrors +`vnext/Scripts/creaternwapp.cmd`, including that script's template selection: a +stable branch lets the CLI pick the template matching its React Native, while a +nightly RN overrides it to a fixed stable `@react-native-community/template` +(`nightlyTemplate`, default `@react-native-community/template@0.86.0`). Keep that +default in sync with the `#15124` override in `creaternwapp.cmd`. + The scheduled pipeline run warms every **enabled** configured module in addition to the latest-patch sync, so brand-new closures stay warm automatically. diff --git a/packages/@rnw-scripts/warm-feed/src/specialModules/createReactNativeApp.ts b/packages/@rnw-scripts/warm-feed/src/specialModules/createReactNativeApp.ts new file mode 100644 index 00000000000..dc7028b096b --- /dev/null +++ b/packages/@rnw-scripts/warm-feed/src/specialModules/createReactNativeApp.ts @@ -0,0 +1,226 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * create-react-native-app special module. + * + * The CLI-init *app* test doesn't install react-native-windows' own dependencies — + * it scaffolds a standalone app with `@react-native-community/cli init` (pulling + * `@react-native-community/template`) and installs *that generated app*. So the + * closure the feed needs is the generated app's dependency graph (e.g. + * `react-native-safe-area-context`), which enumeration can't discover. This module + * reproduces that scaffold and resolves the generated app's closure. It is the app + * counterpart to the create-react-native-library module (the lib test), and mirrors + * `vnext/Scripts/creaternwapp.cmd`. + * + * warm-feed runs only from `main`, but the test runs on every release branch, each + * pinned to a different React Native. The config manifest therefore lists all + * branches; per branch we derive the RN/CLI versions (shared with the lib module: + * nightly for `main` from the working-tree vnext/package.json; for a stable branch, + * the react-native it pins in its own vnext/package.json, read via git), scaffold, + * and read the generated app manifest. + * + * Template selection mirrors creaternwapp.cmd exactly: a stable branch lets the CLI + * pick the template that matches its React Native, while a nightly RN overrides the + * template to a fixed stable one (`@react-native-community/template@0.86.0`) so its + * inner CLI never requests a `nightly` template. Keep this in sync with the + * `#15124`-tagged override in creaternwapp.cmd. + * + * @format + */ + +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {runTool, type DepSpecSet} from '../closure'; +import {manifestSpecs} from '../manifest'; +// Branch config and version resolution are shared with the lib module, which owns +// them today; the app module reuses them rather than re-deriving RN/CLI/RNW versions. +import { + nightlyFixupSpecs, + readRnwWorkspaceSpecs, + resolveBranchVersions, + type BranchVersions, + type CrnlBranch, +} from './createReactNativeLibrary'; +import type {SpecialModule, SpecialModuleContext} from './types'; + +/** The stable template creaternwapp.cmd pins when React Native is a nightly (#15124). */ +const DEFAULT_NIGHTLY_TEMPLATE = '@react-native-community/template@0.86.0'; + +/** A space-free app name so the Windows shell launch needs no quoting. */ +const DEFAULT_APP_NAME = 'warmapp'; + +interface CrnAppConfig { + /** `@react-native-community/template@` used when the RN is a nightly. */ + nightlyTemplate: string; + /** Name of the scaffolded app directory. */ + appName: string; + branches: CrnlBranch[]; +} + +function parseBranch(raw: unknown): CrnlBranch { + const rec = (raw ?? {}) as Record; + if (typeof rec.name !== 'string' || !rec.name) { + throw new Error("each cRNApp branch requires a 'name'"); + } + return { + name: rec.name, + nightly: rec.nightly === true, + reactNativeVersion: + typeof rec.reactNativeVersion === 'string' + ? rec.reactNativeVersion + : undefined, + reactNativeCliVersion: + typeof rec.reactNativeCliVersion === 'string' + ? rec.reactNativeCliVersion + : undefined, + reactNativeWindowsSpec: + typeof rec.reactNativeWindowsSpec === 'string' + ? rec.reactNativeWindowsSpec + : undefined, + ref: typeof rec.ref === 'string' ? rec.ref : undefined, + }; +} + +export function parseCrnAppConfig( + config: Record, +): CrnAppConfig { + const branchesRaw = config.branches; + if (!Array.isArray(branchesRaw) || branchesRaw.length === 0) { + throw new Error( + "create-react-native-app module requires a non-empty 'branches' list", + ); + } + return { + nightlyTemplate: + typeof config.nightlyTemplate === 'string' && config.nightlyTemplate + ? config.nightlyTemplate + : DEFAULT_NIGHTLY_TEMPLATE, + appName: + typeof config.appName === 'string' && config.appName + ? config.appName + : DEFAULT_APP_NAME, + branches: branchesRaw.map(parseBranch), + }; +} + +/** + * The `--template` args passed to `cli init`, mirroring creaternwapp.cmd: none for a + * stable RN (the CLI's default template matches the version), the fixed stable + * template for a nightly RN (so the inner CLI never requests a nightly template). + */ +export function templateArgs(cfg: CrnAppConfig, nightly: boolean): string[] { + return nightly ? ['--template', cfg.nightlyTemplate] : []; +} + +/** + * The generated app manifest as a single spec set. For a nightly branch the + * generated RN-family/CLI specs are rewritten to the nightly versions (mirroring + * vnext/Scripts/UpdateNightlyDependencies.ps1, as the lib module does), and the + * resolved react-native-windows spec is added (the test installs it too). + */ +export function appSpecsFromManifest( + manifest: unknown, + versions: BranchVersions, + branchName: string, +): DepSpecSet[] { + const specs = manifestSpecs(manifest); + if (versions.nightly) { + nightlyFixupSpecs(specs, versions.reactNative, versions.reactNativeCli); + } + if (versions.reactNativeWindowsSpec) { + specs['react-native-windows'] = versions.reactNativeWindowsSpec; + } + return [{label: `crnapp:${branchName}:app`, specs}]; +} + +function scaffold( + mctx: SpecialModuleContext, + cfg: CrnAppConfig, + branch: CrnlBranch, + versions: BranchVersions, +): {workDir: string; projectDir: string} { + if (!versions.reactNativeCli) { + throw new Error( + `cRNApp: could not determine a @react-native-community/cli version for branch '${branch.name}' (set reactNativeCliVersion)`, + ); + } + const workDir = mkdtempSync(join(tmpdir(), 'warm-crnapp-')); + const args = [ + '--yes', + `@react-native-community/cli@${versions.reactNativeCli}`, + 'init', + cfg.appName, + '--version', + versions.reactNative, + ...templateArgs(cfg, versions.nightly), + // Match creaternwapp.cmd: resolve metadata only, no install/pods/git in the warmer. + '--skip-install', + '--install-pods', + 'false', + '--skip-git-init', + 'true', + ]; + try { + runTool( + 'npx', + args, + workDir, + { + npm_config_userconfig: mctx.npmrcPath, + npm_config_registry: mctx.npmRegistryUrl, + }, + `scaffold cRNApp ${branch.name} (react-native ${versions.reactNative})`, + ); + } catch (err) { + // npx failed before we returned workDir, so the caller's cleanup never runs; + // remove the temp tree here to avoid leaking warm-crnapp-* on every failed run. + rmSync(workDir, {recursive: true, force: true}); + throw err; + } + return {workDir, projectDir: join(workDir, cfg.appName)}; +} + +export const createReactNativeAppModule: SpecialModule = { + name: 'create-react-native-app', + async collectDepSpecs(mctx, config) { + const cfg = parseCrnAppConfig(config); + const sets: DepSpecSet[] = []; + const failures: string[] = []; + for (const branch of cfg.branches) { + mctx.ctx.log.info(`cRNApp: preparing closure for branch ${branch.name}`); + try { + const versions = await resolveBranchVersions(mctx, branch); + const {workDir, projectDir} = scaffold(mctx, cfg, branch, versions); + try { + const manifest = JSON.parse( + readFileSync(join(projectDir, 'package.json'), 'utf8'), + ); + sets.push(...appSpecsFromManifest(manifest, versions, branch.name)); + } finally { + rmSync(workDir, {recursive: true, force: true}); + } + if (versions.nightly) { + // The main app-init job installs the locally built RNW, whose manifest + // pins exact canary @react-native-windows/* versions the feed must have. + const rnwSpecs = await readRnwWorkspaceSpecs(mctx); + if (Object.keys(rnwSpecs).length > 0) { + sets.push({ + label: `crnapp:${branch.name}:rnw-workspace`, + specs: rnwSpecs, + }); + } + } + } catch (err) { + // One branch failing must not drop the others; record it so the caller can + // still surface a non-zero exit while the healthy branches are warmed. + mctx.ctx.log.error( + `cRNApp: branch ${branch.name} failed: ${(err as Error).message}`, + ); + failures.push(branch.name); + } + } + return {sets, failures}; + }, +}; diff --git a/packages/@rnw-scripts/warm-feed/src/specialModules/createReactNativeLibrary.ts b/packages/@rnw-scripts/warm-feed/src/specialModules/createReactNativeLibrary.ts index e0f42b86051..b586c4c73ff 100644 --- a/packages/@rnw-scripts/warm-feed/src/specialModules/createReactNativeLibrary.ts +++ b/packages/@rnw-scripts/warm-feed/src/specialModules/createReactNativeLibrary.ts @@ -27,7 +27,7 @@ import {manifestSpecs, readManifestSpecs} from '../manifest'; import {compareSemver, isStable, parseSemver} from '../versions'; import type {SpecialModule, SpecialModuleContext} from './types'; -interface CrnlBranch { +export interface CrnlBranch { /** Branch name, e.g. 'main' or '0.85-stable'. */ name: string; /** `main` uses a nightly RN and needs the generated deps rewritten. */ @@ -51,7 +51,7 @@ interface CrnlConfig { branches: CrnlBranch[]; } -interface BranchVersions { +export interface BranchVersions { reactNative: string; reactNativeCli?: string; reactNativeWindowsSpec?: string; @@ -282,6 +282,20 @@ export async function resolveBranchVersions( }; } +/** + * The react-native version to scaffold cRNL with. cRNL resolves a nightly RN + * through the short-lived `@react-native-community/template@nightly` tag, which + * rotates away and then fails to resolve (ETARGET). So for a nightly we scaffold + * from the matching stable base version (`0.87.0-nightly-...` -> `0.87.0`) and let + * nightlyFixupSpecs rewrite the generated deps back to the nightly. Mirrors + * vnext/Scripts/creaternwlib.cmd's RN_SCAFFOLD_VERSION. + */ +export function scaffoldReactNativeVersion(versions: BranchVersions): string { + return versions.nightly + ? versions.reactNative.split('-')[0] + : versions.reactNative; +} + /** * Rewrite generated nightly deps to the nightly RN/CLI, mirroring * vnext/Scripts/UpdateNightlyDependencies.ps1 (cRNL can't pin nightly RN itself). @@ -376,7 +390,7 @@ function scaffold( cfg.type, ...cfg.tools.map(t => `--tools=${t}`), '--react-native-version', - versions.reactNative, + scaffoldReactNativeVersion(versions), '--example', cfg.example, 'warmlib', diff --git a/packages/@rnw-scripts/warm-feed/src/specialModules/index.ts b/packages/@rnw-scripts/warm-feed/src/specialModules/index.ts index 8f63d4258ce..939f1cfd057 100644 --- a/packages/@rnw-scripts/warm-feed/src/specialModules/index.ts +++ b/packages/@rnw-scripts/warm-feed/src/specialModules/index.ts @@ -10,6 +10,7 @@ import type {SpecialModule} from './types'; import {createReactNativeLibraryModule} from './createReactNativeLibrary'; +import {createReactNativeAppModule} from './createReactNativeApp'; const modules = new Map(); @@ -26,5 +27,6 @@ export function specialModuleNames(): string[] { } registerSpecialModule(createReactNativeLibraryModule); +registerSpecialModule(createReactNativeAppModule); export type {SpecialModule, SpecialModuleContext} from './types'; diff --git a/packages/@rnw-scripts/warm-feed/src/test/createReactNativeApp.test.ts b/packages/@rnw-scripts/warm-feed/src/test/createReactNativeApp.test.ts new file mode 100644 index 00000000000..e89c706f9c2 --- /dev/null +++ b/packages/@rnw-scripts/warm-feed/src/test/createReactNativeApp.test.ts @@ -0,0 +1,130 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + * + * @format + */ + +import { + appSpecsFromManifest, + parseCrnAppConfig, + templateArgs, +} from '../specialModules/createReactNativeApp'; +import type {BranchVersions} from '../specialModules/createReactNativeLibrary'; + +test('parseCrnAppConfig requires a non-empty branches list', () => { + expect(() => parseCrnAppConfig({})).toThrow(/branches/); + expect(() => parseCrnAppConfig({branches: []})).toThrow(/branches/); +}); + +test('parseCrnAppConfig applies defaults and parses branches', () => { + const cfg = parseCrnAppConfig({ + branches: [{name: 'main', nightly: true}, {name: '0.85-stable'}], + }); + expect(cfg.nightlyTemplate).toBe('@react-native-community/template@0.86.0'); + expect(cfg.appName).toBe('warmapp'); + expect(cfg.branches).toHaveLength(2); + expect(cfg.branches[0]).toMatchObject({name: 'main', nightly: true}); + expect(cfg.branches[1]).toMatchObject({name: '0.85-stable', nightly: false}); +}); + +test('parseCrnAppConfig accepts custom nightlyTemplate and appName', () => { + const cfg = parseCrnAppConfig({ + nightlyTemplate: '@react-native-community/template@0.87.0', + appName: 'probe', + branches: [{name: 'main', nightly: true}], + }); + expect(cfg.nightlyTemplate).toBe('@react-native-community/template@0.87.0'); + expect(cfg.appName).toBe('probe'); +}); + +test('parseCrnAppConfig throws when a branch has no name', () => { + expect(() => parseCrnAppConfig({branches: [{}]})).toThrow(/name/); +}); + +test('parseCrnAppConfig carries per-branch overrides', () => { + const cfg = parseCrnAppConfig({ + branches: [ + { + name: '0.83-stable', + reactNativeVersion: '0.83.7', + reactNativeCliVersion: '19.0.0', + reactNativeWindowsSpec: '0.83.0-canary', + ref: 'refs/heads/0.83-stable', + }, + ], + }); + expect(cfg.branches[0]).toMatchObject({ + name: '0.83-stable', + reactNativeVersion: '0.83.7', + reactNativeCliVersion: '19.0.0', + reactNativeWindowsSpec: '0.83.0-canary', + ref: 'refs/heads/0.83-stable', + }); +}); + +test('templateArgs pins the stable template for a nightly, none for a stable RN', () => { + const cfg = parseCrnAppConfig({branches: [{name: 'main', nightly: true}]}); + expect(templateArgs(cfg, true)).toEqual([ + '--template', + '@react-native-community/template@0.86.0', + ]); + expect(templateArgs(cfg, false)).toEqual([]); +}); + +test('appSpecsFromManifest keeps stable deps and adds the resolved RNW spec', () => { + const versions: BranchVersions = { + reactNative: '0.85.3', + reactNativeCli: '20.0.0', + reactNativeWindowsSpec: '0.85.3', + nightly: false, + }; + const sets = appSpecsFromManifest( + { + dependencies: { + 'react-native': '0.85.3', + 'react-native-safe-area-context': '5.10.0', + react: '19.1.0', + }, + }, + versions, + '0.85-stable', + ); + expect(sets).toHaveLength(1); + expect(sets[0].label).toBe('crnapp:0.85-stable:app'); + expect(sets[0].specs).toEqual({ + 'react-native': '0.85.3', + 'react-native-safe-area-context': '5.10.0', + react: '19.1.0', + 'react-native-windows': '0.85.3', + }); +}); + +test('appSpecsFromManifest rewrites the RN family for a nightly branch', () => { + const versions: BranchVersions = { + reactNative: '0.86.0-nightly-x', + reactNativeCli: '21.0.0', + reactNativeWindowsSpec: undefined, + nightly: true, + }; + const [set] = appSpecsFromManifest( + { + dependencies: { + 'react-native': '0.86.0', + '@react-native/new-app-screen': '0.86.0', + '@react-native-community/cli': '20.0.0', + 'react-native-safe-area-context': '5.10.0', + react: '19.1.0', + }, + }, + versions, + 'main', + ); + expect(set.specs['react-native']).toBe('0.86.0-nightly-x'); + expect(set.specs['@react-native/new-app-screen']).toBe('0.86.0-nightly-x'); + expect(set.specs['@react-native-community/cli']).toBe('21.0.0'); + // Third-party and react are untouched; no RNW spec added when it is undefined. + expect(set.specs['react-native-safe-area-context']).toBe('5.10.0'); + expect(set.specs.react).toBe('19.1.0'); + expect(set.specs['react-native-windows']).toBeUndefined(); +}); diff --git a/packages/@rnw-scripts/warm-feed/src/test/createReactNativeLibrary.test.ts b/packages/@rnw-scripts/warm-feed/src/test/createReactNativeLibrary.test.ts index c7de33c627c..660883be2a7 100644 --- a/packages/@rnw-scripts/warm-feed/src/test/createReactNativeLibrary.test.ts +++ b/packages/@rnw-scripts/warm-feed/src/test/createReactNativeLibrary.test.ts @@ -13,6 +13,7 @@ import { parseCrnlConfig, readRnwWorkspaceSpecs, resolveBranchVersions, + scaffoldReactNativeVersion, stableMinor, } from '../specialModules/createReactNativeLibrary'; import type {SpecialModuleContext} from '../specialModules/types'; @@ -94,6 +95,25 @@ test('nightlyFixupSpecs rewrites the RN family and cli specs only', () => { expect(specs.other).toBe('1.0.0'); }); +test('scaffoldReactNativeVersion: strips the suffix for a nightly, passes stable through', () => { + expect( + scaffoldReactNativeVersion({ + reactNative: '0.87.0-nightly-20260704-e04ff69ab', + reactNativeCli: '20.0.0', + reactNativeWindowsSpec: undefined, + nightly: true, + }), + ).toBe('0.87.0'); + expect( + scaffoldReactNativeVersion({ + reactNative: '0.85.3', + reactNativeCli: '20.0.0', + reactNativeWindowsSpec: '0.85.3', + nightly: false, + }), + ).toBe('0.85.3'); +}); + test('resolveBranchVersions: main reads the nightly from vnext, no rnw spec', async () => { const repo = mkdtempSync(join(tmpdir(), 'warm-repo-')); mkdirSync(join(repo, 'vnext')); diff --git a/packages/@rnw-scripts/warm-feed/warm-feed.config.json b/packages/@rnw-scripts/warm-feed/warm-feed.config.json index 9a952979d3f..45621b444ac 100644 --- a/packages/@rnw-scripts/warm-feed/warm-feed.config.json +++ b/packages/@rnw-scripts/warm-feed/warm-feed.config.json @@ -36,6 +36,17 @@ {"name": "0.83-stable"}, {"name": "0.81-stable"} ] + }, + "create-react-native-app": { + "enabled": true, + "nightlyTemplate": "@react-native-community/template@0.86.0", + "branches": [ + {"name": "main", "nightly": true}, + {"name": "0.85-stable"}, + {"name": "0.84-stable"}, + {"name": "0.83-stable"}, + {"name": "0.81-stable"} + ] } }, "nugetLocks": {