Part 20 of the series: "Extending bpmn-io Form-JS Beyond Its Limits"
A form has a "Related Ticket" dropdown. When the user selects a ticket, five fields in the current form should auto-populate with data from that ticket: the assignee, the due date, the priority, the category, and a free-text description. The data lives in an external API. The fields that should be populated depend on configuration set by the form designer in the properties panel.
This is ticket auto-fill. The form designer configures which field watches which ticket dropdown. The runtime evaluator watches for changes, fetches the right data, and writes it into the dependent fields.
Building this correctly means solving four distinct problems simultaneously:
- The watcher map — scan the schema once to know which fields watch which others
- The reset problem — when the ticket selection changes, clear stale data before fetching fresh data
- The cache — avoid redundant API calls when multiple fields watch the same ticket
- Async evaluation — FEEL conditions that need ticket data to evaluate, then async field population
This article documents all four.
The Problem
Form-JS's evaluators, as described in Article 8, run on the changed event and operate synchronously. BindingEvaluator reads a FEEL expression from the schema, evaluates it against form data, and writes the result back — all synchronously, all within the 10ms debounce window.
Auto-fill from an external API is fundamentally different. You can't synchronously fetch from an API. The sequence is:
- User selects ticket #12345
-
changedfires - Detect that the ticket selection changed
- Reset dependent fields (clear stale data)
- Fetch ticket variables from API (async — could take 100-500ms)
- Process the response
- Evaluate FEEL conditions (if configured)
- Format values for each field type
- Write results to form data
Steps 5-9 are async. The evaluator cannot block the changed event handler while waiting. It needs to be async and handle its own lifecycle.
What I Tried First
My first attempt was a simple changed handler that fetched and wrote:
// ❌ Attempt 1: Simple fetch on changed
this._eventBus.on('changed', async (event) => {
const data = event.data || {};
const ticketId = data[watchedFieldKey];
if (!ticketId) return;
const ticketData = await fetchTicketData(ticketId);
const fieldValue = ticketData.variables.find(v => v.name === 'assignee')?.value;
this._form._setState({
data: { ...data, assignee: fieldValue }
});
});
This works for one field, one ticket dropdown, and no condition evaluation. It breaks immediately when:
- Two fields watch the same ticket dropdown — you need two fetches to happen, and you might fetch twice for the same ticket
- The user changes the ticket selection quickly — the first fetch resolves after the second has already started, overwriting the correct data with stale data
- The form has 5 dependent fields — you're doing 5 separate API calls for the same ticket
The correct approach needs a watcher map (know what to update when each field changes), caching (don't fetch the same ticket twice), and atomic execution (don't let a stale fetch overwrite a newer one).
The Architecture: Four Components
TicketAutoFillEvaluator
├── _watchedFields: Map<fieldKey, Component[]>
│ Built on form.init — maps each watched field to its dependents
│
├── _previousTicketIds: Map<fieldKey, ticketId>
│ Tracks last-seen ticket ID per watcher — detects ticket changes
│
├── _ticketDataCache: Map<cacheKey, data>
│ Two-strategy cache:
│ Key: ticketId → variables data
│ Key: full_${ticketId} → complete ticket data
│
└── evaluateChangedFields(changedKeys): Promise<void>
Called when changed fires with the list of changed field keys
Stage 1: The Watcher Map
The watcher map is built once on form.init by scanning the schema. It maps each watched field key to the list of components that depend on it:
_buildWatchMap() {
this._watchedFields.clear();
if (!this._form?._state?.schema) {
console.warn('[TicketAutoFillEvaluator] No schema available');
return;
}
const schema = this._form._state.schema;
const components = this._getAllComponents(schema.components || []);
components.forEach((component) => {
if (!component.key) return;
const config = component.ticketAutoFillLogic;
if (!config) return;
// ✅ Only 'another_ticket' mode uses the watcher map
// 'current_ticket' mode reads from existing form data directly
if (config.behavior !== 'another_ticket' || !config.ticketFieldKey) return;
// The ticketFieldKey might be a FEEL expression: "=selectedTicket"
// Strip the leading = to get the actual field key
let watchKey = config.ticketFieldKey;
if (watchKey.startsWith('=')) {
watchKey = watchKey.substring(1).trim();
}
// Add this component to the list of dependents for watchKey
if (!this._watchedFields.has(watchKey)) {
this._watchedFields.set(watchKey, []);
}
this._watchedFields.get(watchKey).push(component);
});
}
After _buildWatchMap runs, _watchedFields looks like:
Map {
'selected_ticket' → [
{ key: 'assignee', ticketAutoFillLogic: { behavior: 'another_ticket', fieldName: 'task_assignee', ... } },
{ key: 'due_date', ticketAutoFillLogic: { behavior: 'another_ticket', fieldName: 'due_date', ... } },
{ key: 'priority', ticketAutoFillLogic: { behavior: 'another_ticket', fieldName: 'priority_level', ... } }
],
'linked_ticket' → [
{ key: 'vendor_name', ticketAutoFillLogic: { behavior: 'another_ticket', fieldName: 'vendor', ... } }
]
}
When changed fires, the evaluator checks if any changed field key is in _watchedFields:
this._eventBus.on('changed', (event) => {
if (!this._initialized || this._evaluating) return;
const changedData = event.data || {};
const changedKeys = Object.keys(changedData);
setTimeout(() => {
if (!this._evaluating) {
this.evaluateChangedFields(changedKeys);
}
}, 10);
});
When to Rebuild the Watcher Map
The watcher map must be rebuilt when the schema changes — specifically when the form designer changes ticketAutoFillLogic configuration in the editor. The propertiesPanel.updated event signals this:
this._eventBus.on('propertiesPanel.updated', () => {
if (!this._initialized || this._evaluating) return;
setTimeout(() => {
if (!this._evaluating) this._buildWatchMap();
}, 50);
});
Stage 2: The Reset Pattern
When the user changes the selected ticket from #12345 to #67890, the dependent fields should be cleared before the new ticket's data is fetched. Without this reset, there's a window where the fields show #12345's data while #67890's data is loading.
_previousTicketIds tracks the last-seen ticket ID for each watched field:
async evaluateChangedFields(changedKeys) {
if (!changedKeys || changedKeys.length === 0) return;
this._evaluating = true;
try {
const data = this._form._state.data || {};
const updates = {};
const evaluationPromises = [];
const componentKeys = [];
changedKeys.forEach((changedKey) => {
const dependentComponents = this._watchedFields.get(changedKey);
if (!dependentComponents?.length) return;
// ✅ Extract the ticket ID from the current selection
const currentTicketId = this._extractTicketId(data[changedKey]);
const previousTicketId = this._previousTicketIds.get(changedKey);
// ✅ If the ticket changed, reset all dependent fields
if (previousTicketId !== currentTicketId) {
this._resetDependentFields(changedKey, data);
this._previousTicketIds.set(changedKey, currentTicketId);
}
// ✅ Only fetch if there's a valid ticket to fetch from
if (currentTicketId) {
dependentComponents.forEach((component) => {
componentKeys.push(component.key);
evaluationPromises.push(
this._evaluateTicketAutoFill(component, data)
);
});
}
});
// ✅ Run all fetches concurrently
if (evaluationPromises.length > 0) {
const results = await Promise.all(evaluationPromises);
results.forEach((result, index) => {
if (result !== null && result !== undefined) {
updates[componentKeys[index]] = result;
}
});
if (Object.keys(updates).length > 0) {
const currentData = this._form._state.data || {};
this._form._setState({ data: { ...currentData, ...updates } });
}
}
} catch (err) {
console.error('[TicketAutoFillEvaluator] Error:', err);
} finally {
// ✅ Delay release to prevent rapid re-evaluation
setTimeout(() => {
this._evaluating = false;
}, 100);
}
}
_extractTicketId handles two forms of ticket selection — a plain string ID or an object with a ticket_id property:
_extractTicketId(ticketSelection) {
if (!ticketSelection) return null;
if (typeof ticketSelection === 'string') return ticketSelection;
if (ticketSelection.ticket_id) return ticketSelection.ticket_id;
return null;
}
_resetDependentFields clears all fields that depend on the changed watcher:
_resetDependentFields(watchedFieldKey, data) {
const dependentComponents = this._watchedFields.get(watchedFieldKey) || [];
const updates = {};
dependentComponents.forEach(component => {
updates[component.key] = null;
});
if (Object.keys(updates).length > 0) {
const newData = { ...data, ...updates };
this._form._setState({ data: newData });
}
}
The reset happens synchronously before any async fetch starts. This ensures the UI clears immediately when the user changes their ticket selection, then populates progressively as each field's fetch completes.
Stage 3: The Two-Cache Strategy
Every field that watches the same ticket dropdown will fetch data for the same ticket. Without caching, three dependent fields trigger three identical API calls. With caching, the first call fetches and stores; the other two read from the cache.
The cache uses two key strategies because there are two different endpoints with two different data shapes:
// The cache Map
// _ticketDataCache: Map<cacheKey, data>
// Strategy 1: Variables endpoint
// Key: ticketId (e.g., "12345")
// Value: response from /workflow-engine/tickets/business-number/{id}/variables/
// Shape: { data: { variables: [{name, value, ...}] } }
// Strategy 2: Full ticket endpoint
// Key: `full_${ticketId}` (e.g., "full_12345")
// Value: response from /workflow-engine/tickets/{id}
// Shape: { ticket: {...}, variables: [{name, latest_value, ...}] }
Why two endpoints? The variables endpoint (/variables/) gives a list of variable names and values — exactly what you need to populate a field with = assignee → form_field. But for condition evaluation (= status = "active" and priority = "High"), you need the ticket's own properties (status, priority, etc.), not just its variables. The full ticket endpoint gives both.
The fetch methods:
// Fetch variables for field population
async _fetchTicketData(ticketId) {
// ✅ Check cache first
if (this._ticketDataCache.has(ticketId)) {
return this._ticketDataCache.get(ticketId);
}
try {
const data = await deploymentClient.get(
`/workflow-engine/tickets/business-number/${ticketId}/variables/`
);
// ✅ Store in cache before returning
this._ticketDataCache.set(ticketId, data);
return data;
} catch (err) {
console.error(`[TicketAutoFillEvaluator] Failed to fetch ticket ${ticketId}:`, err);
return null;
}
}
// Fetch full ticket data for condition evaluation
async _fetchFullTicketData(ticketId) {
const cacheKey = `full_${ticketId}`;
// ✅ Check cache with different key
if (this._ticketDataCache.has(cacheKey)) {
return this._ticketDataCache.get(cacheKey);
}
try {
const response = await deploymentClient.get(
`/workflow-engine/tickets/${ticketId}`
);
const ticketData = response?.data || response;
// ✅ Store with full_ prefix key
this._ticketDataCache.set(cacheKey, ticketData);
return ticketData;
} catch (err) {
console.error(`[TicketAutoFillEvaluator] Failed to fetch full ticket ${ticketId}:`, err);
return null;
}
}
Cache invalidation: The cache is cleared when the evaluator detects a new ticket selection. _resetDependentFields clears the form fields; clearCache() clears the cached data:
clearCache() {
this._ticketDataCache.clear();
this._previousTicketIds.clear();
}
This is deliberately aggressive — clearing everything on any ticket change. A more sophisticated cache could expire individual entries. In practice, ticket data doesn't change during the user's form-filling session, so the cache hit rate is high and the cost of clearing is low.
Stage 4: Context Enrichment for Condition Evaluation
Some auto-fill configurations have conditions: "fill this field only if the ticket's status is active." The condition is a FEEL expression (= status = "active") that references ticket data not present in form data.
Evaluating this condition requires building an enriched context — form data plus ticket data:
async _evaluateCondition(conditionExpr, data) {
if (!conditionExpr) return true; // No condition = always fill
try {
const ticketId = this._getCurrentTicketId();
let evaluationContext = { ...data }; // ✅ Start with form data
if (ticketId) {
// ✅ Fetch full ticket data (with caching)
const ticketData = await this._fetchFullTicketData(ticketId);
if (ticketData?.ticket) {
const flattenedTicket = {};
// ✅ Flatten ticket properties
Object.entries(ticketData.ticket).forEach(([key, value]) => {
// Skip undefined or invalid keys
if (key === 'undefined' || key.startsWith('undefined_')) return;
// ✅ Form data takes priority — only add if not already in context
if (!(key in evaluationContext) || evaluationContext[key] == null) {
flattenedTicket[key] = value;
}
});
evaluationContext = {
...evaluationContext,
...flattenedTicket
};
// ✅ Add variables to context
if (Array.isArray(ticketData.variables)) {
ticketData.variables.forEach(variable => {
// Form data still takes priority
if (!(variable.name in evaluationContext)) {
evaluationContext[variable.name] = variable.latest_value;
}
});
}
// ✅ Add nested objects for advanced expressions
// = ticket.status = "active" (nested access)
evaluationContext.ticket = ticketData.ticket;
evaluationContext.variables = ticketData.variables;
}
}
const result = this.evaluateFeel(conditionExpr, evaluationContext);
return !!result;
} catch (err) {
console.error('[TicketAutoFillEvaluator] Error evaluating condition:', err);
return false; // Condition evaluation failure → don't fill
}
}
Priority rules for context values:
-
Form data — highest priority, always wins. If the form has a field named
statuswith value"pending", FEEL expressions seestatus = "pending"even if the ticket also has a property calledstatus. - Ticket properties — flat properties from the ticket object, added only if not in form data.
- Ticket variables — variables from the variables list, added only if not in form data or ticket properties.
-
Nested objects —
ticketandvariablesare available as nested objects for advanced expressions.
This priority order prevents ticket data from accidentally overriding form data that the user has explicitly entered.
Stage 5: Per-Component Evaluation
Each dependent component is evaluated independently, asynchronously:
async _evaluateTicketAutoFill(component, data) {
const config = component.ticketAutoFillLogic;
if (!config) return null;
try {
// ✅ Evaluate condition first (if configured)
if (config.condition) {
const conditionResult = await this._evaluateCondition(config.condition, data);
if (!conditionResult) {
return null; // Condition failed — don't fill this field
}
}
if (config.behavior === 'current_ticket') {
return this._evaluateCurrentTicket(config, data);
} else if (config.behavior === 'another_ticket') {
return await this._evaluateAnotherTicket(config, data, component);
}
} catch (err) {
console.error(`[TicketAutoFillEvaluator] Error evaluating ${component.key}:`, err);
return null;
}
return null;
}
Current ticket mode — reads from existing form data:
_evaluateCurrentTicket(config, data) {
const fieldNameExpr = config.currentTicketFieldName;
if (!fieldNameExpr) return null;
try {
// The FEEL expression gives us a field name
// e.g., "=assignee_field" evaluates to "assignee_field"
// Then we read data["assignee_field"]
const fieldName = this.evaluateFeel(fieldNameExpr, data);
if (fieldName && data[fieldName] !== undefined) {
return data[fieldName];
}
} catch (err) {
console.error('[TicketAutoFillEvaluator] Error in current ticket mode:', err);
}
return null;
}
Another ticket mode — fetches from API:
async _evaluateAnotherTicket(config, data, component) {
const { ticketFieldKey, ticketType, ticketPhase, fieldName, populateOptions } = config;
if (!ticketFieldKey || !ticketType || !ticketPhase || !fieldName) {
return null; // Incomplete configuration
}
// Get the field key from the FEEL expression
let watchFieldKey = ticketFieldKey;
if (watchFieldKey.startsWith('=')) {
watchFieldKey = watchFieldKey.substring(1).trim();
}
const ticketSelection = data[watchFieldKey];
if (!ticketSelection) return null;
const ticketId = this._extractTicketId(ticketSelection);
if (!ticketId) return null;
// ✅ Fetch variables (cached)
const ticketData = await this._fetchTicketData(ticketId);
if (!ticketData) return null;
const variables = ticketData.data.variables || [];
const variable = variables.find(v => v.name === fieldName);
if (!variable) {
console.warn(`[TicketAutoFillEvaluator] Variable "${fieldName}" not found in ticket ${ticketId}`);
return null;
}
let value = variable.value;
// Handle special empty string sentinel
if (value === '!emptyString!') value = '';
// ✅ Populate options mode — different from setting a value
if (populateOptions && ['dropdown', 'checklist', 'radio'].includes(component.type)) {
this._populateComponentOptions(component, value);
return null; // Don't set a value — options are populated instead
}
// ✅ Format the value for this component's type
return this._formatValueForComponent(value, component);
}
The populateOptions Mode
When populateOptions is true, instead of setting a field's value, the evaluator replaces the field's options list with data from the ticket. This is used when the ticket contains a list of allowed values for a dropdown:
_populateComponentOptions(component, values) {
// Normalize values to array
if (!Array.isArray(values)) {
if (typeof values === 'string') {
try {
values = JSON.parse(values);
} catch {
values = [values];
}
} else {
values = [values];
}
}
// Convert to label/value pairs
const options = values.map(value => ({
label: this._formatLabel(value),
value: String(value)
}));
// Update the component's config based on its type
if (component.type === 'dropdown') {
if (component.dropdown_config) {
component.dropdown_config.static_values = options;
component.dropdown_config.data_source = 'manual';
}
} else if (component.type === 'checklist' || component.type === 'radio') {
component.values = options;
}
// ✅ Update the schema to persist the change
this._updateComponentInSchema(component.id, component.type === 'dropdown'
? { dropdown_config: component.dropdown_config }
: { values: options }
);
// ✅ Signal that the field was updated
this._eventBus.fire('field.updated', { field: component });
}
_formatLabel(value) {
return String(value)
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
// "active_status" → "Active Status"
}
The Format-by-Type System
Different field types expect different value shapes. A raw string from the ticket API might need to be converted to a number, an array, a Date, or left as-is depending on the target field type:
_formatValueForComponent(value, component) {
const type = component.type;
if (value === null || value === undefined || value === '') return null;
switch (type) {
case 'textfield':
case 'textarea':
// Strings stay as strings
return value;
case 'number':
// Convert string to number
if (typeof value === 'string') {
const num = Number(value);
return isNaN(num) ? value : num;
}
return value;
case 'dropdown': {
const dropdownConfig = component.dropdown_config;
const isMulti = dropdownConfig?.is_multi || false;
if (isMulti) {
// Multi-select expects an array
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed;
} catch {
// Not JSON — try comma-separated
return value.split(',').map(v => v.trim()).filter(Boolean);
}
}
return [value]; // Wrap single value in array
} else {
// Single select expects a string
if (Array.isArray(value)) return value.length > 0 ? value[0] : null;
return String(value);
}
}
case 'checkbox':
// Checkboxes expect boolean
if (typeof value === 'boolean') return value;
if (value === 'true' || value === '1') return true;
if (value === 'false' || value === '0') return false;
return !!value;
case 'checklist':
case 'taglist':
// List fields expect arrays
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed;
} catch {
return value.split(',').map(v => v.trim()).filter(Boolean);
}
}
return [value];
case 'datetime':
case 'date':
case 'time':
// DateTime fields expect specific string formats
if (typeof value === 'string') {
// "2024-01-15 10:30:00" → process by subtype
if (value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) {
const date = new Date(value.replace(' ', 'T'));
if (type === 'date') {
return date.toISOString().split('T')[0]; // "2024-01-15"
} else if (type === 'time') {
return date.toTimeString().split(' ')[0]; // "10:30:00"
} else {
return value; // Keep datetime as-is
}
}
// Already in correct format
if (value.match(/^\d{4}-\d{2}-\d{2}$/)) return value;
}
return value;
case 'fileupload':
// File upload expects array of file objects
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
default:
console.warn(`[TicketAutoFillEvaluator] Unknown component type: ${type}`);
return value;
}
}
The dropdown case validates that the formatted value exists in the component's static options before returning it — if the ticket has a value that's not in the dropdown's option list, returning it would cause the dropdown to show an invalid selection:
// After formatting, validate dropdown value
if (component.type === 'dropdown' && !this._isValidDropdownValue(formattedValue, component)) {
console.warn(
`[TicketAutoFillEvaluator] Value "${formattedValue}" not in dropdown options for ${component.key}`
);
return null; // Don't set an invalid value
}
_isValidDropdownValue(value, component) {
const config = component.dropdown_config;
if (!config || config.data_source !== 'manual' || !config.static_values) {
return true; // Can't validate dynamic dropdowns — assume valid
}
const validValues = config.static_values.map(opt => opt.value);
if (config.is_multi && Array.isArray(value)) {
return value.every(v => validValues.includes(v));
}
return validValues.includes(value);
}
The Complete Evaluator Structure
Putting it all together, the evaluator's lifecycle:
export class TicketAutoFillEvaluator {
constructor(eventBus, form) {
this._eventBus = eventBus;
this._form = form;
this._evaluating = false;
this._initialized = false;
this._feelEngine = null;
this._ticketDataCache = new Map();
this._watchedFields = new Map();
this._previousTicketIds = new Map();
this._currentTicketContext = null;
try {
this._feelEngine = { evaluate };
} catch (err) {
console.warn('[TicketAutoFillEvaluator] FEEL engine unavailable');
}
// ✅ Build watcher map on init
this._eventBus.on('form.init', () => {
this._initialized = true;
setTimeout(() => this._buildWatchMap(), 50);
});
// ✅ Receive ticket context from outside the form
this._eventBus.on('ticket.context.set', (event) => {
this._currentTicketContext = event;
});
// ✅ Evaluate when data changes — only if watched fields changed
this._eventBus.on('changed', (event) => {
if (!this._initialized || this._evaluating) return;
const changedData = event.data || {};
const changedKeys = Object.keys(changedData);
// ✅ Only proceed if a watched field is in the changed keys
const hasWatchedChange = changedKeys.some(key => this._watchedFields.has(key));
if (!hasWatchedChange) return;
setTimeout(() => {
if (!this._evaluating) {
this.evaluateChangedFields(changedKeys);
}
}, 10);
});
// ✅ Rebuild watcher map when schema changes
this._eventBus.on('propertiesPanel.updated', () => {
if (!this._initialized || this._evaluating) return;
setTimeout(() => {
if (!this._evaluating) this._buildWatchMap();
}, 50);
});
}
clearCache() {
this._ticketDataCache.clear();
this._previousTicketIds.clear();
}
}
TicketAutoFillEvaluator.$inject = ['eventBus', 'form'];
The hasWatchedChange optimization is important. Without it, every changed event — including those triggered by the evaluator itself writing auto-fill values — triggers another evaluation pass. The watcher map check ensures the evaluator only runs when a field that has dependents actually changed.
The _updateComponentInSchema Utility
When populateOptions mode updates a component's options, the change must be persisted to the schema — otherwise the next render will revert to the old options:
_updateComponentInSchema(componentId, updates) {
const schema = this._form._state.schema;
if (!schema?.components) return;
const component = this._findComponentInSchema(schema.components, componentId);
if (component) {
Object.assign(component, updates);
// ✅ Trigger re-render with shallow clone
this._form._setState({ schema: { ...schema } });
}
}
_findComponentInSchema(components, componentId) {
for (const component of components) {
if (component.id === componentId) return component;
if (component.type === 'group' && Array.isArray(component.components)) {
const found = this._findComponentInSchema(component.components, componentId);
if (found) return found;
}
}
return null;
}
The Tradeoffs
The cache has no expiration. Ticket data cached at the start of the form session is used for the entire session. If the ticket is updated by another user while the form is open, the form shows stale data. The clearCache() method handles explicit invalidation (called when the ticket selection changes), but there's no time-based expiration. For tickets that change frequently during a session, this is a real problem. A TTL-based cache would require more complexity:
_ticketDataCache = new Map(); // key → { data, timestamp }
const MAX_AGE_MS = 30 * 1000; // 30 seconds
_getCached(key) {
const entry = this._ticketDataCache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > MAX_AGE_MS) {
this._ticketDataCache.delete(key);
return null;
}
return entry.data;
}
Promise.all for concurrent evaluation has no cancellation. When the user changes the ticket selection, _resetDependentFields clears the fields and _previousTicketIds updates. But if evaluateChangedFields is already running from a previous selection, its Promise.all continues in the background. When it resolves, it might write stale data. The _evaluating flag prevents a new evaluation from starting, but doesn't cancel the in-flight one.
The fix is a generation counter on the evaluation cycle (similar to the React root generation counter from Article 9):
this._evaluationGeneration = 0;
async evaluateChangedFields(changedKeys) {
this._evaluating = true;
const currentGeneration = ++this._evaluationGeneration;
try {
// ... evaluation ...
// Before writing results:
if (currentGeneration !== this._evaluationGeneration) {
return; // A newer evaluation started — discard our results
}
this._form._setState({ data: { ...currentData, ...updates } });
} finally {
setTimeout(() => { this._evaluating = false; }, 100);
}
}
The _formatValueForComponent datetime conversion is timezone-sensitive. Converting "2024-01-15 10:30:00" to a Date via new Date(value.replace(' ', 'T')) uses local time (no timezone suffix). If the ticket's datetime is stored in UTC and the user's browser is in a different timezone, the converted date will be offset. Article 17 covers this problem in depth for the DateTime field replacement.
What Comes Next
TicketAutoFillEvaluator is the most complex evaluator in the system — async, cached, multi-endpoint, with condition evaluation and type formatting. It demonstrates how far the evaluator pattern from Article 8 can be extended while maintaining the same fundamental structure.
Article 21 covers subclassing Form and FormEditor — the CustomForm and CustomFormEditor classes that bootstrap all evaluators, providers, and extensions into a deployable unit.
This is Part 20 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 async caching autofill feel runtime-evaluation javascript devex
Top comments (0)