Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Memoize field validation results (#10714)
Browse files Browse the repository at this point in the history
* Memoize field validation results

* Make validation memoization opt-in
  • Loading branch information
t3chguy committed Apr 27, 2023
1 parent a629ce3 commit f5d05f3
Show file tree
Hide file tree
Showing 4 changed files with 85 additions and 55 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"matrix-events-sdk": "0.0.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^1.3.1",
"memoize-one": "^5.1.1",
"minimist": "^1.2.5",
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
Expand Down
1 change: 1 addition & 0 deletions src/components/views/auth/PassphraseField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class PassphraseField extends PureComponent<IProps> {
},
},
],
memoize: true,
});

public onValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
Expand Down
1 change: 1 addition & 0 deletions src/components/views/directory/NetworkDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const validServer = withValidation<undefined, { error?: MatrixError }>({
: _t("Can't find this server or its room list"),
},
],
memoize: true,
});

function useSettingsValueWithSetter<T>(
Expand Down
137 changes: 82 additions & 55 deletions src/components/views/elements/Validation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React, { ReactNode } from "react";
import React, { ReactChild, ReactNode } from "react";
import classNames from "classnames";
import memoizeOne from "memoize-one";

type Data = Pick<IFieldState, "value" | "allowEmpty">;

Expand All @@ -40,6 +41,7 @@ interface IArgs<T, D = void> {
description?(this: T, derivedData: D, results: IResult[]): ReactNode;
hideDescriptionIfValid?: boolean;
deriveData?(data: Data): Promise<D>;
memoize?: boolean;
}

export interface IFieldState {
Expand All @@ -60,7 +62,7 @@ export interface IValidationResult {
* @param {Function} description
* Function that returns a string summary of the kind of value that will
* meet the validation rules. Shown at the top of the validation feedback.
* @param {Boolean} hideDescriptionIfValid
* @param {boolean} hideDescriptionIfValid
* If true, don't show the description if the validation passes validation.
* @param {Function} deriveData
* Optional function that returns a Promise to an object of generic type D.
Expand All @@ -75,6 +77,9 @@ export interface IValidationResult {
* - `valid`: Function returning text to show when the rule is valid. Only shown if set.
* - `invalid`: Function returning text to show when the rule is invalid. Only shown if set.
* - `final`: A Boolean if true states that this rule will only be considered if all rules before it returned valid.
* @param {boolean?} memoize
* If true, will use memoization to avoid calling deriveData & rules unless the value or allowEmpty change.
* Be careful to not use this if your validation is not pure and depends on other fields, such as "repeat password".
* @returns {Function}
* A validation function that takes in the current input value and returns
* the overall validity and a feedback UI that can be rendered for more detail.
Expand All @@ -84,73 +89,87 @@ export default function withValidation<T = void, D = void>({
hideDescriptionIfValid,
deriveData,
rules,
}: IArgs<T, D>) {
return async function onValidate(
memoize,
}: IArgs<T, D>): (fieldState: IFieldState) => Promise<IValidationResult> {
let checkRules = async function (
this: T,
{ value, focused, allowEmpty = true }: IFieldState,
): Promise<IValidationResult> {
if (!value && allowEmpty) {
return {};
}

const data = { value, allowEmpty };
// We know that if deriveData is set then D will not be undefined
const derivedData: D = (await deriveData?.call(this, data)) as D;

data: Data,
derivedData: D,
): Promise<[valid: boolean, results: IResult[]]> {
const results: IResult[] = [];
let valid = true;
if (rules?.length) {
for (const rule of rules) {
if (!rule.key || !rule.test) {
continue;
}
for (const rule of rules) {
if (!rule.key || !rule.test) {
continue;
}

if (!valid && rule.final) {
continue;
}
if (!valid && rule.final) {
continue;
}

if (rule.skip?.call(this, data, derivedData)) {
continue;
}

if (rule.skip?.call(this, data, derivedData)) {
// We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const ruleValid: boolean = await rule.test.call(this, data, derivedData);
valid = valid && ruleValid;
if (ruleValid && rule.valid) {
// If the rule's result is valid and has text to show for
// the valid state, show it.
const text = rule.valid.call(this, derivedData);
if (!text) {
continue;
}

// We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const ruleValid: boolean = await rule.test.call(this, data, derivedData);
valid = valid && ruleValid;
if (ruleValid && rule.valid) {
// If the rule's result is valid and has text to show for
// the valid state, show it.
const text = rule.valid.call(this, derivedData);
if (!text) {
continue;
}
results.push({
key: rule.key,
valid: true,
text,
});
} else if (!ruleValid && rule.invalid) {
// If the rule's result is invalid and has text to show for
// the invalid state, show it.
const text = rule.invalid.call(this, derivedData);
if (!text) {
continue;
}
results.push({
key: rule.key,
valid: false,
text,
});
results.push({
key: rule.key,
valid: true,
text,
});
} else if (!ruleValid && rule.invalid) {
// If the rule's result is invalid and has text to show for
// the invalid state, show it.
const text = rule.invalid.call(this, derivedData);
if (!text) {
continue;
}
results.push({
key: rule.key,
valid: false,
text,
});
}
}

return [valid, results];
};

// We have to memoize it in chunks as `focused` can change frequently, but it isn't passed to these methods
if (memoize) {
if (deriveData) deriveData = memoizeOne(deriveData, isDataEqual);
checkRules = memoizeOne(checkRules, isDerivedDataEqual);
}

return async function onValidate(
this: T,
{ value, focused, allowEmpty = true }: IFieldState,
): Promise<IValidationResult> {
if (!value && allowEmpty) {
return {};
}

const data = { value, allowEmpty };
// We know that if deriveData is set then D will not be undefined
const derivedData = (await deriveData?.call(this, data)) as D;
const [valid, results] = await checkRules.call(this, data, derivedData);

// Hide feedback when not focused
if (!focused) {
return { valid };
}

let details;
let details: ReactNode | undefined;
if (results && results.length) {
details = (
<ul className="mx_Validation_details">
Expand All @@ -170,15 +189,15 @@ export default function withValidation<T = void, D = void>({
);
}

let summary;
let summary: ReactNode | undefined;
if (description && (details || !hideDescriptionIfValid)) {
// We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const content = description.call(this, derivedData, results);
summary = content ? <div className="mx_Validation_description">{content}</div> : undefined;
}

let feedback;
let feedback: ReactChild | undefined;
if (summary || details) {
feedback = (
<div className="mx_Validation">
Expand All @@ -194,3 +213,11 @@ export default function withValidation<T = void, D = void>({
};
};
}

function isDataEqual([a]: [Data], [b]: [Data]): boolean {
return a.value === b.value && a.allowEmpty === b.allowEmpty;
}

function isDerivedDataEqual([a1, a2]: [Data, any], [b1, b2]: [Data, any]): boolean {
return a2 === b2 && isDataEqual([a1], [b1]);
}

0 comments on commit f5d05f3

Please sign in to comment.