Files
orca/config/scripts/mobile-web-app-render.test.mjs
Jinwoo Hong 209d2d8df6 build(mobile): split the Route A page into per-route chunks (OTA phase C, C1.5) (#21475)
* 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
2026-09-18 11:58:42 -04:00

343 lines
14 KiB
JavaScript

import { createServer } from 'node:http'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium } from 'playwright-core'
import { fileURLToPath } from 'node:url'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
// Why a real browser: the route tree is handed to expo-router's own ExpoRoot through a synthesized
// RequireContext. Nothing short of mounting it proves that object is the shape ExpoRoot reads.
const HOST_ROUTE = '/h/render-check-host'
// The sharded `test` job does not install mobile dependencies, so the page cannot be built there.
// The CSP suite below needs none of them and still runs. pr.yml's mobile_web_app job runs both.
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let browser
let origin
let routeChunks = {}
let cspHeader = null
/**
* Both CSP constants are a list of quoted directives with `//` comments between them, and those
* comments quote directive text. Dropping comment lines first is what keeps a comment out of the
* header this test serves.
*/
export function parseCspDirectives(source, startMarker, endMarker) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker)
if (start === -1 || end < start) {
throw new Error(`could not find ${startMarker} .. ${endMarker}`)
}
const body = source
.slice(start, end)
.split('\n')
.filter((line) => !line.trimStart().startsWith('//'))
.join('\n')
const directives = [...body.matchAll(/"([^"]+)"/g)].map((match) => match[1])
if (directives.length < 10) {
throw new Error('could not parse the shell CSP')
}
return directives.join('; ')
}
/**
* The shipped policy, read from the Kotlin source so this test cannot drift from what the shell
* actually sends. Parsed rather than imported: the constant lives in a JVM module.
*/
async function readShellCsp() {
const source = await readFile(
join(
projectDir,
'mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt'
),
'utf8'
)
return parseCspDirectives(source, 'listOf(', ').joinToString')
}
beforeAll(async () => {
cspHeader = await readShellCsp()
if (!bundles) {
return
}
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-render-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
const { outDir } = built
routeChunks = built.routeChunks
server = createServer((request, response) => {
const path = new URL(request.url, 'http://localhost').pathname
// A browser asks for this on its own and the shell's WebView never does. The bundle carries
// no icon, so a 404 would put a console error in every check that runs against a full Chrome
// -- which is what CI resolves -- and none against the bundled headless shell.
if (path === '/favicon.ico') {
response.writeHead(204)
response.end()
return
}
// A route path serves the entrypoint and the page routes client-side. A path naming a file
// has to come out of the bundle or 404, the same as the shell's manifest map: answering it
// with the document instead would hide a publicPath the script cannot fetch from.
const namesAFile = path.slice(path.lastIndexOf('/')).includes('.')
const file = namesAFile ? path.slice(1) : 'index.html'
readFile(join(outDir, file)).then(
(bytes) => {
const headers = {
'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/html'
}
// The document carries the shell's real policy, so a directive the page violates fails
// here rather than on a phone. Assets carry none, exactly as the native handler does.
if (file === 'index.html' && cspHeader) {
headers['content-security-policy'] = cspHeader
}
response.writeHead(200, headers)
response.end(bytes)
},
() => {
response.writeHead(404)
response.end()
}
)
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
origin = `http://127.0.0.1:${String(server.address().port)}`
// CI runs this against the runner's Google Chrome rather than paying for a browser download,
// the same reason and the same override shape as the orcad browser-provider job.
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}, 180_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
// expo-router's Unmatched screen mounts cleanly and paints text, so "no errors, some html" stays
// green with every host route unreachable. Each route below names content only it can produce.
const UNMATCHED = 'Unmatched Route'
/**
* A page with every signal the checks below read: uncaught errors, console errors, and the script
* paths the browser actually fetched. The last one is how a client-side navigation proves it
* pulled the next route's chunk rather than painting out of what the entry already had.
*/
async function openPage() {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
const errors = []
const scripts = []
let reportUncaught = () => {}
// An uncaught error from the entry means nothing will ever mount. Racing it against the wait
// reports that error in a second instead of a 30s timeout that names nothing -- which is what a
// native-only route module, throwing at import before React runs, looks like from here.
// Resolved rather than rejected: this one settles during goto, before anything awaits it.
const uncaught = new Promise((resolve) => {
reportUncaught = resolve
})
page.on('pageerror', (error) => {
errors.push(`${error.name}: ${error.message}`)
reportUncaught(error)
})
page.on('console', (message) => {
if (message.type() === 'error') {
errors.push(`console.error: ${message.text()}`)
}
})
page.on('response', (response) => {
const path = new URL(response.url()).pathname
if (response.status() === 200 && path.endsWith('.js')) {
scripts.push(path)
}
})
return { page, errors, scripts, uncaught }
}
/**
* Wait for the entry to mount and then for the route's own content, polled rather than read once:
* the route manifest defers every screen behind `import()`, so the entry's `mounted` signal lands
* while the route's chunk is still being fetched and the body is briefly empty. Waiting for the
* string the caller is about to assert is what makes the check about the route and not the timing.
*/
async function waitForRoute({ page, errors, uncaught }, route, awaitText) {
const named = (cause, what) =>
new Error(`${route} ${what}: ${errors.join(' | ') || 'no page or console error'}`, { cause })
const race = async (wait) =>
Promise.race([
wait.then(
() => null,
(error) => error
),
uncaught
])
// The entry's own signal, not "#root has children": an error boundary or a half-painted tree
// also fills #root, and this only lands once expo-router's tree below the wrapper has committed.
// Polled on a timer rather than Playwright's default animation frames, which a page that never
// paints never delivers.
const cause = await race(
page.waitForFunction(() => document.documentElement.dataset.orcaWebEntry === 'mounted', {
timeout: 30_000,
polling: 250
})
)
if (cause) {
const state = await page.evaluate(
() => document.documentElement.dataset.orcaWebEntry ?? 'absent'
)
throw named(cause, `never mounted (entry ${state})`)
}
const paintCause = await race(
page.waitForFunction((needle) => document.body.innerText.includes(needle), awaitText, {
timeout: 30_000,
polling: 250
})
)
if (paintCause) {
throw named(paintCause, `mounted but never painted ${JSON.stringify(awaitText)}`)
}
}
async function render(route, awaitText) {
const opened = await openPage()
await opened.page.goto(`${origin}${route}`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
const text = await opened.page.evaluate(() => document.body.innerText)
await opened.page.close()
// A CSP refusal reaches the page as a console error, so the caller's empty-errors assertion is
// also the policy assertion; name it here so a failure says which one broke.
return {
errors: opened.errors,
cspErrors: opened.errors.filter((entry) => entry.includes('Content Security Policy')),
text
}
}
describe('the shell policy this page is tested under', () => {
it('is the same on both platforms, so one render check covers both', async () => {
const swift = await readFile(
join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'),
'utf8'
)
expect(parseCspDirectives(swift, 'static let header = [', '].joined')).toBe(cspHeader)
})
it('reads directives from the source and not from the comments around them', () => {
const source = [
'static let header = [',
" // React Native Web needs \"style-src 'self' 'unsafe-inline'\" and nothing more.",
' "default-src \'none\'",',
' "script-src \'self\'",',
" \"style-src 'self' 'unsafe-inline'\",",
' "img-src \'self\'",',
' "connect-src \'self\'",',
' "worker-src \'none\'",',
' "frame-src \'none\'",',
' "child-src \'none\'",',
' "object-src \'none\'",',
' "base-uri \'none\'",',
' "form-action \'none\'",',
' "frame-ancestors \'none\'"',
'].joined'
].join('\n')
const parsed = parseCspDirectives(source, 'static let header = [', '].joined')
expect(parsed.split('; ')[0]).toBe("default-src 'none'")
expect(parsed.split('; ').filter((entry) => entry.includes('unsafe-inline'))).toEqual([
"style-src 'self' 'unsafe-inline'"
])
})
it('still refuses inline script, which is the directive that matters', () => {
expect(cspHeader).toContain("script-src 'self';")
expect(cspHeader).not.toContain("script-src 'self' 'unsafe-inline'")
})
})
describeRender('the page server this check runs against', () => {
it('404s a file path the bundle does not contain', async () => {
// Without this the document answers every path, and a publicPath the script cannot fetch
// from still renders, because the script is fetched from the one prefix that is served.
expect((await fetch(`${origin}/wrong-prefix/entry.js`)).status).toBe(404)
expect((await fetch(`${origin}/assets/not-a-real-hash.js`)).status).toBe(404)
})
it('answers the icon a browser asks for without an error', async () => {
expect((await fetch(`${origin}/favicon.ico`)).status).toBe(204)
})
it('still serves the document at every route depth', async () => {
for (const route of ['/', HOST_ROUTE, `${HOST_ROUTE}/tasks`]) {
const response = await fetch(`${origin}${route}`)
expect(response.status, route).toBe(200)
expect(await response.text(), route).toContain('<div id="root">')
}
})
})
describeRender('the Route A page in a real browser', () => {
it('mounts the worktree list route, not the unmatched screen', async () => {
const { errors, cspErrors, text } = await render(HOST_ROUTE, 'Host not found')
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
// app/h/[hostId]/index.tsx: the placeholder client knows no host, so the list paints its
// not-found state. Only that route's own component produces this string.
expect(text).toContain('Host not found')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('routes a nested dynamic segment through the same context', async () => {
const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/tasks`, 'Tasks')
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
// app/h/[hostId]/tasks.tsx paints its header and its GitHub filter row.
expect(text).toContain('Tasks')
expect(text).toContain('Issues')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('renders the unmatched route rather than crashing on a path with no module', async () => {
const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/not-a-route`, UNMATCHED)
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
// Asserted positively so the two negatives above are known to discriminate.
expect(text).toContain(UNMATCHED)
}, 60_000)
it("fetches the next route's chunks on a client-side navigation", async () => {
const opened = await openPage()
const { page, errors, scripts } = opened
await page.goto(`${origin}${HOST_ROUTE}`, { waitUntil: 'load' })
await waitForRoute(opened, HOST_ROUTE, 'Host not found')
const loadedForFirstRoute = [...scripts]
// What the shell will do in C1.2: the document is fetched once and every later route is a
// history entry, so the tasks screen can only arrive as a chunk fetched now.
await page.evaluate((to) => {
history.pushState(null, '', to)
dispatchEvent(new PopStateEvent('popstate'))
}, `${HOST_ROUTE}/tasks`)
await waitForRoute(opened, `${HOST_ROUTE}/tasks`, 'Issues')
expect(new URL(page.url()).pathname).toBe(`${HOST_ROUTE}/tasks`)
const fetchedOnNavigation = scripts.filter((path) => !loadedForFirstRoute.includes(path))
// Not "some script arrived": the chunk the builder put the tasks route in, named by the
// builder rather than guessed from the bytes, which is the only thing that says the route
// came over the wire now and not out of what the first route had already loaded.
const tasksChunk = routeChunks['./h/[hostId]/tasks.tsx']
expect(tasksChunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
expect(fetchedOnNavigation, scripts.join(' ')).toContain(`/assets/${tasksChunk}`)
expect(loadedForFirstRoute).not.toContain(`/assets/${tasksChunk}`)
const text = await page.evaluate(() => document.body.innerText)
expect(text).toContain('Tasks')
expect(text).not.toContain(UNMATCHED)
expect(errors).toEqual([])
await page.close()
}, 60_000)
})