From 71254ab3a932a18b5f7e856ec421903082031101 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 20 Sep 2026 06:24:58 -0400 Subject: [PATCH] fix(mobile): turn Zod's JIT probe off for the page, before any module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../scripts/build-mobile-web-app-bundle.mjs | 30 ++++++++++++- ...obile-web-app-browser-pane-render.test.mjs | 45 ++++++++----------- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/config/scripts/build-mobile-web-app-bundle.mjs b/config/scripts/build-mobile-web-app-bundle.mjs index 7360999bd6d..a5232639e88 100644 --- a/config/scripts/build-mobile-web-app-bundle.mjs +++ b/config/scripts/build-mobile-web-app-bundle.mjs @@ -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', diff --git a/config/scripts/mobile-web-app-browser-pane-render.test.mjs b/config/scripts/mobile-web-app-browser-pane-render.test.mjs index ddfed80f381..6fc9abdc305 100644 --- a/config/scripts/mobile-web-app-browser-pane-render.test.mjs +++ b/config/scripts/mobile-web-app-browser-pane-render.test.mjs @@ -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()