Add fetch Instrumentation to Dedupe Fetches (#25516)
* Add fetch instrumentation in cached contexts * Avoid unhandled rejection errors for Promises that we intentionally ignore In the final passes, we ignore the newly generated Promises and use the previous ones. This ensures that if those generate errors, that we intentionally ignore those. * Add extra fetch properties if there were any
Sebastian Markbåge committed
Oct 19, 2022 at 18:37 UTC
65e32e58b6057db1fdfed95a942fad4fc96da191
19 files changed
+335
-1
package.json
+1
@@ -77,6 +77,7 @@
77
"minimist": "^1.2.3",
78
"mkdirp": "^0.5.1",
79
"ncp": "^2.0.0",
80
+ "@actuallyworks/node-fetch": "^2.6.0",
81
"pacote": "^10.3.0",
82
"prettier": "1.19.1",
83
"prop-types": "^15.6.2",
packages/react-reconciler/src/ReactFiberHooks.new.js
+7
@@ -770,6 +770,8 @@ if (enableUseMemoCacheHook) {
770
};
771
}
772
773
+function noop(): void {}
774
+
775
function use<T>(usable: Usable<T>): T {
776
if (usable !== null && typeof usable === 'object') {
777
// $FlowFixMe[method-unbinding]
@@ -795,6 +797,11 @@ function use<T>(usable: Usable<T>): T {
797
index,
798
);
799
if (prevThenableAtIndex !== null) {
800
+ if (thenable !== prevThenableAtIndex) {
801
+ // Avoid an unhandled rejection errors for the Promises that we'll
802
+ // intentionally ignore.
803
+ thenable.then(noop, noop);
804
+ }
805
switch (prevThenableAtIndex.status) {
806
case 'fulfilled': {
807
const fulfilledValue: T = prevThenableAtIndex.value;
packages/react-reconciler/src/ReactFiberHooks.old.js
+7
@@ -770,6 +770,8 @@ if (enableUseMemoCacheHook) {
770
};
771
}
772
773
+function noop(): void {}
774
+
775
function use<T>(usable: Usable<T>): T {
776
if (usable !== null && typeof usable === 'object') {
777
// $FlowFixMe[method-unbinding]
@@ -795,6 +797,11 @@ function use<T>(usable: Usable<T>): T {
797
index,
798
);
799
if (prevThenableAtIndex !== null) {
800
+ if (thenable !== prevThenableAtIndex) {
801
+ // Avoid an unhandled rejection errors for the Promises that we'll
802
+ // intentionally ignore.
803
+ thenable.then(noop, noop);
804
+ }
805
switch (prevThenableAtIndex.status) {
806
case 'fulfilled': {
807
const fulfilledValue: T = prevThenableAtIndex.value;
packages/react-server/src/ReactFizzHooks.js
+5
@@ -608,6 +608,11 @@ function use<T>(usable: Usable<T>): T {
608
index,
609
);
610
if (prevThenableAtIndex !== null) {
611
+ if (thenable !== prevThenableAtIndex) {
612
+ // Avoid an unhandled rejection errors for the Promises that we'll
613
+ // intentionally ignore.
614
+ thenable.then(noop, noop);
615
+ }
616
switch (prevThenableAtIndex.status) {
617
case 'fulfilled': {
618
const fulfilledValue: T = prevThenableAtIndex.value;
packages/react-server/src/ReactFlightCache.js
+14
-1
@@ -9,9 +9,22 @@
9
10
import type {CacheDispatcher} from 'react-reconciler/src/ReactInternalTypes';
11
12
+function createSignal(): AbortSignal {
13
+ return new AbortController().signal;
14
+}
15
+
16
export const DefaultCacheDispatcher: CacheDispatcher = {
17
getCacheSignal(): AbortSignal {
14
- throw new Error('Not implemented.');
18
+ if (!currentCache) {
19
+ throw new Error('Reading the cache is only supported while rendering.');
20
+ }
21
+ let entry: AbortSignal | void = (currentCache.get(createSignal): any);
22
+ if (entry === undefined) {
23
+ entry = createSignal();
24
+ // $FlowFixMe[incompatible-use] found when upgrading Flow
25
+ currentCache.set(createSignal, entry);
26
+ }
27
+ return entry;
28
},
29
getCacheForType<T>(resourceType: () => T): T {
30
if (!currentCache) {
packages/react-server/src/ReactFlightHooks.js
+7
@@ -121,6 +121,8 @@ function useId(): string {
121
return ':' + currentRequest.identifierPrefix + 'S' + id.toString(32) + ':';
122
}
123
124
+function noop(): void {}
125
+
126
function use<T>(usable: Usable<T>): T {
127
if (usable !== null && typeof usable === 'object') {
128
// $FlowFixMe[method-unbinding]
@@ -147,6 +149,11 @@ function use<T>(usable: Usable<T>): T {
149
index,
150
);
151
if (prevThenableAtIndex !== null) {
152
+ if (thenable !== prevThenableAtIndex) {
153
+ // Avoid an unhandled rejection errors for the Promises that we'll
154
+ // intentionally ignore.
155
+ thenable.then(noop, noop);
156
+ }
157
switch (prevThenableAtIndex.status) {
158
case 'fulfilled': {
159
const fulfilledValue: T = prevThenableAtIndex.value;
packages/react/src/React.js
+3
@@ -71,6 +71,9 @@ import ReactSharedInternals from './ReactSharedInternals';
71
import {startTransition} from './ReactStartTransition';
72
import {act} from './ReactAct';
73
74
+// Patch fetch
75
+import './ReactFetch';
76
+
77
// TODO: Move this branching into the other module instead and just re-export.
78
const createElement: any = __DEV__
79
? createElementWithValidation
packages/react/src/ReactFetch.js
new
+133
@@ -0,0 +1,133 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import {
11
+ enableCache,
12
+ enableFetchInstrumentation,
13
+} from 'shared/ReactFeatureFlags';
14
+
15
+import ReactCurrentCache from './ReactCurrentCache';
16
+
17
+function createFetchCache(): Map<string, Array<any>> {
18
+ return new Map();
19
+}
20
+
21
+const simpleCacheKey = '["GET",[],null,"follow",null,null,null,null]'; // generateCacheKey(new Request('https://blank'));
22
+
23
+function generateCacheKey(request: Request): string {
24
+ // We pick the fields that goes into the key used to dedupe requests.
25
+ // We don't include the `cache` field, because we end up using whatever
26
+ // caching resulted from the first request.
27
+ // Notably we currently don't consider non-standard (or future) options.
28
+ // This might not be safe. TODO: warn for non-standard extensions differing.
29
+ // IF YOU CHANGE THIS UPDATE THE simpleCacheKey ABOVE.
30
+ return JSON.stringify([
31
+ request.method,
32
+ Array.from(request.headers.entries()),
33
+ request.mode,
34
+ request.redirect,
35
+ request.credentials,
36
+ request.referrer,
37
+ request.referrerPolicy,
38
+ request.integrity,
39
+ ]);
40
+}
41
+
42
+if (enableCache && enableFetchInstrumentation) {
43
+ if (typeof fetch === 'function') {
44
+ const originalFetch = fetch;
45
+ try {
46
+ // eslint-disable-next-line no-native-reassign
47
+ fetch = function fetch(
48
+ resource: URL | RequestInfo,
49
+ options?: RequestOptions,
50
+ ) {
51
+ const dispatcher = ReactCurrentCache.current;
52
+ if (!dispatcher) {
53
+ // We're outside a cached scope.
54
+ return originalFetch(resource, options);
55
+ }
56
+ if (
57
+ options &&
58
+ options.signal &&
59
+ options.signal !== dispatcher.getCacheSignal()
60
+ ) {
61
+ // If we're passed a signal that is not ours, then we assume that
62
+ // someone else controls the lifetime of this object and opts out of
63
+ // caching. It's effectively the opt-out mechanism.
64
+ // Ideally we should be able to check this on the Request but
65
+ // it always gets initialized with its own signal so we don't
66
+ // know if it's supposed to override - unless we also override the
67
+ // Request constructor.
68
+ return originalFetch(resource, options);
69
+ }
70
+ // Normalize the Request
71
+ let url: string;
72
+ let cacheKey: string;
73
+ if (typeof resource === 'string' && !options) {
74
+ // Fast path.
75
+ cacheKey = simpleCacheKey;
76
+ url = resource;
77
+ } else {
78
+ // Normalize the request.
79
+ const request = new Request(resource, options);
80
+ if (
81
+ (request.method !== 'GET' && request.method !== 'HEAD') ||
82
+ // $FlowFixMe: keepalive is real
83
+ request.keepalive
84
+ ) {
85
+ // We currently don't dedupe requests that might have side-effects. Those
86
+ // have to be explicitly cached. We assume that the request doesn't have a
87
+ // body if it's GET or HEAD.
88
+ // keepalive gets treated the same as if you passed a custom cache signal.
89
+ return originalFetch(resource, options);
90
+ }
91
+ cacheKey = generateCacheKey(request);
92
+ url = request.url;
93
+ }
94
+ const cache = dispatcher.getCacheForType(createFetchCache);
95
+ const cacheEntries = cache.get(url);
96
+ let match;
97
+ if (cacheEntries === undefined) {
98
+ // We pass the original arguments here in case normalizing the Request
99
+ // doesn't include all the options in this environment.
100
+ match = originalFetch(resource, options);
101
+ cache.set(url, [cacheKey, match]);
102
+ } else {
103
+ // We use an array as the inner data structure since it's lighter and
104
+ // we typically only expect to see one or two entries here.
105
+ for (let i = 0, l = cacheEntries.length; i < l; i += 2) {
106
+ const key = cacheEntries[i];
107
+ const value = cacheEntries[i + 1];
108
+ if (key === cacheKey) {
109
+ match = value;
110
+ // I would've preferred a labelled break but lint says no.
111
+ return match.then(response => response.clone());
112
+ }
113
+ }
114
+ match = originalFetch(resource, options);
115
+ cacheEntries.push(cacheKey, match);
116
+ }
117
+ // We clone the response so that each time you call this you get a new read
118
+ // of the body so that it can be read multiple times.
119
+ return match.then(response => response.clone());
120
+ };
121
+ // We don't expect to see any extra properties on fetch but if there are any,
122
+ // copy them over. Useful for extended fetch environments or mocks.
123
+ Object.assign(fetch, originalFetch);
124
+ } catch (error) {
125
+ // Log even in production just to make sure this is seen if only prod is frozen.
126
+ // eslint-disable-next-line react-internal/no-production-logging
127
+ console.warn(
128
+ 'React was unable to patch the fetch() function in this environment. ' +
129
+ 'Suspensey APIs might not work correctly as a result.',
130
+ );
131
+ }
132
+ }
133
+}
packages/react/src/__tests__/ReactFetch-test.js
new
+144
@@ -0,0 +1,144 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @emails react-core
8
+ */
9
+
10
+'use strict';
11
+
12
+// Polyfills for test environment
13
+global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream;
14
+global.TextEncoder = require('util').TextEncoder;
15
+global.TextDecoder = require('util').TextDecoder;
16
+global.Headers = require('node-fetch').Headers;
17
+global.Request = require('node-fetch').Request;
18
+global.Response = require('node-fetch').Response;
19
+
20
+let fetchCount = 0;
21
+async function fetchMock(resource, options) {
22
+ fetchCount++;
23
+ const request = new Request(resource, options);
24
+ return new Response(
25
+ request.method +
26
+ ' ' +
27
+ request.url +
28
+ ' ' +
29
+ JSON.stringify(Array.from(request.headers.entries())),
30
+ );
31
+}
32
+
33
+let React;
34
+let ReactServerDOMServer;
35
+let ReactServerDOMClient;
36
+let use;
37
+
38
+describe('ReactFetch', () => {
39
+ beforeEach(() => {
40
+ jest.resetModules();
41
+ fetchCount = 0;
42
+ global.fetch = fetchMock;
43
+
44
+ React = require('react');
45
+ ReactServerDOMServer = require('react-server-dom-webpack/server.browser');
46
+ ReactServerDOMClient = require('react-server-dom-webpack/client');
47
+ use = React.experimental_use;
48
+ });
49
+
50
+ async function render(Component) {
51
+ const stream = ReactServerDOMServer.renderToReadableStream(<Component />);
52
+ return ReactServerDOMClient.createFromReadableStream(stream);
53
+ }
54
+
55
+ it('can fetch duplicates outside of render', async () => {
56
+ let response = await fetch('world');
57
+ let text = await response.text();
58
+ expect(text).toMatchInlineSnapshot(`"GET world []"`);
59
+ response = await fetch('world');
60
+ text = await response.text();
61
+ expect(text).toMatchInlineSnapshot(`"GET world []"`);
62
+ expect(fetchCount).toBe(2);
63
+ });
64
+
65
+ // @gate enableFetchInstrumentation && enableCache
66
+ it('can dedupe fetches inside of render', async () => {
67
+ function Component() {
68
+ const response = use(fetch('world'));
69
+ const text = use(response.text());
70
+ return text;
71
+ }
72
+ expect(await render(Component)).toMatchInlineSnapshot(`"GET world []"`);
73
+ expect(fetchCount).toBe(1);
74
+ });
75
+
76
+ // @gate enableFetchInstrumentation && enableCache
77
+ it('can dedupe fetches using Request and not', async () => {
78
+ function Component() {
79
+ const response = use(fetch('world'));
80
+ const text = use(response.text());
81
+ const sameRequest = new Request('world', {method: 'get'});
82
+ const response2 = use(fetch(sameRequest));
83
+ const text2 = use(response2.text());
84
+ return text + ' ' + text2;
85
+ }
86
+ expect(await render(Component)).toMatchInlineSnapshot(
87
+ `"GET world [] GET world []"`,
88
+ );
89
+ expect(fetchCount).toBe(1);
90
+ });
91
+
92
+ // @gate enableUseHook
93
+ it('can opt-out of deduping fetches inside of render with custom signal', async () => {
94
+ const controller = new AbortController();
95
+ function useCustomHook() {
96
+ return use(
97
+ fetch('world', {signal: controller.signal}).then(response =>
98
+ response.text(),
99
+ ),
100
+ );
101
+ }
102
+ function Component() {
103
+ return useCustomHook() + ' ' + useCustomHook();
104
+ }
105
+ expect(await render(Component)).toMatchInlineSnapshot(
106
+ `"GET world [] GET world []"`,
107
+ );
108
+ expect(fetchCount).not.toBe(1);
109
+ });
110
+
111
+ // @gate enableUseHook
112
+ it('opts out of deduping for POST requests', async () => {
113
+ function useCustomHook() {
114
+ return use(
115
+ fetch('world', {method: 'POST'}).then(response => response.text()),
116
+ );
117
+ }
118
+ function Component() {
119
+ return useCustomHook() + ' ' + useCustomHook();
120
+ }
121
+ expect(await render(Component)).toMatchInlineSnapshot(
122
+ `"POST world [] POST world []"`,
123
+ );
124
+ expect(fetchCount).not.toBe(1);
125
+ });
126
+
127
+ // @gate enableFetchInstrumentation && enableCache
128
+ it('can dedupe fetches using same headers but not different', async () => {
129
+ function Component() {
130
+ const response = use(fetch('world', {headers: {a: 'A'}}));
131
+ const text = use(response.text());
132
+ const sameRequest = new Request('world', {
133
+ headers: new Headers({b: 'B'}),
134
+ });
135
+ const response2 = use(fetch(sameRequest));
136
+ const text2 = use(response2.text());
137
+ return text + ' ' + text2;
138
+ }
139
+ expect(await render(Component)).toMatchInlineSnapshot(
140
+ `"GET world [[\\"a\\",\\"A\\"]] GET world [[\\"b\\",\\"B\\"]]"`,
141
+ );
142
+ expect(fetchCount).toBe(2);
143
+ });
144
+});
packages/shared/ReactFeatureFlags.js
+1
@@ -84,6 +84,7 @@ export const enableLegacyFBSupport = false;
84
85
export const enableCache = __EXPERIMENTAL__;
86
export const enableCacheElement = __EXPERIMENTAL__;
87
+export const enableFetchInstrumentation = __EXPERIMENTAL__;
88
89
export const enableTransitionTracing = false;
90
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -29,6 +29,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
29
export const enableUpdaterTracking = __PROFILE__;
30
export const enableCache = false;
31
export const enableCacheElement = true;
32
+export const enableFetchInstrumentation = false;
33
export const enableSchedulerDebugging = false;
34
export const debugRenderPhaseSideEffectsForStrictMode = true;
35
export const disableJavaScriptURLs = false;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
22
export const enableUpdaterTracking = __PROFILE__;
23
export const enableCache = false;
24
export const enableCacheElement = false;
25
+export const enableFetchInstrumentation = false;
26
export const disableJavaScriptURLs = false;
27
export const disableCommentsAsDOMContainers = true;
28
export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
22
export const enableUpdaterTracking = false;
23
export const enableCache = __EXPERIMENTAL__;
24
export const enableCacheElement = __EXPERIMENTAL__;
25
+export const enableFetchInstrumentation = __EXPERIMENTAL__;
26
export const disableJavaScriptURLs = false;
27
export const disableCommentsAsDOMContainers = true;
28
export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+1
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
22
export const enableUpdaterTracking = false;
23
export const enableCache = true;
24
export const enableCacheElement = true;
25
+export const enableFetchInstrumentation = false;
26
export const disableJavaScriptURLs = false;
27
export const disableCommentsAsDOMContainers = true;
28
export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
22
export const enableUpdaterTracking = false;
23
export const enableCache = true;
24
export const enableCacheElement = true;
25
+export const enableFetchInstrumentation = false;
26
export const enableSchedulerDebugging = false;
27
export const disableJavaScriptURLs = false;
28
export const disableCommentsAsDOMContainers = true;
packages/shared/forks/ReactFeatureFlags.testing.js
+1
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
22
export const enableUpdaterTracking = false;
23
export const enableCache = __EXPERIMENTAL__;
24
export const enableCacheElement = __EXPERIMENTAL__;
25
+export const enableFetchInstrumentation = __EXPERIMENTAL__;
26
export const disableJavaScriptURLs = false;
27
export const disableCommentsAsDOMContainers = true;
28
export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.testing.www.js
+1
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
22
export const enableUpdaterTracking = false;
23
export const enableCache = true;
24
export const enableCacheElement = true;
25
+export const enableFetchInstrumentation = false;
26
export const disableJavaScriptURLs = true;
27
export const disableCommentsAsDOMContainers = true;
28
export const disableInputAttributeSyncing = false;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -72,6 +72,7 @@ export const enableGetInspectorDataForInstanceInProduction = false;
72
73
export const enableCache = true;
74
export const enableCacheElement = true;
75
+export const enableFetchInstrumentation = false;
76
77
export const disableJavaScriptURLs = true;
78
yarn.lock
+5
@@ -2,6 +2,11 @@
2
# yarn lockfile v1
3
4
5
+"@actuallyworks/node-fetch@^2.6.0":
6
+ version "2.6.0"
7
+ resolved "https://registry.yarnpkg.com/@actuallyworks/node-fetch/-/node-fetch-2.6.0.tgz#d1adc48813ab2866d86481950b99287c8d22d677"
8
+ integrity sha512-4G5iCA6JJk5TYy9GqSyyjGIOnf1w/K+XvCdVDm/Q6ryQGPhAwIGyZpIkPMLFStdjoswlgJTiY64j3yMw4S6l8g==
9
+
10
"@babel/cli@^7.10.5":
11
version "7.10.5"
12
resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.10.5.tgz#57df2987c8cf89d0fc7d4b157ec59d7619f1b77a"