Skip to content

Commit 14f50ad

Browse files
authored
[Flight] Allow lazily resolving outlined models (#28780)
We used to assume that outlined models are emitted before the reference (which was true before Blobs). However, it still wasn't safe to assume that all the data will be available because an "import" (client reference) can be async and therefore if it's directly a child of an outlined model, it won't be able to update in place. This is a similar problem as the one hit by @unstubbable in #28669 with elements, but a little different since these don't follow the same way of wrapping. I don't love the structuring of this code which now needs to pass a first class mapper instead of just being known code. It also shares the host path which is just an identity function. It wouldn't necessarily pass my own review but I don't have a better one for now. I'd really prefer if this was done at a "row" level but that ends up creating even more code. Add test for Blob in FormData and async modules in Maps.
1 parent 01bb3c5 commit 14f50ad

File tree

3 files changed

+197
-91
lines changed

3 files changed

+197
-91
lines changed

packages/react-client/src/ReactFlightClient.js

+106-77
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

+78
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)