Files
react/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js
T
Sebastian Markbåge 7909d8eabb [Flight] Encode ReadableStream and AsyncIterables (#28847)
This adds support in Flight for serializing four kinds of streams:

- `ReadableStream` with objects as a model. This is a single shot
iterator so you can read it only once. It can contain any value
including Server Components. Chunks are encoded as is so if you send in
10 typed arrays, you get the same typed arrays out on the other side.
- Binary `ReadableStream` with `type: 'bytes'` option. This supports the
BYOB protocol. In this mode, the receiving side just gets `Uint8Array`s
and they can be split across any single byte boundary into arbitrary
chunks.
- `AsyncIterable` where the `AsyncIterator` function is different than
the `AsyncIterable` itself. In this case we assume that this might be a
multi-shot iterable and so we buffer its value and you can iterate it
multiple times on the other side. We support the `return` value as a
value in the single completion slot, but you can't pass values in
`next()`. If you want single-shot, return the AsyncIterator instead.
- `AsyncIterator`. These gets serialized as a single-shot as it's just
an iterator.

`AsyncIterable`/`AsyncIterator` yield Promises that are instrumented
with our `.status`/`.value` convention so that they can be synchronously
looped over if available. They are also lazily parsed upon read.

We can't do this with `ReadableStream` because we use the native
implementation of `ReadableStream` which owns the promises.

The format is a leading row that indicates which type of stream it is.
Then a new row with the same ID is emitted for every chunk. Followed by
either an error or close row.

`AsyncIterable`s can also be returned as children of Server Components
and then they're conceptually the same as fragment arrays/iterables.
They can't actually be used as children in Fizz/Fiber but there's a
separate plan for that. Only `AsyncIterable` not `AsyncIterator` will be
valid as children - just like sync `Iterable` is already supported but
single-shot `Iterator` is not. Notably, neither of these streams
represent updates over time to a value. They represent multiple values
in a list.

When the server stream is aborted we also close the underlying stream.
However, closing a stream on the client, doesn't close the underlying
stream.

A couple of possible follow ups I'm not planning on doing right now:

- [ ] Free memory by releasing the buffer if an Iterator has been
exhausted. Single shots could be optimized further to release individual
items as you go.
- [ ] We could clean up the underlying stream if the only pending data
that's still flowing is from streams and all the streams have cleaned
up. It's not very reliable though. It's better to do cancellation for
the whole stream - e.g. at the framework level.
- [ ] Implement smarter Binary Stream chunk handling. Currently we wait
until we've received a whole row for binary chunks and copy them into
consecutive memory. We need this to preserve semantics when passing
typed arrays. However, for binary streams we don't need that. We can
just send whatever pieces we have so far.
2024-04-16 12:20:07 -04:00

144 lines
4.6 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and 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
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
*/
'use strict';
// Polyfills for test environment
global.ReadableStream =
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
if (typeof Blob === 'undefined') {
global.Blob = require('buffer').Blob;
}
if (typeof File === 'undefined') {
global.File = require('buffer').File;
}
// let serverExports;
let webpackServerMap;
let ReactServerDOMServer;
let ReactServerDOMClient;
describe('ReactFlightDOMReplyEdge', () => {
beforeEach(() => {
jest.resetModules();
// Simulate the condition resolution
jest.mock('react', () => require('react/react.react-server'));
jest.mock('react-server-dom-webpack/server', () =>
require('react-server-dom-webpack/server.edge'),
);
const WebpackMock = require('./utils/WebpackMock');
// serverExports = WebpackMock.serverExports;
webpackServerMap = WebpackMock.webpackServerMap;
ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
jest.resetModules();
ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
});
if (typeof FormData === 'undefined') {
// We can't test if we don't have a native FormData implementation because the JSDOM one
// is missing the arrayBuffer() method.
it('cannot test', () => {});
return;
}
it('can encode a reply', async () => {
const body = await ReactServerDOMClient.encodeReply({some: 'object'});
const decoded = await ReactServerDOMServer.decodeReply(
body,
webpackServerMap,
);
expect(decoded).toEqual({some: 'object'});
});
// @gate enableBinaryFlight
it('should be able to serialize any kind of typed array', async () => {
const buffer = new Uint8Array([
123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
]).buffer;
const buffers = [
buffer,
new Int8Array(buffer, 1),
new Uint8Array(buffer, 2),
new Uint8ClampedArray(buffer, 2),
new Int16Array(buffer, 2),
new Uint16Array(buffer, 2),
new Int32Array(buffer, 4),
new Uint32Array(buffer, 4),
new Float32Array(buffer, 4),
new Float64Array(buffer, 0),
new BigInt64Array(buffer, 0),
new BigUint64Array(buffer, 0),
new DataView(buffer, 3),
];
const body = await ReactServerDOMClient.encodeReply(buffers);
const result = await ReactServerDOMServer.decodeReply(
body,
webpackServerMap,
);
expect(result).toEqual(buffers);
});
// @gate enableBinaryFlight
it('should be able to serialize a blob', async () => {
const bytes = new Uint8Array([
123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
]);
const blob = new Blob([bytes, bytes], {
type: 'application/x-test',
});
const body = await ReactServerDOMClient.encodeReply(blob);
const result = await ReactServerDOMServer.decodeReply(
body,
webpackServerMap,
);
expect(result instanceof Blob).toBe(true);
expect(result.size).toBe(bytes.length * 2);
expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer());
});
if (typeof FormData !== 'undefined' && typeof File !== 'undefined') {
it('can transport FormData (blobs)', async () => {
const bytes = new Uint8Array([
123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
]);
const blob = new Blob([bytes, bytes], {
type: 'application/x-test',
});
const formData = new FormData();
formData.append('hi', 'world');
formData.append('file', blob, 'filename.test');
expect(formData.get('file') instanceof File).toBe(true);
expect(formData.get('file').name).toBe('filename.test');
const body = await ReactServerDOMClient.encodeReply(formData);
const result = await ReactServerDOMServer.decodeReply(
body,
webpackServerMap,
);
expect(result instanceof FormData).toBe(true);
expect(result.get('hi')).toBe('world');
const resultBlob = result.get('file');
expect(resultBlob instanceof Blob).toBe(true);
expect(resultBlob.name).toBe('filename.test'); // In this direction we allow file name to pass through but not other direction.
expect(resultBlob.size).toBe(bytes.length * 2);
expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer());
});
}
});