phoenix_live_view 0.19.4 → 0.20.0

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.
@@ -48,6 +48,8 @@ let DOM = {
48
48
 
49
49
  isUploadInput(el){ return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null },
50
50
 
51
+ isAutoUpload(inputEl){ return inputEl.hasAttribute("data-phx-auto-upload") },
52
+
51
53
  findUploadInputs(node){ return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`) },
52
54
 
53
55
  findComponentNodeList(node, cid){
@@ -66,7 +68,16 @@ let DOM = {
66
68
  },
67
69
 
68
70
  isUnloadableFormSubmit(e){
69
- return !e.defaultPrevented && !this.wantsNewTab(e)
71
+ // Ignore form submissions intended to close a native <dialog> element
72
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#usage_notes
73
+ let isDialogSubmit = (e.target && e.target.getAttribute("method") === "dialog") ||
74
+ (e.submitter && e.submitter.getAttribute("formmethod") === "dialog")
75
+
76
+ if(isDialogSubmit){
77
+ return false
78
+ } else {
79
+ return !e.defaultPrevented && !this.wantsNewTab(e)
80
+ }
70
81
  },
71
82
 
72
83
  isNewPageClick(e, currentLocation){
@@ -75,6 +86,7 @@ let DOM = {
75
86
 
76
87
  if(e.defaultPrevented || href === null || this.wantsNewTab(e)){ return false }
77
88
  if(href.startsWith("mailto:") || href.startsWith("tel:")){ return false }
89
+ if(e.target.isContentEditable){ return false }
78
90
 
79
91
  try {
80
92
  url = new URL(href)
@@ -110,7 +110,9 @@ export default class DOMPatch {
110
110
  })
111
111
  if(reset !== undefined){
112
112
  DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, child => {
113
- this.removeStreamChildElement(child)
113
+ if(!inserts[child.id]){
114
+ this.removeStreamChildElement(child)
115
+ }
114
116
  })
115
117
  }
116
118
  deleteIds.forEach(id => {
@@ -284,7 +286,9 @@ export default class DOMPatch {
284
286
 
285
287
  if(externalFormTriggered){
286
288
  liveSocket.unload()
287
- externalFormTriggered.submit()
289
+ // use prototype's submit in case there's a form control with name or id of "submit"
290
+ // https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit
291
+ Object.getPrototypeOf(externalFormTriggered).submit.call(externalFormTriggered)
288
292
  }
289
293
  return true
290
294
  }
@@ -194,12 +194,19 @@ let JS = {
194
194
  },
195
195
 
196
196
  addOrRemoveClasses(el, adds, removes, transition, time, view){
197
- let [transition_run, transition_start, transition_end] = transition || [[], [], []]
198
- if(transition_run.length > 0){
199
- let onStart = () => this.addOrRemoveClasses(el, transition_start.concat(transition_run), [])
200
- let onDone = () => this.addOrRemoveClasses(el, adds.concat(transition_end), removes.concat(transition_run).concat(transition_start))
197
+ let [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []]
198
+ if(transitionRun.length > 0){
199
+ let onStart = () => {
200
+ this.addOrRemoveClasses(el, transitionStart, [].concat(transitionRun).concat(transitionEnd))
201
+ window.requestAnimationFrame(() => {
202
+ this.addOrRemoveClasses(el, transitionRun, [])
203
+ window.requestAnimationFrame(() => this.addOrRemoveClasses(el, transitionEnd, transitionStart))
204
+ })
205
+ }
206
+ let onDone = () => this.addOrRemoveClasses(el, adds.concat(transitionEnd), removes.concat(transitionRun).concat(transitionStart))
201
207
  return view.transition(time, onStart, onDone)
202
208
  }
209
+
203
210
  window.requestAnimationFrame(() => {
204
211
  let [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []])
205
212
  let keepAdds = adds.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name))
@@ -244,4 +251,4 @@ let JS = {
244
251
  }
245
252
  }
246
253
 
247
- export default JS
254
+ export default JS
@@ -407,7 +407,7 @@ export default class LiveSocket {
407
407
  DOM.findPhxSticky(document).forEach(el => newMainEl.appendChild(el))
408
408
  this.outgoingMainEl.replaceWith(newMainEl)
409
409
  this.outgoingMainEl = null
410
- callback && requestAnimationFrame(callback)
410
+ callback && requestAnimationFrame(() => callback(linkRef))
411
411
  onDone()
412
412
  })
413
413
  }
@@ -684,6 +684,7 @@ export default class LiveSocket {
684
684
  let {type, id, root, scroll} = event.state || {}
685
685
  let href = window.location.href
686
686
 
687
+ DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: type === "patch", pop: true}})
687
688
  this.requestDOMUpdate(() => {
688
689
  if(this.main.isConnected() && (type === "patch" && id === this.main.id)){
689
690
  this.main.pushLinkPatch(href, null, () => {
@@ -761,6 +762,7 @@ export default class LiveSocket {
761
762
  if(!this.commitPendingLink(linkRef)){ return }
762
763
 
763
764
  Browser.pushState(linkState, {type: "patch", id: this.main.id}, href)
765
+ DOM.dispatchEvent(window, "phx:navigate", {detail: {patch: true, href, pop: false}})
764
766
  this.registerNewLocation(window.location)
765
767
  }
766
768
 
@@ -773,9 +775,12 @@ export default class LiveSocket {
773
775
  }
774
776
  let scroll = window.scrollY
775
777
  this.withPageLoading({to: href, kind: "redirect"}, done => {
776
- this.replaceMain(href, flash, () => {
777
- Browser.pushState(linkState, {type: "redirect", id: this.main.id, scroll: scroll}, href)
778
- this.registerNewLocation(window.location)
778
+ this.replaceMain(href, flash, (linkRef) => {
779
+ if(linkRef === this.linkRef){
780
+ Browser.pushState(linkState, {type: "redirect", id: this.main.id, scroll: scroll}, href)
781
+ DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: false, pop: false}})
782
+ this.registerNewLocation(window.location)
783
+ }
779
784
  done()
780
785
  })
781
786
  })
@@ -844,8 +849,10 @@ export default class LiveSocket {
844
849
  let currentIterations = iterations
845
850
  iterations++
846
851
  let {at: at, type: lastType} = DOM.private(input, "prev-iteration") || {}
847
- // detect dup because some browsers dispatch both "input" and "change"
848
- if(at === currentIterations - 1 && type !== lastType){ return }
852
+ // Browsers should always fire at least one "input" event before every "change"
853
+ // Ignore "change" events, unless there was no prior "input" event.
854
+ // This could happen if user code triggers a "change" event, or if the browser is non-conforming.
855
+ if(at === currentIterations - 1 && type === "change" && lastType === "input"){ return }
849
856
 
850
857
  DOM.putPrivate(input, "prev-iteration", {at: currentIterations, type: type})
851
858
 
@@ -120,6 +120,7 @@ export default class LiveUploader {
120
120
  })
121
121
 
122
122
  let groupedEntries = this._entries.reduce((acc, entry) => {
123
+ if(!entry.meta){ return acc }
123
124
  let {name, callback} = entry.uploader(liveSocket.uploaders)
124
125
  acc[name] = acc[name] || {callback: callback, entries: []}
125
126
  acc[name].entries.push(entry)
@@ -136,7 +136,7 @@ export default class Rendered {
136
136
  }
137
137
 
138
138
  componentToString(cid){
139
- let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid)
139
+ let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null, false)
140
140
  return [str, streams]
141
141
  }
142
142
 
@@ -186,6 +186,7 @@ export default class Rendered {
186
186
 
187
187
  if(stream !== undefined && (rendered[DYNAMICS].length > 0 || deleteIds.length > 0 || reset)){
188
188
  delete rendered[STREAM]
189
+ rendered[DYNAMICS] = []
189
190
  output.streams.add(stream)
190
191
  }
191
192
  }
@@ -202,7 +203,7 @@ export default class Rendered {
202
203
  }
203
204
  }
204
205
 
205
- recursiveCIDToString(components, cid, onlyCids){
206
+ recursiveCIDToString(components, cid, onlyCids, allowRootComments = true){
206
207
  let component = components[cid] || logError(`no component for CID ${cid}`, components)
207
208
  let template = document.createElement("template")
208
209
  let [html, streams] = this.recursiveToString(component, components, onlyCids)
@@ -223,6 +224,11 @@ export default class Rendered {
223
224
  child.innerHTML = ""
224
225
  }
225
226
  return [true, hasComponents]
227
+ } else if(child.nodeType === Node.COMMENT_NODE){
228
+ // we have to strip root comments when rendering a component directly
229
+ // for patching because the morphdom target must be exactly the root entrypoint
230
+ if(!allowRootComments){ child.remove() }
231
+ return [hasNodes, hasComponents]
226
232
  } else {
227
233
  if(child.nodeValue.trim() !== ""){
228
234
  logError("only HTML element tags are allowed at the root of components.\n\n" +
@@ -256,4 +262,4 @@ export default class Rendered {
256
262
  span.setAttribute(PHX_COMPONENT, cid)
257
263
  return span
258
264
  }
259
- }
265
+ }
@@ -10,6 +10,7 @@ import {
10
10
  } from "./utils"
11
11
 
12
12
  import LiveUploader from "./live_uploader"
13
+ import DOM from "./dom"
13
14
 
14
15
  export default class UploadEntry {
15
16
  static isActive(fileEl, file){
@@ -71,7 +72,7 @@ export default class UploadEntry {
71
72
  error(reason = "failed"){
72
73
  this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated)
73
74
  this.view.pushFileProgress(this.fileEl, this.ref, {error: reason})
74
- LiveUploader.clearFiles(this.fileEl)
75
+ if(!DOM.isAutoUpload(this.fileEl)){ LiveUploader.clearFiles(this.fileEl) }
75
76
  }
76
77
 
77
78
  //private
@@ -656,10 +656,12 @@ export default class View {
656
656
  onJoinError(resp){
657
657
  if(resp.reason === "reload"){
658
658
  this.log("error", () => [`failed mount with ${resp.status}. Falling back to page request`, resp])
659
- return this.onRedirect({to: this.href})
659
+ if(this.isMain()){ this.onRedirect({to: this.href}) }
660
+ return
660
661
  } else if(resp.reason === "unauthorized" || resp.reason === "stale"){
661
662
  this.log("error", () => ["unauthorized live_redirect. Falling back to page request", resp])
662
- return this.onRedirect({to: this.href})
663
+ if(this.isMain()){ this.onRedirect({to: this.href}) }
664
+ return
663
665
  }
664
666
  if(resp.redirect || resp.live_redirect){
665
667
  this.joinPending = false
@@ -911,7 +913,7 @@ export default class View {
911
913
  }
912
914
  this.pushWithReply(refGenerator, "event", event, resp => {
913
915
  DOM.showError(inputEl, this.liveSocket.binding(PHX_FEEDBACK_FOR))
914
- if(DOM.isUploadInput(inputEl) && inputEl.getAttribute("data-phx-auto-upload") !== null){
916
+ if(DOM.isUploadInput(inputEl) && DOM.isAutoUpload(inputEl)){
915
917
  if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){
916
918
  let [ref, _els] = refGenerator()
917
919
  this.uploadFiles(inputEl.form, targetCtx, ref, cid, (_uploads) => {
@@ -1073,7 +1075,10 @@ export default class View {
1073
1075
  let inputs = Array.from(form.elements).filter(el => DOM.isFormInput(el) && el.name && !el.hasAttribute(phxChange))
1074
1076
  if(inputs.length === 0){ return }
1075
1077
 
1076
- let input = inputs.find(el => el.type !== "hidden") || input[0]
1078
+ // we must clear tracked uploads before recovery as they no longer have valid refs
1079
+ inputs.forEach(input => input.hasAttribute(PHX_UPLOAD_REF) && LiveUploader.clearFiles(input))
1080
+ let input = inputs.find(el => el.type !== "hidden") || inputs[0]
1081
+
1077
1082
  let phxEvent = form.getAttribute(this.binding(PHX_AUTO_RECOVER)) || form.getAttribute(this.binding("change"))
1078
1083
  JS.exec("change", phxEvent, view, input, ["push", {_target: input.name, newCid: newCid, callback: callback}])
1079
1084
  })
@@ -1083,8 +1088,9 @@ export default class View {
1083
1088
  let linkRef = this.liveSocket.setPendingLink(href)
1084
1089
  let refGen = targetEl ? () => this.putRef([targetEl], "click") : null
1085
1090
  let fallback = () => this.liveSocket.redirect(window.location.href)
1091
+ let url = href.startsWith("/") ? `${location.protocol}//${location.host}${href}` : href
1086
1092
 
1087
- let push = this.pushWithReply(refGen, "live_patch", {url: href}, resp => {
1093
+ let push = this.pushWithReply(refGen, "live_patch", {url}, resp => {
1088
1094
  this.liveSocket.requestDOMUpdate(() => {
1089
1095
  if(resp.link_redirect){
1090
1096
  this.liveSocket.replaceMain(href, null, callback, linkRef)
@@ -1118,7 +1124,10 @@ export default class View {
1118
1124
  .filter(form => form.elements.length > 0)
1119
1125
  .filter(form => form.getAttribute(this.binding(PHX_AUTO_RECOVER)) !== "ignore")
1120
1126
  .map(form => {
1121
- let newForm = template.content.querySelector(`form[id="${form.id}"][${phxChange}="${form.getAttribute(phxChange)}"]`)
1127
+ // attribute given via JS module needs to be escaped as it contains the symbols []",
1128
+ // which result in an invalid css selector otherwise.
1129
+ const phxChangeValue = form.getAttribute(phxChange).replaceAll(/([\[\]"])/g, '\\$1')
1130
+ let newForm = template.content.querySelector(`form[id="${form.id}"][${phxChange}="${phxChangeValue}"]`)
1122
1131
  if(newForm){
1123
1132
  return [form, newForm, this.targetComponentID(newForm)]
1124
1133
  } else {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_live_view",
3
- "version": "0.19.4",
3
+ "version": "0.20.0",
4
4
  "description": "The Phoenix LiveView JavaScript client.",
5
5
  "license": "MIT",
6
6
  "repository": {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_live_view",
3
- "version": "0.19.4",
3
+ "version": "0.20.0",
4
4
  "description": "The Phoenix LiveView JavaScript client.",
5
5
  "license": "MIT",
6
6
  "module": "./priv/static/phoenix_live_view.esm.js",
@@ -304,6 +304,9 @@ var DOM = {
304
304
  isUploadInput(el) {
305
305
  return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null;
306
306
  },
307
+ isAutoUpload(inputEl) {
308
+ return inputEl.hasAttribute("data-phx-auto-upload");
309
+ },
307
310
  findUploadInputs(node) {
308
311
  return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`);
309
312
  },
@@ -320,7 +323,12 @@ var DOM = {
320
323
  return wantsNewTab || isTargetBlank || isDownload;
321
324
  },
322
325
  isUnloadableFormSubmit(e) {
323
- return !e.defaultPrevented && !this.wantsNewTab(e);
326
+ let isDialogSubmit = e.target && e.target.getAttribute("method") === "dialog" || e.submitter && e.submitter.getAttribute("formmethod") === "dialog";
327
+ if (isDialogSubmit) {
328
+ return false;
329
+ } else {
330
+ return !e.defaultPrevented && !this.wantsNewTab(e);
331
+ }
324
332
  },
325
333
  isNewPageClick(e, currentLocation) {
326
334
  let href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null;
@@ -331,6 +339,9 @@ var DOM = {
331
339
  if (href.startsWith("mailto:") || href.startsWith("tel:")) {
332
340
  return false;
333
341
  }
342
+ if (e.target.isContentEditable) {
343
+ return false;
344
+ }
334
345
  try {
335
346
  url = new URL(href);
336
347
  } catch (e2) {
@@ -793,7 +804,9 @@ var UploadEntry = class {
793
804
  error(reason = "failed") {
794
805
  this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated);
795
806
  this.view.pushFileProgress(this.fileEl, this.ref, { error: reason });
796
- LiveUploader.clearFiles(this.fileEl);
807
+ if (!dom_default.isAutoUpload(this.fileEl)) {
808
+ LiveUploader.clearFiles(this.fileEl);
809
+ }
797
810
  }
798
811
  onDone(callback) {
799
812
  this._onDone = () => {
@@ -930,6 +943,9 @@ var LiveUploader = class {
930
943
  return entry;
931
944
  });
932
945
  let groupedEntries = this._entries.reduce((acc, entry) => {
946
+ if (!entry.meta) {
947
+ return acc;
948
+ }
933
949
  let { name, callback } = entry.uploader(liveSocket.uploaders);
934
950
  acc[name] = acc[name] || { callback, entries: [] };
935
951
  acc[name].entries.push(entry);
@@ -1767,7 +1783,9 @@ var DOMPatch = class {
1767
1783
  });
1768
1784
  if (reset !== void 0) {
1769
1785
  dom_default.all(container, `[${PHX_STREAM_REF}="${ref}"]`, (child) => {
1770
- this.removeStreamChildElement(child);
1786
+ if (!inserts[child.id]) {
1787
+ this.removeStreamChildElement(child);
1788
+ }
1771
1789
  });
1772
1790
  }
1773
1791
  deleteIds.forEach((id) => {
@@ -1940,7 +1958,7 @@ var DOMPatch = class {
1940
1958
  this.transitionPendingRemoves();
1941
1959
  if (externalFormTriggered) {
1942
1960
  liveSocket.unload();
1943
- externalFormTriggered.submit();
1961
+ Object.getPrototypeOf(externalFormTriggered).submit.call(externalFormTriggered);
1944
1962
  }
1945
1963
  return true;
1946
1964
  }
@@ -2162,7 +2180,7 @@ var Rendered = class {
2162
2180
  return merged;
2163
2181
  }
2164
2182
  componentToString(cid) {
2165
- let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid);
2183
+ let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null, false);
2166
2184
  return [str, streams];
2167
2185
  }
2168
2186
  pruneCIDs(cids) {
@@ -2208,6 +2226,7 @@ var Rendered = class {
2208
2226
  }
2209
2227
  if (stream !== void 0 && (rendered[DYNAMICS].length > 0 || deleteIds.length > 0 || reset)) {
2210
2228
  delete rendered[STREAM];
2229
+ rendered[DYNAMICS] = [];
2211
2230
  output.streams.add(stream);
2212
2231
  }
2213
2232
  }
@@ -2222,7 +2241,7 @@ var Rendered = class {
2222
2241
  output.buffer += rendered;
2223
2242
  }
2224
2243
  }
2225
- recursiveCIDToString(components, cid, onlyCids) {
2244
+ recursiveCIDToString(components, cid, onlyCids, allowRootComments = true) {
2226
2245
  let component = components[cid] || logError(`no component for CID ${cid}`, components);
2227
2246
  let template = document.createElement("template");
2228
2247
  let [html, streams] = this.recursiveToString(component, components, onlyCids);
@@ -2243,6 +2262,11 @@ var Rendered = class {
2243
2262
  child.innerHTML = "";
2244
2263
  }
2245
2264
  return [true, hasComponents];
2265
+ } else if (child.nodeType === Node.COMMENT_NODE) {
2266
+ if (!allowRootComments) {
2267
+ child.remove();
2268
+ }
2269
+ return [hasNodes, hasComponents];
2246
2270
  } else {
2247
2271
  if (child.nodeValue.trim() !== "") {
2248
2272
  logError(`only HTML element tags are allowed at the root of components.
@@ -2522,10 +2546,16 @@ var JS = {
2522
2546
  }
2523
2547
  },
2524
2548
  addOrRemoveClasses(el, adds, removes, transition, time, view) {
2525
- let [transition_run, transition_start, transition_end] = transition || [[], [], []];
2526
- if (transition_run.length > 0) {
2527
- let onStart = () => this.addOrRemoveClasses(el, transition_start.concat(transition_run), []);
2528
- let onDone = () => this.addOrRemoveClasses(el, adds.concat(transition_end), removes.concat(transition_run).concat(transition_start));
2549
+ let [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []];
2550
+ if (transitionRun.length > 0) {
2551
+ let onStart = () => {
2552
+ this.addOrRemoveClasses(el, transitionStart, [].concat(transitionRun).concat(transitionEnd));
2553
+ window.requestAnimationFrame(() => {
2554
+ this.addOrRemoveClasses(el, transitionRun, []);
2555
+ window.requestAnimationFrame(() => this.addOrRemoveClasses(el, transitionEnd, transitionStart));
2556
+ });
2557
+ };
2558
+ let onDone = () => this.addOrRemoveClasses(el, adds.concat(transitionEnd), removes.concat(transitionRun).concat(transitionStart));
2529
2559
  return view.transition(time, onStart, onDone);
2530
2560
  }
2531
2561
  window.requestAnimationFrame(() => {
@@ -3126,10 +3156,16 @@ var View = class {
3126
3156
  onJoinError(resp) {
3127
3157
  if (resp.reason === "reload") {
3128
3158
  this.log("error", () => [`failed mount with ${resp.status}. Falling back to page request`, resp]);
3129
- return this.onRedirect({ to: this.href });
3159
+ if (this.isMain()) {
3160
+ this.onRedirect({ to: this.href });
3161
+ }
3162
+ return;
3130
3163
  } else if (resp.reason === "unauthorized" || resp.reason === "stale") {
3131
3164
  this.log("error", () => ["unauthorized live_redirect. Falling back to page request", resp]);
3132
- return this.onRedirect({ to: this.href });
3165
+ if (this.isMain()) {
3166
+ this.onRedirect({ to: this.href });
3167
+ }
3168
+ return;
3133
3169
  }
3134
3170
  if (resp.redirect || resp.live_redirect) {
3135
3171
  this.joinPending = false;
@@ -3402,7 +3438,7 @@ var View = class {
3402
3438
  };
3403
3439
  this.pushWithReply(refGenerator, "event", event, (resp) => {
3404
3440
  dom_default.showError(inputEl, this.liveSocket.binding(PHX_FEEDBACK_FOR));
3405
- if (dom_default.isUploadInput(inputEl) && inputEl.getAttribute("data-phx-auto-upload") !== null) {
3441
+ if (dom_default.isUploadInput(inputEl) && dom_default.isAutoUpload(inputEl)) {
3406
3442
  if (LiveUploader.filesAwaitingPreflight(inputEl).length > 0) {
3407
3443
  let [ref, _els] = refGenerator();
3408
3444
  this.uploadFiles(inputEl.form, targetCtx, ref, cid, (_uploads) => {
@@ -3557,7 +3593,8 @@ var View = class {
3557
3593
  if (inputs.length === 0) {
3558
3594
  return;
3559
3595
  }
3560
- let input = inputs.find((el) => el.type !== "hidden") || input[0];
3596
+ inputs.forEach((input2) => input2.hasAttribute(PHX_UPLOAD_REF) && LiveUploader.clearFiles(input2));
3597
+ let input = inputs.find((el) => el.type !== "hidden") || inputs[0];
3561
3598
  let phxEvent = form.getAttribute(this.binding(PHX_AUTO_RECOVER)) || form.getAttribute(this.binding("change"));
3562
3599
  js_default.exec("change", phxEvent, view, input, ["push", { _target: input.name, newCid, callback }]);
3563
3600
  });
@@ -3566,7 +3603,8 @@ var View = class {
3566
3603
  let linkRef = this.liveSocket.setPendingLink(href);
3567
3604
  let refGen = targetEl ? () => this.putRef([targetEl], "click") : null;
3568
3605
  let fallback = () => this.liveSocket.redirect(window.location.href);
3569
- let push = this.pushWithReply(refGen, "live_patch", { url: href }, (resp) => {
3606
+ let url = href.startsWith("/") ? `${location.protocol}//${location.host}${href}` : href;
3607
+ let push = this.pushWithReply(refGen, "live_patch", { url }, (resp) => {
3570
3608
  this.liveSocket.requestDOMUpdate(() => {
3571
3609
  if (resp.link_redirect) {
3572
3610
  this.liveSocket.replaceMain(href, null, callback, linkRef);
@@ -3593,7 +3631,8 @@ var View = class {
3593
3631
  let template = document.createElement("template");
3594
3632
  template.innerHTML = html;
3595
3633
  return dom_default.all(this.el, `form[${phxChange}]`).filter((form) => form.id && this.ownsElement(form)).filter((form) => form.elements.length > 0).filter((form) => form.getAttribute(this.binding(PHX_AUTO_RECOVER)) !== "ignore").map((form) => {
3596
- let newForm = template.content.querySelector(`form[id="${form.id}"][${phxChange}="${form.getAttribute(phxChange)}"]`);
3634
+ const phxChangeValue = form.getAttribute(phxChange).replaceAll(/([\[\]"])/g, "\\$1");
3635
+ let newForm = template.content.querySelector(`form[id="${form.id}"][${phxChange}="${phxChangeValue}"]`);
3597
3636
  if (newForm) {
3598
3637
  return [form, newForm, this.targetComponentID(newForm)];
3599
3638
  } else {
@@ -3936,7 +3975,7 @@ var LiveSocket = class {
3936
3975
  dom_default.findPhxSticky(document).forEach((el) => newMainEl.appendChild(el));
3937
3976
  this.outgoingMainEl.replaceWith(newMainEl);
3938
3977
  this.outgoingMainEl = null;
3939
- callback && requestAnimationFrame(callback);
3978
+ callback && requestAnimationFrame(() => callback(linkRef));
3940
3979
  onDone();
3941
3980
  });
3942
3981
  }
@@ -4218,6 +4257,7 @@ var LiveSocket = class {
4218
4257
  }
4219
4258
  let { type, id, root, scroll } = event.state || {};
4220
4259
  let href = window.location.href;
4260
+ dom_default.dispatchEvent(window, "phx:navigate", { detail: { href, patch: type === "patch", pop: true } });
4221
4261
  this.requestDOMUpdate(() => {
4222
4262
  if (this.main.isConnected() && (type === "patch" && id === this.main.id)) {
4223
4263
  this.main.pushLinkPatch(href, null, () => {
@@ -4295,6 +4335,7 @@ var LiveSocket = class {
4295
4335
  return;
4296
4336
  }
4297
4337
  browser_default.pushState(linkState, { type: "patch", id: this.main.id }, href);
4338
+ dom_default.dispatchEvent(window, "phx:navigate", { detail: { patch: true, href, pop: false } });
4298
4339
  this.registerNewLocation(window.location);
4299
4340
  }
4300
4341
  historyRedirect(href, linkState, flash) {
@@ -4307,9 +4348,12 @@ var LiveSocket = class {
4307
4348
  }
4308
4349
  let scroll = window.scrollY;
4309
4350
  this.withPageLoading({ to: href, kind: "redirect" }, (done) => {
4310
- this.replaceMain(href, flash, () => {
4311
- browser_default.pushState(linkState, { type: "redirect", id: this.main.id, scroll }, href);
4312
- this.registerNewLocation(window.location);
4351
+ this.replaceMain(href, flash, (linkRef) => {
4352
+ if (linkRef === this.linkRef) {
4353
+ browser_default.pushState(linkState, { type: "redirect", id: this.main.id, scroll }, href);
4354
+ dom_default.dispatchEvent(window, "phx:navigate", { detail: { href, patch: false, pop: false } });
4355
+ this.registerNewLocation(window.location);
4356
+ }
4313
4357
  done();
4314
4358
  });
4315
4359
  });
@@ -4377,7 +4421,7 @@ var LiveSocket = class {
4377
4421
  let currentIterations = iterations;
4378
4422
  iterations++;
4379
4423
  let { at, type: lastType } = dom_default.private(input, "prev-iteration") || {};
4380
- if (at === currentIterations - 1 && type !== lastType) {
4424
+ if (at === currentIterations - 1 && type === "change" && lastType === "input") {
4381
4425
  return;
4382
4426
  }
4383
4427
  dom_default.putPrivate(input, "prev-iteration", { at: currentIterations, type });