fix(editor): asset/logger/position menus broken by TDZ ReferenceError in oneditprepare

The previous oneditprepare ran applyMode(initialMode) early in the
function, which called validateChannelsJson(), which referenced const
declarations (channelsArea, channelsHint) that were declared later in
the same function. JavaScript hoists const into the Temporal Dead Zone,
so accessing them before the declaration line throws a ReferenceError.
That uncaught throw aborted the rest of oneditprepare — including the
waitForMenuData() call that initialises the asset / logger / position
menu placeholders. Symptom for the user: opening a measurement node in
the editor showed Mode + analog fields but the asset menu was empty.

Fixes:

1. Move waitForMenuData() to the very top of oneditprepare so the
   shared menu init is independent of any later mode-block work. Even
   if the mode logic ever throws again, the asset / logger / position
   menus still render.

2. Resolve every DOM reference (modeSelect, analogBlock, digitalBlock,
   modeHint, channelsArea, channelsHint) at the top of the function
   before any helper that touches them is invoked. validateChannelsJson
   and applyMode now read closed-over names that are guaranteed to be
   initialised.

3. Guard applyMode(initialMode) with try/catch as defense in depth and
   add null-checks on every DOM reference. A future template change
   that drops one of the IDs will only no-op rather than break the
   editor.

No runtime change. 71/71 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-13 14:15:06 +02:00
parent d6f8af4395
commit fb8d5c03e6

View File

@@ -74,44 +74,50 @@
oneditprepare: function() { oneditprepare: function() {
const node = this; const node = this;
// === Mode selector — TOP-LEVEL hierarchy === // === Asset / logger / position placeholders (dynamic menus) ===
// The Input Mode drives whether analog-pipeline fields or the // Kick these off FIRST so that any error in the downstream mode
// digital channels editor are shown. Initialize the <select> from // logic can never block the shared menus. Historical regression:
// the saved node value, fall back to 'analog' for legacy nodes // a ReferenceError in the mode block aborted oneditprepare and
// that were saved before the mode field existed. // stopped the asset menu from rendering at all.
const modeSelect = document.getElementById('node-input-mode'); const waitForMenuData = () => {
const initialMode = (node.mode === 'digital' || node.mode === 'analog') ? node.mode : 'analog'; if (window.EVOLV?.nodes?.measurement?.initEditor) {
modeSelect.value = initialMode; window.EVOLV.nodes.measurement.initEditor(node);
} else {
setTimeout(waitForMenuData, 50);
}
};
waitForMenuData();
// IMPORTANT: all DOM references are resolved up front so helper
// functions called during initial applyMode() don't trip over the
// Temporal Dead Zone on later `const` declarations.
const modeSelect = document.getElementById('node-input-mode');
const analogBlock = document.getElementById('analog-only-fields'); const analogBlock = document.getElementById('analog-only-fields');
const digitalBlock = document.getElementById('digital-only-fields'); const digitalBlock = document.getElementById('digital-only-fields');
const modeHint = document.getElementById('mode-hint'); const modeHint = document.getElementById('mode-hint');
const channelsArea = document.getElementById('node-input-channels');
const channelsHint = document.getElementById('channels-validation');
function applyMode(mode) { // Initialise the mode <select> from the saved node.mode. Legacy
const isDigital = mode === 'digital'; // nodes (saved before the mode field existed) fall back to
analogBlock.style.display = isDigital ? 'none' : 'block'; // 'analog' so they keep behaving exactly like before.
digitalBlock.style.display = isDigital ? 'block' : 'none'; const initialMode = (node.mode === 'digital' || node.mode === 'analog') ? node.mode : 'analog';
if (modeHint) { if (modeSelect) modeSelect.value = initialMode;
modeHint.textContent = isDigital
? 'msg.payload must be an OBJECT, e.g. {"temperature": 22.5, "humidity": 45}. Define each key below.'
: 'msg.payload must be a NUMBER (or numeric string). Configure scaling/smoothing below.';
}
validateChannelsJson();
}
modeSelect.addEventListener('change', (e) => applyMode(e.target.value)); // Populate the channels textarea from the saved node.channels
applyMode(initialMode); // (stored as a raw JSON string; parsing happens server-side).
// === Channels JSON live validation (digital only) ===
const channelsArea = document.getElementById('node-input-channels');
const channelsHint = document.getElementById('channels-validation');
if (channelsArea && typeof node.channels === 'string') { if (channelsArea && typeof node.channels === 'string') {
channelsArea.value = node.channels; channelsArea.value = node.channels;
} }
function validateChannelsJson() { function validateChannelsJson() {
if (!channelsHint) return; if (!channelsHint) return;
if (modeSelect.value !== 'digital') { channelsHint.textContent = ''; return; } if (!modeSelect || modeSelect.value !== 'digital') {
const raw = (channelsArea.value || '').trim(); channelsHint.textContent = '';
return;
}
const raw = (channelsArea && channelsArea.value || '').trim();
if (!raw || raw === '[]') { if (!raw || raw === '[]') {
channelsHint.innerHTML = '<span style="color:#b45309;">Digital mode with no channels — no measurements will be emitted.</span>'; channelsHint.innerHTML = '<span style="color:#b45309;">Digital mode with no channels — no measurements will be emitted.</span>';
return; return;
@@ -120,7 +126,7 @@
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error('must be an array'); if (!Array.isArray(parsed)) throw new Error('must be an array');
const missing = parsed const missing = parsed
.map((c, i) => (c && c.key && c.type ? null : `entry ${i}: missing key or type`)) .map((c, i) => (c && c.key && c.type ? null : 'entry ' + i + ': missing key or type'))
.filter(Boolean); .filter(Boolean);
if (missing.length) { if (missing.length) {
channelsHint.innerHTML = '<span style="color:#b45309;">' + missing.join('; ') + '</span>'; channelsHint.innerHTML = '<span style="color:#b45309;">' + missing.join('; ') + '</span>';
@@ -131,17 +137,24 @@
channelsHint.innerHTML = '<span style="color:#b91c1c;">Invalid JSON: ' + e.message + '</span>'; channelsHint.innerHTML = '<span style="color:#b91c1c;">Invalid JSON: ' + e.message + '</span>';
} }
} }
if (channelsArea) channelsArea.addEventListener('input', validateChannelsJson);
// === Asset / logger / position placeholders (dynamic menus) === function applyMode(mode) {
const waitForMenuData = () => { const isDigital = mode === 'digital';
if (window.EVOLV?.nodes?.measurement?.initEditor) { if (analogBlock) analogBlock.style.display = isDigital ? 'none' : 'block';
window.EVOLV.nodes.measurement.initEditor(node); if (digitalBlock) digitalBlock.style.display = isDigital ? 'block' : 'none';
} else { if (modeHint) {
setTimeout(waitForMenuData, 50); modeHint.textContent = isDigital
? 'msg.payload must be an OBJECT, e.g. {"temperature": 22.5, "humidity": 45}. Define each key below.'
: 'msg.payload must be a NUMBER (or numeric string). Configure scaling/smoothing below.';
} }
}; validateChannelsJson();
waitForMenuData(); }
if (modeSelect) modeSelect.addEventListener('change', (e) => applyMode(e.target.value));
if (channelsArea) channelsArea.addEventListener('input', validateChannelsJson);
try { applyMode(initialMode); } catch (e) {
console.error('measurement: applyMode failed', e);
}
// === Smoothing method dropdown (analog only) === // === Smoothing method dropdown (analog only) ===
const smoothMethodSelect = document.getElementById('node-input-smooth_method'); const smoothMethodSelect = document.getElementById('node-input-smooth_method');