Skip to content

Commit d31c387

Browse files
committed
Add fetch instrumentation in cached contexts
1 parent 8e2bde6 commit d31c387

15 files changed

+306
-1
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"minimist": "^1.2.3",
7878
"mkdirp": "^0.5.1",
7979
"ncp": "^2.0.0",
80+
"@actuallyworks/node-fetch": "^2.6.0",
8081
"pacote": "^10.3.0",
8182
"prettier": "1.19.1",
8283
"prop-types": "^15.6.2",

packages/react-server/src/ReactFlightCache.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,22 @@
99

1010
import type {CacheDispatcher} from 'react-reconciler/src/ReactInternalTypes';
1111

12+
function createSignal(): AbortSignal {
13+
return new AbortController().signal;
14+
}
15+
1216
export const DefaultCacheDispatcher: CacheDispatcher = {
1317
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;
1528
},
1629
getCacheForType<T>(resourceType: () => T): T {
1730
if (!currentCache) {

packages/react/src/React.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ import ReactSharedInternals from './ReactSharedInternals';
7171
import {startTransition} from './ReactStartTransition';
7272
import {act} from './ReactAct';
7373

74+
// Patch fetch
75+
import './ReactFetch';
76+
7477
// TODO: Move this branching into the other module instead and just re-export.
7578
const createElement: any = __DEV__
7679
? createElementWithValidation

packages/react/src/ReactFetch.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
} catch (error) {
122+
// Log even in production just to make sure this is seen if only prod is frozen.
123+
// eslint-disable-next-line react-internal/no-production-logging
124+
console.warn(
125+
'React was unable to patch the fetch() function in this environment. ' +
126+
'Suspensey APIs might not work correctly as a result.',
127+
);
128+
}
129+
}
130+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const enableLegacyFBSupport = false;
8484

8585
export const enableCache = __EXPERIMENTAL__;
8686
export const enableCacheElement = __EXPERIMENTAL__;
87+
export const enableFetchInstrumentation = __EXPERIMENTAL__;
8788

8889
export const enableTransitionTracing = false;
8990

packages/shared/forks/ReactFeatureFlags.native-fb.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
2929
export const enableUpdaterTracking = __PROFILE__;
3030
export const enableCache = false;
3131
export const enableCacheElement = true;
32+
export const enableFetchInstrumentation = false;
3233
export const enableSchedulerDebugging = false;
3334
export const debugRenderPhaseSideEffectsForStrictMode = true;
3435
export const disableJavaScriptURLs = false;

packages/shared/forks/ReactFeatureFlags.native-oss.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
2222
export const enableUpdaterTracking = __PROFILE__;
2323
export const enableCache = false;
2424
export const enableCacheElement = false;
25+
export const enableFetchInstrumentation = false;
2526
export const disableJavaScriptURLs = false;
2627
export const disableCommentsAsDOMContainers = true;
2728
export const disableInputAttributeSyncing = false;

packages/shared/forks/ReactFeatureFlags.test-renderer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
2222
export const enableUpdaterTracking = false;
2323
export const enableCache = __EXPERIMENTAL__;
2424
export const enableCacheElement = __EXPERIMENTAL__;
25+
export const enableFetchInstrumentation = __EXPERIMENTAL__;
2526
export const disableJavaScriptURLs = false;
2627
export const disableCommentsAsDOMContainers = true;
2728
export const disableInputAttributeSyncing = false;

packages/shared/forks/ReactFeatureFlags.test-renderer.native.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
2222
export const enableUpdaterTracking = false;
2323
export const enableCache = true;
2424
export const enableCacheElement = true;
25+
export const enableFetchInstrumentation = false;
2526
export const disableJavaScriptURLs = false;
2627
export const disableCommentsAsDOMContainers = true;
2728
export const disableInputAttributeSyncing = false;

packages/shared/forks/ReactFeatureFlags.test-renderer.www.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
2222
export const enableUpdaterTracking = false;
2323
export const enableCache = true;
2424
export const enableCacheElement = true;
25+
export const enableFetchInstrumentation = false;
2526
export const enableSchedulerDebugging = false;
2627
export const disableJavaScriptURLs = false;
2728
export const disableCommentsAsDOMContainers = true;

packages/shared/forks/ReactFeatureFlags.testing.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
2222
export const enableUpdaterTracking = false;
2323
export const enableCache = __EXPERIMENTAL__;
2424
export const enableCacheElement = __EXPERIMENTAL__;
25+
export const enableFetchInstrumentation = __EXPERIMENTAL__;
2526
export const disableJavaScriptURLs = false;
2627
export const disableCommentsAsDOMContainers = true;
2728
export const disableInputAttributeSyncing = false;

packages/shared/forks/ReactFeatureFlags.testing.www.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const enableProfilerNestedUpdateScheduledHook = false;
2222
export const enableUpdaterTracking = false;
2323
export const enableCache = true;
2424
export const enableCacheElement = true;
25+
export const enableFetchInstrumentation = false;
2526
export const disableJavaScriptURLs = true;
2627
export const disableCommentsAsDOMContainers = true;
2728
export const disableInputAttributeSyncing = false;

packages/shared/forks/ReactFeatureFlags.www.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const enableGetInspectorDataForInstanceInProduction = false;
7474

7575
export const enableCache = true;
7676
export const enableCacheElement = true;
77+
export const enableFetchInstrumentation = false;
7778

7879
export const disableJavaScriptURLs = true;
7980

0 commit comments

Comments
 (0)