diff --git a/app/javascript/packages/form-steps/form-steps.spec.tsx b/app/javascript/packages/form-steps/form-steps.spec.tsx
index a562f057d76..dd96c348e90 100644
--- a/app/javascript/packages/form-steps/form-steps.spec.tsx
+++ b/app/javascript/packages/form-steps/form-steps.spec.tsx
@@ -5,6 +5,7 @@ import { waitFor } from '@testing-library/dom';
import sinon from 'sinon';
import { PageHeading } from '@18f/identity-components';
import * as analytics from '@18f/identity-analytics';
+import { t } from '@18f/identity-i18n';
import FormSteps, { FormStepComponentProps, getStepIndexByName } from './form-steps';
import FormError from './form-error';
import FormStepsContext from './form-steps-context';
@@ -390,6 +391,38 @@ describe('FormSteps', () => {
expect(window.location.hash).to.equal('#second');
});
+ it('retains errors from prior steps', async () => {
+ const errors = [
+ {
+ field: 'nonExistentField1',
+ error: new FormError('abcde'),
+ },
+ {
+ field: 'nonExistentField2',
+ error: new FormError('12345'),
+ },
+ ];
+ const { getByText, findByText } = render(
+ ,
+ );
+
+ const checkFormHasExpectedErrors = () =>
+ findByText('Errors:', { exact: false }).then((e) =>
+ expect(e.parentElement?.textContent).contains('abcde,12345'),
+ );
+
+ await expect(checkFormHasExpectedErrors()).to.be.fulfilled();
+
+ await userEvent.click(getByText(t('forms.buttons.continue')));
+
+ await expect(findByText('Second Title')).to.be.fulfilled();
+ await expect(checkFormHasExpectedErrors()).to.be.rejected();
+
+ window.history.back();
+
+ await expect(checkFormHasExpectedErrors()).to.be.fulfilled();
+ });
+
it('shifts focus to next heading on step change', async () => {
const { getByText } = render();
diff --git a/app/javascript/packages/form-steps/form-steps.tsx b/app/javascript/packages/form-steps/form-steps.tsx
index 72486e91b8c..87e6af1bd84 100644
--- a/app/javascript/packages/form-steps/form-steps.tsx
+++ b/app/javascript/packages/form-steps/form-steps.tsx
@@ -177,6 +177,10 @@ interface FormStepsProps {
titleFormat?: string;
}
+interface PreviousStepErrorsLookup {
+ [stepName: string]: FormStepError>[] | undefined;
+}
+
/**
* React hook which sets page title for the current step.
*
@@ -246,6 +250,7 @@ function FormSteps({
const didSubmitWithErrors = useRef(false);
const forceRender = useForceRender();
const ifStillMounted = useIfStillMounted();
+
useEffect(() => {
if (activeErrors.length && didSubmitWithErrors.current) {
const activeErrorFieldElement = getFieldActiveErrorFieldElement(activeErrors, fields.current);
@@ -271,6 +276,17 @@ function FormSteps({
const stepIndex = Math.max(getStepIndexByName(steps, stepName), 0);
const step = steps[stepIndex] as FormStep | undefined;
+ // Preserve/restore non-blocking errors for each step regardless of field association
+ const [previousStepErrors, setPreviousStepErrors] = useState({});
+ useEffect(() => {
+ if (step?.name) {
+ const prevErrs = previousStepErrors[step?.name];
+ if (prevErrs && prevErrs.length > 0) {
+ setActiveErrors(prevErrs);
+ }
+ }
+ }, [step?.name, previousStepErrors]);
+
/**
* After a change in content, maintain focus by resetting to the beginning of the new content.
*/
@@ -361,6 +377,10 @@ function FormSteps({
}
const nextActiveErrors = getValidationErrors();
+ setPreviousStepErrors((prev) => ({
+ ...prev,
+ [stepName || steps[0].name]: activeErrors,
+ }));
setActiveErrors(nextActiveErrors);
if (nextActiveErrors.length) {
didSubmitWithErrors.current = true;