From 3e26f7887cd96de6f8321572d8bdc2a8dfa88b86 Mon Sep 17 00:00:00 2001 From: Miles Date: Fri, 24 Jul 2026 13:41:08 +0800 Subject: [PATCH] Fix(Integration): Load browser manifest via fetch to avoid import attributes The browser manifest facades built a loader with `new Function('specifier', "return import(specifier, { with: { type: 'json' } })")`. JSON import attributes (`with { type: 'json' }`) are only supported in Safari 17.2+, Chrome 123+ and Firefox 130+. On older engines, notably every iOS 15 and iOS 16 (Safari < 17.2), evaluating that dynamic import, even inside `new Function`, throws `SyntaxError: import call expects exactly one argument` while the entry module is evaluated. Because the manifest facade runs at the top level of the app entry, the whole app fails to bootstrap and renders a blank page with no obvious console error. The syntax is invisible to build-time tooling because it lives inside a `new Function` string, so bundler and esbuild target checks never flag it. Load the manifest with `fetch().then(r => r.json())` in the browser facades (`toBrowserManifestFacadeModule` and the browser branch of `toUniversalManifestFacadeModule`). Fetch is universally supported and needs no bundler-hiding indirection. The Node facades keep the JSON import attribute, since Node supports it. Verified on an iOS 15.5 Safari simulator: blank before, mounts after. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/integration/src/manifest-facade.ts | 25 ++++++++++++++------- packages/integration/tests/module.test.ts | 18 ++++++++++++++- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/integration/src/manifest-facade.ts b/packages/integration/src/manifest-facade.ts index 0326d515e..df91483fb 100644 --- a/packages/integration/src/manifest-facade.ts +++ b/packages/integration/src/manifest-facade.ts @@ -41,11 +41,18 @@ export function toManifestPreloadLinkTag(href: string) { } export function toBrowserManifestFacadeModule(urlExpression: string) { + // Load the manifest with `fetch` rather than a JSON import attribute + // (`import(url, { with: { type: 'json' } })`). Import attributes are only + // supported in Safari 17.2+, Chrome 123+, and Firefox 130+; on older engines + // — notably all iOS 15/16 (Safari < 17.2) — evaluating that dynamic import, + // even inside `new Function`, throws `SyntaxError: import call expects exactly + // one argument` while the entry module is being evaluated, so the whole app + // fails to bootstrap and renders blank with no obvious console error. `fetch` + // is universally supported and needs no bundler-hiding indirection. return [ `const masterCSSManifestURL = ${urlExpression};`, - `const loadMasterCSSManifestModule = new Function('specifier', "return import(specifier, { with: { type: 'json' } })");`, - `const masterCSSManifestModule = await loadMasterCSSManifestModule(typeof masterCSSManifestURL === 'string' ? masterCSSManifestURL : masterCSSManifestURL.href);`, - `export default masterCSSManifestModule.default;`, + `const masterCSSManifestResponse = await fetch(typeof masterCSSManifestURL === 'string' ? masterCSSManifestURL : masterCSSManifestURL.href);`, + `export default await masterCSSManifestResponse.json();`, `` ].join('\n') } @@ -89,16 +96,18 @@ export function toUniversalManifestFacadeModule(urlExpression: string) { ` throw lastError;`, `}`, ``, - `async function loadMasterCSSManifestFromImport(url) {`, + `async function loadMasterCSSManifestFromFetch(url) {`, ` const specifier = typeof url === 'string' ? url : url.href;`, - ` const load = new Function('specifier', "return import(specifier, { with: { type: 'json' } })");`, - ` const manifestModule = await load(specifier);`, - ` return manifestModule.default;`, + ` const response = await fetch(specifier);`, + ` return response.json();`, `}`, ``, + // Browsers use `fetch`; only the Node branch keeps the JSON import attribute. + // See `toBrowserManifestFacadeModule` for why import attributes cannot be used + // on the browser (they break Safari < 17.2 / iOS 15-16 at parse time). `export default typeof window === 'undefined'`, ` ? await loadMasterCSSManifestFromFile(masterCSSManifestURL)`, - ` : await loadMasterCSSManifestFromImport(masterCSSManifestURL);`, + ` : await loadMasterCSSManifestFromFetch(masterCSSManifestURL);`, `` ].join('\n') } diff --git a/packages/integration/tests/module.test.ts b/packages/integration/tests/module.test.ts index 3004e5b88..30a331cfc 100644 --- a/packages/integration/tests/module.test.ts +++ b/packages/integration/tests/module.test.ts @@ -15,6 +15,7 @@ import { toManifestJSON, toManifestPreloadLinkAttrs, toManifestPreloadLinkTag, + toBrowserManifestFacadeModule, toEmittedGlobalsModule, toUniversalManifestFacadeModule } from '../src/module' @@ -69,9 +70,24 @@ describe('@master/css-integration module helpers', () => { expect(source).toContain(`join(process.cwd(), '.next', value.slice('/_next/'.length))`) expect(source).toContain(`join(process.cwd(), '.next', 'dev', value.slice('/_next/'.length))`) expect(source).toContain('for (const file of files)') + // Node branch keeps the JSON import attribute (Node supports it). expect(source).toContain(`return import(specifier, options)`) expect(source).toContain(`with: { type: 'json' }`) - expect(source).not.toContain('fetch(') + // Browser branch loads via fetch; import attributes break Safari < 17.2 (iOS 15/16). + expect(source).toContain('await fetch(specifier)') + expect(source).toContain(': await loadMasterCSSManifestFromFetch(masterCSSManifestURL)') expect(source).not.toContain('readFile') }) + + it('builds a browser manifest facade that avoids import attributes', () => { + const source = toBrowserManifestFacadeModule('new URL("./master-css-manifest.json", import.meta.url)') + + // Must load the manifest with fetch, never `import(url, { with: { type: 'json' } })`. + // Import attributes are a SyntaxError on Safari < 17.2 (all iOS 15/16), and evaluating + // them — even inside `new Function` — breaks the entire app at bootstrap with a blank page. + expect(source).toContain('await fetch(') + expect(source).toContain('.json()') + expect(source).not.toContain('with: { type:') + expect(source).not.toContain('new Function') + }) })