DEV Community

Cover image for Scoped Re-evaluation: Preventing Unnecessary FEEL Expression Evaluation in Large Forms
Sam Abaasi
Sam Abaasi

Posted on

Scoped Re-evaluation: Preventing Unnecessary FEEL Expression Evaluation in Large Forms

Part 22 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"


The evaluator pattern from Article 8 has a performance assumption built into it: when changed fires, re-evaluate everything. For small forms this is fine. For a form with 50 fields that loads pre-populated data, it's a problem.

When Form-JS loads pre-populated data, it writes each field's value into form state. Each write fires a changed event. A 50-field form fires 50 changed events on load. If you have five evaluators, each one re-evaluating all 50 components on every event, that's 50 × 5 × 50 = 12,500 evaluations on load. Most of them evaluate the same expressions against the same data they already evaluated 10ms ago.

This article documents the DateTimeValidationEvaluator as a case study in scoped re-evaluation — an evaluator that fires only when relevant fields change, filters its context to only relevant data, and skips _setState calls when nothing changed. Then it generalizes the pattern to a template you can apply to any type-specific evaluator.


The Problem at Scale

To understand why scope matters, trace what happens when a 50-field form loads:

Form loads with pre-populated data
  → form.init fires (evaluators initialize)
  → Form-JS writes field 1 value → changed fires
     DisabledEvaluator.evaluateAll() runs: 50 components × FEEL eval = 50 evals
     HideEvaluator.evaluateAll() runs: 50 components × FEEL eval = 50 evals
     RequiredEvaluator.evaluateAll() runs: 50 components × FEEL eval = 50 evals
     BindingEvaluator.evaluateAll() runs: 50 components × FEEL eval = 50 evals
     DateTimeValidationEvaluator.evaluateAll() runs: 50 components × FEEL eval = 50 evals
     Total: 250 FEEL evaluations
  → Form-JS writes field 2 value → changed fires
     Same: 250 FEEL evaluations
  ...
  → Form-JS writes field 50 value → changed fires
     Same: 250 FEEL evaluations

Total on load: 50 events × 250 evaluations = 12,500 FEEL evaluations
Enter fullscreen mode Exit fullscreen mode

FEEL evaluation is not free. The feelin library parses the expression, builds an AST, evaluates it against the context. A complex expression with multiple variables takes 1-5ms. Twelve thousand evaluations at 1ms each is 12 seconds of JavaScript execution blocking the UI thread.

In practice, most expressions are simple (= status = "closed", = amount > 1000) and feelin is fast. The observed performance cost is more like 200-500ms for a 50-field form — noticeable but not catastrophic. But for forms with 100+ fields, or evaluators with expensive evaluation logic (like DateTimeValidationEvaluator which needs to parse date strings and compare Date objects), the cost accumulates.

The DateTimeValidationEvaluator is the clearest case for scoped re-evaluation because datetime validation has a very narrow scope: it only matters when a datetime field changes. If the user types in a text field, no datetime validation needs to run.


The Baseline Evaluator (Without Optimization)

For comparison, here is what DateTimeValidationEvaluator would look like following the standard pattern from Article 8, without any scoping:

// ❌ Unscoped — re-evaluates everything on every changed event

export class DateTimeValidationEvaluator {
  constructor(eventBus, form) {
    this._eventBus = eventBus;
    this._form = form;
    this._evaluating = false;
    this._initialized = false;
    this._validationErrors = {};

    this._eventBus.on('form.init', () => {
      this._initialized = true;
      setTimeout(() => this.evaluateAll(), 50);
    });

    // ❌ Fires on EVERY field change — even text fields, dropdowns, checkboxes
    this._eventBus.on('changed', () => {
      if (!this._initialized || this._evaluating) return;
      setTimeout(() => {
        if (!this._evaluating) this.evaluateAll();
      }, 10);
    });
  }

  evaluateAll() {
    this._evaluating = true;
    try {
      const schema = this._form._state.schema;
      const data = this._form._state.data || {};
      const components = this._getAllComponents(schema.components || []);

      // ❌ Iterates ALL 50+ components to find datetime fields
      const datetimeComponents = components.filter(c =>
        ['date', 'datetime', 'time'].includes(c.type) && c.key
      );

      // Evaluate validation for each datetime component
      // ...
    } finally {
      this._evaluating = false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

On a 50-field form with 3 datetime fields, this evaluator:

  • Runs 50 times during pre-population (once per field)
  • Each run iterates all 50 components to find the 3 datetime ones
  • Then evaluates validation expressions for those 3 fields
  • Total: 50 × 50 iterations + 50 × 3 FEEL evaluations = 2,650 operations

Most of those operations happen because text fields, dropdowns, and checkboxes changed — events that have nothing to do with datetime validation.


Optimization 1: Field-Type Gating

The first optimization: check whether the changed field is a datetime field before doing anything:

// ✅ Field-type gating — skip evaluation when irrelevant fields change

this._eventBus.on('changed', (event) => {
  if (!this._initialized || this._evaluating) return;

  // ✅ Extract what changed from the event
  const changedData = event.data || {};
  const changedKeys = Object.keys(changedData);

  // ✅ Check if any datetime field changed
  const hasDatetimeChange = changedKeys.some(key =>
    this._isDatetimeField(key)
  );

  // ✅ Skip entirely if no datetime field changed
  if (!hasDatetimeChange) return;

  setTimeout(() => {
    if (!this._evaluating) this.evaluateAll();
  }, 10);
});
Enter fullscreen mode Exit fullscreen mode

The _isDatetimeField check:

_isDatetimeField(fieldKey) {
  if (!this._form?._state?.schema) return false;

  // ✅ Check by schema ID (the component's id property)
  // Sometimes changed fires with the component's id, not its key
  const allComponents = this._getAllComponents(
    this._form._state.schema.components || []
  );

  const component = allComponents.find(c => c.key === fieldKey || c.id === fieldKey);
  if (!component) return false;

  return ['date', 'datetime', 'time'].includes(component.type);
}
Enter fullscreen mode Exit fullscreen mode

The check iterates all components to find one by key or ID. For a 50-component form this is fast — a simple array scan. The cost is much lower than running FEEL evaluation.

With field-type gating, the 50-field form pre-population becomes:

50 changed events fire during pre-population
  → 3 events: datetime field changed → evaluateAll() runs
  → 47 events: non-datetime fields changed → skip (early return)

Total: 3 evaluations instead of 50
Enter fullscreen mode Exit fullscreen mode

An 83% reduction in evaluation runs.


Optimization 2: Schema-Based Field Registry

The _isDatetimeField check scans all components on every changed event. For a large form, this adds up. Build the datetime field set once on form.init instead:

// ✅ Build a Set of datetime field keys on init

_datetimeFieldKeys = new Set();

_buildDatetimeFieldRegistry() {
  this._datetimeFieldKeys.clear();

  if (!this._form?._state?.schema) return;

  const components = this._getAllComponents(
    this._form._state.schema.components || []
  );

  components.forEach(component => {
    if (['date', 'datetime', 'time'].includes(component.type) && component.key) {
      this._datetimeFieldKeys.add(component.key);
      // Also add by id in case events use id instead of key
      if (component.id) this._datetimeFieldKeys.add(component.id);
    }
  });
}

// ✅ O(1) lookup instead of O(n) scan
_isDatetimeField(fieldKey) {
  return this._datetimeFieldKeys.has(fieldKey);
}
Enter fullscreen mode Exit fullscreen mode

Initialize on form.init and rebuild when the schema changes:

this._eventBus.on('form.init', () => {
  this._initialized = true;
  this._buildDatetimeFieldRegistry();
  setTimeout(() => this.evaluateAll(), 50);
});

this._eventBus.on('propertiesPanel.updated', () => {
  // Schema may have changed (new datetime field added or removed)
  this._buildDatetimeFieldRegistry();

  if (!this._initialized || this._evaluating) return;
  setTimeout(() => {
    if (!this._evaluating) this.evaluateAll();
  }, 50);
});
Enter fullscreen mode Exit fullscreen mode

Now _isDatetimeField(key) is a Set lookup — O(1) regardless of form size.


Optimization 3: Context Filtering

The standard evaluator pattern builds a context from all form data. DateTimeValidationEvaluator's FEEL expressions reference other datetime fields — = value >= today(), = startDate <= endDate. They never reference text fields, dropdowns, or checkboxes.

Filtering the context to only datetime values makes evaluation faster and prevents irrelevant data from polluting the FEEL context:

// ❌ Full context — 50+ values, most irrelevant
prepareContext(data) {
  const context = {};
  Object.keys(data).forEach(key => {
    context[key] = this._coerceValue(data[key]);
  });
  context.today = () => { ... };
  context.now = () => new Date();
  return context;
}

// ✅ Filtered context — only datetime values + date helpers
_getRelevantFormData(data) {
  const context = {};

  // Only include datetime field values
  this._datetimeFieldKeys.forEach(fieldKey => {
    if (data[fieldKey] !== undefined) {
      // ✅ Convert string dates to Date objects for FEEL comparison
      const converted = this._coerceDateValue(data[fieldKey]);
      context[fieldKey] = converted !== null ? converted : data[fieldKey];
    }
  });

  // ✅ Current field's value available as 'value'
  // (set per-component during evaluation)

  // ✅ Date/time helpers always available
  context.today = () => {
    const d = new Date();
    d.setHours(0, 0, 0, 0);
    return d;
  };
  context.now = () => new Date();
  context.tomorrow = () => {
    const d = new Date();
    d.setDate(d.getDate() + 1);
    d.setHours(0, 0, 0, 0);
    return d;
  };
  context.yesterday = () => {
    const d = new Date();
    d.setDate(d.getDate() - 1);
    d.setHours(0, 0, 0, 0);
    return d;
  };

  return context;
}
Enter fullscreen mode Exit fullscreen mode

Why smaller context helps FEEL evaluation: feelin builds a lookup table for each variable name in the expression and resolves it against the context. A context with 50 keys has 50 lookups to manage. A context with 5 keys has 5. The difference is small per evaluation but adds up across thousands of evaluations.

More importantly, a smaller context prevents accidental matches. If a text field has a key of startDate and the FEEL expression references startDate, a full-context evaluator would include the text field's string value. The filtered-context evaluator only includes values from actual datetime fields.


The _coerceDateValue Method

DateTime validation evaluates expressions like = value >= today(). For this comparison to work in FEEL, value must be a Date object, not the string "2024-01-15" that's stored in form data.

The coercion converts stored strings to Date objects:

_coerceDateValue(rawValue, type = 'date') {
  if (!rawValue || typeof rawValue !== 'string') return null;

  const v = rawValue.trim();
  if (!v) return null;

  try {
    if (type === 'date' || /^\d{4}-\d{2}-\d{2}$/.test(v)) {
      // "2024-01-15" → Date at local midnight
      return new Date(v + 'T00:00:00');
    }

    if (type === 'datetime' || /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(v)) {
      // "2024-01-15 10:30:00" → Date (space to T)
      return new Date(v.replace(' ', 'T'));
    }

    if (type === 'time' || /^\d{2}:\d{2}:\d{2}$/.test(v)) {
      // "10:30:00" → keep as string (time comparison is string comparison)
      return v;
    }
  } catch (err) {
    console.warn(`[DateTimeValidationEvaluator] Could not coerce value: ${v}`);
    return null;
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

The T00:00:00 suffix for date-only strings prevents the timezone offset problem from Article 17: new Date('2024-01-15') parses as UTC midnight and shifts to the previous day in negative UTC offsets. new Date('2024-01-15T00:00:00') parses as local midnight.


Optimization 4: Change Detection on Results

Even when evaluation runs, the results might be identical to the previous run. Writing identical errors to form state via _setState triggers a re-render, which triggers another changed event, which could trigger evaluation again.

Track the last evaluated values and skip _setState when nothing changed:

_lastEvaluatedValues = {};
_lastValidationErrors = {};

evaluateAll() {
  if (!this._form?._state?.schema) return;

  this._evaluating = true;

  try {
    const schema = this._form._state.schema;
    const data = this._form._state.data || {};
    const allComponents = this._getAllComponents(schema.components || []);

    const datetimeComponents = allComponents.filter(c =>
      this._datetimeFieldKeys.has(c.key) && c.key
    );

    if (!datetimeComponents.length) return;

    // ✅ Get filtered context once — reuse for all components
    const baseContext = this._getRelevantFormData(data);

    const newErrors = {};
    let hasChanges = false;

    datetimeComponents.forEach(component => {
      const key = component.key;
      const rawValue = data[key];

      // ✅ Check if this field's value changed since last evaluation
      if (this._lastEvaluatedValues[key] === rawValue) {
        // Value hasn't changed — reuse previous error state
        if (this._lastValidationErrors[key]) {
          newErrors[key] = this._lastValidationErrors[key];
        }
        return; // Skip re-evaluation for this component
      }

      // ✅ Value changed — run evaluation
      const coercedValue = this._coerceDateValue(rawValue, component.type);
      const componentContext = {
        ...baseContext,
        value: coercedValue   // 'value' always refers to this field's value
      };

      const componentErrors = this._validateDatetimeComponent(
        component,
        componentContext,
        rawValue
      );

      if (componentErrors.length > 0) {
        newErrors[key] = componentErrors;
      }

      // ✅ Track what we evaluated
      this._lastEvaluatedValues[key] = rawValue;
      this._lastValidationErrors[key] = componentErrors.length > 0
        ? componentErrors
        : null;

      // ✅ Check if the error state actually changed
      const prevErrors = this._lastValidationErrors[key];
      const prevHadError = prevErrors && prevErrors.length > 0;
      const nowHasError = componentErrors.length > 0;
      if (prevHadError !== nowHasError || JSON.stringify(prevErrors) !== JSON.stringify(componentErrors)) {
        hasChanges = true;
      }
    });

    // ✅ Only update form state if errors actually changed
    if (hasChanges) {
      const currentErrors = this._form._state.errors || {};
      const mergedErrors = { ...currentErrors };

      // Remove old datetime errors and add new ones
      datetimeComponents.forEach(c => {
        if (newErrors[c.key]) {
          mergedErrors[c.key] = newErrors[c.key];
        } else {
          delete mergedErrors[c.key];
        }
      });

      this._form._setState({ errors: mergedErrors });
      this._validationErrors = newErrors;
    }
  } catch (err) {
    console.error('[DateTimeValidationEvaluator] Error:', err);
  } finally {
    this._evaluating = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

What _lastEvaluatedValues tracks: For each datetime field, the last raw string value that was evaluated. If the current value matches the tracked value, evaluation is skipped and the previous error state is reused. Only fields whose values changed since the last evaluation run.

On a 50-field form after pre-population completes, all 3 datetime fields have been evaluated and their values tracked. When the user types in a text field (triggering changed), the field-type gate catches it first and skips the evaluator entirely. When the user changes a datetime field, only that field's value differs from _lastEvaluatedValues — the other two datetime fields are skipped.


The Validation Logic

The evaluation itself checks configured validation rules:

_validateDatetimeComponent(component, context, rawValue) {
  const errors = [];

  if (!rawValue) {
    // Empty values are handled by RequiredValidator — not our responsibility
    return errors;
  }

  // ✅ Basic format validation first
  const formatError = this._validateDatetimeFormat(rawValue, component.type);
  if (formatError) {
    errors.push(formatError);
    return errors; // Don't run FEEL validation on invalid format
  }

  // ✅ Run configured FEEL validation rules
  const validationRules = component.validate?.customValidations || [];

  for (const rule of validationRules) {
    if (!rule.expression) continue;

    try {
      const result = this._evaluateFeel(rule.expression, context);
      const isValid = !!result;

      if (!isValid) {
        errors.push(rule.message || `Validation failed for ${component.label || component.key}`);
      }
    } catch (err) {
      console.error(
        `[DateTimeValidationEvaluator] Error evaluating rule for ${component.key}:`,
        err.message
      );
    }
  }

  return errors;
}

_validateDatetimeFormat(value, type) {
  if (!value || typeof value !== 'string') return null;

  if (type === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
    return `Invalid date format. Expected YYYY-MM-DD, got: ${value}`;
  }

  if (type === 'datetime' && !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)) {
    return `Invalid datetime format. Expected YYYY-MM-DD HH:mm:ss`;
  }

  if (type === 'time' && !/^\d{2}:\d{2}:\d{2}$/.test(value)) {
    return `Invalid time format. Expected HH:mm:ss`;
  }

  return null; // Format is valid
}
Enter fullscreen mode Exit fullscreen mode

Format validation runs before FEEL validation. If the stored value is "15-01-2024" (wrong format), there's no point running FEEL expressions that expect a parseable date — _coerceDateValue would return null, and = value >= today() would evaluate to null (not false), producing no error when there should be one. Format validation catches the upstream problem first.


Measured Performance

For a form with 50 fields (3 datetime fields) loading with pre-populated data:

Baseline (unscoped):

  • 50 changed events on load
  • 50 evaluator runs
  • Each run: iterate 50 components + evaluate 3 datetime fields
  • Total operations: 50 × (50 + 3) = 2,650

With field-type gating only:

  • 50 changed events on load
  • 3 evaluator runs (only datetime field changes pass the gate)
  • Each run: iterate 50 components + evaluate 3 datetime fields
  • Total operations: 3 × (50 + 3) = 159

With field-type gating + Set registry:

  • 3 evaluator runs
  • Each run: 3 × O(1) Set lookups + evaluate 3 datetime fields
  • Total operations: approximately 3 × (3 + 3) = 18

With all optimizations (gating + Set + context filtering + change detection):

  • 3 evaluator runs
  • First run evaluates all 3 datetime fields: 3 FEEL evaluations
  • Subsequent runs for unchanged datetime fields: 0 FEEL evaluations (change detection skips)
  • Total FEEL evaluations on load: 3

The reduction from 2,650 to ~18 operations is approximately 99% for the load phase. For a 100-field form with 5 datetime fields, the baseline would be 5,000+ operations; the optimized version stays at ~5.

During active use (user filling the form), each datetime field change triggers at most 1 FEEL evaluation for the changed field and 0 for unchanged datetime fields. Text field changes trigger 0 evaluations.


The General Pattern: Field-Type Gating Template

Any evaluator that only cares about specific field types should implement these four optimizations. Here is the template:

// ScopedEvaluator.js — template for type-specific evaluators

export class ScopedEvaluator {
  constructor(eventBus, form) {
    this._eventBus = eventBus;
    this._form = form;
    this._evaluating = false;
    this._initialized = false;

    // ✅ Optimization 1 + 2: Type registry
    this._relevantFieldKeys = new Set();

    // ✅ Optimization 4: Change detection
    this._lastEvaluatedValues = {};
    this._lastResults = {};

    this._feelEngine = null;
    try {
      this._feelEngine = { evaluate };
    } catch (err) {
      console.warn('[ScopedEvaluator] FEEL engine unavailable');
    }

    // Initialize
    this._eventBus.on('form.init', () => {
      this._initialized = true;
      this._buildFieldRegistry();
      setTimeout(() => this.evaluateAll(), 50);
    });

    // ✅ Optimization 1: Field-type gate on changed
    this._eventBus.on('changed', (event) => {
      if (!this._initialized || this._evaluating) return;

      const changedKeys = Object.keys(event.data || {});
      const hasRelevantChange = changedKeys.some(k => this._relevantFieldKeys.has(k));

      if (!hasRelevantChange) return; // ✅ Skip — no relevant fields changed

      setTimeout(() => {
        if (!this._evaluating) this.evaluateAll();
      }, 10);
    });

    // Rebuild registry when schema changes
    this._eventBus.on('propertiesPanel.updated', () => {
      this._buildFieldRegistry();
      if (!this._initialized || this._evaluating) return;
      setTimeout(() => {
        if (!this._evaluating) this.evaluateAll();
      }, 50);
    });

    // Re-apply after render
    this._eventBus.on('form.rendered', () => {
      setTimeout(() => this._applyToDOM(), 50);
    });
  }

  // ✅ Optimization 2: Build Set of relevant field keys
  _buildFieldRegistry() {
    this._relevantFieldKeys.clear();

    if (!this._form?._state?.schema) return;

    const components = this._getAllComponents(
      this._form._state.schema.components || []
    );

    components.forEach(component => {
      // ✅ Replace with your relevant types
      const RELEVANT_TYPES = ['date', 'datetime', 'time']; // example

      if (RELEVANT_TYPES.includes(component.type)) {
        if (component.key) this._relevantFieldKeys.add(component.key);
        if (component.id) this._relevantFieldKeys.add(component.id);
      }
    });
  }

  evaluateAll() {
    if (!this._form?._state?.schema) return;

    this._evaluating = true;

    try {
      const schema = this._form._state.schema;
      const data = this._form._state.data || {};
      const allComponents = this._getAllComponents(schema.components || []);

      // Only process relevant components
      const relevantComponents = allComponents.filter(c =>
        this._relevantFieldKeys.has(c.key) && c.key
      );

      if (!relevantComponents.length) return;

      // ✅ Optimization 3: Build filtered context once
      const baseContext = this._getFilteredContext(data);

      let hasChanges = false;

      relevantComponents.forEach(component => {
        const key = component.key;
        const rawValue = data[key];

        // ✅ Optimization 4: Skip if value hasn't changed
        if (this._lastEvaluatedValues[key] === rawValue) {
          return; // Nothing to do for this component
        }

        // Evaluate this component
        const result = this._evaluateComponent(component, baseContext, rawValue, data);

        // Check if result changed
        if (JSON.stringify(result) !== JSON.stringify(this._lastResults[key])) {
          hasChanges = true;
          this._lastResults[key] = result;
        }

        this._lastEvaluatedValues[key] = rawValue;
      });

      // ✅ Only apply if something changed
      if (hasChanges) {
        this._applyResults();
        this._applyToDOM();
      }
    } catch (err) {
      console.error('[ScopedEvaluator] Error:', err);
    } finally {
      this._evaluating = false;
    }
  }

  // ✅ Optimization 3: Filter context to relevant fields only
  _getFilteredContext(data) {
    const context = {};

    this._relevantFieldKeys.forEach(fieldKey => {
      if (data[fieldKey] !== undefined) {
        context[fieldKey] = this._coerceValue(data[fieldKey]);
      }
    });

    // Add any helpers needed by your FEEL expressions
    context.today = () => { const d = new Date(); d.setHours(0,0,0,0); return d; };
    context.now = () => new Date();

    return context;
  }

  // Implement these for your specific use case:

  _evaluateComponent(component, baseContext, rawValue, data) {
    // Your evaluation logic here
    return null;
  }

  _coerceValue(value) {
    // Convert stored values to FEEL-compatible types
    return value;
  }

  _applyResults() {
    // Write results to form state
  }

  _applyToDOM() {
    // Apply DOM changes if needed
  }

  // Standard utilities
  _getAllComponents(components = []) {
    const all = [];
    for (const c of components) {
      all.push(c);
      if (c.type === 'group' && Array.isArray(c.components)) {
        all.push(...this._getAllComponents(c.components));
      }
    }
    return all;
  }
}

ScopedEvaluator.$inject = ['eventBus', 'form'];
Enter fullscreen mode Exit fullscreen mode

When to Scope vs When to Evaluate Everything

Not all evaluators benefit from scoping. The decision depends on two factors:

Factor 1: What triggers the evaluator?

If your evaluator only needs to run when fields of a specific type change, scope it. If it needs to run when any field changes (like DisabledEvaluator — any field change might affect whether another field should be disabled), don't scope it.

Factor 2: How expensive is evaluation?

If evaluation is cheap (simple boolean expression, no API calls), the overhead of scoping might exceed the savings. If evaluation is expensive (parsing date strings, comparing Date objects, running multiple rules per field), scoping pays off quickly.

For the evaluators in this series:

Evaluator Scope By Type? Reason
BindingEvaluator No Any field can affect any computed binding
DisabledEvaluator No Any field can affect any other field's disabled state
HideEvaluator No Any field can affect any other field's visibility
RequiredEvaluator No Any field can affect any other field's required state
PersistentEvaluator No Any field can affect persistent state
DateTimeValidationEvaluator Yes Only datetime changes affect datetime validation
TicketAutoFillEvaluator Yes (watcher map) Only watched fields trigger auto-fill

TicketAutoFillEvaluator uses the watcher map pattern (Article 20) rather than field-type gating — it's scoped by configuration rather than type. The result is the same: only relevant changes trigger evaluation.


The Tradeoffs

The _lastEvaluatedValues cache can go stale. If a FEEL expression references external state — context.today(), form data from other fields included via the base context — the expression result might change even though the field's raw value hasn't. The _lastEvaluatedValues guard would incorrectly skip re-evaluation.

For DateTimeValidationEvaluator, this is handled by the context filtering: the base context includes other datetime fields, and if those change, they pass the field-type gate and trigger a full evaluateAll() run. The _lastEvaluatedValues guard then re-evaluates only the fields whose values changed.

For evaluators where the context depends on fields outside the scoped set, the _lastEvaluatedValues optimization is unsafe. In that case, use the field-type gate and context filtering but skip the per-value change detection.

The Set registry must be rebuilt when the schema changes. If the form designer adds a new datetime field while the editor is open, _relevantFieldKeys won't include it until _buildFieldRegistry() runs on the next propertiesPanel.updated event. During the window between field addition and registry rebuild, the new field won't receive validation. The 50ms delay on propertiesPanel.updated makes this window longer than necessary. Shortening it (or eliminating the delay) would reduce the window at the cost of potential redundant rebuilds.

JSON.stringify for result comparison has edge cases. For complex result objects (arrays with nested objects), JSON.stringify can produce different strings for equivalent objects if key order differs. For the simple result types used in datetime validation (arrays of strings, booleans, null), this is not a problem in practice.


What Comes Next

Scoped re-evaluation is the performance layer on top of the evaluator pattern. Combined with the caching in TicketAutoFillEvaluator (Article 20), these two articles address the main performance concerns in a large-form extension system.

Article 23 — the capstone — ties everything together: the complete system architecture, the full lifecycle from form instantiation to submission, and the three things I would do differently if starting over.


This is Part 22 of "Extending bpmn-io Form-JS Beyond Its Limits." The series covers the complete architecture for production-grade Form-JS extensions — the documentation that doesn't exist yet.


Tags: camunda bpmn formjs performance feel optimization runtime-evaluation javascript devex

Top comments (0)