Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Failing after 34s
Build & Deploy / 🏗️ Build (push) Has started running
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Smoke Test (push) Has been cancelled
Build & Deploy / ⚡ Lighthouse (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
114 lines
5.4 KiB
Plaintext
114 lines
5.4 KiB
Plaintext
'use client';
|
|
|
|
import { dequal } from 'dequal/lite'; // lite: no need for Map and Set support
|
|
/**
|
|
* This function receives form state from the server and intelligently merges it into the client state.
|
|
* The server contains extra properties that the client may not have, e.g. custom components and error states.
|
|
* We typically do not want to merge properties that rely on user input, however, such as values, unless explicitly requested.
|
|
* Doing this would cause the client to lose any local changes to those fields.
|
|
*
|
|
* Note: Local state is the source of truth, not the new server state that is getting merged in. This is critical for array row
|
|
* manipulation specifically, where the user may have added, removed, or reordered rows while a request was pending and is now stale.
|
|
*
|
|
* This function applies some defaults, as well as cleans up the server response in preparation for the client.
|
|
* e.g. it will set `valid` and `passesCondition` to true if undefined, and remove `addedByServer` from the response.
|
|
*/
|
|
export const mergeServerFormState = ({
|
|
acceptValues,
|
|
currentState = {},
|
|
incomingState
|
|
}) => {
|
|
const newState = {
|
|
...currentState
|
|
};
|
|
for (const [path, incomingField] of Object.entries(incomingState || {})) {
|
|
if (!(path in currentState) && !incomingField.addedByServer) {
|
|
continue;
|
|
}
|
|
/**
|
|
* If it's a new field added by the server, always accept the value.
|
|
* Otherwise:
|
|
* a. accept all values when explicitly requested, e.g. on submit
|
|
* b. only accept values for unmodified fields, e.g. on autosave
|
|
*/
|
|
const shouldAcceptValue = incomingField.addedByServer || acceptValues === true || typeof acceptValues === 'object' && acceptValues !== null &&
|
|
// Note: Must be explicitly `false`, allow `null` or `undefined` to mean true
|
|
acceptValues.overrideLocalChanges === false && !currentState[path]?.isModified;
|
|
let sanitizedIncomingField = incomingField;
|
|
if (!shouldAcceptValue) {
|
|
/**
|
|
* Note: do not delete properties off `incomingField` as this will mutate the original object
|
|
* Instead, omit them from the destructured object by excluding specific keys
|
|
* This will also ensure we don't set `undefined` into the result unnecessarily
|
|
*/
|
|
const {
|
|
initialValue,
|
|
value,
|
|
...rest
|
|
} = incomingField;
|
|
sanitizedIncomingField = rest;
|
|
}
|
|
newState[path] = {
|
|
...currentState[path],
|
|
...sanitizedIncomingField
|
|
};
|
|
if (currentState[path] && 'errorPaths' in currentState[path] && !('errorPaths' in incomingField)) {
|
|
newState[path].errorPaths = [];
|
|
}
|
|
/**
|
|
* Deeply merge the rows array to ensure changes to local state are not lost while the request was pending
|
|
* For example, the server response could come back with a row which has been deleted on the client
|
|
* Loop over the incoming rows, if it exists in client side form state, merge in any new properties from the server
|
|
* Note: read `currentState` and not `newState` here, as the `rows` property have already been merged above
|
|
*/
|
|
if (Array.isArray(incomingField.rows) && path in currentState) {
|
|
newState[path].rows = [...(currentState[path]?.rows || [])]; // shallow copy to avoid mutating the original array
|
|
incomingField.rows.forEach(row => {
|
|
const indexInCurrentState = currentState[path].rows?.findIndex(existingRow => existingRow.id === row.id);
|
|
if (indexInCurrentState > -1) {
|
|
newState[path].rows[indexInCurrentState] = {
|
|
...currentState[path].rows[indexInCurrentState],
|
|
...row
|
|
};
|
|
} else if (row.addedByServer) {
|
|
/**
|
|
* Note: This is a known limitation of computed array and block rows
|
|
* If a new row was added by the server, we append it to the _end_ of this array
|
|
* This is because the client is the source of truth, and it has arrays ordered in a certain position
|
|
* For example, the user may have re-ordered rows client-side while a long running request is processing
|
|
* This means that we _cannot_ slice a new row into the second position on the server, for example
|
|
* By the time it gets back to the client, its index is stale
|
|
*/
|
|
const newRow = {
|
|
...row
|
|
};
|
|
delete newRow.addedByServer;
|
|
newState[path].rows.push(newRow);
|
|
}
|
|
});
|
|
}
|
|
// If `valid` is `undefined`, mark it as `true`
|
|
if (incomingField.valid !== false) {
|
|
newState[path].valid = true;
|
|
}
|
|
// If `passesCondition` is `undefined`, mark it as `true`
|
|
if (incomingField.passesCondition !== false) {
|
|
newState[path].passesCondition = true;
|
|
}
|
|
/**
|
|
* Undefined values for blocksFilterOptions coming back should be treated as "all blocks allowed" and
|
|
* should always be merged in.
|
|
* Without this, an undefined value coming back will incorrectly be ignored, and the previous filter will remain.
|
|
*/
|
|
if (!incomingField.blocksFilterOptions) {
|
|
delete newState[path].blocksFilterOptions;
|
|
}
|
|
// Strip away the `addedByServer` property from the client
|
|
// This will prevent it from being passed back to the server
|
|
delete newState[path].addedByServer;
|
|
}
|
|
// Return the original object reference if the state is unchanged
|
|
// This will avoid unnecessary re-renders and dependency updates
|
|
return dequal(newState, currentState) ? currentState : newState;
|
|
};
|
|
//# sourceMappingURL=mergeServerFormState.js.map |