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()