mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up) (#22182)
* fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up) `nodePaths` is a fallback esbuild consults only where normal resolution fails, so it never reached the four modules under `src/shared` that the page imports: sitting above `mobile/`, their bare `zod` resolved upward to the root's 4.5.4 while the 58 mobile modules beside them resolved to mobile's 4.4.3. Both shipped -- 808,470 bytes of duplicate source, and salvage combinators built by one instance nested inside schemas built by the other. Mobile's copy, because the mobile app already says so: `mobile/tsconfig .json` maps `zod` to `./node_modules/zod`, a shared module joins that program as an imported file, and `--traceResolution` shows tsc holding `zod-salvage.ts` to 4.4.3 today. The bundler was the only layer that disagreed with the app's own compile-time contract. The build drops from 67 scripts to 66 and from 8,055,568 bytes to 7,686,714, nearly all of it before the first route: the entry's static closure falls from 1,612,253 to 1,244,312. So the C7.8 sweep is re-measured rather than bumped, and the entry-budget note's static-import readings are re-measured with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-measure the pins the second Zod was inside The session route's module closure and the asset ceiling both counted the root's copy. Both re-measured rather than adjusted to fit. The closure falls 4363 -> 4269. The two module lists were diffed rather than the total inferred: 94 entries gone, every one of them `zod@4.5.4`, none added, because mobile's 79 were already in the closure, and the `local` count holds at 1021 -- this took no source module out of the page, only the second copy of a package. The asset ceiling is derived from the chunk envelope, which the re-measured sweep moved by one, so 30 routes now derive 248 assets and 31 derive 257. The crossing it exists to name is still 31. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): align three readings with the head that measures them All three described a bundle with two Zods in it, and the third was already stale on main. The escape-hatch note said 5 of the 14 routes break the entry budget on their own. Re-measured by making one route's manifest entry a static import and reading the entry's own static closure back, it is one: session at 3.32 MiB, with five more between 1.85 and 2.17 of the 3 MiB. Same readings as the note in verify-mobile-web-app-bundle.mjs, which is the thing this case asserts against. The mermaid pair said the bundle "emits 69 scripts". It emits 66, and this head's own fourteen-route prefix reads 64. The pair stays at 69 and 172: what the case pins is that the envelope tells the two apart, not either build's size. Saying so in the comment, with the warning that 69 now sits just under the envelope -- a sweep that falls further fails this on a frozen number, which is a signal to re-measure the pair rather than to raise the ceiling. The assets line said 215 against 112. 215 still derives from the pinned 172; 112 never matched the envelope it claimed, which allowed 114 on main and allows 113 here. The route count is the one number here that has to follow the tree, so it is spelled and pinned to the sweep's own length. The static-import readings are measurements rather than table counts and the mermaid pair must not move at all, so neither is spelled. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cut the mermaid and census comments to their claims Round 2 explained the frozen pair over four paragraphs and the census row over six lines. Both now say what they are for and stop: the pair, why neither number moves, and the one warning that matters. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user