mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
* build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) The page bundled as one 8.16 MB script because every route was a static import. The route manifest now defers each screen behind `import()`, the build is esm with splitting on, and the document loads the entry as a module. What the browser parses before the first route can paint drops from 8.16 MB to 908 KiB; the whole page still weighs the same. Two budgets hold it: the chunk count, which catches a split running away, and the bytes the entry reaches by static import, which catches it collapsing back. The second is the one that matters, and it is measured from esbuild's metafile because only that says which import is static. The RequireContext stays synchronous, since expo-router reads keys() to build the route tree before anything renders. A lazy module cannot answer `unstable_settings` or `ErrorBoundary`, which expo-router reads off the namespace, so a test holds that no route in the subtree exports either. The render check now waits for the route's own text: the entry's mount signal lands while the route chunk is still being fetched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): read a route's synchronous exports from esbuild, not a regex `export { x as ErrorBoundary }`, `export class ErrorBoundary` and a re-export all reach the namespace without matching the declaration pattern the guard was matching, so the lazy manifest dropped the boundary and the page painted blank. A star re-export is now reported rather than read as clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say that the entry budget is not a per-route opt-out Measured: statically importing one route already breaks the 3 MiB bound for 5 of the 14. The hatch only works for a layout node, which is the only place expo-router reads a synchronous export from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): derive the chunk ceiling from the route count 64 was three routes of headroom over the 53 chunks 14 routes measure, so C2's routes would have failed on a number measured before they existed. Four per route plus 16 tracks the measured slope; the entry-bytes bound stays the real budget. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the web entry's dead suspense boundary expo-router wraps every screen in its own, so this one never fires; all nine render checks stay green without it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that a client-side navigation fetches the next route's chunk Goes red with splitting off: the tasks screen paints out of the entry and no new script is fetched. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name every bundle output by its bytes, not by esbuild's path hash esbuild's [hash] is over the metafile's input keys, which are paths relative to absWorkingDir, so a checkout at another depth or with node_modules as a symlink named a byte-identical chunk differently and shipped a different buildId for one commit. Outputs are now renamed leaves-first to the sha256 of their final bytes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fail the build on a route the lazy manifest would strip The guard ran only in a test while the docstring said it failed the build. It now runs in bundleMobileWebApp and names the route and the export. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): derive the asset ceiling from the chunk ceiling and the images A flat 128 stopped agreeing with the chunk ceiling at 18 routes, where the asset count would have failed first and named the count instead of the split. Chunks plus images plus the document keeps the chunk ceiling the one that trips. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the route-manifest tests out of the bundle builder's The builder's test file passed 600 lines. The route manifest, the synthesized RequireContext and the web entry are their own subject and move together. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the export guard the builder's route-source loaders Without .js as jsx the guard reported a React Native .js route carrying JSX as "JSX syntax extension is not enabled" instead of reading its exports. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): assert the navigation fetches the tasks route's own chunk "some new script arrived" passed on any fetch. The builder now names the chunk each route lands in, read off the metafile, and the check asserts that exact path arrived and was not already loaded. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): resolve a route's realpath before matching it to its chunk esbuild writes metafile input keys after resolving symlinks, so every scratch route tree under /var on macOS reached no output and failed the build. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fail the build when the asset ceiling outgrows the shell's map The derived ceiling had no upper bound, and the native shells return null for a manifest over their own 256 rather than truncating it. At 42 images the formula crosses that at 50 routes, inside what Phase C adds, so the build would stay green while the phone got nothing. The number is read from the contract through esbuild, not restated here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): cover the two hard stops in the content-addressed naming Both throws only ran through a whole bundle before, where neither can be provoked. A cycle and a route no output claims are now asserted directly; each test goes red when its throw is removed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): exit the app-bundle build on one line, not a stack The route-export guard fails this script by design, and a raw stack put the route and the export name under twelve frames of node internals. Mirrors the verifier's exit; the message is printed as thrown because every throw on this path already names its source. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
243 lines
10 KiB
JavaScript
243 lines
10 KiB
JavaScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
MOBILE_WEB_APP_ROUTE_ROOT,
|
|
ROUTE_CONTEXT_SOURCE,
|
|
ROUTE_MODULE_SYNCHRONOUS_EXPORTS,
|
|
collectMobileWebAppRouteKeys,
|
|
collectMobileWebAppRoutes,
|
|
renderMobileWebAppRouteManifest,
|
|
routeModuleSynchronousExports
|
|
} from './mobile-web-app-route-manifest.mjs'
|
|
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
|
|
|
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
|
|
const appDir = join(projectDir, 'mobile', 'app')
|
|
|
|
// The sharded `test` job does not install mobile dependencies, so anything that runs esbuild over
|
|
// the route tree is skipped there and run for real in pr.yml's mobile_web_app job.
|
|
const itBundling = mobileWebAppDependenciesPresent() ? it : it.skip
|
|
|
|
async function withScratch(run) {
|
|
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-routes-test-'))
|
|
try {
|
|
return await run(scratch)
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
describe('route manifest', () => {
|
|
it('collects the h/ subtree and nothing above it', async () => {
|
|
const keys = await collectMobileWebAppRouteKeys(appDir)
|
|
expect(keys.length).toBeGreaterThan(0)
|
|
for (const key of keys) {
|
|
expect(key.startsWith(`./${MOBILE_WEB_APP_ROUTE_ROOT}/`)).toBe(true)
|
|
}
|
|
// The native-only shell (pairing, settings, notifications) must not reach the page bundle.
|
|
expect(keys).not.toContain('./_layout.tsx')
|
|
expect(keys).not.toContain('./pair.tsx')
|
|
})
|
|
|
|
it('is sorted, so the generated module is a pure function of the tree', async () => {
|
|
const keys = await collectMobileWebAppRouteKeys(appDir)
|
|
expect(keys).toEqual([...keys].sort())
|
|
})
|
|
|
|
it('excludes test files and API routes', async () => {
|
|
// mobile/app holds none of these today, so assert the rule against a tree that does.
|
|
await withScratch(async (scratch) => {
|
|
const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT)
|
|
await mkdir(directory, { recursive: true })
|
|
for (const name of [
|
|
'index.tsx',
|
|
'index.test.tsx',
|
|
'index.spec.tsx',
|
|
'shape.d.ts',
|
|
'+api.ts',
|
|
'tokens+api.ts',
|
|
'+middleware.ts',
|
|
'notes.md'
|
|
]) {
|
|
await writeFile(join(directory, name), 'export default null\n', 'utf8')
|
|
}
|
|
expect(await collectMobileWebAppRouteKeys(scratch)).toEqual(['./h/index.tsx'])
|
|
})
|
|
expect(await collectMobileWebAppRouteKeys(appDir)).not.toContain('./h/_layout.test.tsx')
|
|
})
|
|
|
|
it('refuses an empty subtree rather than emitting a context with no routes', async () => {
|
|
await expect(collectMobileWebAppRouteKeys(appDir, 'does-not-exist')).rejects.toThrow()
|
|
})
|
|
|
|
it('emits one lazy import per key, and no static import of a route', () => {
|
|
const source = renderMobileWebAppRouteManifest([
|
|
{ key: './h/index.tsx', module: '/app/h/index.tsx' },
|
|
{ key: './h/_layout.tsx', module: '/app/h/_layout.tsx' }
|
|
])
|
|
expect(source).toContain('["./h/index.tsx"]: { default: lazy(() => import("/app/h/index.tsx"))')
|
|
expect(source).toContain(
|
|
'["./h/_layout.tsx"]: { default: lazy(() => import("/app/h/_layout.tsx"))'
|
|
)
|
|
// A static import is what collapses the split back into one chunk.
|
|
expect(source).not.toContain('import * as route')
|
|
expect(source.match(/import\(/g)).toHaveLength(2)
|
|
})
|
|
|
|
it('leaves the RequireContext itself synchronous', () => {
|
|
// expo-router calls keys() to build the route tree before anything renders, so the context
|
|
// may not be a promise; only the screen behind each key is deferred.
|
|
const source = renderMobileWebAppRouteManifest([
|
|
{ key: './h/index.tsx', module: '/app/h/index.tsx' }
|
|
])
|
|
expect(source).toContain('routeContext.keys = () => keys.slice()')
|
|
expect(source).not.toContain('async function routeContext')
|
|
expect(source).not.toContain('await import(')
|
|
})
|
|
|
|
it('has no route carrying an export a lazy module would swallow', async () => {
|
|
const routes = await collectMobileWebAppRoutes(appDir)
|
|
expect(routes.length).toBeGreaterThan(0)
|
|
for (const { module } of routes) {
|
|
const { named, starExports } = await routeModuleSynchronousExports(module)
|
|
// expo-router reads these off the namespace while it builds the tree, which a module behind
|
|
// import() cannot answer. Adding one to a page route needs a static import for that route.
|
|
expect(named, `${module} exports ${named.join(', ')}`).toEqual([])
|
|
expect(starExports, `${module} re-exports all of ${starExports.join(', ')}`).toEqual([])
|
|
}
|
|
})
|
|
|
|
// Each of these puts the name on the namespace without declaring it, which is why the guard
|
|
// reads esbuild's parse instead of the source text.
|
|
it('reads the names off the namespace, not off a declaration', async () => {
|
|
expect(ROUTE_MODULE_SYNCHRONOUS_EXPORTS).toEqual(['unstable_settings', 'ErrorBoundary'])
|
|
await withScratch(async (scratch) => {
|
|
const exportsOf = async (name, source) => {
|
|
const file = join(scratch, name)
|
|
await writeFile(file, source, 'utf8')
|
|
return routeModuleSynchronousExports(file)
|
|
}
|
|
expect(
|
|
(await exportsOf('declared.tsx', 'export const unstable_settings = { anchor: "x" }\n'))
|
|
.named
|
|
).toEqual(['unstable_settings'])
|
|
expect(
|
|
(
|
|
await exportsOf(
|
|
'aliased.tsx',
|
|
'const settings = { anchor: "x" }\nexport { settings as unstable_settings }\n'
|
|
)
|
|
).named
|
|
).toEqual(['unstable_settings'])
|
|
expect((await exportsOf('classy.tsx', 'export class ErrorBoundary {}\n')).named).toEqual([
|
|
'ErrorBoundary'
|
|
])
|
|
expect(
|
|
(await exportsOf('forwarded.tsx', 'export { ErrorBoundary } from "./boundary"\n')).named
|
|
).toEqual(['ErrorBoundary'])
|
|
expect(
|
|
(await exportsOf('plain.tsx', 'export default function Route() { return null }\n')).named
|
|
).toEqual([])
|
|
})
|
|
}, 60_000)
|
|
|
|
it('refuses a star re-export rather than reading it as clean', async () => {
|
|
await withScratch(async (scratch) => {
|
|
const file = join(scratch, 'star.tsx')
|
|
// Nothing here says whether ./boundary exports ErrorBoundary, and answering would mean
|
|
// bundling the route. Reported as a violation so the guard fails closed.
|
|
await writeFile(file, 'export * from "./boundary"\nexport default null\n', 'utf8')
|
|
const { named, starExports } = await routeModuleSynchronousExports(file)
|
|
expect(named).toEqual([])
|
|
expect(starExports).toEqual(['./boundary'])
|
|
})
|
|
}, 60_000)
|
|
|
|
itBundling(
|
|
'reads a .js route that carries JSX, which the app tree allows',
|
|
async () => {
|
|
await withScratch(async (scratch) => {
|
|
// React Native ships untranspiled JSX inside .js, and collectMobileWebAppRoutes accepts a
|
|
// .js route, so the guard has to parse one the same way the bundle does.
|
|
const file = join(scratch, 'jsx-route.js')
|
|
await writeFile(
|
|
file,
|
|
'const boundary = () => <div />\nexport { boundary as ErrorBoundary }\nexport default () => <div />\n',
|
|
'utf8'
|
|
)
|
|
expect((await routeModuleSynchronousExports(file)).named).toEqual(['ErrorBoundary'])
|
|
})
|
|
},
|
|
60_000
|
|
)
|
|
|
|
it('imports a .web.tsx sibling under the native route key', async () => {
|
|
await withScratch(async (scratch) => {
|
|
const directory = join(scratch, MOBILE_WEB_APP_ROUTE_ROOT)
|
|
await mkdir(directory, { recursive: true })
|
|
await writeFile(join(directory, 'index.tsx'), 'export default function Route() {}\n')
|
|
expect(await collectMobileWebAppRoutes(scratch)).toEqual([
|
|
{ key: './h/index.tsx', module: join(directory, 'index.tsx') }
|
|
])
|
|
await writeFile(join(directory, 'index.web.tsx'), 'export default function Route() {}\n')
|
|
// The key is still the native filename, so the override changes the code and not the URL.
|
|
expect(await collectMobileWebAppRoutes(scratch)).toEqual([
|
|
{ key: './h/index.tsx', module: join(directory, 'index.web.tsx') }
|
|
])
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('the synthesized RequireContext', () => {
|
|
const build = (modules) =>
|
|
new Function('modules', `${ROUTE_CONTEXT_SOURCE}; return routeContext`)(modules)
|
|
|
|
it('answers the four members expo-router reads', () => {
|
|
const context = build({ './h/index.tsx': { default: 'screen' } })
|
|
expect(context.keys()).toEqual(['./h/index.tsx'])
|
|
expect(context('./h/index.tsx')).toEqual({ default: 'screen' })
|
|
expect(context.resolve('./h/index.tsx')).toBe('./h/index.tsx')
|
|
expect(context.id).toBe('orca-mobile-web-app-routes')
|
|
})
|
|
|
|
it('hands out a copy of keys, so a caller cannot mutate the route tree', () => {
|
|
const context = build({ './h/index.tsx': {} })
|
|
context.keys().push('./injected.tsx')
|
|
expect(context.keys()).toEqual(['./h/index.tsx'])
|
|
})
|
|
|
|
it('throws rather than returning undefined for an unknown key', () => {
|
|
const context = build({ './h/index.tsx': {} })
|
|
expect(() => context('./missing.tsx')).toThrow('no route module')
|
|
expect(() => context.resolve('./missing.tsx')).toThrow('cannot resolve route')
|
|
})
|
|
|
|
it('does not answer inherited Object keys', () => {
|
|
const context = build({ './h/index.tsx': {} })
|
|
expect(() => context('constructor')).toThrow('no route module')
|
|
})
|
|
})
|
|
|
|
describe('the web entry', () => {
|
|
it('leaves the suspense boundary to expo-router', async () => {
|
|
const entry = await readFile(join(projectDir, 'mobile', 'web-entry', 'index.tsx'), 'utf8')
|
|
// A second boundary around the whole tree catches nothing the router has not already caught,
|
|
// and would only make the fallback ambiguous about which layer suspended.
|
|
expect(entry).not.toContain('Suspense')
|
|
})
|
|
|
|
itBundling('because the router already wraps every screen in one', async () => {
|
|
// The premise of the test above, read off the copy that is bundled: getQualifiedRouteComponent
|
|
// wraps each screen itself, which is what makes the lazy route manifest safe without a
|
|
// boundary of our own.
|
|
const useScreens = await readFile(
|
|
join(projectDir, 'mobile', 'node_modules', 'expo-router', 'build', 'useScreens.js'),
|
|
'utf8'
|
|
)
|
|
expect(useScreens).toContain('<react_1.default.Suspense fallback=')
|
|
})
|
|
})
|