htmx.org 4.0.0-alpha5 → 4.0.0-alpha6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ext/hx-ws.js CHANGED
@@ -1,20 +1,33 @@
1
1
  (() => {
2
2
  let api;
3
3
 
4
+ // ========================================
5
+ // ATTRIBUTE HELPERS
6
+ // ========================================
7
+
8
+ // Helper to build proper attribute name respecting htmx prefix
9
+ function buildAttrName(suffix) {
10
+ // htmx.config.prefix replaces 'hx-' entirely, e.g. 'data-hx-'
11
+ // So 'hx-ws:connect' becomes 'data-hx-ws:connect'
12
+ let prefix = htmx.config.prefix || 'hx-';
13
+ return prefix + 'ws' + suffix;
14
+ }
15
+
4
16
  // Helper to get attribute value, checking colon, hyphen, and plain variants
17
+ // Uses api.attributeValue for automatic prefix handling and inheritance support
5
18
  function getWsAttribute(element, attrName) {
6
- // Try colon variant first (hx-ws:connect)
19
+ // Try colon variant first (hx-ws:connect) - prefix applied automatically by htmx
7
20
  let colonValue = api.attributeValue(element, 'hx-ws:' + attrName);
8
- if (colonValue !== null && colonValue !== undefined) return colonValue;
21
+ if (colonValue != null) return colonValue;
9
22
 
10
23
  // Try hyphen variant for JSX (hx-ws-connect)
11
24
  let hyphenValue = api.attributeValue(element, 'hx-ws-' + attrName);
12
- if (hyphenValue !== null && hyphenValue !== undefined) return hyphenValue;
25
+ if (hyphenValue != null) return hyphenValue;
13
26
 
14
27
  // For 'send', also check plain 'hx-ws' (marker attribute)
15
28
  if (attrName === 'send') {
16
29
  let plainValue = api.attributeValue(element, 'hx-ws');
17
- if (plainValue !== null && plainValue !== undefined) return plainValue;
30
+ if (plainValue != null) return plainValue;
18
31
  }
19
32
 
20
33
  return null;
@@ -26,6 +39,14 @@
26
39
  return value !== null && value !== undefined;
27
40
  }
28
41
 
42
+ // Build selector for WS attributes
43
+ function buildWsSelector(attrName) {
44
+ let colonAttr = buildAttrName(':' + attrName);
45
+ let hyphenAttr = buildAttrName('-' + attrName);
46
+ // Escape colon for CSS selector
47
+ return `[${colonAttr.replace(':', '\\:')}],[${hyphenAttr}]`;
48
+ }
49
+
29
50
  // ========================================
30
51
  // CONFIGURATION
31
52
  // ========================================
@@ -36,12 +57,50 @@
36
57
  reconnectDelay: 1000,
37
58
  reconnectMaxDelay: 30000,
38
59
  reconnectJitter: true,
39
- autoConnect: false,
40
- pauseInBackground: true
60
+ // Note: pauseInBackground is NOT implemented. Reconnection continues in background tabs.
61
+ // To implement visibility-aware behavior, listen for htmx:ws:reconnect and cancel if needed.
62
+ pendingRequestTTL: 30000 // TTL for pending requests in ms
41
63
  };
42
64
  return { ...defaults, ...(htmx.config.websockets || {}) };
43
65
  }
44
66
 
67
+ // ========================================
68
+ // URL NORMALIZATION
69
+ // ========================================
70
+
71
+ function normalizeWebSocketUrl(url) {
72
+ // Already a WebSocket URL
73
+ if (url.startsWith('ws://') || url.startsWith('wss://')) {
74
+ return url;
75
+ }
76
+
77
+ // Convert http(s):// to ws(s)://
78
+ if (url.startsWith('http://')) {
79
+ return 'ws://' + url.slice(7);
80
+ }
81
+ if (url.startsWith('https://')) {
82
+ return 'wss://' + url.slice(8);
83
+ }
84
+
85
+ // Relative URL - build absolute ws(s):// URL based on current location
86
+ let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
87
+ let host = window.location.host;
88
+
89
+ if (url.startsWith('//')) {
90
+ // Protocol-relative URL
91
+ return protocol + url;
92
+ }
93
+
94
+ if (url.startsWith('/')) {
95
+ // Absolute path
96
+ return protocol + '//' + host + url;
97
+ }
98
+
99
+ // Relative path - resolve against current location
100
+ let basePath = window.location.pathname.substring(0, window.location.pathname.lastIndexOf('/') + 1);
101
+ return protocol + '//' + host + basePath + url;
102
+ }
103
+
45
104
  // ========================================
46
105
  // CONNECTION REGISTRY
47
106
  // ========================================
@@ -49,44 +108,52 @@
49
108
  const connectionRegistry = new Map();
50
109
 
51
110
  function getOrCreateConnection(url, element) {
52
- if (connectionRegistry.has(url)) {
53
- let entry = connectionRegistry.get(url);
111
+ let normalizedUrl = normalizeWebSocketUrl(url);
112
+
113
+ if (connectionRegistry.has(normalizedUrl)) {
114
+ let entry = connectionRegistry.get(normalizedUrl);
54
115
  entry.refCount++;
55
116
  entry.elements.add(element);
56
117
  return entry;
57
118
  }
58
119
 
120
+ // Create entry but DON'T add to registry yet - wait for before:ws:connect
59
121
  let entry = {
122
+ url: normalizedUrl,
60
123
  socket: null,
61
124
  refCount: 1,
62
125
  elements: new Set([element]),
63
126
  reconnectAttempts: 0,
64
127
  reconnectTimer: null,
65
- pendingRequests: new Map()
128
+ pendingRequests: new Map(),
129
+ listeners: {} // Store listener references for proper cleanup
66
130
  };
67
131
 
68
- connectionRegistry.set(url, entry);
69
- createWebSocket(url, entry);
132
+ // Fire cancelable event BEFORE storing in registry
133
+ if (!triggerEvent(element, 'htmx:before:ws:connect', { url: normalizedUrl })) {
134
+ // Event was cancelled - don't create connection or store entry
135
+ return null;
136
+ }
137
+
138
+ // Event passed - now store in registry and create socket
139
+ connectionRegistry.set(normalizedUrl, entry);
140
+ createWebSocket(normalizedUrl, entry);
70
141
  return entry;
71
142
  }
72
143
 
73
144
  function createWebSocket(url, entry) {
74
145
  let firstElement = entry.elements.values().next().value;
75
- if (firstElement) {
76
- if (!triggerEvent(firstElement, 'htmx:before:ws:connect', { url })) {
77
- return;
78
- }
79
- }
80
146
 
81
- // Close and remove listeners from old socket
147
+ // Close and remove listeners from old socket properly
82
148
  if (entry.socket) {
83
149
  let oldSocket = entry.socket;
84
150
  entry.socket = null;
85
151
 
86
- oldSocket.onopen = null;
87
- oldSocket.onmessage = null;
88
- oldSocket.onclose = null;
89
- oldSocket.onerror = null;
152
+ // Remove listeners using stored references
153
+ if (entry.listeners.open) oldSocket.removeEventListener('open', entry.listeners.open);
154
+ if (entry.listeners.message) oldSocket.removeEventListener('message', entry.listeners.message);
155
+ if (entry.listeners.close) oldSocket.removeEventListener('close', entry.listeners.close);
156
+ if (entry.listeners.error) oldSocket.removeEventListener('error', entry.listeners.error);
90
157
 
91
158
  try {
92
159
  if (oldSocket.readyState === WebSocket.OPEN || oldSocket.readyState === WebSocket.CONNECTING) {
@@ -98,24 +165,30 @@
98
165
  try {
99
166
  entry.socket = new WebSocket(url);
100
167
 
101
- entry.socket.addEventListener('open', () => {
102
- // Don't reset reconnectAttempts immediately - allow backoff to persist across quick reconnections
103
- // It will naturally decrease as the connection remains stable
168
+ // Create and store listener references
169
+ entry.listeners.open = () => {
170
+ // Reset reconnect attempts on successful connection
171
+ entry.reconnectAttempts = 0;
172
+
104
173
  if (firstElement) {
105
174
  triggerEvent(firstElement, 'htmx:after:ws:connect', { url, socket: entry.socket });
106
175
  }
107
- });
176
+ };
108
177
 
109
- entry.socket.addEventListener('message', (event) => {
178
+ entry.listeners.message = (event) => {
110
179
  handleMessage(entry, event);
111
- });
180
+ };
112
181
 
113
- entry.socket.addEventListener('close', (event) => {
182
+ entry.listeners.close = (event) => {
114
183
  // Check if this socket is still the active one
115
184
  if (event.target !== entry.socket) return;
116
185
 
117
186
  if (firstElement) {
118
- triggerEvent(firstElement, 'htmx:ws:close', { url });
187
+ triggerEvent(firstElement, 'htmx:ws:close', {
188
+ url,
189
+ code: event.code,
190
+ reason: event.reason
191
+ });
119
192
  }
120
193
 
121
194
  // Check if entry is still valid (not cleared)
@@ -125,15 +198,23 @@
125
198
  if (config.reconnect && entry.refCount > 0) {
126
199
  scheduleReconnect(url, entry);
127
200
  } else {
201
+ cleanupPendingRequests(entry);
128
202
  connectionRegistry.delete(url);
129
203
  }
130
- });
204
+ };
131
205
 
132
- entry.socket.addEventListener('error', (error) => {
206
+ entry.listeners.error = (error) => {
133
207
  if (firstElement) {
134
208
  triggerEvent(firstElement, 'htmx:ws:error', { url, error });
135
209
  }
136
- });
210
+ };
211
+
212
+ // Add listeners
213
+ entry.socket.addEventListener('open', entry.listeners.open);
214
+ entry.socket.addEventListener('message', entry.listeners.message);
215
+ entry.socket.addEventListener('close', entry.listeners.close);
216
+ entry.socket.addEventListener('error', entry.listeners.error);
217
+
137
218
  } catch (error) {
138
219
  if (firstElement) {
139
220
  triggerEvent(firstElement, 'htmx:ws:error', { url, error });
@@ -144,12 +225,12 @@
144
225
  function scheduleReconnect(url, entry) {
145
226
  let config = getConfig();
146
227
 
147
- // Increment attempts before calculating delay for proper exponential backoff
148
- let attempts = entry.reconnectAttempts;
228
+ // Increment attempts FIRST, then calculate delay
149
229
  entry.reconnectAttempts++;
230
+ let attempts = entry.reconnectAttempts;
150
231
 
151
232
  let delay = Math.min(
152
- (config.reconnectDelay || 1000) * Math.pow(2, attempts),
233
+ (config.reconnectDelay || 1000) * Math.pow(2, attempts - 1),
153
234
  config.reconnectMaxDelay || 30000
154
235
  );
155
236
 
@@ -161,6 +242,7 @@
161
242
  if (entry.refCount > 0) {
162
243
  let firstElement = entry.elements.values().next().value;
163
244
  if (firstElement) {
245
+ // attempts now means "this is attempt number N"
164
246
  triggerEvent(firstElement, 'htmx:ws:reconnect', { url, attempts });
165
247
  }
166
248
  createWebSocket(url, entry);
@@ -169,9 +251,12 @@
169
251
  }
170
252
 
171
253
  function decrementRef(url, element) {
172
- if (!connectionRegistry.has(url)) return;
254
+ // Try both original and normalized URL
255
+ let normalizedUrl = normalizeWebSocketUrl(url);
173
256
 
174
- let entry = connectionRegistry.get(url);
257
+ if (!connectionRegistry.has(normalizedUrl)) return;
258
+
259
+ let entry = connectionRegistry.get(normalizedUrl);
175
260
  entry.elements.delete(element);
176
261
  entry.refCount--;
177
262
 
@@ -179,10 +264,31 @@
179
264
  if (entry.reconnectTimer) {
180
265
  clearTimeout(entry.reconnectTimer);
181
266
  }
267
+ cleanupPendingRequests(entry);
182
268
  if (entry.socket && entry.socket.readyState === WebSocket.OPEN) {
183
269
  entry.socket.close();
184
270
  }
185
- connectionRegistry.delete(url);
271
+ connectionRegistry.delete(normalizedUrl);
272
+ }
273
+ }
274
+
275
+ // ========================================
276
+ // PENDING REQUEST MANAGEMENT
277
+ // ========================================
278
+
279
+ function cleanupPendingRequests(entry) {
280
+ entry.pendingRequests.clear();
281
+ }
282
+
283
+ function cleanupExpiredRequests(entry) {
284
+ let config = getConfig();
285
+ let now = Date.now();
286
+ let ttl = config.pendingRequestTTL || 30000;
287
+
288
+ for (let [requestId, pending] of entry.pendingRequests) {
289
+ if (now - pending.timestamp > ttl) {
290
+ entry.pendingRequests.delete(requestId);
291
+ }
186
292
  }
187
293
  }
188
294
 
@@ -190,37 +296,83 @@
190
296
  // MESSAGE SENDING
191
297
  // ========================================
192
298
 
193
- function sendMessage(element, event) {
299
+ // Check if a value looks like a URL (vs a boolean marker like "" or "true")
300
+ function looksLikeUrl(value) {
301
+ if (!value) return false;
302
+ // Check for URL-like patterns: paths, protocols, protocol-relative
303
+ return value.startsWith('/') ||
304
+ value.startsWith('.') ||
305
+ value.startsWith('ws:') ||
306
+ value.startsWith('wss:') ||
307
+ value.startsWith('http:') ||
308
+ value.startsWith('https:') ||
309
+ value.startsWith('//');
310
+ }
311
+
312
+ async function sendMessage(element, event) {
194
313
  // Find connection URL
195
314
  let url = getWsAttribute(element, 'send');
196
- if (!url) {
197
- // Look for nearest ancestor with hx-ws:connect or hx-ws-connect
198
- let prefix = htmx.config.prefix || '';
199
- let ancestor = element.closest('[' + prefix + 'hx-ws\\:connect],[' + prefix + 'hx-ws-connect]');
315
+ if (!looksLikeUrl(url)) {
316
+ // Value is empty, "true", or other non-URL marker - look for ancestor connection
317
+ let selector = buildWsSelector('connect');
318
+ let ancestor = element.closest(selector);
200
319
  if (ancestor) {
201
320
  url = getWsAttribute(ancestor, 'connect');
321
+ } else {
322
+ url = null;
202
323
  }
203
324
  }
204
325
 
205
326
  if (!url) {
206
- console.error('No WebSocket connection found for hx-ws:send element', element);
327
+ // Emit error event instead of console.error
328
+ triggerEvent(element, 'htmx:wsSendError', {
329
+ element,
330
+ error: 'No WebSocket connection found for element'
331
+ });
207
332
  return;
208
333
  }
209
334
 
210
- let entry = connectionRegistry.get(url);
335
+ let normalizedUrl = normalizeWebSocketUrl(url);
336
+ let entry = connectionRegistry.get(normalizedUrl);
211
337
  if (!entry || !entry.socket || entry.socket.readyState !== WebSocket.OPEN) {
212
- triggerEvent(element, 'htmx:wsSendError', { url, error: 'Connection not open' });
338
+ triggerEvent(element, 'htmx:wsSendError', { url: normalizedUrl, error: 'Connection not open' });
213
339
  return;
214
340
  }
215
341
 
342
+ // Cleanup expired pending requests periodically
343
+ cleanupExpiredRequests(entry);
344
+
216
345
  // Build message
217
346
  let form = element.form || element.closest('form');
218
347
  let body = api.collectFormData(element, form, event.submitter);
219
- api.handleHxVals(element, body);
348
+ let valsResult = api.handleHxVals(element, body);
349
+ if (valsResult) await valsResult;
220
350
 
351
+ // Preserve multi-value form fields (checkboxes, multi-selects)
221
352
  let values = {};
222
353
  for (let [key, value] of body) {
223
- values[key] = value;
354
+ if (key in values) {
355
+ // Convert to array if needed
356
+ if (!Array.isArray(values[key])) {
357
+ values[key] = [values[key]];
358
+ }
359
+ values[key].push(value);
360
+ } else {
361
+ values[key] = value;
362
+ }
363
+ }
364
+
365
+ // Build headers object
366
+ let headers = {
367
+ 'HX-Request': 'true',
368
+ 'HX-Current-URL': window.location.href
369
+ };
370
+ if (element.id) {
371
+ headers['HX-Trigger'] = element.id;
372
+ }
373
+ let targetAttr = api.attributeValue(element, 'hx-target');
374
+ if (targetAttr) {
375
+ headers['HX-Target'] = targetAttr;
224
376
  }
225
377
 
226
378
  let requestId = generateUUID();
@@ -228,29 +380,30 @@
228
380
  type: 'request',
229
381
  request_id: requestId,
230
382
  event: event.type,
383
+ headers: headers,
231
384
  values: values,
232
- path: url
385
+ path: normalizedUrl
233
386
  };
234
387
 
235
388
  if (element.id) {
236
389
  message.id = element.id;
237
390
  }
238
391
 
239
- // Allow modification via event
240
- let detail = { message, element, url };
392
+ // Allow modification via event - use 'data' as documented
393
+ let detail = { data: message, element, url: normalizedUrl };
241
394
  if (!triggerEvent(element, 'htmx:before:ws:send', detail)) {
242
395
  return;
243
396
  }
244
397
 
245
398
  try {
246
- entry.socket.send(JSON.stringify(detail.message));
399
+ entry.socket.send(JSON.stringify(detail.data));
247
400
 
248
401
  // Store pending request for response matching
249
402
  entry.pendingRequests.set(requestId, { element, timestamp: Date.now() });
250
403
 
251
- triggerEvent(element, 'htmx:after:ws:send', { message: detail.message, url });
404
+ triggerEvent(element, 'htmx:after:ws:send', { data: detail.data, url: normalizedUrl });
252
405
  } catch (error) {
253
- triggerEvent(element, 'htmx:wsSendError', { url, error });
406
+ triggerEvent(element, 'htmx:wsSendError', { url: normalizedUrl, error });
254
407
  }
255
408
  }
256
409
 
@@ -271,10 +424,10 @@
271
424
  try {
272
425
  envelope = JSON.parse(event.data);
273
426
  } catch (e) {
274
- // Not JSON, emit unknown message event
427
+ // Not JSON, emit unknown message event for parse failures
275
428
  let firstElement = entry.elements.values().next().value;
276
429
  if (firstElement) {
277
- triggerEvent(firstElement, 'htmx:wsUnknownMessage', { data: event.data });
430
+ triggerEvent(firstElement, 'htmx:wsUnknownMessage', { data: event.data, parseError: e });
278
431
  }
279
432
  return;
280
433
  }
@@ -301,46 +454,45 @@
301
454
  // Route based on channel
302
455
  if (envelope.channel === 'ui' && envelope.format === 'html') {
303
456
  handleHtmlMessage(targetElement, envelope);
304
- } else if (envelope.channel && (envelope.channel === 'audio' || envelope.channel === 'json' || envelope.channel === 'binary')) {
305
- // Known custom channel - emit event for extensions to handle
306
- triggerEvent(targetElement, 'htmx:wsMessage', { ...envelope, element: targetElement });
307
457
  } else {
308
- // Unknown channel/format - emit unknown message event
309
- triggerEvent(targetElement, 'htmx:wsUnknownMessage', { ...envelope, element: targetElement });
458
+ // Any non-ui/html message emits htmx:wsMessage for application handling
459
+ // This is extensible - apps can handle json, audio, binary, custom channels, etc.
460
+ triggerEvent(targetElement, 'htmx:wsMessage', { ...envelope, element: targetElement });
310
461
  }
311
462
 
312
463
  triggerEvent(targetElement, 'htmx:after:ws:message', { envelope, element: targetElement });
313
464
  }
314
465
 
315
466
  // ========================================
316
- // HTML PARTIAL HANDLING
467
+ // HTML PARTIAL HANDLING - Using htmx.swap(ctx)
317
468
  // ========================================
318
469
 
319
470
  function handleHtmlMessage(element, envelope) {
320
471
  let parser = new DOMParser();
321
- let doc = parser.parseFromString(envelope.payload, 'text/html');
472
+ let doc = parser.parseFromString(envelope.payload || '', 'text/html');
322
473
 
323
- // Find all hx-partial elements
474
+ // Find all hx-partial elements (legacy format)
324
475
  let partials = doc.querySelectorAll('hx-partial');
325
476
 
326
477
  if (partials.length === 0) {
327
478
  // No partials, treat entire payload as content for element's target
328
479
  let target = resolveTarget(element, envelope.target);
329
480
  if (target) {
330
- swapContent(target, envelope.payload, element, envelope.swap);
481
+ swapWithHtmx(target, envelope.payload, element, envelope.swap);
331
482
  }
332
483
  return;
333
484
  }
334
485
 
335
- partials.forEach(partial => {
486
+ // Process each partial
487
+ for (let partial of partials) {
336
488
  let targetId = partial.getAttribute('id');
337
- if (!targetId) return;
489
+ if (!targetId) continue;
338
490
 
339
491
  let target = document.getElementById(targetId);
340
- if (!target) return;
492
+ if (!target) continue;
341
493
 
342
- swapContent(target, partial.innerHTML, element);
343
- });
494
+ swapWithHtmx(target, partial.innerHTML, element);
495
+ }
344
496
  }
345
497
 
346
498
  function resolveTarget(element, envelopeTarget) {
@@ -360,54 +512,28 @@
360
512
  return element;
361
513
  }
362
514
 
363
- function swapContent(target, content, sourceElement, envelopeSwap) {
515
+ function swapWithHtmx(target, content, sourceElement, envelopeSwap) {
516
+ // Determine swap style from envelope, element attribute, or default
364
517
  let swapStyle = envelopeSwap || api.attributeValue(sourceElement, 'hx-swap') || htmx.config.defaultSwap;
365
518
 
366
- // Parse swap style (just get the main style, ignore modifiers for now)
367
- let style = swapStyle.split(' ')[0];
368
-
369
- // Normalize swap style
370
- style = normalizeSwapStyle(style);
371
-
372
- // Perform swap
373
- switch (style) {
374
- case 'innerHTML':
375
- target.innerHTML = content;
376
- break;
377
- case 'outerHTML':
378
- target.outerHTML = content;
379
- break;
380
- case 'beforebegin':
381
- target.insertAdjacentHTML('beforebegin', content);
382
- break;
383
- case 'afterbegin':
384
- target.insertAdjacentHTML('afterbegin', content);
385
- break;
386
- case 'beforeend':
387
- target.insertAdjacentHTML('beforeend', content);
388
- break;
389
- case 'afterend':
390
- target.insertAdjacentHTML('afterend', content);
391
- break;
392
- case 'delete':
393
- target.remove();
394
- break;
395
- case 'none':
396
- // Do nothing
397
- break;
398
- default:
399
- target.innerHTML = content;
400
- }
401
-
402
- // Process new content with HTMX
403
- htmx.process(target);
404
- }
405
-
406
- function normalizeSwapStyle(style) {
407
- return style === 'before' ? 'beforebegin' :
408
- style === 'after' ? 'afterend' :
409
- style === 'prepend' ? 'afterbegin' :
410
- style === 'append' ? 'beforeend' : style;
519
+ // Create a document fragment from the HTML content
520
+ let template = document.createElement('template');
521
+ template.innerHTML = content || '';
522
+ let fragment = template.content;
523
+
524
+ // Use htmx's internal insertContent which handles:
525
+ // - All swap styles correctly
526
+ // - Processing new content with htmx.process()
527
+ // - Preserved elements
528
+ // - Auto-focus
529
+ // - Scroll handling
530
+ let task = {
531
+ target: target,
532
+ swapSpec: swapStyle, // Can be a string - insertContent will parse it
533
+ fragment: fragment
534
+ };
535
+
536
+ api.insertContent(task);
411
537
  }
412
538
 
413
539
  // ========================================
@@ -432,30 +558,38 @@
432
558
  element._htmx = element._htmx || {};
433
559
  element._htmx.wsInitialized = true;
434
560
 
435
- let config = getConfig();
436
561
  let triggerSpec = api.attributeValue(element, 'hx-trigger');
437
562
 
438
- if (!triggerSpec && config.autoConnect === true) {
439
- // Auto-connect on element initialization
440
- getOrCreateConnection(connectUrl, element);
441
- element._htmx = element._htmx || {};
442
- element._htmx.wsUrl = connectUrl;
443
- } else if (triggerSpec) {
444
- // Connect based on trigger
563
+ if (!triggerSpec) {
564
+ // No trigger specified - connect immediately (default behavior)
565
+ // This is the most common use case: connect when element appears
566
+ let entry = getOrCreateConnection(connectUrl, element);
567
+ if (entry) {
568
+ element._htmx.wsUrl = entry.url;
569
+ }
570
+ } else {
571
+ // Connect based on explicit trigger
572
+ // Note: We only support bare event names for connection triggers.
573
+ // Modifiers like once, delay, throttle, from, target are NOT supported
574
+ // for connection establishment. Use htmx:before:ws:connect event for
575
+ // custom connection control logic.
445
576
  let specs = api.parseTriggerSpecs(triggerSpec);
446
577
  if (specs.length > 0) {
447
578
  let spec = specs[0];
448
579
  if (spec.name === 'load') {
449
- getOrCreateConnection(connectUrl, element);
450
- element._htmx = element._htmx || {};
451
- element._htmx.wsUrl = connectUrl;
580
+ // Explicit load trigger - connect immediately
581
+ let entry = getOrCreateConnection(connectUrl, element);
582
+ if (entry) {
583
+ element._htmx.wsUrl = entry.url;
584
+ }
452
585
  } else {
453
- // Set up event listener for other triggers
586
+ // Set up event listener for other triggers (bare event name only)
454
587
  element.addEventListener(spec.name, () => {
455
588
  if (!element._htmx?.wsUrl) {
456
- getOrCreateConnection(connectUrl, element);
457
- element._htmx = element._htmx || {};
458
- element._htmx.wsUrl = connectUrl;
589
+ let entry = getOrCreateConnection(connectUrl, element);
590
+ if (entry) {
591
+ element._htmx.wsUrl = entry.url;
592
+ }
459
593
  }
460
594
  }, { once: true });
461
595
  }
@@ -466,7 +600,9 @@
466
600
  function initializeSendElement(element) {
467
601
  if (element._htmx?.wsSendInitialized) return;
468
602
 
469
- let sendUrl = getWsAttribute(element, 'send');
603
+ let sendAttr = getWsAttribute(element, 'send');
604
+ // Only treat as URL if it looks like one (not "", "true", etc.)
605
+ let sendUrl = looksLikeUrl(sendAttr) ? sendAttr : null;
470
606
  let triggerSpec = api.attributeValue(element, 'hx-trigger');
471
607
 
472
608
  if (!triggerSpec) {
@@ -476,11 +612,14 @@
476
612
  'click';
477
613
  }
478
614
 
615
+ // Note: We only support bare event names for send triggers.
616
+ // Modifiers like once, delay, throttle, from, target are NOT supported.
617
+ // For complex trigger logic, use htmx:before:ws:send to implement custom behavior.
479
618
  let specs = api.parseTriggerSpecs(triggerSpec);
480
619
  if (specs.length > 0) {
481
620
  let spec = specs[0];
482
621
 
483
- let handler = (evt) => {
622
+ let handler = async (evt) => {
484
623
  // Prevent default for forms
485
624
  if (element.matches('form') && evt.type === 'submit') {
486
625
  evt.preventDefault();
@@ -489,13 +628,14 @@
489
628
  // If this element has its own URL, ensure connection exists
490
629
  if (sendUrl) {
491
630
  if (!element._htmx?.wsUrl) {
492
- getOrCreateConnection(sendUrl, element);
493
- element._htmx = element._htmx || {};
494
- element._htmx.wsUrl = sendUrl;
631
+ let entry = getOrCreateConnection(sendUrl, element);
632
+ if (entry) {
633
+ element._htmx.wsUrl = entry.url;
634
+ }
495
635
  }
496
636
  }
497
637
 
498
- sendMessage(element, evt);
638
+ await sendMessage(element, evt);
499
639
  };
500
640
 
501
641
  element.addEventListener(spec.name, handler);
@@ -528,14 +668,16 @@
528
668
  // Map legacy attributes to new ones (prefer hyphen variant for broader compatibility)
529
669
  if (element.hasAttribute('ws-connect')) {
530
670
  let url = element.getAttribute('ws-connect');
531
- if (!element.hasAttribute('hx-ws-connect')) {
532
- element.setAttribute('hx-ws-connect', url);
671
+ let hyphenAttr = buildAttrName('-connect');
672
+ if (!element.hasAttribute(hyphenAttr)) {
673
+ element.setAttribute(hyphenAttr, url);
533
674
  }
534
675
  }
535
676
 
536
677
  if (element.hasAttribute('ws-send')) {
537
- if (!element.hasAttribute('hx-ws-send')) {
538
- element.setAttribute('hx-ws-send', '');
678
+ let hyphenAttr = buildAttrName('-send');
679
+ if (!element.hasAttribute(hyphenAttr)) {
680
+ element.setAttribute(hyphenAttr, '');
539
681
  }
540
682
  }
541
683
  }
@@ -574,8 +716,13 @@
574
716
  // Process the element itself
575
717
  processNode(element);
576
718
 
577
- // Process descendants
578
- element.querySelectorAll('[hx-ws\\:connect], [hx-ws-connect], [hx-ws\\:send], [hx-ws-send], [hx-ws], [ws-connect], [ws-send]').forEach(processNode);
719
+ // Process descendants - build proper selector respecting prefix
720
+ let connectSelector = buildWsSelector('connect');
721
+ let sendSelector = buildWsSelector('send');
722
+ let plainAttr = buildAttrName('');
723
+ let fullSelector = `${connectSelector},${sendSelector},[${plainAttr}],[ws-connect],[ws-send]`;
724
+
725
+ element.querySelectorAll(fullSelector).forEach(processNode);
579
726
  },
580
727
 
581
728
  htmx_before_cleanup: (element) => {
@@ -605,8 +752,8 @@
605
752
  entry.pendingRequests.clear();
606
753
  });
607
754
  },
608
- get: (key) => connectionRegistry.get(key),
609
- has: (key) => connectionRegistry.has(key),
755
+ get: (key) => connectionRegistry.get(normalizeWebSocketUrl(key)),
756
+ has: (key) => connectionRegistry.has(normalizeWebSocketUrl(key)),
610
757
  size: connectionRegistry.size
611
758
  })
612
759
  };