[Flight] Progressively Enhanced Server Actions (#26774)

This automatically exposes `$$FORM_ACTIONS` on Server References coming
from Flight. So that when they're used in a form action, we can encode
the ID for the server reference as a hidden field or as part of the name
of a button.

If the Server Action is a bound function it can have complex data
associated with it. In this case this additional data is encoded as
additional form fields.

To process a POST on the server there's now a `decodeAction` helper that
can take one of these progressive posts from FormData and give you a
function that is prebound with the correct closure and FormData so that
you can just invoke it.

I updated the fixture which now has a "Server State" that gets
automatically refreshed. This also lets us visualize form fields.
There's no "Action State" here for showing error messages that are not
thrown, that's still up to user space.
This commit is contained in:
Sebastian Markbåge 2023-05-03 18:36:57 -04:00 committed by GitHub
parent c10010a6a0
commit aef7ce5547
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 588 additions and 95 deletions

View File

@ -95,6 +95,8 @@ app.all('/', async function (req, res, next) {
if (req.get('rsc-action')) {
proxiedHeaders['Content-type'] = req.get('Content-type');
proxiedHeaders['rsc-action'] = req.get('rsc-action');
} else if (req.get('Content-type')) {
proxiedHeaders['Content-type'] = req.get('Content-type');
}
const promiseForData = request(

View File

@ -36,6 +36,7 @@ const bodyParser = require('body-parser');
const busboy = require('busboy');
const app = express();
const compress = require('compression');
const {Readable} = require('node:stream');
app.use(compress());
@ -45,7 +46,7 @@ const {readFile} = require('fs').promises;
const React = require('react');
app.get('/', async function (req, res) {
async function renderApp(res, returnValue) {
const {renderToPipeableStream} = await import(
'react-server-dom-webpack/server'
);
@ -91,37 +92,74 @@ app.get('/', async function (req, res) {
),
React.createElement(App),
];
const {pipe} = renderToPipeableStream(root, moduleMap);
// For client-invoked server actions we refresh the tree and return a return value.
const payload = returnValue ? {returnValue, root} : root;
const {pipe} = renderToPipeableStream(payload, moduleMap);
pipe(res);
}
app.get('/', async function (req, res) {
await renderApp(res, null);
});
app.post('/', bodyParser.text(), async function (req, res) {
const {renderToPipeableStream, decodeReply, decodeReplyFromBusboy} =
await import('react-server-dom-webpack/server');
const {
renderToPipeableStream,
decodeReply,
decodeReplyFromBusboy,
decodeAction,
} = await import('react-server-dom-webpack/server');
const serverReference = req.get('rsc-action');
const [filepath, name] = serverReference.split('#');
const action = (await import(filepath))[name];
// Validate that this is actually a function we intended to expose and
// not the client trying to invoke arbitrary functions. In a real app,
// you'd have a manifest verifying this before even importing it.
if (action.$$typeof !== Symbol.for('react.server.reference')) {
throw new Error('Invalid action');
}
if (serverReference) {
// This is the client-side case
const [filepath, name] = serverReference.split('#');
const action = (await import(filepath))[name];
// Validate that this is actually a function we intended to expose and
// not the client trying to invoke arbitrary functions. In a real app,
// you'd have a manifest verifying this before even importing it.
if (action.$$typeof !== Symbol.for('react.server.reference')) {
throw new Error('Invalid action');
}
let args;
if (req.is('multipart/form-data')) {
// Use busboy to streamingly parse the reply from form-data.
const bb = busboy({headers: req.headers});
const reply = decodeReplyFromBusboy(bb);
req.pipe(bb);
args = await reply;
let args;
if (req.is('multipart/form-data')) {
// Use busboy to streamingly parse the reply from form-data.
const bb = busboy({headers: req.headers});
const reply = decodeReplyFromBusboy(bb);
req.pipe(bb);
args = await reply;
} else {
args = await decodeReply(req.body);
}
const result = action.apply(null, args);
try {
// Wait for any mutations
await result;
} catch (x) {
// We handle the error on the client
}
// Refresh the client and return the value
renderApp(res, result);
} else {
args = await decodeReply(req.body);
// This is the progressive enhancement case
const UndiciRequest = require('undici').Request;
const fakeRequest = new UndiciRequest('http://localhost', {
method: 'POST',
headers: {'Content-Type': req.headers['content-type']},
body: Readable.toWeb(req),
duplex: 'half',
});
const formData = await fakeRequest.formData();
const action = await decodeAction(formData);
try {
// Wait for any mutations
await action();
} catch (x) {
const {setServerState} = await import('../src/ServerState.js');
setServerState('Error: ' + x.message);
}
renderApp(res, null);
}
const result = action.apply(null, args);
const {pipe} = renderToPipeableStream(result, {});
pipe(res);
});
app.get('/todos', function (req, res) {

View File

@ -11,6 +11,8 @@ import Form from './Form.js';
import {like, greet} from './actions.js';
import {getServerState} from './ServerState.js';
export default async function App() {
const res = await fetch('http://localhost:3001/todos');
const todos = await res.json();
@ -23,7 +25,7 @@ export default async function App() {
</head>
<body>
<Container>
<h1>Hello, world</h1>
<h1>{getServerState()}</h1>
<Counter />
<Counter2 />
<ul>

View File

@ -7,12 +7,7 @@ import ErrorBoundary from './ErrorBoundary.js';
function ButtonDisabledWhilePending({action, children}) {
const {pending} = useFormStatus();
return (
<button
disabled={pending}
formAction={async () => {
const result = await action();
console.log(result);
}}>
<button disabled={pending} formAction={action}>
{children}
</button>
);

View File

@ -14,11 +14,7 @@ export default function Form({action, children}) {
return (
<ErrorBoundary>
<form
action={async formData => {
const result = await action(formData);
alert(result);
}}>
<form action={action}>
<label>
Name: <input name="name" />
</label>

View File

@ -0,0 +1,9 @@
let serverState = 'Hello World';
export function setServerState(message) {
serverState = message;
}
export function getServerState() {
return serverState;
}

View File

@ -1,11 +1,15 @@
'use server';
import {setServerState} from './ServerState.js';
export async function like() {
setServerState('Liked!');
return new Promise((resolve, reject) => resolve('Liked'));
}
export async function greet(formData) {
const name = formData.get('name') || 'you';
setServerState('Hi ' + name);
const file = formData.get('file');
if (file) {
return `Ok, ${name}, here is ${file.name}:

View File

@ -1,11 +1,29 @@
import * as React from 'react';
import {use, Suspense} from 'react';
import {use, Suspense, useState, startTransition} from 'react';
import ReactDOM from 'react-dom/client';
import {createFromFetch, encodeReply} from 'react-server-dom-webpack/client';
// TODO: This should be a dependency of the App but we haven't implemented CSS in Node yet.
import './style.css';
let updateRoot;
async function callServer(id, args) {
const response = fetch('/', {
method: 'POST',
headers: {
Accept: 'text/x-component',
'rsc-action': id,
},
body: await encodeReply(args),
});
const {returnValue, root} = await createFromFetch(response, {callServer});
// Refresh the tree with the new RSC payload.
startTransition(() => {
updateRoot(root);
});
return returnValue;
}
let data = createFromFetch(
fetch('/', {
headers: {
@ -13,22 +31,14 @@ let data = createFromFetch(
},
}),
{
async callServer(id, args) {
const response = fetch('/', {
method: 'POST',
headers: {
Accept: 'text/x-component',
'rsc-action': id,
},
body: await encodeReply(args),
});
return createFromFetch(response);
},
callServer,
}
);
function Shell({data}) {
return use(data);
const [root, setRoot] = useState(use(data));
updateRoot = setRoot;
return root;
}
ReactDOM.hydrateRoot(document, <Shell data={data} />);

View File

@ -20,6 +20,8 @@ import type {
import type {HintModel} from 'react-server/src/ReactFlightServerConfig';
import type {CallServerCallback} from './ReactFlightReplyClient';
import {
resolveClientReference,
preloadModule,
@ -28,13 +30,16 @@ import {
dispatchHint,
} from './ReactFlightClientConfig';
import {knownServerReferences} from './ReactFlightServerReferenceRegistry';
import {
encodeFormAction,
knownServerReferences,
} from './ReactFlightReplyClient';
import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
export type {CallServerCallback};
export type JSONValue =
| number
@ -500,6 +505,9 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
return callServer(metaData.id, bound.concat(args));
});
};
// Expose encoder for use by SSR.
// TODO: Only expose this in SSR builds and not the browser client.
proxy.$$FORM_ACTION = encodeFormAction;
knownServerReferences.set(proxy, metaData);
return proxy;
}

View File

@ -7,12 +7,7 @@
* @flow
*/
import type {Thenable} from 'shared/ReactTypes';
import {
knownServerReferences,
createServerReference,
} from './ReactFlightServerReferenceRegistry';
import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes';
import {
REACT_ELEMENT_TYPE,
@ -28,6 +23,10 @@ import {
} from 'shared/ReactSerializationErrors';
import isArray from 'shared/isArray';
import type {
FulfilledThenable,
RejectedThenable,
} from '../../shared/ReactTypes';
type ReactJSONValue =
| string
@ -39,6 +38,15 @@ type ReactJSONValue =
export opaque type ServerReference<T> = T;
export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
export type ServerReferenceId = any;
export const knownServerReferences: WeakMap<
Function,
{id: ServerReferenceId, bound: null | Thenable<Array<any>>},
> = new WeakMap();
// Serializable values
export type ReactServerValue =
// References are passed by their value
@ -363,4 +371,104 @@ export function processReply(
}
}
export {createServerReference};
const boundCache: WeakMap<
{id: ServerReferenceId, bound: null | Thenable<Array<any>>},
Thenable<FormData>,
> = new WeakMap();
function encodeFormData(reference: any): Thenable<FormData> {
let resolve, reject;
// We need to have a handle on the thenable so that we can synchronously set
// its status from processReply, when it can complete synchronously.
const thenable: Thenable<FormData> = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
processReply(
reference,
'',
(body: string | FormData) => {
if (typeof body === 'string') {
const data = new FormData();
data.append('0', body);
body = data;
}
const fulfilled: FulfilledThenable<FormData> = (thenable: any);
fulfilled.status = 'fulfilled';
fulfilled.value = body;
resolve(body);
},
e => {
const rejected: RejectedThenable<FormData> = (thenable: any);
rejected.status = 'rejected';
rejected.reason = e;
reject(e);
},
);
return thenable;
}
export function encodeFormAction(
this: any => Promise<any>,
identifierPrefix: string,
): ReactCustomFormAction {
const reference = knownServerReferences.get(this);
if (!reference) {
throw new Error(
'Tried to encode a Server Action from a different instance than the encoder is from. ' +
'This is a bug in React.',
);
}
let data: null | FormData = null;
let name;
const boundPromise = reference.bound;
if (boundPromise !== null) {
let thenable = boundCache.get(reference);
if (!thenable) {
thenable = encodeFormData(reference);
boundCache.set(reference, thenable);
}
if (thenable.status === 'rejected') {
throw thenable.reason;
} else if (thenable.status !== 'fulfilled') {
throw thenable;
}
const encodedFormData = thenable.value;
// This is hacky but we need the identifier prefix to be added to
// all fields but the suspense cache would break since we might get
// a new identifier each time. So we just append it at the end instead.
const prefixedData = new FormData();
// $FlowFixMe[prop-missing]
encodedFormData.forEach((value: string | File, key: string) => {
prefixedData.append('$ACTION_' + identifierPrefix + ':' + key, value);
});
data = prefixedData;
// We encode the name of the prefix containing the data.
name = '$ACTION_REF_' + identifierPrefix;
} else {
// This is the simple case so we can just encode the ID.
name = '$ACTION_ID_' + reference.id;
}
return {
name: name,
method: 'POST',
encType: 'multipart/form-data',
data: data,
};
}
export function createServerReference<A: Iterable<any>, T>(
id: ServerReferenceId,
callServer: CallServerCallback,
): (...A) => Promise<T> {
const proxy = function (): Promise<T> {
// $FlowFixMe[method-unbinding]
const args = Array.prototype.slice.call(arguments);
return callServer(id, args);
};
// Expose encoder for use by SSR.
// TODO: Only expose this in SSR builds and not the browser client.
proxy.$$FORM_ACTION = encodeFormAction;
knownServerReferences.set(proxy, {id: id, bound: null});
return proxy;
}

View File

@ -1,32 +0,0 @@
/**
* 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.
*
* @flow
*/
import type {Thenable} from 'shared/ReactTypes';
export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
type ServerReferenceId = any;
export const knownServerReferences: WeakMap<
Function,
{id: ServerReferenceId, bound: null | Thenable<Array<any>>},
> = new WeakMap();
export function createServerReference<A: Iterable<any>, T>(
id: ServerReferenceId,
callServer: CallServerCallback,
): (...A) => Promise<T> {
const proxy = function (): Promise<T> {
// $FlowFixMe[method-unbinding]
const args = Array.prototype.slice.call(arguments);
return callServer(id, args);
};
knownServerReferences.set(proxy, {id: id, bound: null});
return proxy;
}

View File

@ -672,7 +672,7 @@ function makeFormFieldPrefix(responseState: ResponseState): string {
// I'm just reusing this counter. It's not really the same namespace as "name".
// It could just be its own counter.
const id = responseState.nextSuspenseID++;
return responseState.idPrefix + '$ACTION:' + id + ':';
return responseState.idPrefix + id;
}
// Since this will likely be repeated a lot in the HTML, we use a more concise message

View File

@ -25,6 +25,8 @@ import {
getRoot,
} from 'react-server/src/ReactFlightReplyServer';
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
type Options = {
identifierPrefix?: string,
signal?: AbortSignal,
@ -87,4 +89,4 @@ function decodeReply<T>(
return getRoot(response);
}
export {renderToReadableStream, decodeReply};
export {renderToReadableStream, decodeReply, decodeAction};

View File

@ -25,6 +25,8 @@ import {
getRoot,
} from 'react-server/src/ReactFlightReplyServer';
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
type Options = {
identifierPrefix?: string,
signal?: AbortSignal,
@ -87,4 +89,4 @@ function decodeReply<T>(
return getRoot(response);
}
export {renderToReadableStream, decodeReply};
export {renderToReadableStream, decodeReply, decodeAction};

View File

@ -36,6 +36,8 @@ import {
getRoot,
} from 'react-server/src/ReactFlightReplyServer';
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
function createDrainHandler(destination: Destination, request: Request) {
return () => startFlowing(request, destination);
}
@ -148,4 +150,9 @@ function decodeReply<T>(
return getRoot(response);
}
export {renderToPipeableStream, decodeReplyFromBusboy, decodeReply};
export {
renderToPipeableStream,
decodeReplyFromBusboy,
decodeReply,
decodeAction,
};

View File

@ -0,0 +1,231 @@
/**
* 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
*/
'use strict';
import {insertNodesAndExecuteScripts} from 'react-dom/src/test-utils/FizzTestUtils';
// Polyfills for test environment
global.ReadableStream =
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
global.TextEncoder = require('util').TextEncoder;
global.TextDecoder = require('util').TextDecoder;
let container;
let serverExports;
let webpackServerMap;
let React;
let ReactDOMServer;
let ReactServerDOMServer;
let ReactServerDOMClient;
describe('ReactFlightDOMReply', () => {
beforeEach(() => {
jest.resetModules();
const WebpackMock = require('./utils/WebpackMock');
serverExports = WebpackMock.serverExports;
webpackServerMap = WebpackMock.webpackServerMap;
React = require('react');
ReactServerDOMServer = require('react-server-dom-webpack/server.browser');
ReactServerDOMClient = require('react-server-dom-webpack/client');
ReactDOMServer = require('react-dom/server.browser');
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
async function POST(formData) {
const boundAction = await ReactServerDOMServer.decodeAction(
formData,
webpackServerMap,
);
return boundAction();
}
function submit(submitter) {
const form = submitter.form || submitter;
if (!submitter.form) {
submitter = undefined;
}
const submitEvent = new Event('submit', {bubbles: true, cancelable: true});
submitEvent.submitter = submitter;
const returnValue = form.dispatchEvent(submitEvent);
if (!returnValue) {
return;
}
const action =
(submitter && submitter.getAttribute('formaction')) || form.action;
if (!/\s*javascript:/i.test(action)) {
const method = (submitter && submitter.formMethod) || form.method;
const encType = (submitter && submitter.formEnctype) || form.enctype;
if (method === 'post' && encType === 'multipart/form-data') {
let formData;
if (submitter) {
const temp = document.createElement('input');
temp.name = submitter.name;
temp.value = submitter.value;
submitter.parentNode.insertBefore(temp, submitter);
formData = new FormData(form);
temp.parentNode.removeChild(temp);
} else {
formData = new FormData(form);
}
return POST(formData);
}
throw new Error('Navigate to: ' + action);
}
}
async function readIntoContainer(stream) {
const reader = stream.getReader();
let result = '';
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
result += Buffer.from(value).toString('utf8');
}
const temp = document.createElement('div');
temp.innerHTML = result;
insertNodesAndExecuteScripts(temp, container, null);
}
// @gate enableFormActions
it('can submit a passed server action without hydrating it', async () => {
let foo = null;
const serverAction = serverExports(function action(formData) {
foo = formData.get('foo');
return 'hello';
});
function App() {
return (
<form action={serverAction}>
<input type="text" name="foo" defaultValue="bar" />
</form>
);
}
const rscStream = ReactServerDOMServer.renderToReadableStream(<App />);
const response = ReactServerDOMClient.createFromReadableStream(rscStream);
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form = container.firstChild;
expect(foo).toBe(null);
const result = await submit(form);
expect(result).toBe('hello');
expect(foo).toBe('bar');
});
// @gate enableFormActions
it('can submit an imported server action without hydrating it', async () => {
let foo = null;
const ServerModule = serverExports(function action(formData) {
foo = formData.get('foo');
return 'hi';
});
const serverAction = ReactServerDOMClient.createServerReference(
ServerModule.$$id,
);
function App() {
return (
<form action={serverAction}>
<input type="text" name="foo" defaultValue="bar" />
</form>
);
}
const ssrStream = await ReactDOMServer.renderToReadableStream(<App />);
await readIntoContainer(ssrStream);
const form = container.firstChild;
expect(foo).toBe(null);
const result = await submit(form);
expect(result).toBe('hi');
expect(foo).toBe('bar');
});
// @gate enableFormActions
it('can submit a complex closure server action without hydrating it', async () => {
let foo = null;
const serverAction = serverExports(function action(bound, formData) {
foo = formData.get('foo') + bound.complex;
return 'hello';
});
function App() {
return (
<form action={serverAction.bind(null, {complex: 'object'})}>
<input type="text" name="foo" defaultValue="bar" />
</form>
);
}
const rscStream = ReactServerDOMServer.renderToReadableStream(<App />);
const response = ReactServerDOMClient.createFromReadableStream(rscStream);
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form = container.firstChild;
expect(foo).toBe(null);
const result = await submit(form);
expect(result).toBe('hello');
expect(foo).toBe('barobject');
});
// @gate enableFormActions
it('can submit a multiple complex closure server action without hydrating it', async () => {
let foo = null;
const serverAction = serverExports(function action(bound, formData) {
foo = formData.get('foo') + bound.complex;
return 'hello' + bound.complex;
});
function App() {
return (
<form action={serverAction.bind(null, {complex: 'a'})}>
<input type="text" name="foo" defaultValue="bar" />
<button formAction={serverAction.bind(null, {complex: 'b'})} />
<button formAction={serverAction.bind(null, {complex: 'c'})} />
<input
type="submit"
formAction={serverAction.bind(null, {complex: 'd'})}
/>
</form>
);
}
const rscStream = ReactServerDOMServer.renderToReadableStream(<App />);
const response = ReactServerDOMClient.createFromReadableStream(rscStream);
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);
const form = container.firstChild;
expect(foo).toBe(null);
const result = await submit(form.getElementsByTagName('button')[1]);
expect(result).toBe('helloc');
expect(foo).toBe('barc');
});
});

View File

@ -0,0 +1,110 @@
/**
* 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.
*
* @flow
*/
import type {Thenable} from 'shared/ReactTypes';
import type {
ServerManifest,
ClientReference as ServerReference,
} from 'react-client/src/ReactFlightClientConfig';
import {
resolveServerReference,
preloadModule,
requireModule,
} from 'react-client/src/ReactFlightClientConfig';
import {createResponse, close, getRoot} from './ReactFlightReplyServer';
type ServerReferenceId = any;
function bindArgs(fn: any, args: any) {
return fn.bind.apply(fn, [null].concat(args));
}
function loadServerReference<T>(
bundlerConfig: ServerManifest,
id: ServerReferenceId,
bound: null | Thenable<Array<any>>,
): Promise<T> {
const serverReference: ServerReference<T> =
resolveServerReference<$FlowFixMe>(bundlerConfig, id);
// We expect most servers to not really need this because you'd just have all
// the relevant modules already loaded but it allows for lazy loading of code
// if needed.
const preloadPromise = preloadModule(serverReference);
if (bound) {
return Promise.all([(bound: any), preloadPromise]).then(
([args]: Array<any>) => bindArgs(requireModule(serverReference), args),
);
} else if (preloadPromise) {
return Promise.resolve(preloadPromise).then(() =>
requireModule(serverReference),
);
} else {
// Synchronously available
return Promise.resolve(requireModule(serverReference));
}
}
export function decodeAction<T>(
body: FormData,
serverManifest: ServerManifest,
): Promise<() => T> | null {
// We're going to create a new formData object that holds all the fields except
// the implementation details of the action data.
const formData = new FormData();
let action: Promise<(formData: FormData) => T> | null = null;
// $FlowFixMe[prop-missing]
body.forEach((value: string | File, key: string) => {
if (!key.startsWith('$ACTION_')) {
formData.append(key, value);
return;
}
// Later actions may override earlier actions if a button is used to override the default
// form action.
if (key.startsWith('$ACTION_REF_')) {
const formFieldPrefix = '$ACTION_' + key.slice(12) + ':';
// The data for this reference is encoded in multiple fields under this prefix.
const actionResponse = createResponse(
serverManifest,
formFieldPrefix,
body,
);
close(actionResponse);
const refPromise = getRoot<{
id: ServerReferenceId,
bound: null | Promise<Array<any>>,
}>(actionResponse);
// Force it to initialize
// $FlowFixMe
refPromise.then(() => {});
if (refPromise.status !== 'fulfilled') {
// $FlowFixMe
throw refPromise.reason;
}
const metaData = refPromise.value;
action = loadServerReference(serverManifest, metaData.id, metaData.bound);
return;
}
if (key.startsWith('$ACTION_ID_')) {
const id = key.slice(11);
action = loadServerReference(serverManifest, id, null);
return;
}
});
if (action === null) {
return null;
}
// Return the action with the remaining FormData bound to the first argument.
return action.then(fn => fn.bind(null, formData));
}

View File

@ -465,5 +465,6 @@
"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. It probably means you are closing over binary data or FormData in a Server Action.",
"481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React."
}