diff --git a/patches/expo@57.0.8.patch b/patches/expo@57.0.8.patch index 1fe8af5078..0a6a4bf13d 100644 --- a/patches/expo@57.0.8.patch +++ b/patches/expo@57.0.8.patch @@ -1,8 +1,97 @@ diff --git a/build/winter/runtime.native.d.ts b/build/winter/runtime.native.d.ts -index 85c4227e4e37003dd3769a590e73a69874de5fac..27bd245675d09b906c2474d4da9efd073dd4f72c 100644 +index 85c4227e4e37003dd3769a590e73a69874de5fac..e389ca8edd2310df9ce29684991465c275e804c5 100644 --- a/build/winter/runtime.native.d.ts +++ b/build/winter/runtime.native.d.ts @@ -1,3 +1,2 @@ import 'react-native/Libraries/Core/InitializeCore'; -import '../../types'; //# sourceMappingURL=runtime.native.d.ts.map +\ No newline at end of file +diff --git a/src/winter/fetch/RequestUtils.ts b/src/winter/fetch/RequestUtils.ts +index a93473fd2beaf2ab0f3880951d292e58e7288d10..c27911bd5cb56ba6051fb8ebf4194cced43bc0fd 100644 +--- a/src/winter/fetch/RequestUtils.ts ++++ b/src/winter/fetch/RequestUtils.ts +@@ -50,7 +50,11 @@ function isBlob(obj: any): obj is Blob { + */ + export async function normalizeBodyInitAsync( + body: BodyInit | null | undefined +-): Promise<{ body: Uint8Array | null; overriddenHeaders?: NativeHeadersType }> { ++): Promise<{ ++ body: Uint8Array | null; ++ overriddenHeaders?: NativeHeadersType; ++ fallbackHeaders?: NativeHeadersType; ++}> { + if (body == null) { + return { body: null }; + } +@@ -71,7 +75,12 @@ export async function normalizeBodyInitAsync( + if (body instanceof Blob || isBlob(body)) { + return { + body: new Uint8Array(await blobToArrayBufferAsync(body)), +- overriddenHeaders: [['Content-Type', body.type]], ++ /* ++ * Per the fetch spec, a blob's type is only a default for Content-Type: ++ * it must not replace a header the caller set explicitly, and an empty ++ * type contributes no header at all. ++ */ ++ fallbackHeaders: body.type ? [['Content-Type', body.type]] : undefined, + }; + } + +@@ -137,6 +146,25 @@ export function overrideHeaders( + return result; + } + ++/** ++ * Create a new header array by adding new headers only for keys not already ++ * present (by case-insensitive header key). Used for body-derived defaults ++ * that must not replace caller-provided headers. ++ */ ++export function fillMissingHeaders( ++ headers: NativeHeadersType, ++ newHeaders: NativeHeadersType ++): NativeHeadersType { ++ const existingKeySet = new Set(headers.map(([key]) => key.toLocaleLowerCase())); ++ const result: NativeHeadersType = [...headers]; ++ for (const [key, value] of newHeaders) { ++ if (!existingKeySet.has(key.toLocaleLowerCase())) { ++ result.push([key, value]); ++ } ++ } ++ return result; ++} ++ + /** Normalizes known HTTP methods to uppercase */ + export function normalizeMethod(method: string): string { + const normalized = method.toUpperCase(); +diff --git a/src/winter/fetch/fetch.ts b/src/winter/fetch/fetch.ts +index ac789c024c96379e239bf44903c47e6a8e540d63..4f953b845c145de02976e8162ba2f63f8bd4dfd5 100644 +--- a/src/winter/fetch/fetch.ts ++++ b/src/winter/fetch/fetch.ts +@@ -3,6 +3,7 @@ import { FetchError } from './FetchErrors'; + import { FetchResponse, type AbortSubscriptionCleanupFunction } from './FetchResponse'; + import type { NativeRequest, NativeRequestInit } from './NativeRequest'; + import { ++ fillMissingHeaders, + normalizeBodyInitAsync, + normalizeHeadersInit, + overrideHeaders, +@@ -62,10 +63,17 @@ export async function fetch( + + const request = new ExpoFetchModule.NativeRequest(response) as NativeRequest; + +- const { body: requestBody, overriddenHeaders } = await normalizeBodyInitAsync(body); ++ const { ++ body: requestBody, ++ overriddenHeaders, ++ fallbackHeaders, ++ } = await normalizeBodyInitAsync(body); + if (overriddenHeaders) { + headers = overrideHeaders(headers, overriddenHeaders); + } ++ if (fallbackHeaders) { ++ headers = fillMissingHeaders(headers, fallbackHeaders); ++ } + + const nativeRequestInit: NativeRequestInit = { + credentials: credentials ?? 'include', diff --git a/patches/expo@57.0.8.patch.md b/patches/expo@57.0.8.patch.md index 239fd3e958..4b2f98ea29 100644 --- a/patches/expo@57.0.8.patch.md +++ b/patches/expo@57.0.8.patch.md @@ -24,3 +24,33 @@ import. Can be removed if Expo stops referencing `./react-native-web` from the types loaded by the native winter runtime, or guards the augmentation to web. + +## src/winter/fetch/RequestUtils.ts + fetch.ts - Blob body must not clobber an explicit Content-Type + +Expo 57 installs `expo/fetch` as the global `fetch` on native +(`src/winter/runtime.native.ts`), replacing React Native's whatwg-fetch. When +the request body is a Blob, `normalizeBodyInitAsync` returned +`overriddenHeaders: [['Content-Type', blob.type]]`, which `fetch.ts` applied +*over* the caller's headers (introduced in expo/expo#33405). This is backwards +per the fetch spec: a blob's type is only a default, used when no Content-Type +was provided, and an empty type must contribute no header at all. + +In this app it broke publishing posts with any image blob on Android. The +composer uploads via `agent.uploadBlob(blob, {encoding})`; `@atproto/xrpc` +sets `content-type: ` explicitly, but the blob comes from an XHR +`file://` read of a `.bin`-renamed jpeg (the RN#27099 workaround in +`src/lib/api/upload-blob.ts`), for which Android's BlobModule returns an empty +mime. expo/fetch replaced the good header with the empty blob type and the PDS +rejected the upload with "Request encoding (Content-Type) required but not +provided". Also silently rewrote the intended mime on every other blob upload +(avatars, banners, caption files) even when the blob type was non-empty. + +The patch splits body-derived headers into two channels: FormData keeps +`overriddenHeaders` (its boundary header must win), while the Blob branch +returns new `fallbackHeaders` (skipped entirely when `blob.type` is empty) +that `fetch.ts` applies via `fillMissingHeaders` only for header keys the +caller did not set. This matches browser behavior. + +Upstream: expo/expo#33405 introduced the override; the SDK 58 Request rewrite +(expo/expo#46630) is expected to make this spec-compliant, so re-evaluate on +the next SDK bump. Worth filing an issue against expo/expo referencing this.