Skip to content

An country selector - #3959

Draft
andguy95 wants to merge 13 commits into
previewfrom
an-country-selector
Draft

An country selector#3959
andguy95 wants to merge 13 commits into
previewfrom
an-country-selector

Conversation

@andguy95

@andguy95 andguy95 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

TL;DR: Every Hydrogen storefront hand-rolls the same country/currency switching boilerplate: a static country list, a locale-from-URL utility, a resource route, and a form action that updates the cart and redirects. This PR adds a first-class localization domain module to @shopify/hydrogen that absorbs that boilerplate behind small, framework-neutral primitives, and wires a working selector into examples/hydrogen. The design mirrors the Liquid localization form: everything works with JavaScript disabled, and JS only enhances it.

Before

Apps ship their own static locale map and matching logic (examples/hydrogen/app/lib/i18n.ts):

const LOCALES_BY_PATH_PART: Record<string, Pick<I18nLocale, "country" | "language">> = {
  "EN-CA": { country: "CA", language: "EN" },
  "EN-US": { country: "US", language: "EN" },
  "FR-CA": { country: "CA", language: "FR" },
};

export function getLocaleFromRequest(request: Request): I18nLocale {
  // hand-rolled path parsing against the map above
}

There is no selector, no way for a buyer to change country, and no cart currency sync.

After

Locale resolution, the selector endpoints, and cross-visit persistence come from the package:

// One config, shared by matching and handlers
const i18n = matchLocaleFromRequest(request, LOCALIZATION_CONFIG); // {defaultLocale, supportedLocales}

// Selector endpoints join the existing handlers array
handleShopifyRoutes({ ..., handlers: [cartHandlers, localizationHandlers] });

// Optional: returning buyers landing on "/" are redirected to their saved locale
const localeRedirect = await getLocaleRedirect(request, { config: LOCALIZATION_CONFIG, i18n, sessionManager });
if (localeRedirect) return localeRedirect;

A plain HTML form posts to /localization; the server validates the selection against live Markets data, updates the cart buyer identity, saves the choice to the session, and 303-redirects back to the same page under the new locale's path prefix.

What this changes

  • core/localization module with:
    • matchLocaleFromRequest — pure, synchronous URL-prefix matching (/{language}-{country}). Rendering is a function of the URL only, so pages stay CDN-cacheable. All primitives share one required LocalizationConfig ({defaultLocale, supportedLocales}), so matching, the endpoints, and selector filtering can never disagree. supportedLocales is an explicit list (strict allowlist) or "all", a deliberate opt-in that validates prefixes against generated Shopify ISO code sets so markets added in admin route without a deploy. Permissive matching is never reachable by omitting config (review feedback). matchLocalePathname covers callers holding a path rather than a Request.
    • getLocalizedPath — re-homes a path from one locale prefix to another, preserving search and hash.
    • getLocaleRedirect — opt-in session persistence. Redirects unprefixed page navigations to the buyer's saved locale; prefixed URLs always win, form posts / fetch() calls are never redirected, and stale session locales the config no longer serves are ignored.
    • createLocalizationServerHandlers — GET returns a locale-anonymous, publicly cacheable payload of available countries (intersected with supportedLocales, with a drift warning when Markets config grows beyond it). POST validates the submission, syncs cart buyer identity, writes the session, and 303-redirects. Cart and session failures are soft: the redirect always proceeds.
    • localizationQueries / makeLocalizationQueries (fragment overrides), queryLocalization, getSupportedCountries, and exported constants for the endpoint path and form field names.
  • Generated runtime ISO code sets (iso-codes.ts): the intersection of the Storefront and Customer Account API enums, produced by a new codegen step and guarded by a drift test, so enum-typed input can be validated at runtime boundaries.
  • Client fix: explicitly passed $country/$language variables now win over auto-injection. Previously they were silently overwritten by the request context i18n.
  • examples/hydrogen integration: the hand-rolled lib/i18n.ts is replaced with package utilities, the handlers and redirect helper are wired into server.ts, and a progressively enhanced country/language selector renders in the header.

Developer impact

New public exports on @shopify/hydrogen (all additive): matchLocaleFromRequest, matchLocalePathname, getLocalizedPath, getLocaleRedirect, createLocalizationServerHandlers, queryLocalization, fetchLocalization, getSupportedCountries, localizationQueries, makeLocalizationQueries, plus localization constants and types.

One behavior change in the storefront client: user-supplied $country/$language variables are no longer clobbered by i18n auto-injection. Passing them was a silent no-op before, so existing apps should be unaffected.

A minor changeset still needs to be added before this leaves draft (new additive exports, one client behavior fix).

UX impact

examples/hydrogen gets a country/language selector in the header, left of the sign-in link, rendered as a single bordered control:

Country and language selector in the header
  • Works fully with JavaScript disabled (visible submit buttons); when hydrated, selecting an option submits immediately and the buttons hide.
  • Country and language are separate forms, so switching country never submits a language the new country doesn't offer — the server resolves the best language instead.
  • Language options render in their own language (endonymName) with a lang attribute per option.
  • Buyers land back on the page they were on (redirectTo preserves path and query), and returning buyers hitting an unprefixed URL are redirected to their saved locale.

Out of scope

Deferred to stacked PRs, per the implementation plan:

  • Form register API (attribute objects for building custom selector markup)
  • Client store with debounced selection, lazy loading, and country filtering, plus React/Vue bindings
  • templates/react-router integration, examples/core reference partial, and the country-selector agent skill

Risk

  • The client variable-injection change alters public behavior. It only affects callers who were already passing $country/$language explicitly, which previously did nothing.
  • Navigation detection for getLocaleRedirect trusts Sec-Fetch-Mode: navigate when present (page scripts cannot spoof it) and falls back to GET/HEAD + Accept: text/html, because fetch-based proxy hops rewrite Sec-Fetch-Mode to cors (verified against mini-oxygen's dev proxy). Requests matching neither signal are never redirected.
  • The GET endpoint caches for up to an hour by default, so merchants changing Markets config may see stale selector data until the cache expires. Configurable via cacheControl.
  • The generated ISO sets version with the package: a country code Shopify adds later resolves to the default locale until apps upgrade. Failure mode is graceful.

How to Test

  1. Run pnpm install and pnpm --filter @shopify/hydrogen build from the repo root.
  2. Run pnpm dev in examples/hydrogen and open the printed local URL.
  3. In the header selector, switch the country to Canada. Confirm you land on the same page under /en-ca and prices show CAD.
  4. Switch the language to Français. Confirm the URL changes to /fr-ca and the selector shows both selects.
  5. Disable JavaScript in DevTools and repeat a switch. Confirm the submit buttons appear and the flow still works end to end.
  6. With JavaScript back on, navigate to the bare / in the address bar. Confirm a redirect back to /fr-ca (session persistence).
  7. Visit /en-ca directly while the session says fr-CA. Confirm it renders English Canada with no redirect (the URL wins).
  8. Add a product to the cart, switch country, and open the cart. Confirm cart pricing follows the new country.
  9. Open /localization directly. Confirm a JSON payload of available countries limited to the configured locales, with public cache headers, and no active-locale field.

Resolves a request's locale from its URL path prefix (PRD FR-1). Pure and
synchronous: the URL is the only input, so the same URL always resolves to
the same locale — rendering stays CDN-cacheable and deterministic.

Strict mode compares the first path segment against each supported
locale's canonical prefix (dictionary lookup, no shape parsing), which
makes underscore language codes (PT_BR -> /pt-br-br) unambiguous. The
default locale is served only unprefixed; its own prefix resolves like an
unknown one so every page has one canonical URL.

Core stays framework-agnostic: callers normalize framework URL shapes
(e.g. React Router single-fetch .data suffixes) before matching, per the
integration recipes in the PRD skill section.

Round-trip property covered: match(getLocalizedPath(x)) === x for all
supported locales. Permissive mode (no supportedLocales) lands next.
…code sets

Makes supportedLocales optional on matchLocaleFromRequest (PRD FR-1).
When omitted, prefixes are validated against runtime mirrors of the
ShopifyCountryCode/ShopifyLanguageCode types — the intersection of the
Storefront and Customer Account API enums — so any real ISO pair matches
with zero per-store configuration, while nonsense prefixes (/zz-zz is
real, /fr-qq is not) resolve to the default locale.

The code sets are generated by scripts/generate-iso-codes.ts (wired into
postcodegen) and guarded by a drift test that recomputes the intersection
from the introspection schemas, so the artifact can never silently
diverge from the types. Segment parsing splits on the last hyphen, which
maps hyphenated URL forms back to underscore language codes
(pt-br-br -> PT_BR + BR) without regex ambiguity.
Opt-in cross-visit locale persistence (PRD FR-1). Redirects unprefixed
requests to the buyer's saved locale with a 302; prefixed URLs always win
and are never redirected, so shared links stay deterministic and redirect
loops are unrepresentable. The session changes which URL a buyer lands
on, never what a URL renders — rendering stays a pure function of the URL.

Redirect responses are marked private, no-store so shared caches never
serve one buyer's locale to another. Session values are untrusted:
malformed data degrades to no-redirect with a warn log, and a throwing
session read degrades with an error log, per the error-reporting policy.
The resolveLocaleUrl option is the escape hatch for subdomain/domain-per-
market URL schemes.
Adds the SFAPI localization data layer (PRD FR-2): a default query
covering the current country/language, active market handle, and
availableCountries with per-country currency and languages, plus
makeLocalizationQueries({fragments}) following the predictive-search
fragment-override pattern (contract-checked LocalizationCountryFragment/
LocalizationLanguageFragment with minimal defaults).

queryLocalization returns the data; fetchLocalization also exposes
response headers so the upcoming GET handler can forward cache headers.
$country/$language are auto-injected from the request context i18n by
the storefront client. The new files join the existing oxlint
consistent-type-assertions opt-out group, matching the generic-boundary
pattern used by predictive-search and cart.
buildVariables unconditionally overwrote user-supplied country/language
variables with the request context i18n, so passing them explicitly was
a silent no-op. Injection is now a default rather than an override:
variables the caller sets win, and only missing ones are filled in.

Explicit-over-implicit matches the package API principles, and the
localization GET endpoint needs it to forward ?country/?language query
params to @incontext for translated country names.
Adds createLocalizationServerHandlers() with both endpoints (PRD FR-3),
registered through the existing handleShopifyRoutes handlers array.

GET /localization returns a locale-anonymous payload (availableCountries
+ market, never the active locale) so the response is identical for every
buyer at a given URL and public caching cannot leak locale across buyers.
Default policy is max-age=3600 with stale-while-revalidate, configurable
via cacheControl. Optional country/language query params are validated
against the generated ISO sets and forwarded to @incontext, keeping the
cache key honest (the full URL). With supportedLocales configured, the
payload is the pair-wise intersection of live Markets data with the list,
and a drift warning logs when the merchant enables markets the config
does not cover.

POST /localization validates submissions (form shape, ISO codes, live
Markets data, supportedLocales pairs), syncs the cart buyer identity
country when a cart cookie exists, writes the selection to the session,
commits it, and 303-redirects to the localized equivalent of redirectTo
(same-origin only; cross-origin falls back to the root). Cart sync and
session persistence are soft failures: the redirect always proceeds and
errors are logged per the error-reporting policy. Because the endpoint is
unprefixed, its request context i18n is the app's default locale, so no
separate defaultLocale option exists to misconfigure. When language is
omitted, the buyer's current language (recovered from redirectTo's
prefix) is kept if the target country offers it.

queryLocalization gains explicit country/language overrides, and
matchLocalePathname is extracted for handler reuse. server-handlers.ts
joins the oxlint type-assertion opt-out group like its predictive-search
counterpart.
…rect

getLocaleRedirect redirected any unprefixed request, including POSTs —
and a 302 after POST is followed with GET, silently dropping the form
body. A returning international buyer submitting any unprefixed form
(including the localization form itself) would lose the submission.
Client fetch() calls (JSON endpoints, framework data requests) were
equally at risk of being relocated away from the URL they asked for.

Guard on navigations only: GET/HEAD methods that accept text/html.
Sec-Fetch-Mode was deliberately rejected as the signal: it is a
forbidden header that fetch-based proxy hops rewrite to 'cors'
(verified against mini-oxygen's dev proxy), while Accept survives
proxies and cleanly separates document navigations from data requests.
This also makes the helper order-independent relative to
handleShopifyRoutes instead of requiring a 'call after route handling'
footnote every integration could get wrong.
Integrating the selector into examples/hydrogen surfaced a divergence
bug: an SSR loader querying localization data directly bypasses the GET
handler's supportedLocales intersection, so the rendered selector could
offer countries the router and POST validation reject.

Extract the pair-wise intersection into a public, pure helper used by
both the server handlers and SSR consumers — one definition of 'which
locales may be offered', applied at every surface that feeds a selector.
Drift warning stays in the handlers, so per-page SSR filtering does not
spam logs.
Lightweight end-to-end demonstration of the localization module — every
piece works with JavaScript disabled:

- app/lib/i18n.ts swaps the hand-rolled locale map for
  matchLocaleFromRequest with a DEFAULT_LOCALE + SUPPORTED_LOCALES config
  defined once and shared with the handlers. React Router single-fetch
  .data URLs are normalized here, at the integration boundary, keeping
  the package matcher framework-agnostic.
- server.ts registers createLocalizationServerHandlers in the existing
  handlers array and adds getLocaleRedirect so returning buyers landing
  on unprefixed URLs are taken back to their chosen locale.
- The root loader fetches the country list (Cache.long) and applies the
  same getSupportedCountries intersection as the endpoints, so the
  selector can never offer a locale the router won't serve.
- CountrySelector renders country and language as separate plain HTML
  forms (the Liquid theme pattern): a country switch never submits a
  language the new country might not offer — the server resolves the
  best language instead. Language options render their endonym with a
  lang attribute per the accessibility contract.

Verified against a dev store: POST 303s to the localized redirectTo,
cart buyer identity syncs, the session round-trips, returning
navigations 302 to the saved locale, fetch/data requests are never
redirected, and prefixed URLs always win over the session.
…ontrol

Selecting a country or language now submits immediately (full document
navigation — every rendered price changes with the locale), with the
submit buttons hidden after hydration. The buttons stay in server markup
so the zero-JS baseline keeps working unchanged; the upcoming client
store will add debounced selection on top of the same markup.

The two forms render as a single bordered control with an inner divider
and sit in the header CTAs, left of the sign-in link.
@andguy95 andguy95 added gsd:50917 New Hydrogen preview labels Aug 18, 2026

@frandiox frandiox left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looking at what this PR does, I think it makes sense to have a primitive / server handler for this, especially considering it also handles cart buyer identity automatically and is optional and tree-shakeable.

Some random thoughts:

  • Changing locale should update window.Shopify.routes.root and window.Shopify.locale. Perhaps a hard navigation is the best for changing locales since it's not a common thing?
  • I think handling redirects properly is challenging. There might be localized pathnames, and even localized resource handles. We could do the simple redirect and let the user handle it with a second redirect in-app (I think that's what the example does right now). Other alternatives are:
    • Allow options in the localization server handler to resolve the redirect target (like a function hook they can run at that point and return the target).
    • Do it ourselves: accept a boolean option to enable this behavior, do an SFAPI request to get the localized handle, and accept a map of routeTemplates (indexed by locale) so that we can match in one language and create the target for another one.
  • Localization could also happen in subdomains or tlds like ca.example.com or example.ca. User session cookies might not transfer in those. I think the utilities proposed here only for for localization in pathnames but perhaps we should also keep these in mind?
    • Maybe we could have separate utilities like createPathLocaleStrategy(...), createOriginLocaleStrategy({ origins: {'es-ES': 'ca.example.com' | 'example.ca'}), and those would help us do strategy.matchRequest(request) or similar?
    • Or maybe we only handle the pathname ones but still allow overwriting the matching (I think this might be already the case in this PR?).

I don't have full opinions on the above, just wanted to point them out early.

Comment on lines +78 to +85
* and `fetch()` callers do not. `Sec-Fetch-Mode` is deliberately not used: it is a forbidden
* header that fetch-based proxy hops (dev servers, edge runtimes) silently rewrite to `cors`.
*/
function isNavigationRequest(request: Request): boolean {
if (!NAVIGATION_METHODS.has(request.method)) return false;

return request.headers.get(ACCEPT_HEADER)?.includes(NAVIGATION_ACCEPT_VALUE) ?? false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not sure why is sec-fetch-mode "forbidden"? It's sent by modern browsers and we are able to read it. I think if it's available then it's a stronger signal than accept header?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This was an agent implementation but it recommended it for this reason:

Browsers do send it, the problem is what happens between the browser and the worker. Forbidden header means the fetch implementation owns it, so any fetch-based proxy hop rewrites it to the mode of its own fetch call. I verified this against mini-oxygen's dev proxy: a real browser navigation arrives at the worker with sec-fetch-mode: cors, so the redirect never fires in dev while working in prod. Accept survives proxies.

That said we could OR the signals: sec-fetch-mode: navigate is a definite yes (page JS can't spoof it), and fall back to GET/HEAD + Accept: text/html otherwise. Happy to do that if you prefer the stronger positive signal.

@fredericoo fredericoo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

one more pass and this would be great! left some requests

path?: string;
/** Cache-Control for the GET endpoint. Defaults to `DEFAULT_LOCALIZATION_CACHE_CONTROL`. */
cacheControl?: string;
/** Same list passed to `matchLocaleFromRequest` (strict mode); omit for permissive mode. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we share one immutable locale/routing config across these primitives while keeping preference persistence on the existing sessionManager?

const i18n = {
  defaultLocale,
  supportedLocales,
  routing: {
    match(url) {},
    resolveUrl({request, redirectTo, locale}) {},
  },
} satisfies LocalizationConfig;

match needs to accept an arbitrary URL/path rather than only the endpoint Request, because the POST handler resolves the source locale from redirectTo, not /localization. The existing functions can stay composable and accept this config, while persistence-aware functions continue accepting sessionManager directly. A pathname helper can provide the defaults; an origin strategy can return absolute URLs; and resolveUrl may need to be async when localised handles require an SFAPI lookup.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is a good call! Will see what can be done here.

* Locales the app serves under `/{language}-{country}` prefixes (strict mode). When omitted,
* any prefix built from valid Shopify country/language codes matches (permissive mode).
*/
supportedLocales?: readonly SupportedLocale[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think permissive mode is safe enough to be the no-config fallback, maybe it shouldn't even exist.

going to /fr-de/products resolves as French/Germany just because FR and DE each exist in their respective enums. That creates cacheable, indexable URL variants which are not backed by the store's Markets configuration.

requiring the configured locale list, or delegating matching to the configured strategy, would also remove the generated ISO arrays, codegen script, drift test, and runtime sets. That's a large amount of machinery for accepting combinations we cannot prove the storefront supports.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I decided to include permissive mode as the default so merchants' market changes reflect immediately instead of being blocked on a developer update (closer to Liquid behavior). Invalid combos in a selector itself isn't really a risk here since in permissive mode it should ideally be powered by the live Markets API, so it only ever offers real combos.

Without permissive mode, the routing config and Markets have to be kept in sync manually. If a team wants that control, that's exactly what strict mode is for.

I think permissive is still a good option to have. Maybe something like an explicit supportedLocales: "all" opt-in, so it's a deliberate choice?

@andguy95

Copy link
Copy Markdown
Collaborator Author

@frandiox

Changing locale should update window.Shopify.routes.root and window.Shopify.locale. Perhaps a hard navigation is the best for changing locales since it's not a common thing?

Agreed, hard navigation seems like the right behavior. This is how it is implemented today. POST to /localization → if valid locale we 303 a full document navigation

There might be localized pathnames
This is true! I like the option of a resolver hook on the handler, and it lines up with Freddie's suggestion of a shared routing config with a resolveUrl that can be async for the SFAPI handle lookup.

Localization could also happen in subdomains
Also true, however, I left it out of this PR here because I wanted to handle the typical path. I do like the strategy approach with choosing which to use.

…zationConfig

Review feedback on the PR surfaced two config problems:

Permissive matching as the no-config default meant any valid ISO pair
(/fr-de/...) minted a routable, cacheable, indexable URL variant the
store's Markets config never backed — an SEO and cache-key hazard that
could be reached by simply omitting an option. Permissive mode survives
because it is what lets merchants launch markets from admin without a
deploy (Liquid parity), but it is now an explicit supportedLocales:
"all" opt-in and can never happen by accident.

The same locale facts were also threaded into each primitive through
separate option fields, leaving room for matching, the endpoints, and
selector filtering to disagree. All primitives now accept one immutable
LocalizationConfig ({defaultLocale, supportedLocales}); the POST handler
takes defaultLocale from it instead of requiring requestContext.i18n,
and getLocaleRedirect uses it to ignore stale session locales pointing
at removed markets rather than redirecting onto a prefix the router
resolves as the default.

matchLocalePathname is now public: the POST handler resolves the source
locale from a redirect target path, not a Request, and integrations hold
bare paths in the same situations.

The example threads a single LOCALIZATION_CONFIG from app/lib/i18n.ts
through matching, the server handlers, and selector data filtering.
…llback

Review feedback correctly noted Sec-Fetch-Mode is a stronger navigation
signal than Accept: it is a forbidden header page scripts cannot spoof.
It cannot be the only signal, though — forbidden also means fetch-based
proxy hops own it, and mini-oxygen's dev proxy rewrites a browser's
'navigate' to 'cors' before the worker sees it (verified empirically;
relying on it alone made session redirects work in prod but never in
dev).

getLocaleRedirect now takes the best of both: a present 'navigate' is
trusted immediately, anything else falls back to GET/HEAD with an HTML
Accept header, which survives proxies. The method guard stays in front
of both signals — a form POST is a navigation too, but redirecting it
would drop the body.
* makes every code pair a routable URL variant, so canonical/hreflang tags become the
* app's responsibility.
*/
supportedLocales: readonly SupportedLocale[] | "all";

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@fredericoo reverse it where strict mode is first, and the SupportedLocale prevents and non-valid ISO codes.

*/
export function matchLocaleFromRequest(
request: Request,
config: LocalizationConfig,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moved to one localization config to pass. Can utilize this as a way to eventually support redirect callbacks etc.

import { routeTemplates } from "~/lib/route-templates";

const predictiveSearchHandlers = createPredictiveSearchServerHandlers();
const localizationHandlers = createLocalizationServerHandlers(LOCALIZATION_CONFIG);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

THe localizationHandler now takes in one config, and this will contain defaultLocale, meaning our localization POST handler will not have to pass in the request context

if (!sessionLocale || isSameLocale(sessionLocale, options.i18n)) return null;
// A session locale the config no longer serves (e.g. a removed market) is simply ignored;
// redirecting to its prefix would land on a URL the router resolves as the default locale.
if (!isSupportedLocale(sessionLocale, options.config.supportedLocales)) return null;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@fredericoo to your point about invalid locales, this will now check the strict config and redirect back to a valid locale, rather then just an "incorrect" locale looking URL

* runtimes) silently rewrite it to `cors`, so its absence falls back to `Accept: text/html`,
* which browsers send for document requests, survives proxies, and `fetch()` callers do not.
*/
function isNavigationRequest(request: Request): boolean {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@frandiox to your comment earlier. I now trust sec-fetch-mode: navigate first when it's present.

There was a called out catch which any fetch-based proxy hop could rewrites it (mini-oxygen's dev proxy turns a browser navigation into cors), so when it's missing or mangled we fall back to GET/HEAD + Accept: text/html, which survives proxies. Best of both for now.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants