diff --git a/fixtures/blocks/src/server/Comments.js b/fixtures/blocks/src/server/Comments.js index cf954d841a..3de28d5d25 100644 --- a/fixtures/blocks/src/server/Comments.js +++ b/fixtures/blocks/src/server/Comments.js @@ -7,7 +7,7 @@ /* eslint-disable import/first */ import * as React from 'react'; -import {fetch} from 'react-data/fetch'; +import {fetch} from 'react-fetch'; // TODO: Replace with asset reference. import Link from '../client/Link'; diff --git a/fixtures/blocks/src/server/Feed.js b/fixtures/blocks/src/server/Feed.js index f00c5fff7e..260c2e09fa 100644 --- a/fixtures/blocks/src/server/Feed.js +++ b/fixtures/blocks/src/server/Feed.js @@ -7,7 +7,7 @@ /* eslint-disable import/first */ import * as React from 'react'; -import {fetch} from 'react-data/fetch'; +import {fetch} from 'react-fetch'; import PostList from './PostList'; export default function Feed() { diff --git a/fixtures/blocks/src/server/PostList.js b/fixtures/blocks/src/server/PostList.js index 0efe90cd86..8d323e6910 100644 --- a/fixtures/blocks/src/server/PostList.js +++ b/fixtures/blocks/src/server/PostList.js @@ -8,7 +8,7 @@ import * as React from 'react'; import {Suspense, unstable_SuspenseList as SuspenseList} from 'react'; -import {preload} from 'react-data/fetch'; +import {preload} from 'react-fetch'; import PostGlimmer from './PostGlimmer'; import Post from './Post'; diff --git a/fixtures/blocks/src/server/ProfileBio.js b/fixtures/blocks/src/server/ProfileBio.js index 90797864d3..e6beddcc0c 100644 --- a/fixtures/blocks/src/server/ProfileBio.js +++ b/fixtures/blocks/src/server/ProfileBio.js @@ -7,7 +7,7 @@ /* eslint-disable import/first */ import * as React from 'react'; -import {fetch} from 'react-data/fetch'; +import {fetch} from 'react-fetch'; export default function ProfileBio({userId}) { const user = fetch(`/users/${userId}`).json(); diff --git a/fixtures/blocks/src/server/ProfilePage.js b/fixtures/blocks/src/server/ProfilePage.js index ca70741e92..d1814a77e2 100644 --- a/fixtures/blocks/src/server/ProfilePage.js +++ b/fixtures/blocks/src/server/ProfilePage.js @@ -7,7 +7,7 @@ import * as React from 'react'; import {Suspense} from 'react'; -import {fetch} from 'react-data/fetch'; +import {fetch} from 'react-fetch'; import {matchRoute} from './ServerRouter'; import ProfileTimeline from './ProfileTimeline'; import ProfileBio from './ProfileBio'; diff --git a/fixtures/blocks/src/server/ProfileTimeline.js b/fixtures/blocks/src/server/ProfileTimeline.js index 96c0190f06..d78a8c83f0 100644 --- a/fixtures/blocks/src/server/ProfileTimeline.js +++ b/fixtures/blocks/src/server/ProfileTimeline.js @@ -7,7 +7,7 @@ /* eslint-disable import/first */ import * as React from 'react'; -import {fetch} from 'react-data/fetch'; +import {fetch} from 'react-fetch'; import PostList from './PostList'; export default function ProfileTimeline({userId}) { diff --git a/packages/react-data/npm/fetch.js b/packages/react-data/npm/fetch.js deleted file mode 100644 index 1b9f07068a..0000000000 --- a/packages/react-data/npm/fetch.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -if (process.env.NODE_ENV === 'production') { - module.exports = require('./cjs/react-data-fetch.production.min.js'); -} else { - module.exports = require('./cjs/react-data-fetch.development.js'); -} diff --git a/packages/react-data/npm/index.js b/packages/react-data/npm/index.js deleted file mode 100644 index 2fd2e2df88..0000000000 --- a/packages/react-data/npm/index.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -if (process.env.NODE_ENV === 'production') { - module.exports = require('./cjs/react-data.production.min.js'); -} else { - module.exports = require('./cjs/react-data.development.js'); -} diff --git a/packages/react-data/src/__tests__/ReactDataFetch-test.js b/packages/react-data/src/__tests__/ReactDataFetch-test.js deleted file mode 100644 index 1c6785913c..0000000000 --- a/packages/react-data/src/__tests__/ReactDataFetch-test.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @emails react-core - */ - -'use strict'; - -describe('ReactDataFetch', () => { - let ReactDataFetch; - - beforeEach(() => { - ReactDataFetch = require('react-data/fetch'); - }); - - // TODO: test something useful. - it('exports something', () => { - expect(ReactDataFetch.fetch).not.toBe(undefined); - }); -}); diff --git a/packages/react-data/README.md b/packages/react-fetch/README.md similarity index 96% rename from packages/react-data/README.md rename to packages/react-fetch/README.md index 44295ba673..95bc5804bf 100644 --- a/packages/react-data/README.md +++ b/packages/react-fetch/README.md @@ -1,4 +1,4 @@ -# react-data +# react-fetch This package is meant to be used alongside yet-to-be-released, experimental React features. It's unlikely to be useful in any other context. diff --git a/packages/react-data/fetch.js b/packages/react-fetch/index.browser.js similarity index 83% rename from packages/react-data/fetch.js rename to packages/react-fetch/index.browser.js index 9b33b05684..1e5c241f60 100644 --- a/packages/react-data/fetch.js +++ b/packages/react-fetch/index.browser.js @@ -9,4 +9,4 @@ 'use strict'; -export * from './src/fetch/ReactDataFetch'; +export * from './src/ReactFetchBrowser'; diff --git a/packages/react-data/index.js b/packages/react-fetch/index.js similarity index 86% rename from packages/react-data/index.js rename to packages/react-fetch/index.js index 4cc6606104..ceb2071c4f 100644 --- a/packages/react-data/index.js +++ b/packages/react-fetch/index.js @@ -9,4 +9,4 @@ 'use strict'; -export * from './src/ReactData'; +export * from './index.node'; diff --git a/packages/react-data/src/ReactData.js b/packages/react-fetch/index.node.js similarity index 79% rename from packages/react-data/src/ReactData.js rename to packages/react-fetch/index.node.js index 9b5230b768..4bf38f14e2 100644 --- a/packages/react-data/src/ReactData.js +++ b/packages/react-fetch/index.node.js @@ -7,6 +7,6 @@ * @flow */ -export function createResource(): any { - // TODO -} +'use strict'; + +export * from './src/ReactFetchNode'; diff --git a/packages/react-fetch/npm/index.browser.js b/packages/react-fetch/npm/index.browser.js new file mode 100644 index 0000000000..6ecf769fed --- /dev/null +++ b/packages/react-fetch/npm/index.browser.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-fetch.browser.production.min.js'); +} else { + module.exports = require('./cjs/react-fetch.browser.development.js'); +} diff --git a/packages/react-fetch/npm/index.js b/packages/react-fetch/npm/index.js new file mode 100644 index 0000000000..ee510df2ad --- /dev/null +++ b/packages/react-fetch/npm/index.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./index.node'); diff --git a/packages/react-fetch/npm/index.node.js b/packages/react-fetch/npm/index.node.js new file mode 100644 index 0000000000..bc76412003 --- /dev/null +++ b/packages/react-fetch/npm/index.node.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-fetch.node.production.min.js'); +} else { + module.exports = require('./cjs/react-fetch.node.development.js'); +} diff --git a/packages/react-data/package.json b/packages/react-fetch/package.json similarity index 67% rename from packages/react-data/package.json rename to packages/react-fetch/package.json index e27abf8372..316ede6a58 100644 --- a/packages/react-data/package.json +++ b/packages/react-fetch/package.json @@ -1,22 +1,26 @@ { "private": true, - "name": "react-data", + "name": "react-fetch", "description": "Helpers for creating React data sources", "version": "0.0.0", "repository": { "type" : "git", "url" : "https://github.com/facebook/react.git", - "directory": "packages/react-data" + "directory": "packages/react-fetch" }, "files": [ "LICENSE", "README.md", "build-info.json", "index.js", - "fetch.js", + "index.node.js", + "index.browser.js", "cjs/" ], "peerDependencies": { "react": "^16.13.1" + }, + "browser": { + "./index.js": "./index.browser.js" } } diff --git a/packages/react-data/src/fetch/ReactDataFetch.js b/packages/react-fetch/src/ReactFetchBrowser.js similarity index 100% rename from packages/react-data/src/fetch/ReactDataFetch.js rename to packages/react-fetch/src/ReactFetchBrowser.js diff --git a/packages/react-fetch/src/ReactFetchNode.js b/packages/react-fetch/src/ReactFetchNode.js new file mode 100644 index 0000000000..e082fbaf37 --- /dev/null +++ b/packages/react-fetch/src/ReactFetchNode.js @@ -0,0 +1,229 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Wakeable} from 'shared/ReactTypes'; + +import * as http from 'http'; +import * as https from 'https'; + +import {readCache} from 'react/unstable-cache'; + +type FetchResponse = {| + // Properties + headers: any, + ok: boolean, + redirected: boolean, + status: number, + statusText: string, + type: 'basic', + url: string, + // Methods + arrayBuffer(): ArrayBuffer, + blob(): any, + json(): any, + text(): string, +|}; + +function nodeFetch( + url: string, + options: mixed, + onResolve: any => void, + onReject: any => void, +): void { + const {hostname, pathname, search, port, protocol} = new URL(url); + const nodeOptions = { + hostname, + port, + path: pathname + search, + // TODO: cherry-pick supported user-passed options. + }; + const nodeImpl = protocol === 'https:' ? https : http; + const request = nodeImpl.request(nodeOptions, response => { + // TODO: support redirects. + onResolve(new Response(response)); + }); + request.on('error', error => { + onReject(error); + }); + request.end(); +} + +const Pending = 0; +const Resolved = 1; +const Rejected = 2; + +type PendingResult = {| + status: 0, + value: Wakeable, +|}; + +type ResolvedResult = {| + status: 1, + value: V, +|}; + +type RejectedResult = {| + status: 2, + value: mixed, +|}; + +type Result = PendingResult | ResolvedResult | RejectedResult; + +const fetchKey = {}; + +function readResultMap(): Map> { + const resources = readCache().resources; + let map = resources.get(fetchKey); + if (map === undefined) { + map = new Map(); + resources.set(fetchKey, map); + } + return map; +} + +function readResult(result: Result): T { + if (result.status === Resolved) { + return result.value; + } else { + throw result.value; + } +} + +function Response(nativeResponse) { + this.headers = nativeResponse.headers; + this.ok = nativeResponse.statusCode >= 200 && nativeResponse.statusCode < 300; + this.redirected = false; // TODO + this.status = nativeResponse.statusCode; + this.statusText = nativeResponse.statusMessage; + this.type = 'basic'; + this.url = nativeResponse.url; + + this._response = nativeResponse; + this._blob = null; + this._json = null; + this._text = null; + + const callbacks = []; + function wake() { + // This assumes they won't throw. + while (callbacks.length > 0) { + const cb = callbacks.pop(); + cb(); + } + } + const result: PendingResult = (this._result = { + status: Pending, + value: { + then(cb) { + callbacks.push(cb); + }, + }, + }); + const data = []; + nativeResponse.on('data', chunk => data.push(chunk)); + nativeResponse.on('end', () => { + if (result.status === Pending) { + const resolvedResult = ((result: any): ResolvedResult); + resolvedResult.status = Resolved; + resolvedResult.value = Buffer.concat(data); + wake(); + } + }); + nativeResponse.on('error', err => { + if (result.status === Pending) { + const rejectedResult = ((result: any): RejectedResult); + rejectedResult.status = Rejected; + rejectedResult.value = err; + wake(); + } + }); +} + +Response.prototype = { + constructor: Response, + arrayBuffer() { + const buffer = readResult(this._result); + return buffer; + }, + blob() { + // TODO: Is this needed? + throw new Error('Not implemented.'); + }, + json() { + const buffer = readResult(this._result); + return JSON.parse(buffer.toString()); + }, + text() { + const buffer = readResult(this._result); + return buffer.toString(); + }, +}; + +function preloadResult(url: string, options: mixed): Result { + const map = readResultMap(); + let entry = map.get(url); + if (!entry) { + if (options) { + if (options.method || options.body || options.signal) { + // TODO: wire up our own cancellation mechanism. + // TODO: figure out what to do with POST. + throw Error('Unsupported option'); + } + } + const callbacks = []; + const wakeable = { + then(cb) { + callbacks.push(cb); + }, + }; + const wake = () => { + // This assumes they won't throw. + while (callbacks.length > 0) { + const cb = callbacks.pop(); + cb(); + } + }; + const result: Result = (entry = { + status: Pending, + value: wakeable, + }); + nodeFetch( + url, + options, + response => { + if (result.status === Pending) { + const resolvedResult = ((result: any): ResolvedResult); + resolvedResult.status = Resolved; + resolvedResult.value = response; + wake(); + } + }, + err => { + if (result.status === Pending) { + const rejectedResult = ((result: any): RejectedResult); + rejectedResult.status = Rejected; + rejectedResult.value = err; + wake(); + } + }, + ); + map.set(url, entry); + } + return entry; +} + +export function preload(url: string, options: mixed): void { + preloadResult(url, options); + // Don't return anything. +} + +export function fetch(url: string, options: mixed): FetchResponse { + const result = preloadResult(url, options); + return readResult(result); +} diff --git a/packages/react-data/src/__tests__/ReactData-test.js b/packages/react-fetch/src/__tests__/ReactFetchBrowser-test.js similarity index 66% rename from packages/react-data/src/__tests__/ReactData-test.js rename to packages/react-fetch/src/__tests__/ReactFetchBrowser-test.js index 9826ba55cf..8ae38d54d6 100644 --- a/packages/react-data/src/__tests__/ReactData-test.js +++ b/packages/react-fetch/src/__tests__/ReactFetchBrowser-test.js @@ -9,15 +9,15 @@ 'use strict'; -describe('ReactData', () => { - let ReactData; +describe('ReactFetchBrowser', () => { + let ReactFetchBrowser; beforeEach(() => { - ReactData = require('react-data'); + ReactFetchBrowser = require('react-fetch'); }); // TODO: test something useful. it('exports something', () => { - expect(ReactData.createResource).not.toBe(undefined); + expect(ReactFetchBrowser.fetch).not.toBe(undefined); }); }); diff --git a/packages/react-fetch/src/__tests__/ReactFetchNode-test.js b/packages/react-fetch/src/__tests__/ReactFetchNode-test.js new file mode 100644 index 0000000000..4acc271dcf --- /dev/null +++ b/packages/react-fetch/src/__tests__/ReactFetchNode-test.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +describe('ReactFetchNode', () => { + let ReactCache; + let ReactFetchNode; + let http; + let fetch; + let server; + let serverEndpoint; + let serverImpl; + + beforeEach(done => { + jest.resetModules(); + ReactCache = require('react/unstable-cache'); + ReactFetchNode = require('react-fetch'); + http = require('http'); + fetch = ReactFetchNode.fetch; + + server = http.createServer((req, res) => { + serverImpl(req, res); + }); + server.listen(done); + serverEndpoint = `http://localhost:${server.address().port}/`; + + // TODO: A way to pass load context. + ReactCache.CacheProvider._context._currentValue = ReactCache.createCache(); + }); + + afterEach(done => { + server.close(done); + server = null; + }); + + async function waitForSuspense(fn) { + while (true) { + try { + return fn(); + } catch (promise) { + if (typeof promise.then === 'function') { + await promise; + } else { + throw promise; + } + } + } + } + + it('can read text', async () => { + serverImpl = (req, res) => { + res.write('ok'); + res.end(); + }; + await waitForSuspense(() => { + const response = fetch(serverEndpoint); + expect(response.status).toBe(200); + expect(response.statusText).toBe('OK'); + expect(response.ok).toBe(true); + expect(response.text()).toEqual('ok'); + // Can read again: + expect(response.text()).toEqual('ok'); + }); + }); + + it('can read json', async () => { + serverImpl = (req, res) => { + res.write(JSON.stringify({name: 'Sema'})); + res.end(); + }; + await waitForSuspense(() => { + const response = fetch(serverEndpoint); + expect(response.status).toBe(200); + expect(response.statusText).toBe('OK'); + expect(response.ok).toBe(true); + expect(response.json()).toEqual({ + name: 'Sema', + }); + // Can read again: + expect(response.json()).toEqual({ + name: 'Sema', + }); + }); + }); +}); diff --git a/scripts/rollup/build.js b/scripts/rollup/build.js index 7e588665b4..92375e7bfe 100644 --- a/scripts/rollup/build.js +++ b/scripts/rollup/build.js @@ -260,7 +260,7 @@ function getFormat(bundleType) { function getFilename(name, globalName, bundleType) { // we do this to replace / to -, for react-dom/server - name = name.replace('/', '-'); + name = name.replace('/index.', '.').replace('/', '-'); switch (bundleType) { case UMD_DEV: return `${name}.development.js`; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 9f750a1282..6eb1ef6450 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -115,16 +115,7 @@ const bundles = [ externals: ['react'], }, - /******* React Data (experimental, new) *******/ - { - bundleTypes: [NODE_DEV, NODE_PROD, NODE_PROFILING], - moduleType: ISOMORPHIC, - entry: 'react-data', - global: 'ReactData', - externals: ['react'], - }, - - /******* React Data Fetch (experimental, new) *******/ + /******* React Fetch Browser (experimental, new) *******/ { bundleTypes: [ NODE_DEV, @@ -135,9 +126,18 @@ const bundles = [ FB_WWW_PROFILING, ], moduleType: ISOMORPHIC, - entry: 'react-data/fetch', - global: 'ReactDataFetch', - externals: ['react', 'react-data'], + entry: 'react-fetch/index.browser', + global: 'ReactFetch', + externals: ['react'], + }, + + /******* React Fetch Node (experimental, new) *******/ + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: ISOMORPHIC, + entry: 'react-fetch/index.node', + global: 'ReactFetch', + externals: ['react', 'http', 'https'], }, /******* React DOM *******/ diff --git a/scripts/rollup/modules.js b/scripts/rollup/modules.js index 59b2945729..90b2b762ba 100644 --- a/scripts/rollup/modules.js +++ b/scripts/rollup/modules.js @@ -15,6 +15,7 @@ const importSideEffects = Object.freeze({ 'scheduler/tracing': HAS_NO_SIDE_EFFECTS_ON_IMPORT, 'react-dom/server': HAS_NO_SIDE_EFFECTS_ON_IMPORT, 'react/jsx-dev-runtime': HAS_NO_SIDE_EFFECTS_ON_IMPORT, + 'react-fetch/node': HAS_NO_SIDE_EFFECTS_ON_IMPORT, }); // Bundles exporting globals that other modules rely on.