Nested Form Updating Parent with AJV, data is null

Hi,

I have this setup…

<JsonForms
    ajv={customAjv}
    schema={formSchemaData}
    uischema={formUiSchemaData}
    data={data}
    renderers={renderers}
    cells={materialCells}
    onChange={({ data, errors }) => setDataAndValidate(data, errors)}
    />

The schema contains a custom control that fetches a list of items from an endpoint into a dropdown, upon selecting an item, another call is made to fetch the formschema and formuischema for that item. This then renders a child form beneath that control.

All good so far. Inside the custom control, the JsonForms part looks like this:

{selectedFeature != null &&
    <JsonForms
        ajv={customAjv}
        schema={formSchemaData}
        uischema={formUiSchemaData}
        data={featureData}
        renderers={renderers}
        cells={materialCells}
        onChange={({ data, errors }) => setDataAndValidate(data, errors)}
    />
}

with SetDataAndValidate as such:

const setDataAndValidate = (data: {}, errors: ErrorObject[] | undefined) => {
    if (data != null && featureData !== data) {
        setFeatureData(data)
        handleChange(path + ".data", data);
    }
    if (errors && setFeatureDataErrors !== undefined) {
        setFeatureDataErrors(() => (errors));
    }
}

So handleChange sets the value from the child form in the parent path, under “.data”.

This all works fine without a custom ajv instance passed into the parent form. This is being used in both forms to allow for custom errors with ajv-errors…

const customAjv = createAjv({ allErrors: true });
require("ajv-errors")(customAjv);

But when using this Ajv instance, the handleChange function does not actually set the value in the parent.

In the debugger, I can get as far as seeing the value passed over to dispatch within handleChange, but then nothing within updater after it:

Any ideas what I’m missing here? I can’t see how the custom ajv instance could be causing this…

Hi!

In general I don’t really see how using a custom AJV is causing the described issue. To see what is going on you should debug within JSON Forms’ core where we handle the consequence of a data update including running AJV.

With ajv-errors you must use the version which is compatible with the AJV version used in JSON Forms. That’s AJV v6 before 3.0 and AJV v8 starting with 3.0. The ajv-errors version must be chosen accordingly. If there is a mismatch all kind of weird things could occur.

Why exactly do you need ajv-errors for? For customized error messages you could also use the built-in i18n support.

Thanks for the reply!

I don’t think I can use i18n, because I don’t want to handle many different error messages in code given the child form schema’s will vary significantly. E.g I’d prefer all the error messages to be handled in the schema json which is external to the main application and not in the code.

It still occurs if I comment out ajv-errors: //require(“ajv-errors”)(customAjv);

So I assume there is some bug somewhere. I’ll take a look at case UPDATE_DATA now, thanks for the pointer.

It looks like a state issue with setting action.data in UPDATE_CORE, with the custom Ajv instance in the parent form, action.data here is empty, but state.data does happen to contain the update. However the value that gets returned is action.data:

const stateChanged = state.data !== action.data || state.schema !== action.schema || state.uischema !== action.uischema || state.ajv !== thisAjv || state.errors !== errors || state.validator !== validator || state.validationMode !== validationMode || state.additionalErrors !== additionalErrors;
        return stateChanged ? {
          ...state,
          data: action.data,
          schema: action.schema,
          uischema: action.uischema,
          ajv: thisAjv,
          errors: isEqual(errors, state.errors) ? state.errors : errors,
          validator: validator,
          validationMode: validationMode,
          additionalErrors
        } : state;

Hi @chriswhitehead, this looks like an issue with the synchronization between the two JSON Forms instances. UPDATE_CORE is called whenever a JSON Forms instance is rerendered. So it seems the nested JSON Forms is rerendered with old data and therefore the data is reset. Can you show the full code of the renderer embedding JSON Forms?

Thanks Stefan, makes sense, though this does not happen at all when you don’t pass in a custom instance of Ajv, it only occurs when that is included. So, I guess we are getting a re-render due to the custom Ajv?

I’ve snipped out a few functions to reduce the size here (they fetch data into the MuiAutocomplete control and call setOptions, setFormSchemaData and setFormUiSchemaData when initially clicking and then when selecting an item in the autocomplete control).

import React, { ReactNode, useContext, useEffect, useState } from 'react';
import {
    and,
    ControlProps,
    createAjv,
    EnumCellProps,
    isDescriptionHidden,
    JsonSchema,
    optionIs,
    OwnPropsOfEnum,
    RankedTester,
    rankWith,
    UISchemaElement,
    uiTypeIs,
    WithClassname,
} from '@jsonforms/core';
import {
    JsonForms,
    TranslateProps,
    withJsonFormsOneOfEnumProps,
    withTranslateProps,
} from '@jsonforms/react';
import { ErrorObject } from 'ajv';
import { materialCells, materialRenderers } from '@jsonforms/material-renderers';
import merge from 'lodash/merge';
import {
    Autocomplete,
    AutocompleteRenderOptionState,
    Box,
    CircularProgress,
    createFilterOptions,
    FilterOptionsState,
    FormHelperText,
    Hidden,
    TextField,
    Tooltip,
    Typography
} from '@mui/material';
import MoreIcon from '@mui/icons-material/More';
import { useFocus } from '@jsonforms/material-renderers';
import parse from 'autosuggest-highlight/parse';
import match from 'autosuggest-highlight/match';
import { authRequest, protectedResources } from '../../../authConfig';
import { customRenderers } from '../CustomRenderers';
import { useSessionStorage } from '../../../hooks/useSessionStorage';
import { FeatureDataErrorsContext } from '../../../pages/InformationForm';

export interface WithOptionLabel {
    getOptionLabel?(option: Feature): string;
    renderOption?(
        props: React.HTMLAttributes<HTMLLIElement>,
        option: Feature,
        state: AutocompleteRenderOptionState
    ): ReactNode;
    filterOptions?(
        options: Feature[],
        state: FilterOptionsState<Feature>
    ): Feature[];
}

interface Feature {
    name: string;
    id: number;
    tags: string;
    formSchema?: string;
    formUiSchema?: string;
}

const MuiAutocomplete = (
    props: ControlProps & EnumCellProps & WithClassname & WithOptionLabel
) => {
    const {
        description,
        errors,
        visible,
        required,
        label,
        data,
        className,
        id,
        enabled,
        uischema,
        path,
        handleChange,
        config,
        getOptionLabel,
        isValid,
    } = props;

    const appliedUiSchemaOptions = merge({}, config, uischema.options);

    const renderers = [
        ...materialRenderers,
        ...customRenderers
    ];

    const customAjv = createAjv({ useDefaults: true, allErrors: true });
    require("ajv-errors")(customAjv);

    const [formSchemaData, setFormSchemaData] = useState<JsonSchema | undefined>(undefined);
    const [formUiSchemaData, setFormUiSchemaData] = useState<UISchemaElement | undefined>(undefined);
    const [featureData, setFeatureData] = useState(data?.data ?? {});
    const { setFeatureDataErrors } = useContext(FeatureDataErrorsContext);

    const [focused, onFocus, onBlur] = useFocus();
    const [open, setOpen] = useState(false);
    const [options, setOptions] = useSessionStorage('features', []);
    const [selectedFeature, setSelectedFeature] = useSessionStorage('selectedFeature', data ?? null);
    const [selectedFeatureName, setSelectedFeatureName] = useState<string>(data?.name ?? '');
    const [selectedFeatureId, setSelectedFeatureId] = useState<number | null>(data?.id ?? null);
    const loading = (open && options?.length === 0);
    
    const filterOptions = createFilterOptions({
        matchFrom: 'any',
        limit: 1000,
        stringify: (option: Feature) => option.name + option.tags,
    });

    const findOption = options?.find((o: { id: any; }) => data?.id === o.id) ?? null;

**<SNIPPED OUT UseEffect calls that fetch the data into the MuiAutoComplete>**

    const showDescription = !isDescriptionHidden(
        visible,
        description,
        focused,
        appliedUiSchemaOptions.showUnfocusedDescription
    );

    const firstFormHelperText = showDescription
        ? description
        : !isValid
            ? errors
            : null;
    const secondFormHelperText = showDescription && !isValid ? errors : null;

    const setDataAndValidate = (data: {}, errors: ErrorObject[] | undefined) => {
        if (data != null && featureData !== data) {
            setFeatureData(data)
            handleChange(path + ".data", data);
        }
        if (errors && setFeatureDataErrors !== undefined) {
            setFeatureDataErrors(() => (errors));
        }
    }

    return (
        <Hidden xsUp={!visible}>
            <Autocomplete
                className={className}
                id={id}
                open={open}
                onOpen={() => {
                    setOpen(true);
                }}
                onClose={() => {
                    setOpen(false);
                }}
                disabled={!enabled}
                value={findOption}
                onChange={(_event, newValue) => {
                    if (newValue === null) {
                        handleChange(path, null);
                        setSelectedFeatureName('');
                    } else {
                        handleChange(path, { id: newValue?.id, name: newValue?.name, data: {} });
                        setSelectedFeatureId(newValue?.id);
                    }
                }}
                inputValue={selectedFeatureName}
                onInputChange={(_event, newInputValue) => {
                    if (newInputValue !== '') {
                        setSelectedFeatureName(newInputValue);
                    }
                    else {
                        setSelectedFeatureName('');
                    }
                }}
                isOptionEqualToValue={(option, value) => option.id === value.id}
                options={options}
                loading={loading}
                autoHighlight
                autoComplete
                fullWidth
                getOptionLabel={getOptionLabel || ((option) => option?.name)}
                freeSolo={false}
                renderInput={(params) => {
                    return (
                        <TextField
                            label={label}
                            variant={'standard'}
                            type='text'
                            inputRef={params.InputProps.ref}
                            autoFocus={appliedUiSchemaOptions.focus}
                            {...params}
                            inputProps={params.inputProps}
                            InputProps={{
                                ...params.InputProps,
                                endAdornment: (
                                    <React.Fragment>
                                        {loading ? <CircularProgress color="inherit" size={20} /> : null}
                                        {params.InputProps.endAdornment}
                                    </React.Fragment>
                                ),
                            }}
                            disabled={!enabled}
                            id={id + '-input'}
                            required={
                                required && !appliedUiSchemaOptions.hideRequiredAsterisk
                            }
                            error={!isValid}
                            fullWidth={!appliedUiSchemaOptions.trim}
                            InputLabelProps={data ? { shrink: true } : undefined}
                            onFocus={onFocus}
                            onBlur={onBlur}
                            focused={focused}
                        />
                    );
                }}
                renderOption={(props, option, { inputValue }) => {
                    const matches = match(option.name, inputValue, { insideWords: true });
                    const parts = parse(option.name, matches);

                    const matchesTags = match(option.tags, inputValue, { insideWords: true });
                    const partsTags = parse(option.tags, matchesTags);
                    const tooltipTitle = partsTags.map((part, index) => (
                        <span
                            key={index}
                            style={{
                                fontWeight: part.highlight ? 700 : 400,
                                color: part.highlight ? "yellow" : "white",
                            }}
                        >
                            {part.text}
                        </span>
                    ));

                    return (
                        <li {...props}>
                            <div>
                                <Box>
                                    {parts.map((part, index) => (
                                        <span
                                            key={index}
                                            style={{
                                                fontWeight: part.highlight ? 700 : 400,
                                            }}
                                        >
                                            {part.text}
                                        </span>
                                    ))}
                                    {matchesTags.length > 0 && (
                                        <Tooltip arrow sx={{ ml: 1, verticalAlign: "middle" }} title={<Typography variant="caption">Feature Alt Tags: <br /><br />{tooltipTitle}</Typography>}>
                                            <MoreIcon color="secondary" />
                                        </Tooltip>
                                        )}
                                </Box>
                            </div>
                        </li>
                    );
                }}
                filterOptions={filterOptions}
            />
            <FormHelperText error={!isValid && !showDescription}>
                {firstFormHelperText}
            </FormHelperText>
            <FormHelperText error={!isValid}>{secondFormHelperText}</FormHelperText>

            {selectedFeature != null &&
                <JsonForms
                    ajv={customAjv}
                    schema={formSchemaData}
                    uischema={formUiSchemaData}
                    data={featureData}
                    renderers={renderers}
                    cells={materialCells}
                    onChange={({ data, errors }) => setDataAndValidate(data, errors)}
                />
            }

        </Hidden>
    );
};

export const FeaturesAutocomplete = (
    props: ControlProps & OwnPropsOfEnum & WithOptionLabel & TranslateProps
) => {
    const { errors } = props;
    const isValid = errors.length === 0;

    return (
        <MuiAutocomplete {...props} isValid={isValid}  />
    );
};

const isFeatureAutocompleteControl = and(
    uiTypeIs('Control'),
    optionIs('asyncFeatureControl', true)
);

export const featuresAutocompleteTester: RankedTester = rankWith(
    1000,
    isFeatureAutocompleteControl
);

export default withJsonFormsOneOfEnumProps(
    withTranslateProps(React.memo(FeaturesAutocomplete)),
    false
);

Hi @chriswhitehead,

The custom AJV must be stable. At the moment a new AJV instance is created with every rerender which has the following effect:

  • JSON Forms realizes it is handed over a new AJV instance
  • The new AJV instance is stored in the form-wide storage and the schema is recompiled to a new validator which is an expensive operation
  • Validation is performed which will lead to a new onChange being emitted with the validation results
  • This might trigger a rerender and therefore an endless loop. You might not directly notice the loop as we have a debounce in there which slows the loop down.

To fix the issue AJV must be placed in a useState or useMemo or moved outside of the component.

Just a performance issue: renderers should also be stable. Otherwise your nested form will fully rerender every time a change is triggered.

In short: All props given to JSON Forms should be as stable as possible. The only exception is onChange. Handing over a new function with every render is fine.

1 Like

That did the trick, you are a :star2: thank you!

Noted re useMemo and I’ve put that in place in a few places now - thanks.