fix(web): restore htmx script re-execution timing that Alpine x-data depends on

Removing the custom htmx:afterSwap script-reexecution handler (in a prior
commit, as a "duplicate execution" cleanup) broke every partial whose Alpine
x-data component function is defined by an inline <script> in that same
partial (e.g. wifi.html's wifiSetup()) - confirmed live via browser console:
"Alpine Expression Error: wifiSetup is not defined" on every field in the
WiFi tab.

Root cause: htmx's own native script execution runs during its "settle"
phase (~20ms after swap, per htmx's own defaultSettleDelay), but Alpine's
MutationObserver evaluates x-data on newly-inserted elements synchronously,
right as the swap lands - before settle. So the inline <script> defining
wifiSetup() was still un-run when Alpine tried to call it, and Alpine does
not retry a failed x-data evaluation later once the function does become
defined.

Fix: re-execute swapped <script> tags ourselves on htmx:afterSwap (which
fires synchronously, before settle, beating Alpine's observer), and disable
htmx's own native script re-execution (htmx.config.allowScriptTags = false)
so the same script doesn't also run a second time during settle - restoring
correct timing without reintroducing the original double-execution bug.

Also in this commit:
- fix XSS: unescaped repoUrl in a title attribute in renderSavedRepositories
- replace .includes('github.com') substring checks with real URL hostname
  validation (CodeQL: incomplete URL substring sanitization)
This commit is contained in:
ChuckBuilds
2026-07-16 20:17:32 -04:00
parent 35bc299162
commit 99ea157fb2
2 changed files with 48 additions and 14 deletions
+29 -8
View File
@@ -162,14 +162,35 @@
console.warn('HTMX swap error:', event.detail);
});
// Note: no custom htmx:afterSwap script re-execution here.
// htmx's own default config (allowScriptTags: true, on by
// default - confirmed in the vendored htmx.min.js) already
// clones and re-inserts <script> tags in swapped content
// to make the browser execute them, exactly like a real
// page load. A duplicate handler here previously did the
// same clone-and-reinsert a second time, executing every
// inline <script> in every HTMX-loaded partial TWICE.
// Execute <script> tags in swapped content ourselves, on
// htmx:afterSwap (synchronous, right after the swap) rather
// than relying on htmx's own script handling, which runs
// during its later "settle" phase (~20ms after swap, per
// htmx's defaultSettleDelay). Alpine's MutationObserver
// processes newly-inserted x-data elements synchronously
// as soon as the swap lands, which is BEFORE htmx's settle
// phase - so any partial whose x-data component function
// (e.g. wifiSetup()) is defined by an inline <script> in
// that same partial would have that script still un-run
// when Alpine evaluates x-data, permanently failing with
// "wifiSetup is not defined" (Alpine does not retry).
// Disable htmx's own native script re-execution so the
// same script doesn't also run a second time via settle.
if (typeof htmx !== 'undefined' && htmx.config) {
htmx.config.allowScriptTags = false;
}
document.body.addEventListener('htmx:afterSwap', function(event) {
const target = event.detail && event.detail.target;
if (!target || !(target instanceof Element)) return;
target.querySelectorAll('script').forEach(function(oldScript) {
const newScript = document.createElement('script');
for (const attr of oldScript.attributes) {
newScript.setAttribute(attr.name, attr.value);
}
newScript.textContent = oldScript.textContent;
oldScript.replaceWith(newScript);
});
});
// Mark tab containers as loaded once their content settles, so switching
// away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab
+19 -6
View File
@@ -667,7 +667,7 @@ window.handleGitHubPluginInstall = function() {
return;
}
if (!repoUrl.includes('github.com')) {
if (!isGithubUrl(repoUrl)) {
if (statusDiv) {
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
}
@@ -4022,9 +4022,9 @@ function renderSavedRepositories(repositories) {
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<i class="fas ${repoType === 'registry' ? 'fa-folder-open' : 'fa-code-branch'} text-gray-400 text-xs"></i>
<span class="text-sm font-medium text-gray-900 truncate" title="${repoUrl}">${escapeHtml(repoName)}</span>
<span class="text-sm font-medium text-gray-900 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoName)}</span>
</div>
<p class="text-xs text-gray-500 truncate" title="${repoUrl}">${escapeHtml(repoUrl)}</p>
<p class="text-xs text-gray-500 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoUrl)}</p>
</div>
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${escapeJs(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository">
<i class="fas fa-trash"></i>
@@ -4107,7 +4107,7 @@ function attachInstallButtonHandler() {
return;
}
if (!repoUrl.includes('github.com')) {
if (!isGithubUrl(repoUrl)) {
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
}
@@ -4280,7 +4280,7 @@ function setupGitHubInstallHandlers() {
return;
}
if (!repoUrl.includes('github.com')) {
if (!isGithubUrl(repoUrl)) {
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
return;
}
@@ -4336,7 +4336,7 @@ function setupGitHubInstallHandlers() {
return;
}
if (!repoUrl.includes('github.com')) {
if (!isGithubUrl(repoUrl)) {
showError('Please enter a valid GitHub URL');
return;
}
@@ -4480,6 +4480,19 @@ function showError(message) {
}
// Validate that a URL's actual host is github.com (not just a substring
// match, which 'evil.com/github.com' or 'github.com.evil.com' would pass).
// This is only a UX nicety pointing users at a valid URL - the server does
// its own proper hostname validation before actually acting on the URL.
function isGithubUrl(url) {
try {
const hostname = new URL(url).hostname.toLowerCase();
return hostname === 'github.com' || hostname === 'www.github.com';
} catch {
return false;
}
}
// Utility function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');