Skip to content

feat(cli-build): preconnect and gate modulepreload of the CDN sanity module#1402

Open
jordanl17 wants to merge 1 commit into
mainfrom
safari-cors-revert-investigation
Open

feat(cli-build): preconnect and gate modulepreload of the CDN sanity module#1402
jordanl17 wants to merge 1 commit into
mainfrom
safari-cors-revert-investigation

Conversation

@jordanl17

@jordanl17 jordanl17 commented Jun 29, 2026

Copy link
Copy Markdown
Member

Description

Re-introduces the preconnect + modulepreload resource hints for the CDN sanity module in the auto-update studio HTML, originally added in #1276 and reverted in #1400. This time the risky hint is gated so it cannot break Safari.

#1276 was reverted because it blanked auto-update studios in Safari. The studio loads the sanity module from sanity-cdn.com, which responds with a cross-origin redirect to the bucket that actually holds the bytes (modules.sanity-cdn.com). That redirect is the auto-update mechanism: it resolves a version range to a specific pinned build. When a modulepreload follows a cross-origin redirect, WebKit (Safari) forces the request into credentialed CORS mode even though the hint is crossorigin="anonymous" - a WebKit bug. The bytes are served straight from storage with Access-Control-Allow-Origin: * and no credentials header, so the credentialed check fails, the preload errors, and the studio renders blank. Chromium and Firefox follow the same redirect correctly, which is why #1276 passed review and only Safari broke.

This PR re-lands the hints with the unsafe one gated behind a browser allowlist (default-deny):

  • preconnect only opens a connection; it never follows the redirect or downloads the module, so it is safe everywhere and runs unconditionally.
  • modulepreload is emitted only for engines confirmed to handle the cross-origin redirect (Chromium or Gecko, with all iOS devices excluded). Any other or unrecognised engine falls back to the normal import-map load - a missed download, never a blank studio. A denylist was rejected on purpose: a missed Safari user-agent would fail dangerous, whereas default-deny fails safe.

Why this is not fixed in the server layer instead: the proper fix has to change what the module origin serves, and that is infrastructure work owned outside the CLI, not a code change here. The module bytes come from a storage/CDN bucket that our API gateway never sees, so we cannot patch the response headers there. A real fix is one of: serve the module bytes from the same origin as the redirect (so there is no cross-origin hop and the bug never triggers), or front the bucket with an edge that returns a specific Access-Control-Allow-Origin plus Access-Control-Allow-Credentials: true (the storage bucket alone cannot send either). Both are larger, riskier changes than this CLI gate, and they extend the optimization to Safari rather than block it. This PR recovers the win now for the browsers that can already use it, safely; the infra fix can follow.

What to review

  • packages/@sanity/cli-build/src/actions/build/renderDocumentWorker/addTimestampImportMapScriptToHtml.ts - the injected runtime script. Focus on the gating predicate isKnownSafeEngine (!/\b(iPad|iPhone|iPod)\b/.test(ua) && /Chrom(e|ium)|Firefox/.test(ua)): preconnect runs unconditionally, modulepreload only when it passes.
  • The modulepreload href reuses replaceTimestamp(...) so it matches the import-map entry exactly; a mismatched timestamp would download the largest chunk twice. Both hints use crossorigin="anonymous" so the warmed connection is reused for the module fetch.
  • Affected flow: auto-update studio builds only. Non-CDN import maps emit no hints.

Testing

Unit tests in __tests__/addTimestampImportMapScriptToHtml.test.ts run the injected script in JSDOM with a stubbed navigator.userAgent:

  • preconnect is emitted with crossorigin="anonymous" across every engine (desktop Chrome, desktop Safari, iOS Safari, iOS webview with a Chrome token, unrecognised engine).
  • modulepreload is emitted only for the allowlisted engine, with a timestamp equal to the import-map entry (guards against the double download).
  • modulepreload is withheld for desktop Safari, iOS Safari, an iOS user-agent carrying a Chrome token (proving the iOS guard is load-bearing), and an unrecognised engine, while the safe preconnect and the import-map and stylesheet rewrites still happen.
  • No hints when sanity resolves to a non-CDN host, or when no import is a sanity-cdn host.

Note

Medium Risk
Changes production HTML for auto-update studios and browser-specific loading behavior; gating is default-deny so WebKit should not regress, but UA heuristics could misclassify edge clients (missed preload only).

Overview
Re-lands CDN resource hints in the auto-update studio HTML injector (addTimestampImportMapScriptToHtml.ts), after they were removed in #1400 because modulepreload blanked WebKit when following the CDN’s cross-origin redirect.

For import maps that resolve to sanity-cdn hosts, the runtime script now always injects link rel="preconnect" (with crossorigin="anonymous") to warm the CDN connection. It injects link rel="modulepreload" for the sanity entry only when navigator.userAgent passes a default-deny allowlist: not an iOS device (iPad|iPhone|iPod) and Chromium or Firefox. Safari, iOS (including Chrome-branded UAs), and unknown engines still get the import map and preconnect but no modulepreload, avoiding the WebKit CORS/redirect bug at the cost of a possible extra fetch. Hints are skipped when nothing points at a sanity-cdn host; the preload URL reuses the same timestamp rewrite as the import map to avoid double-downloading the main chunk.

Tests exercise the injected script in JSDOM with stubbed userAgent, covering preconnect everywhere, preload only on allowlisted UAs, graceful fallback when preload is withheld, and no hints for non-CDN maps. AGENTS.md documents automatic changesets; a minor changeset is included for @sanity/cli-build.

Reviewed by Cursor Bugbot for commit bc94fe3. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @sanity/cli

Compared against main (e29d4bbd)

@sanity/cli

Metric Value vs main (e29d4bb)
Internal (raw) 2.7 KB -
Internal (gzip) 1.0 KB -
Bundled (raw) 11.16 MB -
Bundled (gzip) 2.10 MB -
Import time 896ms +11ms, +1.2%

bin:sanity

Metric Value vs main (e29d4bb)
Internal (raw) 782 B -
Internal (gzip) 423 B -
Bundled (raw) 9.87 MB -
Bundled (gzip) 1.78 MB -
Import time 2.27s -61ms, -2.6%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @sanity/cli-core

Compared against main (e29d4bbd)

Metric Value vs main (e29d4bb)
Internal (raw) 106.7 KB -
Internal (gzip) 26.7 KB -
Bundled (raw) 21.72 MB -
Bundled (gzip) 3.46 MB -
Import time 782ms +2ms, +0.2%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — create-sanity

Compared against main (e29d4bbd)

Metric Value vs main (e29d4bb)
Internal (raw) 908 B -
Internal (gzip) 483 B -
Bundled (raw) 931 B -
Bundled (gzip) 491 B -
Import time ❌ ChildProcess denied: node -
Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Delta

File Statements
packages/@sanity/cli-build/src/actions/build/renderDocumentWorker/addTimestampImportMapScriptToHtml.ts 100.0% (±0%)

Comparing 1 changed file against main @ e29d4bbd254f47afd976876587f5cd57dce149bc

Overall Coverage

Metric Coverage
Statements 74.3% (±0%)
Branches 64.2% (±0%)
Functions 68.8% (±0%)
Lines 74.9% (±0%)

@jordanl17 jordanl17 marked this pull request as ready for review July 1, 2026 12:07
@jordanl17 jordanl17 requested a review from a team as a code owner July 1, 2026 12:07
@jordanl17 jordanl17 requested a review from bjoerge July 1, 2026 12:07

@bjoerge bjoerge left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants