Skip to content

Commit c1dc05b

Browse files
committed
Allow lazily resolving outlined models
Add test for Blob in FormData and async modules in Maps
1 parent e63918d commit c1dc05b

File tree

2 files changed

+184
-77
lines changed

2 files changed

+184
-77
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 106 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,8 @@ function createModelResolver<T>(
581581
parentObject: Object,
582582
key: string,
583583
cyclic: boolean,
584+
response: Response,
585+
map: (response: Response, model: any) => T,
584586
): (value: any) => void {
585587
let blocked;
586588
if (initializingChunkBlockedModel) {
@@ -595,12 +597,12 @@ function createModelResolver<T>(
595597
};
596598
}
597599
return value => {
598-
parentObject[key] = value;
600+
parentObject[key] = map(response, value);
599601

600602
// If this is the root object for a model reference, where `blocked.value`
601603
// is a stale `null`, the resolved value can be used directly.
602604
if (key === '' && blocked.value === null) {
603-
blocked.value = value;
605+
blocked.value = parentObject[key];
604606
}
605607

606608
blocked.deps--;
@@ -651,24 +653,103 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
651653
return proxy;
652654
}
653655

654-
function getOutlinedModel(response: Response, id: number): any {
656+
function getOutlinedModel<T>(
657+
response: Response,
658+
id: number,
659+
parentObject: Object,
660+
key: string,
661+
map: (response: Response, model: any) => T,
662+
): T {
655663
const chunk = getChunk(response, id);
656664
switch (chunk.status) {
657665
case RESOLVED_MODEL:
658666
initializeModelChunk(chunk);
659667
break;
668+
case RESOLVED_MODULE:
669+
initializeModuleChunk(chunk);
670+
break;
660671
}
661672
// The status might have changed after initialization.
662673
switch (chunk.status) {
663-
case INITIALIZED: {
664-
return chunk.value;
665-
}
666-
// We always encode it first in the stream so it won't be pending.
674+
case INITIALIZED:
675+
const chunkValue = map(response, chunk.value);
676+
if (__DEV__ && chunk._debugInfo) {
677+
// If we have a direct reference to an object that was rendered by a synchronous
678+
// server component, it might have some debug info about how it was rendered.
679+
// We forward this to the underlying object. This might be a React Element or
680+
// an Array fragment.
681+
// If this was a string / number return value we lose the debug info. We choose
682+
// that tradeoff to allow sync server components to return plain values and not
683+
// use them as React Nodes necessarily. We could otherwise wrap them in a Lazy.
684+
if (
685+
typeof chunkValue === 'object' &&
686+
chunkValue !== null &&
687+
(Array.isArray(chunkValue) ||
688+
chunkValue.$$typeof === REACT_ELEMENT_TYPE) &&
689+
!chunkValue._debugInfo
690+
) {
691+
// We should maybe use a unique symbol for arrays but this is a React owned array.
692+
// $FlowFixMe[prop-missing]: This should be added to elements.
693+
Object.defineProperty((chunkValue: any), '_debugInfo', {
694+
configurable: false,
695+
enumerable: false,
696+
writable: true,
697+
value: chunk._debugInfo,
698+
});
699+
}
700+
}
701+
return chunkValue;
702+
case PENDING:
703+
case BLOCKED:
704+
case CYCLIC:
705+
const parentChunk = initializingChunk;
706+
chunk.then(
707+
createModelResolver(
708+
parentChunk,
709+
parentObject,
710+
key,
711+
chunk.status === CYCLIC,
712+
response,
713+
map,
714+
),
715+
createModelReject(parentChunk),
716+
);
717+
return (null: any);
667718
default:
668719
throw chunk.reason;
669720
}
670721
}
671722

723+
function createMap(
724+
response: Response,
725+
model: Array<[any, any]>,
726+
): Map<any, any> {
727+
return new Map(model);
728+
}
729+
730+
function createSet(response: Response, model: Array<any>): Set<any> {
731+
return new Set(model);
732+
}
733+
734+
function createBlob(response: Response, model: Array<any>): Blob {
735+
return new Blob(model.slice(1), {type: model[0]});
736+
}
737+
738+
function createFormData(
739+
response: Response,
740+
model: Array<[any, any]>,
741+
): FormData {
742+
const formData = new FormData();
743+
for (let i = 0; i < model.length; i++) {
744+
formData.append(model[i][0], model[i][1]);
745+
}
746+
return formData;
747+
}
748+
749+
function createModel(response: Response, model: any): any {
750+
return model;
751+
}
752+
672753
function parseModelString(
673754
response: Response,
674755
parentObject: Object,
@@ -710,8 +791,13 @@ function parseModelString(
710791
case 'F': {
711792
// Server Reference
712793
const id = parseInt(value.slice(2), 16);
713-
const metadata = getOutlinedModel(response, id);
714-
return createServerReferenceProxy(response, metadata);
794+
return getOutlinedModel(
795+
response,
796+
id,
797+
parentObject,
798+
key,
799+
createServerReferenceProxy,
800+
);
715801
}
716802
case 'T': {
717803
// Temporary Reference
@@ -728,33 +814,31 @@ function parseModelString(
728814
case 'Q': {
729815
// Map
730816
const id = parseInt(value.slice(2), 16);
731-
const data = getOutlinedModel(response, id);
732-
return new Map(data);
817+
return getOutlinedModel(response, id, parentObject, key, createMap);
733818
}
734819
case 'W': {
735820
// Set
736821
const id = parseInt(value.slice(2), 16);
737-
const data = getOutlinedModel(response, id);
738-
return new Set(data);
822+
return getOutlinedModel(response, id, parentObject, key, createSet);
739823
}
740824
case 'B': {
741825
// Blob
742826
if (enableBinaryFlight) {
743827
const id = parseInt(value.slice(2), 16);
744-
const data = getOutlinedModel(response, id);
745-
return new Blob(data.slice(1), {type: data[0]});
828+
return getOutlinedModel(response, id, parentObject, key, createBlob);
746829
}
747830
return undefined;
748831
}
749832
case 'K': {
750833
// FormData
751834
const id = parseInt(value.slice(2), 16);
752-
const data = getOutlinedModel(response, id);
753-
const formData = new FormData();
754-
for (let i = 0; i < data.length; i++) {
755-
formData.append(data[i][0], data[i][1]);
756-
}
757-
return formData;
835+
return getOutlinedModel(
836+
response,
837+
id,
838+
parentObject,
839+
key,
840+
createFormData,
841+
);
758842
}
759843
case 'I': {
760844
// $Infinity
@@ -803,62 +887,7 @@ function parseModelString(
803887
default: {
804888
// We assume that anything else is a reference ID.
805889
const id = parseInt(value.slice(1), 16);
806-
const chunk = getChunk(response, id);
807-
switch (chunk.status) {
808-
case RESOLVED_MODEL:
809-
initializeModelChunk(chunk);
810-
break;
811-
case RESOLVED_MODULE:
812-
initializeModuleChunk(chunk);
813-
break;
814-
}
815-
// The status might have changed after initialization.
816-
switch (chunk.status) {
817-
case INITIALIZED:
818-
const chunkValue = chunk.value;
819-
if (__DEV__ && chunk._debugInfo) {
820-
// If we have a direct reference to an object that was rendered by a synchronous
821-
// server component, it might have some debug info about how it was rendered.
822-
// We forward this to the underlying object. This might be a React Element or
823-
// an Array fragment.
824-
// If this was a string / number return value we lose the debug info. We choose
825-
// that tradeoff to allow sync server components to return plain values and not
826-
// use them as React Nodes necessarily. We could otherwise wrap them in a Lazy.
827-
if (
828-
typeof chunkValue === 'object' &&
829-
chunkValue !== null &&
830-
(Array.isArray(chunkValue) ||
831-
chunkValue.$$typeof === REACT_ELEMENT_TYPE) &&
832-
!chunkValue._debugInfo
833-
) {
834-
// We should maybe use a unique symbol for arrays but this is a React owned array.
835-
// $FlowFixMe[prop-missing]: This should be added to elements.
836-
Object.defineProperty(chunkValue, '_debugInfo', {
837-
configurable: false,
838-
enumerable: false,
839-
writable: true,
840-
value: chunk._debugInfo,
841-
});
842-
}
843-
}
844-
return chunkValue;
845-
case PENDING:
846-
case BLOCKED:
847-
case CYCLIC:
848-
const parentChunk = initializingChunk;
849-
chunk.then(
850-
createModelResolver(
851-
parentChunk,
852-
parentObject,
853-
key,
854-
chunk.status === CYCLIC,
855-
),
856-
createModelReject(parentChunk),
857-
);
858-
return null;
859-
default:
860-
throw chunk.reason;
861-
}
890+
return getOutlinedModel(response, id, parentObject, key, createModel);
862891
}
863892
}
864893
}

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ global.TextDecoder = require('util').TextDecoder;
1818
if (typeof Blob === 'undefined') {
1919
global.Blob = require('buffer').Blob;
2020
}
21+
if (typeof File === 'undefined') {
22+
global.File = require('buffer').File;
23+
}
2124

2225
// Don't wait before processing work on the server.
2326
// TODO: we can replace this with FlightServer.act().
@@ -352,6 +355,81 @@ describe('ReactFlightDOMEdge', () => {
352355
expect(await result.arrayBuffer()).toEqual(await blob.arrayBuffer());
353356
});
354357

358+
if (typeof FormData !== 'undefined' && typeof File !== 'undefined') {
359+
// @gate enableBinaryFlight
360+
it('can transport FormData (blobs)', async () => {
361+
const bytes = new Uint8Array([
362+
123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
363+
]);
364+
const blob = new Blob([bytes, bytes], {
365+
type: 'application/x-test',
366+
});
367+
368+
const formData = new FormData();
369+
formData.append('hi', 'world');
370+
formData.append('file', blob, 'filename.test');
371+
372+
expect(formData.get('file') instanceof File).toBe(true);
373+
expect(formData.get('file').name).toBe('filename.test');
374+
375+
const stream = passThrough(
376+
ReactServerDOMServer.renderToReadableStream(formData),
377+
);
378+
const result = await ReactServerDOMClient.createFromReadableStream(
379+
stream,
380+
{
381+
ssrManifest: {
382+
moduleMap: null,
383+
moduleLoading: null,
384+
},
385+
},
386+
);
387+
388+
expect(result instanceof FormData).toBe(true);
389+
expect(result.get('hi')).toBe('world');
390+
const resultBlob = result.get('file');
391+
expect(resultBlob instanceof Blob).toBe(true);
392+
expect(resultBlob.name).toBe('blob'); // We should not pass through the file name for security.
393+
expect(resultBlob.size).toBe(bytes.length * 2);
394+
expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer());
395+
});
396+
}
397+
398+
it('can pass an async import that resolves later to an outline object like a Map', async () => {
399+
let resolve;
400+
const promise = new Promise(r => (resolve = r));
401+
402+
const asyncClient = clientExports(promise);
403+
404+
// We await the value on the servers so it's an async value that the client should wait for
405+
const awaitedValue = await asyncClient;
406+
407+
const map = new Map();
408+
map.set('value', awaitedValue);
409+
410+
const stream = passThrough(
411+
ReactServerDOMServer.renderToReadableStream(map, webpackMap),
412+
);
413+
414+
// Parsing the root blocks because the module hasn't loaded yet
415+
const resultPromise = ReactServerDOMClient.createFromReadableStream(
416+
stream,
417+
{
418+
ssrManifest: {
419+
moduleMap: null,
420+
moduleLoading: null,
421+
},
422+
},
423+
);
424+
425+
// Afterwards we finally resolve the module value so it's available on the client
426+
resolve('hello');
427+
428+
const result = await resultPromise;
429+
expect(result instanceof Map).toBe(true);
430+
expect(result.get('value')).toBe('hello');
431+
});
432+
355433
it('warns if passing a this argument to bind() of a server reference', async () => {
356434
const ServerModule = serverExports({
357435
greet: function () {},

0 commit comments

Comments
 (0)