htmx.org 4.0.0-alpha4 → 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,53 +108,87 @@
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
- }
146
+
147
+ // Close and remove listeners from old socket properly
148
+ if (entry.socket) {
149
+ let oldSocket = entry.socket;
150
+ entry.socket = null;
151
+
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);
157
+
158
+ try {
159
+ if (oldSocket.readyState === WebSocket.OPEN || oldSocket.readyState === WebSocket.CONNECTING) {
160
+ oldSocket.close();
161
+ }
162
+ } catch (e) {}
79
163
  }
80
164
 
81
165
  try {
82
166
  entry.socket = new WebSocket(url);
83
167
 
84
- entry.socket.addEventListener('open', () => {
85
- // Don't reset reconnectAttempts immediately - allow backoff to persist across quick reconnections
86
- // 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
+
87
173
  if (firstElement) {
88
174
  triggerEvent(firstElement, 'htmx:after:ws:connect', { url, socket: entry.socket });
89
175
  }
90
- });
176
+ };
91
177
 
92
- entry.socket.addEventListener('message', (event) => {
178
+ entry.listeners.message = (event) => {
93
179
  handleMessage(entry, event);
94
- });
180
+ };
95
181
 
96
- entry.socket.addEventListener('close', () => {
182
+ entry.listeners.close = (event) => {
183
+ // Check if this socket is still the active one
184
+ if (event.target !== entry.socket) return;
185
+
97
186
  if (firstElement) {
98
- triggerEvent(firstElement, 'htmx:ws:close', { url });
187
+ triggerEvent(firstElement, 'htmx:ws:close', {
188
+ url,
189
+ code: event.code,
190
+ reason: event.reason
191
+ });
99
192
  }
100
193
 
101
194
  // Check if entry is still valid (not cleared)
@@ -105,15 +198,23 @@
105
198
  if (config.reconnect && entry.refCount > 0) {
106
199
  scheduleReconnect(url, entry);
107
200
  } else {
201
+ cleanupPendingRequests(entry);
108
202
  connectionRegistry.delete(url);
109
203
  }
110
- });
204
+ };
111
205
 
112
- entry.socket.addEventListener('error', (error) => {
206
+ entry.listeners.error = (error) => {
113
207
  if (firstElement) {
114
208
  triggerEvent(firstElement, 'htmx:ws:error', { url, error });
115
209
  }
116
- });
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
+
117
218
  } catch (error) {
118
219
  if (firstElement) {
119
220
  triggerEvent(firstElement, 'htmx:ws:error', { url, error });
@@ -124,12 +225,12 @@
124
225
  function scheduleReconnect(url, entry) {
125
226
  let config = getConfig();
126
227
 
127
- // Increment attempts before calculating delay for proper exponential backoff
128
- let attempts = entry.reconnectAttempts;
228
+ // Increment attempts FIRST, then calculate delay
129
229
  entry.reconnectAttempts++;
230
+ let attempts = entry.reconnectAttempts;
130
231
 
131
232
  let delay = Math.min(
132
- (config.reconnectDelay || 1000) * Math.pow(2, attempts),
233
+ (config.reconnectDelay || 1000) * Math.pow(2, attempts - 1),
133
234
  config.reconnectMaxDelay || 30000
134
235
  );
135
236
 
@@ -141,6 +242,7 @@
141
242
  if (entry.refCount > 0) {
142
243
  let firstElement = entry.elements.values().next().value;
143
244
  if (firstElement) {
245
+ // attempts now means "this is attempt number N"
144
246
  triggerEvent(firstElement, 'htmx:ws:reconnect', { url, attempts });
145
247
  }
146
248
  createWebSocket(url, entry);
@@ -149,9 +251,12 @@
149
251
  }
150
252
 
151
253
  function decrementRef(url, element) {
152
- if (!connectionRegistry.has(url)) return;
254
+ // Try both original and normalized URL
255
+ let normalizedUrl = normalizeWebSocketUrl(url);
256
+
257
+ if (!connectionRegistry.has(normalizedUrl)) return;
153
258
 
154
- let entry = connectionRegistry.get(url);
259
+ let entry = connectionRegistry.get(normalizedUrl);
155
260
  entry.elements.delete(element);
156
261
  entry.refCount--;
157
262
 
@@ -159,10 +264,31 @@
159
264
  if (entry.reconnectTimer) {
160
265
  clearTimeout(entry.reconnectTimer);
161
266
  }
267
+ cleanupPendingRequests(entry);
162
268
  if (entry.socket && entry.socket.readyState === WebSocket.OPEN) {
163
269
  entry.socket.close();
164
270
  }
165
- 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
+ }
166
292
  }
167
293
  }
168
294
 
@@ -170,37 +296,83 @@
170
296
  // MESSAGE SENDING
171
297
  // ========================================
172
298
 
173
- 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) {
174
313
  // Find connection URL
175
314
  let url = getWsAttribute(element, 'send');
176
- if (!url) {
177
- // Look for nearest ancestor with hx-ws:connect or hx-ws-connect
178
- let prefix = htmx.config.prefix || '';
179
- 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);
180
319
  if (ancestor) {
181
320
  url = getWsAttribute(ancestor, 'connect');
321
+ } else {
322
+ url = null;
182
323
  }
183
324
  }
184
325
 
185
326
  if (!url) {
186
- 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
+ });
187
332
  return;
188
333
  }
189
334
 
190
- let entry = connectionRegistry.get(url);
335
+ let normalizedUrl = normalizeWebSocketUrl(url);
336
+ let entry = connectionRegistry.get(normalizedUrl);
191
337
  if (!entry || !entry.socket || entry.socket.readyState !== WebSocket.OPEN) {
192
- triggerEvent(element, 'htmx:wsSendError', { url, error: 'Connection not open' });
338
+ triggerEvent(element, 'htmx:wsSendError', { url: normalizedUrl, error: 'Connection not open' });
193
339
  return;
194
340
  }
195
341
 
342
+ // Cleanup expired pending requests periodically
343
+ cleanupExpiredRequests(entry);
344
+
196
345
  // Build message
197
346
  let form = element.form || element.closest('form');
198
347
  let body = api.collectFormData(element, form, event.submitter);
199
- api.handleHxVals(element, body);
348
+ let valsResult = api.handleHxVals(element, body);
349
+ if (valsResult) await valsResult;
200
350
 
351
+ // Preserve multi-value form fields (checkboxes, multi-selects)
201
352
  let values = {};
202
353
  for (let [key, value] of body) {
203
- 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;
204
376
  }
205
377
 
206
378
  let requestId = generateUUID();
@@ -208,29 +380,30 @@
208
380
  type: 'request',
209
381
  request_id: requestId,
210
382
  event: event.type,
383
+ headers: headers,
211
384
  values: values,
212
- path: url
385
+ path: normalizedUrl
213
386
  };
214
387
 
215
388
  if (element.id) {
216
389
  message.id = element.id;
217
390
  }
218
391
 
219
- // Allow modification via event
220
- let detail = { message, element, url };
392
+ // Allow modification via event - use 'data' as documented
393
+ let detail = { data: message, element, url: normalizedUrl };
221
394
  if (!triggerEvent(element, 'htmx:before:ws:send', detail)) {
222
395
  return;
223
396
  }
224
397
 
225
398
  try {
226
- entry.socket.send(JSON.stringify(detail.message));
399
+ entry.socket.send(JSON.stringify(detail.data));
227
400
 
228
401
  // Store pending request for response matching
229
402
  entry.pendingRequests.set(requestId, { element, timestamp: Date.now() });
230
403
 
231
- triggerEvent(element, 'htmx:after:ws:send', { message: detail.message, url });
404
+ triggerEvent(element, 'htmx:after:ws:send', { data: detail.data, url: normalizedUrl });
232
405
  } catch (error) {
233
- triggerEvent(element, 'htmx:wsSendError', { url, error });
406
+ triggerEvent(element, 'htmx:wsSendError', { url: normalizedUrl, error });
234
407
  }
235
408
  }
236
409
 
@@ -251,10 +424,10 @@
251
424
  try {
252
425
  envelope = JSON.parse(event.data);
253
426
  } catch (e) {
254
- // Not JSON, emit unknown message event
427
+ // Not JSON, emit unknown message event for parse failures
255
428
  let firstElement = entry.elements.values().next().value;
256
429
  if (firstElement) {
257
- triggerEvent(firstElement, 'htmx:wsUnknownMessage', { data: event.data });
430
+ triggerEvent(firstElement, 'htmx:wsUnknownMessage', { data: event.data, parseError: e });
258
431
  }
259
432
  return;
260
433
  }
@@ -281,49 +454,54 @@
281
454
  // Route based on channel
282
455
  if (envelope.channel === 'ui' && envelope.format === 'html') {
283
456
  handleHtmlMessage(targetElement, envelope);
284
- } else if (envelope.channel && (envelope.channel === 'audio' || envelope.channel === 'json' || envelope.channel === 'binary')) {
285
- // Known custom channel - emit event for extensions to handle
286
- triggerEvent(targetElement, 'htmx:wsMessage', { ...envelope, element: targetElement });
287
457
  } else {
288
- // Unknown channel/format - emit unknown message event
289
- 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 });
290
461
  }
291
462
 
292
463
  triggerEvent(targetElement, 'htmx:after:ws:message', { envelope, element: targetElement });
293
464
  }
294
465
 
295
466
  // ========================================
296
- // HTML PARTIAL HANDLING
467
+ // HTML PARTIAL HANDLING - Using htmx.swap(ctx)
297
468
  // ========================================
298
469
 
299
470
  function handleHtmlMessage(element, envelope) {
300
471
  let parser = new DOMParser();
301
- let doc = parser.parseFromString(envelope.payload, 'text/html');
472
+ let doc = parser.parseFromString(envelope.payload || '', 'text/html');
302
473
 
303
- // Find all hx-partial elements
474
+ // Find all hx-partial elements (legacy format)
304
475
  let partials = doc.querySelectorAll('hx-partial');
305
476
 
306
477
  if (partials.length === 0) {
307
478
  // No partials, treat entire payload as content for element's target
308
- let target = resolveTarget(element);
479
+ let target = resolveTarget(element, envelope.target);
309
480
  if (target) {
310
- swapContent(target, envelope.payload, element);
481
+ swapWithHtmx(target, envelope.payload, element, envelope.swap);
311
482
  }
312
483
  return;
313
484
  }
314
485
 
315
- partials.forEach(partial => {
486
+ // Process each partial
487
+ for (let partial of partials) {
316
488
  let targetId = partial.getAttribute('id');
317
- if (!targetId) return;
489
+ if (!targetId) continue;
318
490
 
319
491
  let target = document.getElementById(targetId);
320
- if (!target) return;
492
+ if (!target) continue;
321
493
 
322
- swapContent(target, partial.innerHTML, element);
323
- });
494
+ swapWithHtmx(target, partial.innerHTML, element);
495
+ }
324
496
  }
325
497
 
326
- function resolveTarget(element) {
498
+ function resolveTarget(element, envelopeTarget) {
499
+ if (envelopeTarget) {
500
+ if (envelopeTarget === 'this') {
501
+ return element;
502
+ }
503
+ return document.querySelector(envelopeTarget);
504
+ }
327
505
  let targetSelector = api.attributeValue(element, 'hx-target');
328
506
  if (targetSelector) {
329
507
  if (targetSelector === 'this') {
@@ -334,54 +512,28 @@
334
512
  return element;
335
513
  }
336
514
 
337
- function swapContent(target, content, sourceElement) {
338
- let swapStyle = api.attributeValue(sourceElement, 'hx-swap') || htmx.config.defaultSwap;
339
-
340
- // Parse swap style (just get the main style, ignore modifiers for now)
341
- let style = swapStyle.split(' ')[0];
342
-
343
- // Normalize swap style
344
- style = normalizeSwapStyle(style);
345
-
346
- // Perform swap
347
- switch (style) {
348
- case 'innerHTML':
349
- target.innerHTML = content;
350
- break;
351
- case 'outerHTML':
352
- target.outerHTML = content;
353
- break;
354
- case 'beforebegin':
355
- target.insertAdjacentHTML('beforebegin', content);
356
- break;
357
- case 'afterbegin':
358
- target.insertAdjacentHTML('afterbegin', content);
359
- break;
360
- case 'beforeend':
361
- target.insertAdjacentHTML('beforeend', content);
362
- break;
363
- case 'afterend':
364
- target.insertAdjacentHTML('afterend', content);
365
- break;
366
- case 'delete':
367
- target.remove();
368
- break;
369
- case 'none':
370
- // Do nothing
371
- break;
372
- default:
373
- target.innerHTML = content;
374
- }
375
-
376
- // Process new content with HTMX
377
- htmx.process(target);
378
- }
379
-
380
- function normalizeSwapStyle(style) {
381
- return style === 'before' ? 'beforebegin' :
382
- style === 'after' ? 'afterend' :
383
- style === 'prepend' ? 'afterbegin' :
384
- style === 'append' ? 'beforeend' : style;
515
+ function swapWithHtmx(target, content, sourceElement, envelopeSwap) {
516
+ // Determine swap style from envelope, element attribute, or default
517
+ let swapStyle = envelopeSwap || api.attributeValue(sourceElement, 'hx-swap') || htmx.config.defaultSwap;
518
+
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);
385
537
  }
386
538
 
387
539
  // ========================================
@@ -406,30 +558,38 @@
406
558
  element._htmx = element._htmx || {};
407
559
  element._htmx.wsInitialized = true;
408
560
 
409
- let config = getConfig();
410
561
  let triggerSpec = api.attributeValue(element, 'hx-trigger');
411
562
 
412
- if (!triggerSpec && config.autoConnect === true) {
413
- // Auto-connect on element initialization
414
- getOrCreateConnection(connectUrl, element);
415
- element._htmx = element._htmx || {};
416
- element._htmx.wsUrl = connectUrl;
417
- } else if (triggerSpec) {
418
- // 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.
419
576
  let specs = api.parseTriggerSpecs(triggerSpec);
420
577
  if (specs.length > 0) {
421
578
  let spec = specs[0];
422
579
  if (spec.name === 'load') {
423
- getOrCreateConnection(connectUrl, element);
424
- element._htmx = element._htmx || {};
425
- 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
+ }
426
585
  } else {
427
- // Set up event listener for other triggers
586
+ // Set up event listener for other triggers (bare event name only)
428
587
  element.addEventListener(spec.name, () => {
429
588
  if (!element._htmx?.wsUrl) {
430
- getOrCreateConnection(connectUrl, element);
431
- element._htmx = element._htmx || {};
432
- element._htmx.wsUrl = connectUrl;
589
+ let entry = getOrCreateConnection(connectUrl, element);
590
+ if (entry) {
591
+ element._htmx.wsUrl = entry.url;
592
+ }
433
593
  }
434
594
  }, { once: true });
435
595
  }
@@ -440,7 +600,9 @@
440
600
  function initializeSendElement(element) {
441
601
  if (element._htmx?.wsSendInitialized) return;
442
602
 
443
- 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;
444
606
  let triggerSpec = api.attributeValue(element, 'hx-trigger');
445
607
 
446
608
  if (!triggerSpec) {
@@ -450,11 +612,14 @@
450
612
  'click';
451
613
  }
452
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.
453
618
  let specs = api.parseTriggerSpecs(triggerSpec);
454
619
  if (specs.length > 0) {
455
620
  let spec = specs[0];
456
621
 
457
- let handler = (evt) => {
622
+ let handler = async (evt) => {
458
623
  // Prevent default for forms
459
624
  if (element.matches('form') && evt.type === 'submit') {
460
625
  evt.preventDefault();
@@ -463,13 +628,14 @@
463
628
  // If this element has its own URL, ensure connection exists
464
629
  if (sendUrl) {
465
630
  if (!element._htmx?.wsUrl) {
466
- getOrCreateConnection(sendUrl, element);
467
- element._htmx = element._htmx || {};
468
- element._htmx.wsUrl = sendUrl;
631
+ let entry = getOrCreateConnection(sendUrl, element);
632
+ if (entry) {
633
+ element._htmx.wsUrl = entry.url;
634
+ }
469
635
  }
470
636
  }
471
637
 
472
- sendMessage(element, evt);
638
+ await sendMessage(element, evt);
473
639
  };
474
640
 
475
641
  element.addEventListener(spec.name, handler);
@@ -502,14 +668,16 @@
502
668
  // Map legacy attributes to new ones (prefer hyphen variant for broader compatibility)
503
669
  if (element.hasAttribute('ws-connect')) {
504
670
  let url = element.getAttribute('ws-connect');
505
- if (!element.hasAttribute('hx-ws-connect')) {
506
- element.setAttribute('hx-ws-connect', url);
671
+ let hyphenAttr = buildAttrName('-connect');
672
+ if (!element.hasAttribute(hyphenAttr)) {
673
+ element.setAttribute(hyphenAttr, url);
507
674
  }
508
675
  }
509
676
 
510
677
  if (element.hasAttribute('ws-send')) {
511
- if (!element.hasAttribute('hx-ws-send')) {
512
- element.setAttribute('hx-ws-send', '');
678
+ let hyphenAttr = buildAttrName('-send');
679
+ if (!element.hasAttribute(hyphenAttr)) {
680
+ element.setAttribute(hyphenAttr, '');
513
681
  }
514
682
  }
515
683
  }
@@ -548,8 +716,13 @@
548
716
  // Process the element itself
549
717
  processNode(element);
550
718
 
551
- // Process descendants
552
- 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);
553
726
  },
554
727
 
555
728
  htmx_before_cleanup: (element) => {
@@ -579,8 +752,8 @@
579
752
  entry.pendingRequests.clear();
580
753
  });
581
754
  },
582
- get: (key) => connectionRegistry.get(key),
583
- has: (key) => connectionRegistry.has(key),
755
+ get: (key) => connectionRegistry.get(normalizeWebSocketUrl(key)),
756
+ has: (key) => connectionRegistry.has(normalizeWebSocketUrl(key)),
584
757
  size: connectionRegistry.size
585
758
  })
586
759
  };