main
js 210 lines 6.32 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import type {ReactFormState} from 'shared/ReactTypes';
11
12 import type {
13 ServerManifest,
14 ClientReference as ServerReference,
15 } from 'react-client/src/ReactFlightClientConfig';
16
17 import {
18 resolveServerReference,
19 preloadModule,
20 requireModule,
21 } from 'react-client/src/ReactFlightClientConfig';
22
23 import {
24 createResponse,
25 close,
26 getRoot,
27 MAX_BOUND_ARGS,
28 } from './ReactFlightReplyServer';
29
30 type ServerReferenceId = any;
31
32 function bindArgs(fn: any, args: any) {
33 if (args.length > MAX_BOUND_ARGS) {
34 throw new Error(
35 'Server Function has too many bound arguments. Received ' +
36 args.length +
37 ' but the limit is ' +
38 MAX_BOUND_ARGS +
39 '.',
40 );
41 }
42
43 return fn.bind.apply(fn, [null].concat(args));
44 }
45
46 function loadServerReference<T>(
47 bundlerConfig: ServerManifest,
48 metaData: {
49 id: ServerReferenceId,
50 bound: null | Promise<Array<any>>,
51 },
52 ): Promise<T> {
53 const id: ServerReferenceId = metaData.id;
54 if (typeof id !== 'string') {
55 return null as any;
56 }
57 const serverReference: ServerReference<T> =
58 resolveServerReference<$FlowFixMe>(bundlerConfig, id);
59 // We expect most servers to not really need this because you'd just have all
60 // the relevant modules already loaded but it allows for lazy loading of code
61 // if needed.
62 const preloadPromise = preloadModule(serverReference);
63 const bound = metaData.bound;
64 if (bound instanceof Promise) {
65 return Promise.all([bound as any, preloadPromise]).then(
66 ([args]: Array<any>) => bindArgs(requireModule(serverReference), args),
67 );
68 } else if (preloadPromise) {
69 return Promise.resolve(preloadPromise).then(() =>
70 requireModule(serverReference),
71 );
72 } else {
73 // Synchronously available
74 return Promise.resolve(requireModule(serverReference));
75 }
76 }
77
78 function decodeBoundActionMetaData(
79 body: FormData,
80 serverManifest: ServerManifest,
81 formFieldPrefix: string,
82 arraySizeLimit: void | number,
83 ): {id: ServerReferenceId, bound: null | Promise<Array<any>>} {
84 // The data for this reference is encoded in multiple fields under this prefix.
85 const actionResponse = createResponse(
86 serverManifest,
87 formFieldPrefix,
88 undefined,
89 body,
90 arraySizeLimit,
91 );
92 close(actionResponse);
93 const refPromise = getRoot<{
94 id: ServerReferenceId,
95 bound: null | Promise<Array<any>>,
96 }>(actionResponse);
97 // Force it to initialize
98 // $FlowFixMe[incompatible-type]
99 refPromise.then(() => {});
100 if (refPromise.status !== 'fulfilled') {
101 // $FlowFixMe[prop-missing]
102 throw refPromise.reason;
103 }
104 return refPromise.value;
105 }
106
107 export function decodeAction<T>(
108 body: FormData,
109 serverManifest: ServerManifest,
110 ): Promise<() => T> | null {
111 // We're going to create a new formData object that holds all the fields except
112 // the implementation details of the action data.
113 const formData = new FormData();
114
115 let maybeActionKey: null | string = null;
116
117 // $FlowFixMe[prop-missing]
118 body.forEach((value: string | File, key: string) => {
119 if (!key.startsWith('$ACTION_')) {
120 // $FlowFixMe[incompatible-type]
121 formData.append(key, value);
122 } else if (key.startsWith('$ACTION_REF_')) {
123 // Later actions may override earlier actions if a button is used to
124 // override the default form action. However, we don't expect the same
125 // action ref field to be sent multiple times in legitimate form data.
126 maybeActionKey = key;
127 } else if (key.startsWith('$ACTION_ID_')) {
128 // A simple action with no bound arguments may appear twice in the form data
129 // if a button specifies the same action as the default form action.
130 maybeActionKey = key;
131 }
132 });
133
134 if (maybeActionKey === null) {
135 return null;
136 }
137 const actionKey = maybeActionKey;
138
139 let action: Promise<(formData: FormData) => T> | null = null;
140 if (actionKey.startsWith('$ACTION_REF_')) {
141 const formFieldPrefix =
142 '$ACTION_' + actionKey.slice('$ACTION_REF_'.length) + ':';
143 const metaData = decodeBoundActionMetaData(
144 body,
145 serverManifest,
146 formFieldPrefix,
147 );
148 action = loadServerReference(serverManifest, metaData);
149 } else if (actionKey.startsWith('$ACTION_ID_')) {
150 const id = actionKey.slice('$ACTION_ID_'.length);
151 action = loadServerReference(serverManifest, {
152 id,
153 bound: null,
154 });
155 } else {
156 throw new Error('Cannot handle action key. This is a bug in React.');
157 }
158
159 // Return the action with the remaining FormData bound to the first argument.
160 return action.then(fn => fn.bind(null, formData));
161 }
162
163 export function decodeFormState<S>(
164 actionResult: S,
165 body: FormData,
166 serverManifest: ServerManifest,
167 ): Promise<ReactFormState<S, ServerReferenceId> | null> {
168 const keyPath = body.get('$ACTION_KEY');
169 if (typeof keyPath !== 'string') {
170 // This form submission did not include any form state.
171 return Promise.resolve(null);
172 }
173 // Search through the form data object to get the reference id and the number
174 // of bound arguments. This repeats some of the work done in decodeAction.
175 let actionKey: null | string = null;
176 // $FlowFixMe[prop-missing]
177 body.forEach((value: string | File, key: string) => {
178 if (key.startsWith('$ACTION_REF_')) {
179 actionKey = key;
180 }
181 // We don't check for the simple $ACTION_ID_ case because form state actions
182 // are always bound to the state argument.
183 });
184 if (actionKey === null) {
185 // Should be unreachable.
186 return Promise.resolve(null);
187 }
188
189 const formFieldPrefix =
190 '$ACTION_' + actionKey.slice('$ACTION_REF_'.length) + ':';
191 const metaData = decodeBoundActionMetaData(
192 body,
193 serverManifest,
194 formFieldPrefix,
195 );
196
197 const referenceId = metaData.id;
198 return Promise.resolve(metaData.bound).then(bound => {
199 if (bound === null) {
200 // Should be unreachable because form state actions are always bound to the
201 // state argument.
202 return null;
203 }
204 // The form action dispatch method is always bound to the initial state.
205 // But when comparing signatures, we compare to the original unbound action.
206 // Subtract one from the arity to account for this.
207 const boundArity = bound.length - 1;
208 return [actionResult, keyPath, referenceId, boundArity];
209 });
210 }