expo 56.0.6 → 56.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@ buildscript {
10
10
  }
11
11
 
12
12
  group = 'host.exp.exponent'
13
- version = '56.0.6'
13
+ version = '56.0.8'
14
14
 
15
15
  expoModule {
16
16
  // We can't prebuild the module because it depends on the generated files.
@@ -21,7 +21,7 @@ android {
21
21
  namespace "expo.core"
22
22
  defaultConfig {
23
23
  versionCode 1
24
- versionName "56.0.6"
24
+ versionName "56.0.8"
25
25
  consumerProguardFiles("proguard-rules.pro")
26
26
  }
27
27
  testOptions {
@@ -26,7 +26,7 @@ class ExpoFetchModule : Module() {
26
26
  OkHttpClientProvider.createClient(reactContext)
27
27
  .newBuilder()
28
28
  .addInterceptor(OkHttpFileUrlInterceptor(reactContext))
29
- .addInterceptor(CompressionInterceptor)
29
+ .addInterceptor(TransparentCompressionInterceptor)
30
30
  .build()
31
31
  }
32
32
  private val cookieHandler by lazy { ForwardingCookieHandler(reactContext) }
@@ -34,23 +34,26 @@ import org.brotli.dec.BrotliInputStream
34
34
  * header, and decompresses (and strips `Content-Encoding`/`Content-Length` from) responses
35
35
  * encoded with any of the three.
36
36
  *
37
- * Modeled after `okhttp3.brotli.BrotliInterceptor`; this replaces the transparent gzip
38
- * compression in okhttp's `BridgeInterceptor`. Callers who set their own `Accept-Encoding`
39
- * opt out of automatic decompression.
37
+ * Unlike OkHttp, which disables transparent decompression when `Accept-Encoding` is explicitly set by the caller,
38
+ * this interceptor continues to decompress responses to match the behavior of the Fetch API, iOS `URLSession`,
39
+ * and [React Native fetch](https://github.com/facebook/react-native/blob/622941d9dca684ecfc8f5086eb42c8178c3062d1/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.kt#L656-L691).
40
+ *
41
+ * The `Content-Encoding` and `Content-Length` headers are removed after
42
+ * decompression.
40
43
  */
41
- object CompressionInterceptor : Interceptor {
42
- override fun intercept(chain: Interceptor.Chain): Response =
43
- if (chain.request().header("Accept-Encoding") == null) {
44
- val request = chain.request().newBuilder()
45
- .header("Accept-Encoding", "zstd, br, gzip")
46
- .build()
47
-
48
- val response = chain.proceed(request)
44
+ object TransparentCompressionInterceptor : Interceptor {
45
+ override fun intercept(chain: Interceptor.Chain): Response {
46
+ val request = chain.request()
49
47
 
50
- uncompress(response)
51
- } else {
52
- chain.proceed(chain.request())
53
- }
48
+ return uncompress(
49
+ chain.proceed(
50
+ when (request.header("Accept-Encoding")) {
51
+ null -> request.newBuilder().header("Accept-Encoding", "zstd, br, gzip").build()
52
+ else -> request
53
+ }
54
+ )
55
+ )
56
+ }
54
57
 
55
58
  internal fun uncompress(response: Response): Response {
56
59
  if (!response.promisesBody()) {
@@ -17,7 +17,7 @@ import okio.GzipSink
17
17
  import okio.buffer
18
18
  import org.junit.Test
19
19
 
20
- class CompressionInterceptorTest {
20
+ class TransparentCompressionInterceptorTest {
21
21
  // brotli of "hello world" produced offline with `printf 'hello world' | brotli`.
22
22
  // Avoids pulling in a brotli encoder just for this test; the decoder side is what we exercise.
23
23
  private val brotliHelloWorld = hexToBytes("0f058068656c6c6f20776f726c6403")
@@ -32,12 +32,41 @@ class CompressionInterceptorTest {
32
32
  }
33
33
 
34
34
  @Test
35
- fun `should pass through unchanged when caller sets Accept-Encoding`() {
35
+ fun `should leave caller-set Accept-Encoding header untouched`() {
36
36
  val capturedRequest = interceptAndCapture(initialAcceptEncoding = "gzip")
37
37
 
38
38
  assertThat(capturedRequest.header("Accept-Encoding")).isEqualTo("gzip")
39
39
  }
40
40
 
41
+ @Test
42
+ fun `should still decompress response when caller set Accept-Encoding`() {
43
+ val payload = "hello world"
44
+
45
+ val request = Request.Builder()
46
+ .url("https://example.test/")
47
+ .header("Accept-Encoding", "gzip")
48
+ .build()
49
+
50
+ val chain = mockk<Interceptor.Chain>()
51
+
52
+ every { chain.request() } returns request
53
+ every { chain.proceed(any()) } answers {
54
+ Response.Builder()
55
+ .request(request)
56
+ .protocol(Protocol.HTTP_1_1)
57
+ .code(200)
58
+ .message("OK")
59
+ .header("Content-Encoding", "gzip")
60
+ .body(payload.toByteArray().gzipCompressed().toResponseBody("application/octet-stream".toMediaType()))
61
+ .build()
62
+ }
63
+
64
+ val response = TransparentCompressionInterceptor.intercept(chain)
65
+
66
+ assertThat(response.body!!.string()).isEqualTo(payload)
67
+ assertThat(response.header("Content-Encoding")).isNull()
68
+ }
69
+
41
70
  // endregion
42
71
 
43
72
  // region uncompress() — decompression
@@ -47,7 +76,7 @@ class CompressionInterceptorTest {
47
76
  val payload = "hello world"
48
77
  val response = encodedResponse(payload.toByteArray().gzipCompressed(), "gzip")
49
78
 
50
- val uncompressed = CompressionInterceptor.uncompress(response)
79
+ val uncompressed = TransparentCompressionInterceptor.uncompress(response)
51
80
 
52
81
  assertThat(uncompressed.body!!.string()).isEqualTo(payload)
53
82
  assertThat(uncompressed.header("Content-Encoding")).isNull()
@@ -58,7 +87,7 @@ class CompressionInterceptorTest {
58
87
  fun `should decompress br response and strip Content-Encoding and Content-Length headers`() {
59
88
  val response = encodedResponse(brotliHelloWorld, "br")
60
89
 
61
- val uncompressed = CompressionInterceptor.uncompress(response)
90
+ val uncompressed = TransparentCompressionInterceptor.uncompress(response)
62
91
 
63
92
  assertThat(uncompressed.body!!.string()).isEqualTo("hello world")
64
93
  assertThat(uncompressed.header("Content-Encoding")).isNull()
@@ -70,7 +99,7 @@ class CompressionInterceptorTest {
70
99
  val payload = "hello world"
71
100
  val response = encodedResponse(payload.toByteArray().gzipCompressed(), "GZIP")
72
101
 
73
- val uncompressed = CompressionInterceptor.uncompress(response)
102
+ val uncompressed = TransparentCompressionInterceptor.uncompress(response)
74
103
 
75
104
  assertThat(uncompressed.body!!.string()).isEqualTo(payload)
76
105
  }
@@ -85,7 +114,7 @@ class CompressionInterceptorTest {
85
114
  .body("plain".toResponseBody("text/plain".toMediaType()))
86
115
  .build()
87
116
 
88
- val result = CompressionInterceptor.uncompress(response)
117
+ val result = TransparentCompressionInterceptor.uncompress(response)
89
118
 
90
119
  assertThat(result).isSameInstanceAs(response)
91
120
  }
@@ -94,7 +123,7 @@ class CompressionInterceptorTest {
94
123
  fun `should leave response unchanged when Content-Encoding is unknown`() {
95
124
  val response = encodedResponse("noop".toByteArray(), "lz4")
96
125
 
97
- val result = CompressionInterceptor.uncompress(response)
126
+ val result = TransparentCompressionInterceptor.uncompress(response)
98
127
 
99
128
  assertThat(result).isSameInstanceAs(response)
100
129
  }
@@ -108,7 +137,7 @@ class CompressionInterceptorTest {
108
137
  .header("Content-Encoding", "gzip")
109
138
  .build()
110
139
 
111
- val result = CompressionInterceptor.uncompress(response)
140
+ val result = TransparentCompressionInterceptor.uncompress(response)
112
141
 
113
142
  assertThat(result).isSameInstanceAs(response)
114
143
  }
@@ -138,7 +167,7 @@ class CompressionInterceptorTest {
138
167
  .build()
139
168
  }
140
169
 
141
- CompressionInterceptor.intercept(chain)
170
+ TransparentCompressionInterceptor.intercept(chain)
142
171
  return captured.captured
143
172
  }
144
173
 
@@ -1 +1 @@
1
- {"version":3,"file":"FetchResponse.d.ts","sourceRoot":"","sources":["../../../src/winter/fetch/FetchResponse.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEzE,QAAA,MAAM,sBAAsB,EAAqC,OAAO,cAAc,CAAC;AACvF,MAAM,MAAM,gCAAgC,GAAG,MAAM,IAAI,CAAC;AAI1D,KAAK,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,UAAU,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;AACvE,KAAK,iBAAiB,GAAG,UAAU,CAAC,QAAQ,GAAG,UAAU,CAAC;AAW1D,QAAA,MAAM,QAAQ,eAAgC,CAAC;AAkH/C;;GAEG;AACH,qBAAa,aAAc,SAAQ,sBAAuB,YAAW,QAAQ;IAM/D,OAAO,CAAC,QAAQ,CAAC,oBAAoB;IALjD,OAAO,CAAC,CAAC,QAAQ,CAAC,CAGhB;gBAE2B,oBAAoB,EAAE,gCAAgC;IAQnF,IAAa,WAAW,IAAI,iBAAiB,CAE5C;IAED,IAAa,MAAM,IAAI,MAAM,CAE5B;IAED,IAAa,UAAU,IAAI,MAAM,CAEhC;IAED,IAAa,GAAG,IAAI,MAAM,CAEzB;IAED,IAAa,UAAU,IAAI,OAAO,CAEjC;IAED,IAAI,IAAI,IAAI,SAAS,CAEpB;IAED,IAAI,IAAI,IAAI,cAAc,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,IAAI,CAoEzD;IAED,IAAa,QAAQ,IAAI,OAAO,CAE/B;IAED,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,IAAI,EAAE,IAAI,OAAO,CAEhB;IAED;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAMrB,QAAQ,IAAI,OAAO,CAAC,iBAAiB,CAAC;IAgBtC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC;IAMpB,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;IAKhC,WAAW,IAAI,OAAO,CAAC,WAAW,CAAC;IAanC,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC;IAatC,QAAQ,IAAI,MAAM;IAIlB,MAAM,IAAI,MAAM;IAShB,KAAK,IAAI,aAAa;IAsCtB,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,QAAQ,CAQd;CACH"}
1
+ {"version":3,"file":"FetchResponse.d.ts","sourceRoot":"","sources":["../../../src/winter/fetch/FetchResponse.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEzE,QAAA,MAAM,sBAAsB,EAAqC,OAAO,cAAc,CAAC;AACvF,MAAM,MAAM,gCAAgC,GAAG,MAAM,IAAI,CAAC;AAI1D,KAAK,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,UAAU,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;AACvE,KAAK,iBAAiB,GAAG,UAAU,CAAC,QAAQ,GAAG,UAAU,CAAC;AAW1D,QAAA,MAAM,QAAQ,eAAgC,CAAC;AAqI/C;;GAEG;AACH,qBAAa,aAAc,SAAQ,sBAAuB,YAAW,QAAQ;IAM/D,OAAO,CAAC,QAAQ,CAAC,oBAAoB;IALjD,OAAO,CAAC,CAAC,QAAQ,CAAC,CAGhB;gBAE2B,oBAAoB,EAAE,gCAAgC;IAQnF,IAAa,WAAW,IAAI,iBAAiB,CAE5C;IAED,IAAa,MAAM,IAAI,MAAM,CAE5B;IAED,IAAa,UAAU,IAAI,MAAM,CAEhC;IAED,IAAa,GAAG,IAAI,MAAM,CAEzB;IAED,IAAa,UAAU,IAAI,OAAO,CAEjC;IAED,IAAI,IAAI,IAAI,SAAS,CAEpB;IAED,IAAI,IAAI,IAAI,cAAc,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,IAAI,CA0EzD;IAED,IAAa,QAAQ,IAAI,OAAO,CAE/B;IAED,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,IAAI,EAAE,IAAI,OAAO,CAEhB;IAED;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAMrB,QAAQ,IAAI,OAAO,CAAC,iBAAiB,CAAC;IAgBtC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC;IAMpB,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;IAKhC,WAAW,IAAI,OAAO,CAAC,WAAW,CAAC;IAanC,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC;IAatC,QAAQ,IAAI,MAAM;IAIlB,MAAM,IAAI,MAAM;IAShB,KAAK,IAAI,aAAa;IAgDtB,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,QAAQ,CAQd;CACH"}
@@ -2,7 +2,7 @@
2
2
  "@expo/fingerprint": "~0.19.3",
3
3
  "@expo/metro-runtime": "~56.0.13",
4
4
  "@expo/vector-icons": "^15.0.2",
5
- "@expo/ui": "~56.0.14",
5
+ "@expo/ui": "~56.0.15",
6
6
  "@react-native-async-storage/async-storage": "2.2.0",
7
7
  "@react-native-community/datetimepicker": "9.1.0",
8
8
  "@react-native-masked-view/masked-view": "0.3.2",
@@ -16,19 +16,19 @@
16
16
  "expo-analytics-amplitude": "~11.3.0",
17
17
  "expo-app-auth": "~11.1.0",
18
18
  "expo-app-loader-provider": "~8.0.0",
19
- "expo-app-metrics": "~56.0.14",
19
+ "expo-app-metrics": "~56.0.16",
20
20
  "expo-apple-authentication": "~56.0.4",
21
21
  "expo-application": "~56.0.3",
22
22
  "expo-asset": "~56.0.15",
23
23
  "expo-audio": "~56.0.11",
24
- "expo-auth-session": "~56.0.12",
25
- "expo-background-fetch": "~56.0.15",
26
- "expo-background-task": "~56.0.15",
24
+ "expo-auth-session": "~56.0.13",
25
+ "expo-background-fetch": "~56.0.16",
26
+ "expo-background-task": "~56.0.16",
27
27
  "expo-battery": "~56.0.4",
28
28
  "expo-blur": "~56.0.3",
29
29
  "expo-brightness": "~56.0.5",
30
- "expo-brownfield": "~56.0.15",
31
- "expo-build-properties": "~56.0.15",
30
+ "expo-brownfield": "~56.0.17",
31
+ "expo-build-properties": "~56.0.16",
32
32
  "expo-calendar": "~56.0.8",
33
33
  "expo-camera": "~56.0.7",
34
34
  "expo-cellular": "~56.0.5",
@@ -37,7 +37,7 @@
37
37
  "expo-constants": "~56.0.16",
38
38
  "expo-contacts": "~56.0.7",
39
39
  "expo-crypto": "~56.0.4",
40
- "expo-dev-client": "~56.0.16",
40
+ "expo-dev-client": "~56.0.18",
41
41
  "expo-device": "~56.0.4",
42
42
  "expo-document-picker": "~56.0.4",
43
43
  "expo-file-system": "~56.0.7",
@@ -48,37 +48,37 @@
48
48
  "expo-haptics": "~56.0.3",
49
49
  "expo-image": "~56.0.9",
50
50
  "expo-image-loader": "~56.0.3",
51
- "expo-image-manipulator": "~56.0.15",
52
- "expo-image-picker": "~56.0.14",
51
+ "expo-image-manipulator": "~56.0.16",
52
+ "expo-image-picker": "~56.0.15",
53
53
  "expo-intent-launcher": "~56.0.4",
54
- "expo-insights": "~56.0.14",
54
+ "expo-insights": "~56.0.15",
55
55
  "expo-keep-awake": "~56.0.3",
56
56
  "expo-linear-gradient": "~56.0.4",
57
- "expo-linking": "~56.0.12",
57
+ "expo-linking": "~56.0.13",
58
58
  "expo-local-authentication": "~56.0.4",
59
59
  "expo-localization": "~56.0.6",
60
- "expo-location": "~56.0.14",
60
+ "expo-location": "~56.0.15",
61
61
  "expo-mail-composer": "~56.0.4",
62
62
  "expo-manifests": "~56.0.4",
63
63
  "expo-maps": "~56.0.6",
64
64
  "expo-mcp": "~0.2.1",
65
65
  "expo-media-library": "~56.0.6",
66
66
  "expo-mesh-gradient": "~56.0.3",
67
- "expo-module-template": "~56.0.11",
68
- "expo-modules-core": "~56.0.13",
67
+ "expo-module-template": "~56.0.12",
68
+ "expo-modules-core": "~56.0.14",
69
69
  "expo-navigation-bar": "~56.0.3",
70
70
  "expo-network": "~56.0.4",
71
- "expo-notifications": "~56.0.14",
72
- "expo-observe": "~56.0.16",
71
+ "expo-notifications": "~56.0.15",
72
+ "expo-observe": "~56.0.18",
73
73
  "expo-print": "~56.0.3",
74
74
  "expo-live-photo": "~56.0.3",
75
- "expo-router": "~56.2.7",
75
+ "expo-router": "~56.2.8",
76
76
  "expo-screen-capture": "~56.0.4",
77
77
  "expo-screen-orientation": "~56.0.5",
78
78
  "expo-secure-store": "~56.0.4",
79
79
  "expo-sensors": "~56.0.5",
80
80
  "expo-server": "~56.0.4",
81
- "expo-sharing": "~56.0.14",
81
+ "expo-sharing": "~56.0.15",
82
82
  "expo-sms": "~56.0.3",
83
83
  "expo-speech": "~56.0.3",
84
84
  "expo-splash-screen": "~56.0.10",
@@ -87,13 +87,13 @@
87
87
  "expo-store-review": "~56.0.3",
88
88
  "expo-symbols": "~56.0.5",
89
89
  "expo-system-ui": "~56.0.5",
90
- "expo-task-manager": "~56.0.15",
90
+ "expo-task-manager": "~56.0.16",
91
91
  "expo-tracking-transparency": "~56.0.5",
92
92
  "expo-updates": "~56.0.17",
93
93
  "expo-video-thumbnails": "~56.0.3",
94
94
  "expo-video": "~56.1.2",
95
95
  "expo-web-browser": "~56.0.5",
96
- "expo-widgets": "~56.0.15",
96
+ "expo-widgets": "~56.0.16",
97
97
  "jest-expo": "~56.0.4",
98
98
  "lottie-react-native": "~7.3.4",
99
99
  "react": "19.2.3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo",
3
- "version": "56.0.6",
3
+ "version": "56.0.8",
4
4
  "description": "The Expo SDK",
5
5
  "main": "src/Expo.ts",
6
6
  "module": "src/Expo.ts",
@@ -68,7 +68,7 @@
68
68
  "homepage": "https://github.com/expo/expo/tree/main/packages/expo",
69
69
  "dependencies": {
70
70
  "@babel/runtime": "^7.20.0",
71
- "@expo/cli": "^56.1.12",
71
+ "@expo/cli": "^56.1.13",
72
72
  "@expo/config": "~56.0.9",
73
73
  "@expo/config-plugins": "~56.0.8",
74
74
  "@expo/devtools": "~56.0.2",
@@ -79,14 +79,14 @@
79
79
  "@expo/metro": "~56.0.0",
80
80
  "@expo/metro-config": "~56.0.13",
81
81
  "@ungap/structured-clone": "^1.3.0",
82
- "babel-preset-expo": "~56.0.13",
82
+ "babel-preset-expo": "~56.0.14",
83
83
  "expo-asset": "~56.0.15",
84
84
  "expo-constants": "~56.0.16",
85
85
  "expo-file-system": "~56.0.7",
86
86
  "expo-font": "~56.0.5",
87
87
  "expo-keep-awake": "~56.0.3",
88
88
  "expo-modules-autolinking": "~56.0.14",
89
- "expo-modules-core": "~56.0.13",
89
+ "expo-modules-core": "~56.0.14",
90
90
  "pretty-format": "^29.7.0",
91
91
  "react-refresh": "^0.14.2",
92
92
  "whatwg-url-minimum": "^0.1.2"
@@ -100,9 +100,9 @@
100
100
  "react-native": "0.85.3",
101
101
  "rimraf": "^6.1.2",
102
102
  "web-streams-polyfill": "^3.3.2",
103
- "@expo/metro-runtime": "56.0.13",
103
+ "@expo/dom-webview": "56.0.5",
104
104
  "expo-updates": "56.0.17",
105
- "@expo/dom-webview": "56.0.5"
105
+ "@expo/metro-runtime": "56.0.13"
106
106
  },
107
107
  "peerDependencies": {
108
108
  "@expo/dom-webview": "*",
@@ -139,7 +139,7 @@
139
139
  "scripts/resolveAppEntry.js"
140
140
  ]
141
141
  },
142
- "gitHead": "087b9a6ae9aeaa939544a178ced423a69d7a61a7",
142
+ "gitHead": "7df487c3b35d509e8988c87c0abcf8a7d76aa202",
143
143
  "scripts": {
144
144
  "build": "tsc",
145
145
  "clean": "rimraf build",
@@ -20,17 +20,25 @@ interface ResponseMetadata {
20
20
 
21
21
  const stateKey = Symbol('FetchResponse.state');
22
22
 
23
+ interface ConsumptionWrapper {
24
+ stream: ReadableStream<Uint8Array<ArrayBuffer>>;
25
+ // Stops the wrapper from marking its body as consumed. Called by clone()
26
+ // when reads start coming through tee internals instead of from the user.
27
+ detach: () => void;
28
+ }
29
+
23
30
  function wrapWithConsumption(
24
31
  source: ReadableStream<Uint8Array<ArrayBuffer>>,
25
32
  body: Body
26
- ): ReadableStream<Uint8Array<ArrayBuffer>> {
33
+ ): ConsumptionWrapper {
27
34
  const reader = source.getReader();
28
35
  let markedConsumed = false;
36
+ let markedDetached = false;
29
37
 
30
- return new ReadableStream(
38
+ const stream = new ReadableStream(
31
39
  {
32
40
  async pull(controller) {
33
- if (!markedConsumed) {
41
+ if (!markedConsumed && !markedDetached) {
34
42
  markedConsumed = true;
35
43
  body.consumed = true;
36
44
  }
@@ -50,7 +58,7 @@ function wrapWithConsumption(
50
58
  }
51
59
  },
52
60
  cancel(reason) {
53
- if (!markedConsumed) {
61
+ if (!markedConsumed && !markedDetached) {
54
62
  markedConsumed = true;
55
63
  body.consumed = true;
56
64
  }
@@ -67,6 +75,13 @@ function wrapWithConsumption(
67
75
  highWaterMark: 0,
68
76
  }
69
77
  );
78
+
79
+ return {
80
+ stream,
81
+ detach: () => {
82
+ markedDetached = true;
83
+ },
84
+ };
70
85
  }
71
86
 
72
87
  // JS-side body state. Held behind the stateKey symbol slot.
@@ -76,6 +91,10 @@ class Body {
76
91
  cloned: boolean;
77
92
  consumed = false;
78
93
 
94
+ // Detach fn for the wrapper currently held in `stream`. Null until the
95
+ // first clone wraps the native stream.
96
+ detach: (() => void) | null = null;
97
+
79
98
  constructor({ cloned }: { cloned: boolean }) {
80
99
  this.cloned = cloned;
81
100
  }
@@ -196,13 +215,19 @@ export class FetchResponse extends ConcreteNativeResponse implements Response {
196
215
  });
197
216
 
198
217
  this.addListener('didComplete', () => {
199
- controller.close();
218
+ if (isControllerClosed) {
219
+ return;
220
+ }
200
221
  isControllerClosed = true;
222
+ controller.close();
201
223
  });
202
224
 
203
225
  this.addListener('didFailWithError', (error: string) => {
204
- controller.error(new Error(error));
226
+ if (isControllerClosed) {
227
+ return;
228
+ }
205
229
  isControllerClosed = true;
230
+ controller.error(new Error(error));
206
231
  });
207
232
  },
208
233
 
@@ -358,9 +383,19 @@ export class FetchResponse extends ConcreteNativeResponse implements Response {
358
383
  // Tee so both responses can be read independently. Each branch is wrapped
359
384
  // so the first read flips the right consumed flag (otherwise bodyUsed lies).
360
385
  if (this.body != null) {
386
+ // Detach the existing wrapper so reads via the new tee don't flip
387
+ // this body's consumed flag through it.
388
+ state.body.detach?.();
389
+
361
390
  const [stream1, stream2] = this.body.tee();
362
- state.body.stream = wrapWithConsumption(stream1, state.body);
363
- cloneState.body.stream = wrapWithConsumption(stream2, cloneState.body);
391
+ const own = wrapWithConsumption(stream1, state.body);
392
+ const sibling = wrapWithConsumption(stream2, cloneState.body);
393
+
394
+ state.body.stream = own.stream;
395
+ state.body.detach = own.detach;
396
+
397
+ cloneState.body.stream = sibling.stream;
398
+ cloneState.body.detach = sibling.detach;
364
399
  }
365
400
 
366
401
  state.body.cloned = true;
@@ -13,6 +13,7 @@ jest.mock('../ExpoFetchModule', () => {
13
13
  const helloWorld = new TextEncoder().encode('hello world');
14
14
 
15
15
  class StubNativeResponse {
16
+ private listeners = new Map<string, Set<(...args: any[]) => void>>();
16
17
  private _bodyUsed = false;
17
18
 
18
19
  // Getters on the prototype, like the real native binding, so super.x works.
@@ -36,9 +37,31 @@ jest.mock('../ExpoFetchModule', () => {
36
37
  return this._bodyUsed;
37
38
  }
38
39
 
39
- addListener() {}
40
- removeListener() {}
41
- removeAllListeners() {}
40
+ addListener(event: string, listener: (...args: any[]) => void) {
41
+ if (!this.listeners.has(event)) {
42
+ this.listeners.set(event, new Set());
43
+ }
44
+ this.listeners.get(event)!.add(listener);
45
+ }
46
+
47
+ removeListener(event: string, listener: (...args: any[]) => void) {
48
+ this.listeners.get(event)?.delete(listener);
49
+ }
50
+
51
+ removeAllListeners(event: string) {
52
+ this.listeners.delete(event);
53
+ }
54
+
55
+ emit(event: string, ...args: any[]) {
56
+ const listeners = this.listeners.get(event);
57
+ if (!listeners) {
58
+ return;
59
+ }
60
+
61
+ for (const listener of listeners) {
62
+ listener(...args);
63
+ }
64
+ }
42
65
 
43
66
  async arrayBuffer(): Promise<ArrayBuffer> {
44
67
  this._bodyUsed = true;
@@ -79,6 +102,41 @@ describe('FetchResponse', () => {
79
102
  expect(Object.prototype.toString.call(FetchResponse.prototype)).toBe('[object Response]');
80
103
  });
81
104
 
105
+ it('does not throw when native emits didComplete after the stream was canceled', async () => {
106
+ // Repros expo/expo#34804: native can deliver didComplete after the JS
107
+ // consumer has already canceled the stream. Before the fix this would
108
+ // call controller.close() on an already-closed controller and throw
109
+ // "The stream is not in a state that permits close" out of the event
110
+ // listener, surfacing as a fatal unhandled error.
111
+ const response = makeResponse();
112
+ const body = response.body!;
113
+ const reader = body.getReader();
114
+
115
+ await reader.cancel('consumer canceled');
116
+ expect(() => (response as any).emit('didComplete')).not.toThrow();
117
+ });
118
+
119
+ it('does not throw when native emits didFailWithError after the stream was canceled', async () => {
120
+ const response = makeResponse();
121
+ const body = response.body!;
122
+ const reader = body.getReader();
123
+
124
+ await reader.cancel('consumer canceled');
125
+ expect(() => (response as any).emit('didFailWithError', 'late error')).not.toThrow();
126
+ });
127
+
128
+ it('does not throw when native emits didComplete twice', async () => {
129
+ const response = makeResponse();
130
+ const body = response.body!;
131
+ const reader = body.getReader();
132
+ const readPromise = reader.read();
133
+
134
+ (response as any).emit('didComplete');
135
+ expect(() => (response as any).emit('didComplete')).not.toThrow();
136
+
137
+ await readPromise;
138
+ });
139
+
82
140
  describe('clone()', () => {
83
141
  it('returns a Response that exposes the same metadata', () => {
84
142
  const response = makeResponse();
@@ -116,6 +174,25 @@ describe('FetchResponse', () => {
116
174
  expect(bytes.byteLength).toBe(11);
117
175
  });
118
176
 
177
+ it('does not flip bodyUsed on siblings when a second clone is read', async () => {
178
+ const response = makeResponse();
179
+ const second = response.clone();
180
+ const third = response.clone();
181
+
182
+ await third.json().catch(() => {});
183
+
184
+ expect(response.bodyUsed).toBe(false);
185
+ expect(second.bodyUsed).toBe(false);
186
+ expect(third.bodyUsed).toBe(true);
187
+ });
188
+
189
+ it('lets the original be read after being cloned twice', async () => {
190
+ const response = makeResponse();
191
+ response.clone();
192
+ response.clone();
193
+ expect((await response.arrayBuffer()).byteLength).toBe(11);
194
+ });
195
+
119
196
  it('throws a TypeError if the body has already been read', async () => {
120
197
  const response = makeResponse();
121
198
  await response.arrayBuffer();
package/template.tgz CHANGED
Binary file