Skip to content

Commit

Permalink
[Fizz] Fallback to client replaying actions if we're trying to serial…
Browse files Browse the repository at this point in the history
…ize a Blob (#28987)

This follows the same principle as in #28611.

We cannot serialize Blobs of a form data into HTML because you can't
initialize a file input to some value. However the serialization of
state in an Action can contain blobs. In this case we do error but
outside the try/catch that recovers to error to client replaying instead
of MPA mode. This errors earlier to ensure that this works.

Testing this is a bit annoying because JSDOM doesn't have any of the
Blob methods but the Blob needs to be compatible with FormData and the
FormData needs to be compatible with `<form>` nodes in these tests. So I
polyfilled those in JSDOM with some hacks.

A possible future enhancement would be to encode these blobs in a base64
mode instead and have some way to receive them on the server. It's just
a matter of layering this. I think the RSC layer's `FORM_DATA`
implementation can pass some flag to encode as base64 and then have
decodeAction include some way to parse them. That way this case would
work in MPA mode too.
  • Loading branch information
sebmarkbage authored May 8, 2024
1 parent 826bf4e commit 6bac4f2
Show file tree
Hide file tree
Showing 3 changed files with 117 additions and 8 deletions.
30 changes: 23 additions & 7 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -1019,12 +1019,7 @@ function pushAdditionalFormField(
): void {
const target: Array<Chunk | PrecomputedChunk> = this;
target.push(startHiddenInputChunk);
if (typeof value !== 'string') {
throw new Error(
'File/Blob fields are not yet supported in progressive forms. ' +
'It probably means you are closing over binary data or FormData in a Server Action.',
);
}
validateAdditionalFormField(value, key);
pushStringAttribute(target, 'name', key);
pushStringAttribute(target, 'value', value);
target.push(endOfStartTagSelfClosing);
Expand All @@ -1040,6 +1035,23 @@ function pushAdditionalFormFields(
}
}

function validateAdditionalFormField(value: string | File, key: string): void {
if (typeof value !== 'string') {
throw new Error(
'File/Blob fields are not yet supported in progressive forms. ' +
'Will fallback to client hydration.',
);
}
}

function validateAdditionalFormFields(formData: void | null | FormData) {
if (formData != null) {
// $FlowFixMe[prop-missing]: FormData has forEach.
formData.forEach(validateAdditionalFormField);
}
return formData;
}

function getCustomFormFields(
resumableState: ResumableState,
formAction: any,
Expand All @@ -1048,7 +1060,11 @@ function getCustomFormFields(
if (typeof customAction === 'function') {
const prefix = makeFormFieldPrefix(resumableState);
try {
return formAction.$$FORM_ACTION(prefix);
const customFields = formAction.$$FORM_ACTION(prefix);
if (customFields) {
validateAdditionalFormFields(customFields.data);
}
return customFields;
} catch (x) {
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
// Rethrow suspense.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@ global.ReadableStream =
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;

// Polyfill stream methods on JSDOM.
global.Blob.prototype.stream = function () {
const impl = Object.getOwnPropertySymbols(this)[0];
const buffer = this[impl]._buffer;
return new ReadableStream({
start(c) {
c.enqueue(new Uint8Array(buffer));
c.close();
},
});
};

global.Blob.prototype.text = async function () {
const impl = Object.getOwnPropertySymbols(this)[0];
return this[impl]._buffer.toString('utf8');
};

// Don't wait before processing work on the server.
// TODO: we can replace this with FlightServer.act().
global.setTimeout = cb => cb();
Expand Down Expand Up @@ -962,4 +979,80 @@ describe('ReactFlightDOMForm', () => {
expect(form2.textContent).toBe('error message');
expect(form2.firstChild.tagName).toBe('DIV');
});

// @gate enableAsyncActions && enableBinaryFlight
it('useActionState can return binary state during MPA form submission', async () => {
const serverAction = serverExports(
async function action(prevState, formData) {
return new Blob([new Uint8Array([104, 105])]);
},
);

let blob;

function Form({action}) {
const [errorMsg, dispatch] = useActionState(action, null);
let text;
if (errorMsg) {
blob = errorMsg;
text = React.use(blob.text());
}
return <form action={dispatch}>{text}</form>;
}

const FormRef = await clientExports(Form);

const rscStream = ReactServerDOMServer.renderToReadableStream(
<FormRef action={serverAction} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);

const form1 = container.getElementsByTagName('form')[0];
expect(form1.textContent).toBe('');

async function submitTheForm() {
const form = container.getElementsByTagName('form')[0];
const {formState} = await submit(form);

// Simulate an MPA form submission by resetting the container and
// rendering again.
container.innerHTML = '';

const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
{formState, root: <FormRef action={serverAction} />},
webpackMap,
);
const postbackResponse =
await ReactServerDOMClient.createFromReadableStream(postbackRscStream, {
ssrManifest: {
moduleMap: null,
moduleLoading: null,
},
});
const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
postbackResponse.root,
{formState: postbackResponse.formState},
);
await readIntoContainer(postbackSsrStream);
}

await expect(submitTheForm).toErrorDev(
'Warning: Failed to serialize an action for progressive enhancement:\n' +
'Error: File/Blob fields are not yet supported in progressive forms. Will fallback to client hydration.',
);

expect(blob instanceof Blob).toBe(true);
expect(blob.size).toBe(2);

const form2 = container.getElementsByTagName('form')[0];
expect(form2.textContent).toBe('hi');
});
});
2 changes: 1 addition & 1 deletion scripts/error-codes/codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@
"477": "React Internal Error: processHintChunk is not implemented for Native-Relay. The fact that this method was called means there is a bug in React.",
"478": "Thenable should have already resolved. This is a bug in React.",
"479": "Cannot update optimistic state while rendering.",
"480": "File/Blob fields are not yet supported in progressive forms. It probably means you are closing over binary data or FormData in a Server Action.",
"480": "File/Blob fields are not yet supported in progressive forms. Will fallback to client hydration.",
"481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React.",
"482": "async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.",
"483": "Hooks are not supported inside an async component. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.",
Expand Down

0 comments on commit 6bac4f2

Please sign in to comment.