fix(mobile): turn Zod's JIT probe off for the page, before any module

The page filed a CSP `script-src` violation on every load: Zod decides
whether it may compile by constructing `new Function('')` and reading
the throw as "no JIT here", the shell's `script-src 'self'` is exactly
that throw, and the browser reports it before Zod catches it. Zod's own
source gates the probe on `jitless` for this case.

`z.config({ jitless: true })` at the entry does not work, and the
reviewer's suggestion of putting it there was measured losing the race.
`$ZodObject` reads `allowsEval` when a schema is constructed, not when
one is parsed, so the first module-scope `z.object(...)` in the bundle
fires the probe — and esbuild evaluates the chunk holding zod and its
callers before the chunk holding any module of ours that imports zod. A
Function-constructor trap in the page put the call under `new ZodObject`
ahead of the entry's first statement.

`globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with
`??=` rather than replacing, so the banner can set the flag before any
module runs. That is where it now lives, beside the `process` shim and
under the same `MOBILE_WEB_APP_SHIMS` contract, which asserts it is
applied. Nothing is lost: the compiled path was never reachable in a
page under this policy.

The render check's `newCsp()` filter is gone. It dropped violations by
`blockedURI === 'eval'`, which would have hidden a real one, and every
case now asserts zero. The first case walks load and first paint, which
is where the second of the two reports fired. The dead `violations`
array is deleted.

Red first: blank the banner constant and four of the six cases fail,
each naming a `blockedUri: 'eval'` the filter used to swallow.

Finding, not fixed here and reported instead: the page bundles two
copies of zod, mobile's 4.4.3 and the repo root's 4.5.4, because
`src/shared/zod-salvage.ts` resolves upward. That is 808 KB of duplicate
source. Aliasing `zod` to one copy in the builder fixes it and was
measured working, but it changes which zod shared code runs in the
shipped page, which is a call to make on its own rather than inside a
CSP fix.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 06:24:58 -04:00
parent fa933248e4
commit 71254ab3a9
2 changed files with 47 additions and 28 deletions
+29 -1
View File
@@ -54,6 +54,13 @@ export const MOBILE_WEB_APP_SHIMS = [
name: 'process-banner',
appliesTo: (options) => options.banner?.js?.includes('globalThis.process ??=') === true
},
{
// Zod probes for a usable JIT with `new Function('')`, which the shell's CSP reports even
// though Zod catches the throw and runs interpreted. Turned off before any module, because a
// schema constructed at module scope reaches the probe before our own code can run.
name: 'zod-jitless-banner',
appliesTo: (options) => options.banner?.js?.includes('__zod_globalConfig') === true
},
{
// 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.
@@ -111,6 +118,27 @@ const PAGE_ASYNC_STORAGE_MODULE = join(
'page-async-storage.ts'
)
/**
* Zod's compiled path, off before any module runs.
*
* Zod decides whether it may compile by constructing `new Function('')` and reading the throw as
* "no JIT here". Under the shell's `script-src 'self'` that throw is exactly what happens, Zod
* catches it and takes the interpreted path — but the browser files a `securitypolicyviolation`
* report first, and it does so on every page load. Zod's own source gates the probe on `jitless`
* for this case, so nothing here is a workaround.
*
* In the banner rather than a module that calls `z.config`, because a module cannot win the race.
* `$ZodObject` reads `allowsEval` when a schema is *constructed*, not parsed, so the first
* module-scope `z.object(...)` in the bundle fires the probe — and esbuild evaluates the chunk
* holding zod and its callers before the chunk holding any module of ours that imports zod. An
* entry import placed first was measured losing that race; the banner runs before every module.
*
* `globalConfig` is `globalThis.__zod_globalConfig`, which zod adopts with `??=` rather than
* replacing, so setting the flag on it here is what zod itself reads.
*/
const ZOD_JITLESS_BANNER =
'globalThis.__zod_globalConfig ??= {}; globalThis.__zod_globalConfig.jitless = true;'
const ROUTE_MANIFEST_PLUGIN_NAME = 'orca-route-manifest'
const LUCIDE_PLUGIN_NAME = 'orca-lucide-barrel-provider'
@@ -210,7 +238,7 @@ export function mobileWebAppBuildOptions(routes) {
// script would resolve against the route instead.
publicPath: '/assets',
banner: {
js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };"
js: `globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };${ZOD_JITLESS_BANNER}`
},
define: {
global: 'globalThis',
@@ -158,7 +158,6 @@ async function openPane({ grants }) {
const context = await browser.newContext({ viewport: VIEWPORT })
const page = await context.newPage()
const consoleErrors = []
const violations = []
const foreignRequests = []
page.on('console', (message) => {
if (message.type() === 'error') {
@@ -201,14 +200,8 @@ async function openPane({ grants }) {
page,
context,
consoleErrors,
violations,
foreignRequests,
csp: () => page.evaluate(() => globalThis.__orcaRenderCheckCsp),
/** Every violation but the one the bundle already reports on any route: see ZOD_JIT_PROBE. */
newCsp: async () =>
(await page.evaluate(() => globalThis.__orcaRenderCheckCsp)).filter(
(violation) => violation.blockedUri !== 'eval'
)
csp: () => page.evaluate(() => globalThis.__orcaRenderCheckCsp)
}
}
@@ -303,26 +296,24 @@ const waitForPaint = (page, count) =>
{ timeout: 15_000 }
)
/**
* The one CSP violation this bundle already makes, on any route, before the pane is involved.
*
* Zod 4 feature-detects its compiled path by constructing `new Function('')` and treating a throw
* as "no JIT here". Under the shell's `script-src 'self'` the construction throws, Zod catches it
* and takes the interpreted path, so the page is correct — but the browser has already filed a
* violation report by then, on every page load. Recorded rather than fixed: it is not the pane's,
* and a check that asserted no violations at all would fail on it and say nothing about the frame
* path, which is what this file is for.
*/
const ZOD_JIT_PROBE = { directive: 'script-src', blockedUri: 'eval' }
describePane('the browser pane in a page', () => {
it('reports only the Zod JIT probe, which the page survives', async () => {
/**
* Zero, which it was not until `page-zod-jitless.ts` landed.
*
* Zod decided whether it could compile by constructing `new Function('')`, which the shell's
* `script-src 'self'` reports even though Zod catches the throw — once on load and again on
* first paint. This file filtered those out by `blockedURI === 'eval'` for one round, which
* would also have hidden a real one, so the filter is gone and the cause is fixed instead.
*/
it('files no CSP violation at all, through load and first paint', async () => {
const view = await openPane({ grants: [faultGrant, BINARY_GRANT] })
try {
await view.page.waitForFunction(() => globalThis.__orcaRenderCheckSubscribes.length > 0)
// Named, so a second `eval` from anywhere else is visible as a count rather than hidden by
// the filter every other case uses.
expect(await view.csp()).toEqual([ZOD_JIT_PROBE])
const b64 = await encodeNoiseJpeg(view.page, { ...FRAME, seed: 33 })
await emitFrame(view.page, { b64, frameSeq: 1, ...FRAME })
await waitForPaint(view.page, 1)
expect(await view.csp()).toEqual([])
expect(view.consoleErrors).toEqual([])
} finally {
await view.context.close()
@@ -349,7 +340,7 @@ describePane('the browser pane in a page', () => {
expect(layers.length).toBeGreaterThan(0)
expect(layers.filter((layer) => layer.opacity === '1')).toHaveLength(1)
expect(view.consoleErrors).toEqual([])
expect(await view.newCsp()).toEqual([])
expect(await view.csp()).toEqual([])
expect(view.foreignRequests).toEqual([])
} finally {
await view.context.close()
@@ -384,7 +375,7 @@ describePane('the browser pane in a page', () => {
expect(visible).toHaveLength(1)
expect(visible[0].digest).not.toBe(before.find((l) => l.opacity === '1')?.digest)
expect(view.consoleErrors).toEqual([])
expect(await view.newCsp()).toEqual([])
expect(await view.csp()).toEqual([])
} finally {
await view.context.close()
}
@@ -436,7 +427,7 @@ describePane('the browser pane in a page', () => {
)
expect(await view.page.evaluate(() => globalThis.__orcaRenderCheckSubscribes)).toEqual([])
expect(view.consoleErrors).toEqual([])
expect(await view.newCsp()).toEqual([])
expect(await view.csp()).toEqual([])
expect(view.foreignRequests).toEqual([])
} finally {
await view.context.close()