survey-library icon indicating copy to clipboard operation
survey-library copied to clipboard

[idea] Simplify & improve performance of expressions and conditions

Open SamMousa opened this issue 6 months ago • 3 comments

Are you requesting a feature, reporting a bug or asking a question?

None of the above; I 'm proposing an idea.

What is the current behavior?

Whenever a value changes, for example via survey.setVariable('name', 'newValue') surveyJS inefficiently recalculates a whole bunch of stuff. For example it always recalculates all calculated values, iterates over all pages etc:

private runConditionsCore(properties: any) {
    var pages = this.pages;
    for (var i = 0; i < this.calculatedValues.length; i++) {
      this.calculatedValues[i].resetCalculation();
    }
    for (var i = 0; i < this.calculatedValues.length; i++) {
      this.calculatedValues[i].doCalculation(
        this.calculatedValues,
        this.conditionValues,
        properties
      );
    }
    super.runConditionCore(this.conditionValues, properties);
    for (let i = 0; i < pages.length; i++) {
      pages[i].runCondition(this.conditionValues, properties);
    }
  }

Not only is this inefficient, it also requires a top down awareness of all things that might need updating.

Bottom up approach using observables

An observable is similar to a promise, but instead of one value it can have multiple values. Using a library like rxjs we have advanced operators that allow for automatic optimization. The automatic optimization happens due to the way operators work. For example if we have this code:

const survey: SurveyModel = new SurveyModel(...);

const oQuestion1 = new BehaviorSubject(survey.getValue('question1');
const oQuestion2 = new BehaviorSubject(survey.getValue('question2');

survey.onValueChanged((survey: SurveyModel) => {
    // We ignore the fact that we have the question, name and value available as other arguments.
    oQuestion1.next(survey.getValue('question1'));
    oQuestion2.next(survey.getValue('question2'));
});

// Now we have an expression like this {question1} + {question2}:
oExpression1 = combineLatest([
    oQuestion1.pipe(distinctUntilChanged()),
    oQuestion2.pipe(distinctUntilChanged()),
]).pipe(map([question1, question2] => question1 + question2);

The cool thing about these is that they are only evaluated when actually used. So if no one is currently using oExpression1, because, for example, the page is not visible, the value will not be calculated. Also because since the dependencies are managed locally by each observable, we stop the change as early as possible not wasting time recalculating an expression if the value didn't actually change, or if a value that we don't care about changed.

This approach could simplify and improve many of the survey engine logic, and the great thing is, it doesn't have to replace everything at once. It could be added incrementally under the hood and externally no one would notice.

Other areas where observables might simplify life

Localizations could use a similar pattern, where you create an observable that combines the survey current language (as an observable) with the string configuration (as an observable) and its current value in the current locale is exposed as an observable. Rendering wise this integrates very easily with all modern frameworks, so much so that frameworks will often unsubscribe from observable when their content is not visible; further optimizing performance.

SamMousa avatar Jan 18 '24 11:01 SamMousa