fix(backfill): typeahead picks broken by JSON.stringify quote collision in onclick

The inline onclick on each typeahead dropdown item was:

  onclick="onTypeaheadPick(event, 'cid', 'location', 'loc-id', ${JSON.stringify(m.name)})"

For any name with spaces/punctuation (i.e. every real location name like
"Area 1 - Loc 1 - 87 Jenks"), JSON.stringify emits double quotes around
the value, which collide with the onclick attribute's own double quotes
and terminate the attribute early.  The dropdown rendered fine via
.innerHTML, but the browser's HTML parser saw a broken attribute and
never bound the click handler — clicks on dropdown items silently did
nothing.

Same pattern that broke the location Remove button yesterday.  Same fix:
move args into data-* attributes and dispatch through a tiny trampoline
that reads from this.dataset.  Robust against any character in
project/location names.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 03:59:38 +00:00
parent c48c6e5bca
commit ba1f28ee53
+29 -2
View File
@@ -275,7 +275,13 @@ async function _fetchTypeahead(input, fieldKind) {
return; return;
} }
// Args go in data-* attributes (not inline onclick) to avoid the quote
// collision when location names contain characters JSON.stringify quotes
// (e.g. anything with spaces/punctuation — basically every real name).
// _esc() escapes for HTML attribute context (entities for <>&"), then
// the browser decodes them when reading the dataset value.
dropdown.innerHTML = items.map((it, idx) => { dropdown.innerHTML = items.map((it, idx) => {
const cid = _esc(input.dataset.clusterId);
if (it.kind === 'match') { if (it.kind === 'match') {
const m = it.payload; const m = it.payload;
const scoreBadge = m.score >= 0.99 const scoreBadge = m.score >= 0.99
@@ -291,16 +297,24 @@ async function _fetchTypeahead(input, fieldKind) {
} }
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : ''; const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
return `<button type="button" return `<button type="button"
data-cid="${cid}"
data-field-kind="${fieldKind}"
data-entity-id="${_esc(m.id)}"
data-entity-name="${_esc(m.name)}"
onmousedown="event.preventDefault()" onmousedown="event.preventDefault()"
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '${_esc(m.id)}', ${JSON.stringify(m.name)})" onclick="_typeaheadPickFromButton(this)"
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0"> class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div> <div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div>
${metaLine} ${metaLine}
</button>`; </button>`;
} }
return `<button type="button" return `<button type="button"
data-cid="${cid}"
data-field-kind="${fieldKind}"
data-entity-id=""
data-entity-name="${_esc(it.name)}"
onmousedown="event.preventDefault()" onmousedown="event.preventDefault()"
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '', ${JSON.stringify(it.name)})" onclick="_typeaheadPickFromButton(this)"
class="w-full text-left px-3 py-2 hover:bg-orange-50 dark:hover:bg-orange-900/20 border-t border-gray-200 dark:border-gray-700 text-seismo-orange font-medium text-sm"> class="w-full text-left px-3 py-2 hover:bg-orange-50 dark:hover:bg-orange-900/20 border-t border-gray-200 dark:border-gray-700 text-seismo-orange font-medium text-sm">
+ ${_esc(it.label)} + ${_esc(it.label)}
</button>`; </button>`;
@@ -308,6 +322,19 @@ async function _fetchTypeahead(input, fieldKind) {
dropdown.classList.remove('hidden'); dropdown.classList.remove('hidden');
} }
// Trampoline — reads the click target's data-* attributes and forwards
// to onTypeaheadPick. Keeps the inline onclick attribute free of any
// string interpolation that could collide with HTML quoting.
function _typeaheadPickFromButton(btn) {
onTypeaheadPick(
null,
btn.dataset.cid,
btn.dataset.fieldKind,
btn.dataset.entityId || '',
btn.dataset.entityName || ''
);
}
function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) { function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) {
// entityId is empty string for "create new", or a UUID for matched existing. // entityId is empty string for "create new", or a UUID for matched existing.
const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`); const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`);