diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 52b9b3c8c13..cd9fc99ea3f 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -61,6 +61,14 @@ export const MOBILE_WEB_APP_SHIMS = [ name: 'zod-jitless-banner', appliesTo: (options) => options.banner?.js?.includes('__zod_globalConfig') === true }, + { + // Four modules under src/shared resolve `zod` upward to the root's copy, so the page bundled + // two Zods and built salvage combinators with one instance to nest inside schemas built by the + // other. mobile/tsconfig.json already maps `zod` to mobile's for the whole mobile program, + // those shared modules included; this is the bundler catching up to that contract. + name: 'one-zod', + appliesTo: (options) => options.alias?.zod === MOBILE_ZOD_PACKAGE + }, { // lucide-react-native@1.14.0's barrel re-exports LucideProvider from a context.mjs that does // not export it. Metro's loose CJS interop tolerates it; esbuild's strict ESM does not. @@ -118,6 +126,24 @@ const PAGE_ASYNC_STORAGE_MODULE = join( 'page-async-storage.ts' ) +/** + * The one Zod the page runs. + * + * `nodePaths` is a fallback, consulted only where normal resolution fails, so it never reached + * `src/shared/zod-salvage.ts`: that file sits above `mobile/`, its bare `zod` resolves upward to + * the root's 4.5.4, and the 58 mobile modules beside it resolved to mobile's 4.4.3. Both shipped. + * + * Mobile's copy and not the root's, because the mobile app already says so: `mobile/tsconfig.json` + * maps `zod` to `./node_modules/zod`, and a shared module joins that program as an imported file, + * so tsc holds `zod-salvage.ts` to 4.4.3 today. The composition says the same thing from the other + * side — `salvagingArray` and friends are leaves nested inside `z.object(...)` built by mobile's + * Zod, so the leaves belong to the container's instance. + * + * The package directory rather than a file: nothing imports a `zod/...` subpath, and esbuild reads + * the `module` field here, which is the same ESM entry the package's `import` condition names. + */ +const MOBILE_ZOD_PACKAGE = join(mobileDir, 'node_modules', 'zod') + /** * Zod's compiled path, off before any module runs. * @@ -207,10 +233,13 @@ export function mobileWebAppBuildOptions(routes) { logLevel: 'silent', jsx: 'automatic', // One React: resolve everything from mobile/node_modules, which is where the entry lives. + // A fallback only, so it settles nothing for a module that resolves on its own — see + // MOBILE_ZOD_PACKAGE, which is a repo-root import this never reached. nodePaths: [join(mobileDir, 'node_modules')], alias: { 'react-native': 'react-native-web', - '@react-native-async-storage/async-storage': PAGE_ASYNC_STORAGE_MODULE + '@react-native-async-storage/async-storage': PAGE_ASYNC_STORAGE_MODULE, + zod: MOBILE_ZOD_PACKAGE }, plugins: [routeManifestPlugin(renderMobileWebAppRouteManifest(routes)), lucideBarrelPlugin], resolveExtensions: [ diff --git a/config/scripts/build-mobile-web-app-bundle.test.mjs b/config/scripts/build-mobile-web-app-bundle.test.mjs index 3004f918d3f..45e42fdfa5c 100644 --- a/config/scripts/build-mobile-web-app-bundle.test.mjs +++ b/config/scripts/build-mobile-web-app-bundle.test.mjs @@ -36,6 +36,7 @@ import { BINARY_SOURCE_EXTENSIONS, assertNoCarriageReturnsInSource } from './verify-mobile-web-bundle.mjs' +import { spelledCountsAgainstTables } from './spelled-count-census.mjs' import { hashedAsset, readDesktopVersion, @@ -450,9 +451,11 @@ describe('the Phase C budget', () => { join(projectDir, 'config', 'scripts', 'verify-mobile-web-app-bundle.mjs'), 'utf8' ) - // The bound reads like a per-route escape hatch and is not one: 5 of the 14 routes break it - // on their own. What keeps it survivable is that expo-router wants a synchronous export off - // layout nodes only, so the note has to name the layout and the export that drives it. + // The bound reads like a per-route escape hatch and is not one: of the fourteen routes in the + // tree, session breaks it outright at 3.32 MiB and five more spend most of it, so the hatch is + // one route away from unusable rather than free. What keeps it survivable is that expo-router + // wants a synchronous export off layout nodes only, so the note has to name the layout and the + // export that drives it. const doc = source.slice( 0, source.indexOf('export const MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES') @@ -462,6 +465,21 @@ describe('the Phase C budget', () => { expect(note).toContain('unstable_settings') }) + /** The one count above that must follow the tree, spelled so the census reads it. The sweep is + * a row per route plus `h/_layout.tsx`, which is no screen, so it is that length less one. */ + it('spells the route count off the sweep it is a count of', async () => { + const source = await readFile( + join(projectDir, 'config', 'scripts', 'build-mobile-web-app-bundle.test.mjs'), + 'utf8' + ) + const rows = [ + { precedes: 'routes in the tree', counted: MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.length - 1 } + ] + for (const { precedes, spelled, counts } of spelledCountsAgainstTables(source, rows)) { + expect(spelled, precedes).toEqual(counts) + } + }) + it('budgets what loads first well under what the whole page weighs', () => { // The point of the split: the entry budget is the one a route must not grow, and it is a // fraction of the total the bundle is still allowed to weigh. @@ -510,21 +528,19 @@ describe('the Phase C budget', () => { }) it('refuses an engine chunked along its own lazy boundaries, and passes one artifact', () => { - // The two builds this ceiling has to tell apart, both measured at 14 routes and both still - // told apart by the envelope, which is tighter than the 4r + 16 they were first read against. + // The two builds the ceiling must tell apart: mermaid through one pre-bundled artifact + // emitted 69 scripts, importing the package emitted 172, esbuild splitting along the diagram + // types mermaid lazily imports. Both frozen at the head that measured them, because this case + // pins the discrimination and not either build's size. // - // The page reaches mermaid through one pre-bundled artifact and the bundle emits 69 scripts - // (68 of them the page's own split, one the deferred engine). Importing the package instead - // emitted 172: mermaid lazily imports each of its own diagram types and esbuild splits along - // those boundaries, all of it inside the generation the phone has already downloaded. The - // route term is the only term precisely so that the second of those fails here -- a ceiling - // raised to admit 172 would have admitted any split at all. + // 69 now sits just under the envelope: a sweep that falls further means re-measure, not raise. const ROUTES = 14 const WITH_ONE_ARTIFACT = 69 const CHUNKED_ALONG_THE_ENGINE = 172 expect(WITH_ONE_ARTIFACT).toBeLessThanOrEqual(mobileWebAppBundleMaxChunks(ROUTES)) expect(CHUNKED_ALONG_THE_ENGINE).toBeGreaterThan(mobileWebAppBundleMaxChunks(ROUTES)) - // And the assets that came with it: 215 against 112, of the 256 the shell will load. + // And the assets that came with it: 215 against the 113 this head's envelope allows, of the + // 256 the shell will load. expect(mobileWebAppBundleMaxAssets(ROUTES, 42)).toBeLessThan(CHUNKED_ALONG_THE_ENGINE + 42 + 1) }) @@ -570,9 +586,9 @@ describe('the Phase C budget', () => { // in from 50 with the envelope: it grants the worst swept route to each one past the sweep, // where `4r + 16` granted four, so re-measuring a tree whose routes share more moves it out. expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS) - expect(assertAssetCeilingFitsShell(30, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(249) + expect(assertAssetCeilingFitsShell(30, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(248) expect(() => assertAssetCeilingFitsShell(31, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow( - /258 .*256/ + /257 .*256/ ) }) }) diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index e929f7824d0..67ed9ad20cc 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -325,8 +325,16 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/' * * modules 4360 -> 4363 (+3, and 4359 -> 4363 from the shared base) * local modules 1018 -> 1021 (+3) + * + * The C6.5 follow-up then aliased `zod` in the builder, so the four modules under `src/shared` that + * this route reaches stop pulling the root's second copy in. The only reading here that has ever + * fallen, and the only one where the two lists were diffed and every entry on the difference was + * vendored: 94 gone, all of them `zod@4.5.4`, none added, because mobile's 79 were already here. + * + * modules 4363 -> 4269 (-94) + * local modules 1021 -> 1021 (unchanged) */ -const SESSION_ROUTE_MODULES = 4363 +const SESSION_ROUTE_MODULES = 4269 /** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */ const ROUTE_ENTRY = [ diff --git a/config/scripts/verify-mobile-web-app-bundle.mjs b/config/scripts/verify-mobile-web-app-bundle.mjs index aa6a492f4e5..2d6556670ba 100644 --- a/config/scripts/verify-mobile-web-app-bundle.mjs +++ b/config/scripts/verify-mobile-web-app-bundle.mjs @@ -36,7 +36,7 @@ export function mobileWebAppBundleMaxAssets(routeCount, imageCount) { * refused asset on a phone. Splitting barely moves it — the same code is emitted in more files — * so shrinking this still means cutting code. * - * This head reads 8,055,568 bytes of the 9,437,184 here, 85.4%, leaving 1,381,616. A reading and + * This head reads 7,686,714 bytes of the 9,437,184 here, 81.5%, leaving 1,750,470. A reading and * not a pin: nothing asserts it, because the number moves with every build. It is here so the * generation that spends the rest can see it was already this close. */ @@ -56,19 +56,19 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024 export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [ ['./h/[hostId]/[...page].tsx', 3], ['./h/[hostId]/accounts.tsx', 7], - ['./h/[hostId]/agent-history/[worktreeId].tsx', 12], - ['./h/[hostId]/edit.tsx', 17], - ['./h/[hostId]/files/[worktreeId].tsx', 20], - ['./h/[hostId]/files/preview/[worktreeId].tsx', 27], - ['./h/[hostId]/history/[worktreeId].tsx', 29], - ['./h/[hostId]/index.tsx', 34], - ['./h/[hostId]/pr/[worktreeId].tsx', 35], - ['./h/[hostId]/review/[worktreeId].tsx', 44], - ['./h/[hostId]/session/[worktreeId].tsx', 52], - ['./h/[hostId]/source-control/[worktreeId].tsx', 57], - ['./h/[hostId]/tasks.tsx', 64], - ['./h/[hostId]/web.tsx', 65], - ['./h/_layout.tsx', 67] + ['./h/[hostId]/agent-history/[worktreeId].tsx', 11], + ['./h/[hostId]/edit.tsx', 16], + ['./h/[hostId]/files/[worktreeId].tsx', 19], + ['./h/[hostId]/files/preview/[worktreeId].tsx', 26], + ['./h/[hostId]/history/[worktreeId].tsx', 28], + ['./h/[hostId]/index.tsx', 33], + ['./h/[hostId]/pr/[worktreeId].tsx', 34], + ['./h/[hostId]/review/[worktreeId].tsx', 43], + ['./h/[hostId]/session/[worktreeId].tsx', 51], + ['./h/[hostId]/source-control/[worktreeId].tsx', 56], + ['./h/[hostId]/tasks.tsx', 63], + ['./h/[hostId]/web.tsx', 64], + ['./h/_layout.tsx', 66] ] const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts) @@ -81,11 +81,14 @@ export const MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD = sweptScripts /** * How far above the measurement the envelope sits, and the only slack a refactor gets. * - * Measured, not chosen: the head that wrote the old `4r + 16` swept the same prefix lengths and - * read 32, 43, 61 and 69 at 8, 10, 12 and 14 routes, where this head reads 34, 44, 57 and 65. So - * four is the most the count has been seen to move at a fixed route count with no route added, - * which is what a shared importer set moving between heads costs. A refactor inside that keeps - * building; anything past it re-measures the sweep. + * Measured, not chosen, and per head rather than cumulative: at 8, 10, 12 and 14 routes the head + * that wrote the old `4r + 16` read 32, 43, 61 and 69, the head that first swept these prefixes + * read 34, 44, 57 and 65, and this one reads 33, 43, 56 and 64. So one head has moved the count by + * as much as four at a fixed route count with no route added (61 to 57), and the step that dropped + * the page's second Zod moved it by one everywhere. Four is that worst step, which is what a shared + * importer set moving between heads costs. Summing the steps instead would grow this number every + * head and loosen the fence for free. A refactor inside four keeps building; anything past it + * re-measures the sweep. */ export const MOBILE_WEB_APP_BUNDLE_SCRIPT_MARGIN = 4 @@ -126,16 +129,17 @@ export function mobileWebAppBundleMaxChunks(routeCount) { /** * What the browser must parse before the first route can paint: the entry plus every chunk it * reaches by static import. This is the budget splitting exists to hold — it was 8.16 MB as one - * chunk and measures 1,612,253 bytes split on this head, 1.54 of the 3 MiB — so a route + * chunk and measures 1,244,312 bytes split on this head, 1.19 of the 3 MiB — so a route * re-imported statically, or `splitting` dropped, fails the build here instead of arriving as a * slow first open on a phone. * - * It is not a per-route escape hatch. Importing one route statically already breaks this bound - * for 5 of the 14: session at 7.16 MiB, tasks 5.91, source-control 5.78, review 5.77, - * files/preview 5.21. What keeps the hatch usable at all is that expo-router reads - * `unstable_settings` off layout nodes only, and the subtree's one layout, `h/_layout.tsx`, - * measures 2.22 MiB static. Any other route needing a synchronous export needs this number - * re-measured, not a static import. + * It is not a per-route escape hatch. Re-measured here by making one route's manifest entry a + * static import and reading this same closure back: session alone breaks the bound at 3.32 MiB, + * and tasks at 2.17, source-control 2.04, review 2.03, index 1.89 and files/preview 1.85 each + * spend most of a budget that has to cover the entry as well. What keeps the hatch usable at all + * is that expo-router reads `unstable_settings` off layout nodes only, and the subtree's one + * layout, `h/_layout.tsx`, measures 1.89 MiB static. Any other route needing a synchronous export + * needs this number re-measured, not a static import. */ export const MOBILE_WEB_APP_BUNDLE_MAX_ENTRY_BYTES = 3 * 1024 * 1024