1
votes

I have a very weird bug that I'm trying to understand for 1.5 days now. The problem with this bug is, that it is very hard to show it without showing around 2000 lines of code - I tried rebuilding a simple example in a codesandbox but couldn't reproduce the bug.

The bug can be easily described, though:

I have a parent component A, and a child component B. Both are connected to the same redux store and subscribed to a reducer called active. Both components print the exact same activeQuestion state property. Both components are connected to the redux store individually via connect()

I dispatch an action SET_ACTIVE_QUESTION and the components rerender (I'm not sure why each re-render happens) and component B now has the updated state from the store and component A doesn't ... and I can't seem to figure out why that is.

The real application is fairly big but there are a couple of weird things that I observed:

  • The bug disappears when I subscribe the parent component of A to the active state (Component A is subscribed itself).

  • The action to change the active question is qued before it is fired with setTimeout(() => doAction(), 0). If I remove the setTimeout the bug disappears.

Here is why I think this question is relevant even without code: How is it even possible that an action is dispatched in the redux store (the first console log is directly from the reducer) and the wrong state is displayed on a subsequent render? I'm not sure how this could even be possible unless its a closure or something.

enter image description here


Update (mapStateToProps) functions:

Component A (wrong state):

const mapStateToProps = (state: AppState) => ({
    active: state.active,
    answerList: state.answerList,
    surveyNotifications: state.surveyNotifications,
    activeDependencies: state.activeDependencies,
});

Component B (right state):

const mapStateToProps = (state: AppState) => ({
    surveyNotifications: state.surveyNotifications,
    active: state.active,
    answerList: state.answerList,
    activeDependencies: state.activeDependencies,
});

Update:

The state transition is triggered by component B (correct state) with this function:

const goToNextQuestionWithTransition = (
    where: string,
    shouldPerformValidation?: boolean
) => {
    setInState(false);
    setTimeout(() => {
        props.goToQuestion(where, shouldPerformValidation);
    }, 200);
};

Removing the setTimeout removes the bug (but I don't know why)

Update (show reducer):

export const INITIAL_SATE = {
    activeQuestionUUID: '',
    ...
};

export default function (state = INITIAL_SATE, action) {
    switch (action.type) {
        case actionTypes.SET_ACTIVE_QUESTION:
            console.log('Action from reducer', action)
            return { ...state, activeQuestionUUID: action.payload };
        ...
        default:
            return {...state};
    }
}

Update Component A - correct state

const Survey: React.FC<IProps> = (props) => {
    const {
        survey,
        survey: { tenantModuleSet },
    } = props;
    const [isComplete, setIsComplete] = React.useState(false);
    const classes = useStyles();
    const surveyUtils = useSurveyUtils();

    console.log('Log from component A', props.active.activeQuestionUUID)

    React.useEffect(() => {
        const firstModule = tenantModuleSet[0];
        if (firstModule) {
            props.setActiveModule(firstModule.uuid);
        } else {
            setIsComplete(true);
        }
    }, []);

    const orderedLists: IOrderedLists = useMemo(() => {
        let orderedQuestionList: Array<string> = [];
        let orderedModuleList: Array<string> = [];

        tenantModuleSet.forEach((module) => {
            orderedModuleList.push(module.uuid);
            module.tenantQuestionSet.forEach((question) => {
                orderedQuestionList.push(question.uuid);
            });
        });

        return {
            questions: orderedQuestionList,
            modules: orderedModuleList,
        };
    }, [survey]);

    const validateQuestion = (question: IQuestion) => {
        ...
    };

    const findModuleForQuestion = (questionUUID: string) => {
        ...
    };

    const { setActiveQuestion, setActiveModule, active } = props;
    const { activeQuestionUUID, activeModuleUUID } = props.active;
    const currentQuestionIndex = orderedLists.questions.indexOf(
        activeQuestionUUID
    );

    const currentModuleIndex = orderedLists.modules.indexOf(activeModuleUUID);

    const currentModule = props.survey.tenantModuleSet.filter(
        (module) => module.uuid === active.activeModuleUUID
    )[0];

    if (!currentModule) return null;

    const currentQuestion = currentModule.tenantQuestionSet.filter(
        (question) => question.uuid === activeQuestionUUID
    )[0];

    const handleActiveSurveyScrollDirection = (destination: string) => {
    ...
    };

    const isQuestionLastInModule = ...

    const moveToNextQuestion = (modules: string[], questions: string[]) => {
        if (isQuestionLastInModule) {
            if (currentModule.uuid === modules[modules.length - 1]) {
                props.setActiveSurveyView("form");
            } else {
                setActiveQuestion("");
                setActiveModule(modules[currentModuleIndex + 1]);
            }
        } else {
            console.log('this is the move function')
            setActiveQuestion(questions[currentQuestionIndex + 1]);
        }
    };


    const goToQuestiton = (destination: string, useValidation = true) => {
            ....
            moveToNextQuestion(modules, questions);
            
    };

    return (
        <section className={classes.view}>
            {isComplete ? (
                <SurveyComplete />
            ) : (
                <div className={classes.bodySection}>
                    <Module
                        // adding a key here is nessesary
                        // or the Module will not unmount when the module changes
                        key={currentModule.uuid}
                        module={currentModule}
                        survey={props.survey}
                        goToQuestion={goToQuestiton}
                    />
                </div>
            )}
            {!isComplete && (
                <div className={classes.footerSection}>
                    <SurveyFooter
                        tenantModuleSet={props.survey.tenantModuleSet}
                        goToQuestion={goToQuestiton}
                        orderedLists={orderedLists}
                    />
                </div>
            )}
        </section>
    );
};

const mapStateToProps = (state: AppState) => ({
    active: state.active,
    answerList: state.answerList,
    surveyNotifications: state.surveyNotifications,
    activeDependencies: state.activeDependencies,
});

const mapDispatchToProps = (dispatch: Dispatch) =>
    bindActionCreators(
        {
            removeQuestionNotification,
            setActiveQuestion,
            setActiveModule,
            setActiveSurveyScrollDirection,
        },
        dispatch
    );

export default connect(mapStateToProps, mapDispatchToProps)(Survey);

Component B (wrong state)

const Question: React.FC<IProps> = (props: IProps) => {
    const [showSubmitButton, setShowSubmitButton] = React.useState(false);
    const [inState, setInState] = React.useState(true);
    const classes = useStyles();

    const { question, module, goToQuestion, active } = props;

    const notifications: Array<IQuestionNotification> =
        props.surveyNotifications[question.uuid] || [];

    const answerArr = props.answerList[question.uuid];

    const dependency = props.activeDependencies.questions[question.uuid];


    useEffect(() => {
        /**
         * Function that moves to next or previous question based on the activeSurveyScrollDirection
         */
        const move =
            active.activeSurveyScrollDirection === "forwards"
                ? () => goToQuestion("next", false)
                : () => goToQuestion("prev", false); // backwards

        if (!dependency) {
            if (!question.isVisible) move();
        } else {
            const { type } = dependency;
            if (type === DependencyTypeEnum.SUBTRACT) {
                console.log('DEPENDENCY MOVE')
                move();
            }
        }
    }, [dependency, question, active.activeQuestionUUID]);

    console.log('Log from component B', active.activeQuestionUUID)

    const goToNextQuestionWithTransition = (
        where: string,
        shouldPerformValidation?: boolean
    ) => {
        // props.goToQuestion(where, shouldPerformValidation);
        setInState(false);
        setTimeout(() => {
            props.goToQuestion(where, shouldPerformValidation);
        }, 200);
    };

    /**
     * Questions that only accept one answer will auto submit
     * Questions that have more than one answer will display
     * complete button after one answer is passed.
     */
    const doAutoComplete = () => {
        if (answerArr?.length) {
            if (question.maxSelect === 1) {
                goToNextQuestionWithTransition("next");
            }

            if (question.maxSelect > 1) {
                setShowSubmitButton(true);
            }
        }
    };

    useDidUpdateEffect(() => {
        doAutoComplete();
    }, [answerArr]);

    return (
        <Grid container justify="center">
            <Grid item xs={11} md={8} lg={5}>
                <div className={clsx(classes.question, !inState && classes.questionOut)}>
                    <QuestionBody
                        question={question}
                        notifications={notifications}
                        module={module}
                        answerArr={answerArr}
                    />
                </div>
                {showSubmitButton &&
                active.activeQuestionUUID === question.uuid ? (
                    <Button
                        variant="contained"
                        color="secondary"
                        onClick={() => goToNextQuestionWithTransition("next")}
                    >
                        Ok!
                    </Button>
                ) : null}
            </Grid>
        </Grid>
    );
};

const mapStateToProps = (state: AppState) => ({
    surveyNotifications: state.surveyNotifications,
    active: state.active,
    answerList: state.answerList,
    activeDependencies: state.activeDependencies,
});

const mapDispatchToProps = (dispatch: Dispatch) =>
    bindActionCreators(
        {
            setActiveQuestion,
        },
        dispatch
    );

export default connect(mapStateToProps, mapDispatchToProps)(Question);
1
@RameshReddy, I completely understand! I was very hesitant to even ask this here since I wasn't sure what to show. If you have any idea what part could be especially relevant, I'm happy to show it.Xen_mar
Can you show the reducer, as well as what value you're logging and where you're logging it?markerikson
hey @markerikson, I've added the reducer. Sorry, for the delay.Xen_mar
SET_ACTIVE_QUESTION is also the only action that can modify the activeQuestion state. There is no action in between that could somehow modify the state. I also checked with redux devtools that there is no other action modifying the state.Xen_mar
Can you show where you're doing this logging in the components? In fact, can you show the actual logic for both components? (you can omit all of the JSX rendering output - I just want to see where the values are being logged relative to the lifecycles, and when things are getting updated)markerikson

1 Answers

0
votes

Can you post a copy of the mapStateToProps of both component B and component A? If you are using reselect (or similar libraries), can you also post the selectors definitions? Where are you putting the setTimeout() call?

If you are sure that there are no side effects within the mapStateToProps then it seems that you are mutating the activeQuestion property somewhere before or after the component B re-renders, assigning the old value. (Maybe you have to search for some assignement in conditions).

Also note that you can not always trust the console log, as it's value can be evaluated at later time the you call it.