phoenix_live_view 0.20.2 → 0.20.4

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.
@@ -2,6 +2,7 @@ import DOM from "./dom"
2
2
  import ARIA from "./aria"
3
3
 
4
4
  let focusStack = null
5
+ let default_transition_time = 200
5
6
 
6
7
  let JS = {
7
8
  exec(eventType, phxEvent, view, sourceEl, defaults){
@@ -9,8 +10,6 @@ let JS = {
9
10
  let commands = phxEvent.charAt(0) === "[" ?
10
11
  JSON.parse(phxEvent) : [[defaultKind, defaultArgs]]
11
12
 
12
-
13
-
14
13
  commands.forEach(([kind, args]) => {
15
14
  if(kind === defaultKind && defaultArgs.data){
16
15
  args.data = Object.assign(args.data || {}, defaultArgs.data)
@@ -40,7 +39,7 @@ let JS = {
40
39
 
41
40
  // commands
42
41
 
43
- exec_exec(eventType, phxEvent, view, sourceEl, el, [attr, to]){
42
+ exec_exec(eventType, phxEvent, view, sourceEl, el, {attr, to}){
44
43
  let nodes = to ? DOM.all(document, to) : [sourceEl]
45
44
  nodes.forEach(node => {
46
45
  let encodedJS = node.getAttribute(attr)
@@ -56,13 +55,12 @@ let JS = {
56
55
  },
57
56
 
58
57
  exec_push(eventType, phxEvent, view, sourceEl, el, args){
59
- if(!view.isConnected()){ return }
60
-
61
58
  let {event, data, target, page_loading, loading, value, dispatcher, callback} = args
62
59
  let pushOpts = {loading, value, target, page_loading: !!page_loading}
63
60
  let targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl
64
61
  let phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc
65
62
  view.withinTargets(phxTarget, (targetView, targetCtx) => {
63
+ if(!targetView.isConnected()){ return }
66
64
  if(eventType === "change"){
67
65
  let {newCid, _target} = args
68
66
  _target = _target || (DOM.isFormInput(sourceEl) ? sourceEl.name : undefined)
@@ -112,6 +110,28 @@ let JS = {
112
110
  this.addOrRemoveClasses(el, [], names, transition, time, view)
113
111
  },
114
112
 
113
+ exec_toggle_class(eventType, phxEvent, view, sourceEl, el, {to, names, transition, time}){
114
+ this.toggleClasses(el, names, transition, view)
115
+ },
116
+
117
+ exec_toggle_attr(eventType, phxEvent, view, sourceEl, el, {attr: [attr, val1, val2]}){
118
+ if(el.hasAttribute(attr)){
119
+ if(val2 !== undefined){
120
+ // toggle between val1 and val2
121
+ if(el.getAttribute(attr) === val1){
122
+ this.setOrRemoveAttrs(el, [[attr, val2]], [])
123
+ } else {
124
+ this.setOrRemoveAttrs(el, [[attr, val1]], [])
125
+ }
126
+ } else {
127
+ // remove attr
128
+ this.setOrRemoveAttrs(el, [], [attr])
129
+ }
130
+ } else {
131
+ this.setOrRemoveAttrs(el, [[attr, val1]], [])
132
+ }
133
+ },
134
+
115
135
  exec_transition(eventType, phxEvent, view, sourceEl, el, {time, transition}){
116
136
  this.addOrRemoveClasses(el, [], [], transition, time, view)
117
137
  },
@@ -151,6 +171,7 @@ let JS = {
151
171
  },
152
172
 
153
173
  toggle(eventType, view, el, display, ins, outs, time){
174
+ time = time || default_transition_time
154
175
  let [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []]
155
176
  let [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []]
156
177
  if(inClasses.length > 0 || outClasses.length > 0){
@@ -203,7 +224,17 @@ let JS = {
203
224
  }
204
225
  },
205
226
 
227
+ toggleClasses(el, classes, transition, time, view){
228
+ window.requestAnimationFrame(() => {
229
+ let [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []])
230
+ let newAdds = classes.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name))
231
+ let newRemoves = classes.filter(name => prevRemoves.indexOf(name) < 0 && el.classList.contains(name))
232
+ this.addOrRemoveClasses(el, newAdds, newRemoves, transition, time, view)
233
+ })
234
+ },
235
+
206
236
  addOrRemoveClasses(el, adds, removes, transition, time, view){
237
+ time = time || default_transition_time
207
238
  let [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []]
208
239
  if(transitionRun.length > 0){
209
240
  let onStart = () => {
@@ -235,9 +266,9 @@ let JS = {
235
266
  setOrRemoveAttrs(el, sets, removes){
236
267
  let [prevSets, prevRemoves] = DOM.getSticky(el, "attrs", [[], []])
237
268
 
238
- let alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes);
239
- let newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets);
240
- let newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes);
269
+ let alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes)
270
+ let newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets)
271
+ let newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes)
241
272
 
242
273
  DOM.putSticky(el, "attrs", currentEl => {
243
274
  newRemoves.forEach(attr => currentEl.removeAttribute(attr))
@@ -106,7 +106,6 @@ import {
106
106
  closestPhxBinding,
107
107
  closure,
108
108
  debug,
109
- isObject,
110
109
  maybe
111
110
  } from "./utils"
112
111
 
@@ -400,23 +399,28 @@ export default class LiveSocket {
400
399
 
401
400
  this.main = this.newRootView(newMainEl, flash, liveReferer)
402
401
  this.main.setRedirect(href)
403
- this.transitionRemoves()
402
+ this.transitionRemoves(null, true)
404
403
  this.main.join((joinCount, onDone) => {
405
404
  if(joinCount === 1 && this.commitPendingLink(linkRef)){
406
405
  this.requestDOMUpdate(() => {
407
406
  DOM.findPhxSticky(document).forEach(el => newMainEl.appendChild(el))
408
407
  this.outgoingMainEl.replaceWith(newMainEl)
409
408
  this.outgoingMainEl = null
410
- callback && requestAnimationFrame(() => callback(linkRef))
409
+ callback && callback(linkRef)
411
410
  onDone()
412
411
  })
413
412
  }
414
413
  })
415
414
  }
416
415
 
417
- transitionRemoves(elements){
416
+ transitionRemoves(elements, skipSticky){
418
417
  let removeAttr = this.binding("remove")
419
418
  elements = elements || DOM.all(document, `[${removeAttr}]`)
419
+
420
+ if(skipSticky){
421
+ const stickies = DOM.findPhxSticky(document) || []
422
+ elements = elements.filter(el => !DOM.isChildOfAny(el, stickies))
423
+ }
420
424
  elements.forEach(el => {
421
425
  this.execJS(el, el.getAttribute(removeAttr), "remove")
422
426
  })
@@ -631,6 +635,9 @@ export default class LiveSocket {
631
635
  if(capture){
632
636
  target = e.target.matches(`[${click}]`) ? e.target : e.target.querySelector(`[${click}]`)
633
637
  } else {
638
+ // a synthetic click event (detail 0) will not have caused a mousedown event,
639
+ // therefore the clickStartedAtTarget is stale
640
+ if(e.detail === 0) this.clickStartedAtTarget = e.target
634
641
  let clickStartedAtTarget = this.clickStartedAtTarget || e.target
635
642
  target = closestPhxBinding(clickStartedAtTarget, click)
636
643
  this.dispatchClickAway(e, clickStartedAtTarget)
@@ -659,7 +666,7 @@ export default class LiveSocket {
659
666
  let phxClickAway = this.binding("click-away")
660
667
  DOM.all(document, `[${phxClickAway}]`, el => {
661
668
  if(!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))){
662
- this.withinOwners(e.target, view => {
669
+ this.withinOwners(el, view => {
663
670
  let phxEvent = el.getAttribute(phxClickAway)
664
671
  if(JS.isVisible(el) && JS.isInViewport(el)){
665
672
  JS.exec("click", phxEvent, view, el, ["push", {data: this.eventMeta("click", e, e.target)}])
@@ -727,7 +734,7 @@ export default class LiveSocket {
727
734
  }, false)
728
735
  }
729
736
 
730
- maybeScroll(scroll) {
737
+ maybeScroll(scroll){
731
738
  if(typeof(scroll) === "number"){
732
739
  requestAnimationFrame(() => {
733
740
  window.scrollTo(0, scroll)
@@ -750,7 +757,7 @@ export default class LiveSocket {
750
757
  }
751
758
 
752
759
  pushHistoryPatch(href, linkState, targetEl){
753
- if(!this.isConnected()){ return Browser.redirect(href) }
760
+ if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href) }
754
761
 
755
762
  this.withPageLoading({to: href, kind: "patch"}, done => {
756
763
  this.main.pushLinkPatch(href, targetEl, linkRef => {
@@ -769,8 +776,9 @@ export default class LiveSocket {
769
776
  }
770
777
 
771
778
  historyRedirect(href, linkState, flash){
779
+ if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href, flash) }
780
+
772
781
  // convert to full href if only path prefix
773
- if(!this.isConnected()){ return Browser.redirect(href, flash) }
774
782
  if(/^\/$|^\/[^\/]+.*$/.test(href)){
775
783
  let {protocol, host} = window.location
776
784
  href = `${protocol}//${host}${href}`
@@ -873,10 +881,12 @@ export default class LiveSocket {
873
881
  let form = e.target
874
882
  DOM.resetForm(form, this.binding(PHX_FEEDBACK_FOR))
875
883
  let input = Array.from(form.elements).find(el => el.type === "reset")
876
- // wait until next tick to get updated input value
877
- window.requestAnimationFrame(() => {
878
- input.dispatchEvent(new Event("input", {bubbles: true, cancelable: false}))
879
- })
884
+ if(input){
885
+ // wait until next tick to get updated input value
886
+ window.requestAnimationFrame(() => {
887
+ input.dispatchEvent(new Event("input", {bubbles: true, cancelable: false}))
888
+ })
889
+ }
880
890
  })
881
891
  }
882
892
 
@@ -94,7 +94,11 @@ export default class LiveUploader {
94
94
  }
95
95
 
96
96
  static filesAwaitingPreflight(input){
97
- return this.activeFiles(input).filter(f => !UploadEntry.isPreflighted(input, f))
97
+ return this.activeFiles(input).filter(f => !UploadEntry.isPreflighted(input, f) && !UploadEntry.isPreflightInProgress(f))
98
+ }
99
+
100
+ static markPreflightInProgress(entries){
101
+ entries.forEach(entry => UploadEntry.markPreflightInProgress(entry.file))
98
102
  }
99
103
 
100
104
  constructor(inputEl, view, onComplete){
@@ -104,6 +108,9 @@ export default class LiveUploader {
104
108
  Array.from(LiveUploader.filesAwaitingPreflight(inputEl) || [])
105
109
  .map(file => new UploadEntry(inputEl, file, view))
106
110
 
111
+ // prevent sending duplicate preflight requests
112
+ LiveUploader.markPreflightInProgress(this._entries)
113
+
107
114
  this.numEntriesInProgress = this._entries.length
108
115
  }
109
116
 
@@ -37,60 +37,41 @@ const VOID_TAGS = new Set([
37
37
  "track",
38
38
  "wbr"
39
39
  ])
40
- const endingTagNameChars = new Set([">", "/", " ", "\n", "\t", "\r"])
41
40
  const quoteChars = new Set(["'", '"'])
42
41
 
43
42
  export let modifyRoot = (html, attrs, clearInnerHTML) => {
44
43
  let i = 0
45
44
  let insideComment = false
46
45
  let beforeTag, afterTag, tag, tagNameEndsAt, id, newHTML
47
- while(i < html.length){
48
- let char = html.charAt(i)
49
- if(insideComment){
50
- if(char === "-" && html.slice(i, i + 3) === "-->"){
51
- insideComment = false
52
- i += 3
53
- } else {
46
+
47
+ let lookahead = html.match(/^(\s*(?:<!--.*?-->\s*)*)<([^\s\/>]+)/)
48
+ if(lookahead === null) { throw new Error(`malformed html ${html}`) }
49
+
50
+ i = lookahead[0].length
51
+ beforeTag = lookahead[1]
52
+ tag = lookahead[2]
53
+ tagNameEndsAt = i
54
+
55
+ // Scan the opening tag for id, if there is any
56
+ for(i; i < html.length; i++){
57
+ if(html.charAt(i) === ">" ){ break }
58
+ if(html.charAt(i) === "="){
59
+ let isId = html.slice(i - 3, i) === " id"
60
+ i++;
61
+ let char = html.charAt(i)
62
+ if (quoteChars.has(char)) {
63
+ let attrStartsAt = i
54
64
  i++
55
- }
56
- } else if(char === "<" && html.slice(i, i + 4) === "<!--"){
57
- insideComment = true
58
- i += 4
59
- } else if(char === "<"){
60
- beforeTag = html.slice(0, i)
61
- let iAtOpen = i
62
- i++
63
- for(i; i < html.length; i++){
64
- if(endingTagNameChars.has(html.charAt(i))){ break }
65
- }
66
- tagNameEndsAt = i
67
- tag = html.slice(iAtOpen + 1, tagNameEndsAt)
68
- // Scan the opening tag for id, if there is any
69
- for(i; i < html.length; i++){
70
- if(html.charAt(i) === ">" ){ break }
71
- if(html.charAt(i) === "="){
72
- let isId = html.slice(i - 3, i) === " id"
73
- i++;
74
- let char = html.charAt(i)
75
- if (quoteChars.has(char)) {
76
- let attrStartsAt = i
77
- i++
78
- for(i; i < html.length; i++){
79
- if(html.charAt(i) === char){ break }
80
- }
81
- if (isId) {
82
- id = html.slice(attrStartsAt + 1, i)
83
- break
84
- }
85
- }
65
+ for(i; i < html.length; i++){
66
+ if(html.charAt(i) === char){ break }
67
+ }
68
+ if (isId) {
69
+ id = html.slice(attrStartsAt + 1, i)
70
+ break
86
71
  }
87
72
  }
88
- break
89
- } else {
90
- i++
91
73
  }
92
74
  }
93
- if(!tag){ throw new Error(`malformed html ${html}`) }
94
75
 
95
76
  let closeAt = html.length - 1
96
77
  insideComment = false
@@ -174,6 +155,14 @@ export default class Rendered {
174
155
 
175
156
  getComponent(diff, cid){ return diff[COMPONENTS][cid] }
176
157
 
158
+ resetRender(cid){
159
+ // we are racing a component destroy, it could not exist, so
160
+ // make sure that we don't try to set reset on undefined
161
+ if(this.rendered[COMPONENTS][cid]){
162
+ this.rendered[COMPONENTS][cid].reset = true
163
+ }
164
+ }
165
+
177
166
  mergeDiff(diff){
178
167
  let newc = diff[COMPONENTS]
179
168
  let cache = {}
@@ -305,7 +294,7 @@ export default class Rendered {
305
294
  //
306
295
  // changeTracking controls if we can apply the PHX_SKIP optimization.
307
296
  // It is disabled for comprehensions since we must re-render the entire collection
308
- // and no invidial element is tracked inside the comprehension.
297
+ // and no individual element is tracked inside the comprehension.
309
298
  toOutputBuffer(rendered, templates, output, changeTracking, rootAttrs = {}){
310
299
  if(rendered[DYNAMICS]){ return this.comprehensionToBuffer(rendered, templates, output) }
311
300
  let {[STATIC]: statics} = rendered
@@ -314,6 +303,8 @@ export default class Rendered {
314
303
  let prevBuffer = output.buffer
315
304
  if(isRoot){ output.buffer = "" }
316
305
 
306
+ // this condition is called when first rendering an optimizable function component.
307
+ // LC have their magicId previously set
317
308
  if(changeTracking && isRoot && !rendered.magicId){
318
309
  rendered.newRender = true
319
310
  rendered.magicId = this.nextMagicID()
@@ -332,13 +323,17 @@ export default class Rendered {
332
323
  if(isRoot){
333
324
  let skip = false
334
325
  let attrs
335
- if(changeTracking || Object.keys(rootAttrs).length > 0){
336
- skip = !rendered.newRender
326
+ // when a LC is added on the page, we need to re-render the entire LC tree,
327
+ // therefore changeTracking is false; however, we need to keep all the magicIds
328
+ // from any function component so the next time the LC is updated, we can apply
329
+ // the skip optimization
330
+ if(changeTracking || rendered.magicId){
331
+ skip = changeTracking && !rendered.newRender
337
332
  attrs = {[PHX_MAGIC_ID]: rendered.magicId, ...rootAttrs}
338
333
  } else {
339
334
  attrs = rootAttrs
340
335
  }
341
- if(skip){ attrs[PHX_SKIP] = true}
336
+ if(skip){ attrs[PHX_SKIP] = true }
342
337
  let [newRoot, commentBefore, commentAfter] = modifyRoot(output.buffer, attrs, skip)
343
338
  rendered.newRender = false
344
339
  output.buffer = prevBuffer + commentBefore + newRoot + commentAfter
@@ -395,15 +390,23 @@ export default class Rendered {
395
390
  //
396
391
  // 2. The root PHX_SKIP optimization generalizes to all HEEx function components, and
397
392
  // works in the same PHX_SKIP attribute fashion as 1, but the newRender tracking is done
398
- // at the general diff merge level. If we merge a diff with new dynamics, we necessariy have
393
+ // at the general diff merge level. If we merge a diff with new dynamics, we necessarily have
399
394
  // experienced a change which must be a newRender, and thus we can't skip the render.
400
395
  //
401
396
  // Both optimization flows apply here. newRender is set based on the onlyCids optimization, and
402
397
  // we track a deterministic magicId based on the cid.
398
+ //
399
+ // By default changeTracking is enabled, but we special case the flow where the client is pruning
400
+ // cids and the server adds the component back. In such cases, we explicitly disable changeTracking
401
+ // with resetRender for this cid, then re-enable it after the recursive call to skip the optimization
402
+ // for the entire component tree.
403
403
  component.newRender = !skip
404
404
  component.magicId = `${this.parentViewId()}-c-${cid}`
405
- let changeTracking = true
405
+ // enable change tracking as long as the component hasn't been reset
406
+ let changeTracking = !component.reset
406
407
  let [html, streams] = this.recursiveToString(component, components, onlyCids, changeTracking, attrs)
408
+ // disable reset after we've rendered
409
+ delete component.reset
407
410
 
408
411
  return [html, streams]
409
412
  }
@@ -15,9 +15,10 @@ import DOM from "./dom"
15
15
  export default class UploadEntry {
16
16
  static isActive(fileEl, file){
17
17
  let isNew = file._phxRef === undefined
18
+ let isPreflightInProgress = UploadEntry.isPreflightInProgress(file)
18
19
  let activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",")
19
20
  let isActive = activeRefs.indexOf(LiveUploader.genFileRef(file)) >= 0
20
- return file.size > 0 && (isNew || isActive)
21
+ return file.size > 0 && (isNew || isActive || !isPreflightInProgress)
21
22
  }
22
23
 
23
24
  static isPreflighted(fileEl, file){
@@ -26,6 +27,14 @@ export default class UploadEntry {
26
27
  return isPreflighted && this.isActive(fileEl, file)
27
28
  }
28
29
 
30
+ static isPreflightInProgress(file){
31
+ return file._preflightInProgress === true
32
+ }
33
+
34
+ static markPreflightInProgress(file){
35
+ file._preflightInProgress = true
36
+ }
37
+
29
38
  constructor(fileEl, file, view){
30
39
  this.ref = LiveUploader.genFileRef(file)
31
40
  this.fileEl = fileEl
@@ -56,18 +56,28 @@ import ViewHook from "./view_hook"
56
56
  import JS from "./js"
57
57
 
58
58
  let serializeForm = (form, metadata, onlyNames = []) => {
59
- let {submitter, ...meta} = metadata
60
-
61
- // TODO: Replace with `new FormData(form, submitter)` when supported by latest browsers,
62
- // and mention `formdata-submitter-polyfill` in the docs.
63
- let formData = new FormData(form)
64
-
65
- // TODO: Remove when FormData constructor supports the submitter argument.
66
- if(submitter && submitter.hasAttribute("name") && submitter.form && submitter.form === form){
67
- formData.append(submitter.name, submitter.value)
59
+ const {submitter, ...meta} = metadata
60
+
61
+ // We must inject the submitter in the order that it exists in the DOM
62
+ // releative to other inputs. For example, for checkbox groups, the order must be maintained.
63
+ let injectedElement
64
+ if(submitter && submitter.name){
65
+ const input = document.createElement("input")
66
+ input.type = "hidden"
67
+ // set the form attribute if the submitter has one;
68
+ // this can happen if the element is outside the actual form element
69
+ const formId = submitter.getAttribute("form")
70
+ if(formId){
71
+ input.setAttribute("form", form)
72
+ }
73
+ input.name = submitter.name
74
+ input.value = submitter.value
75
+ submitter.parentElement.insertBefore(input, submitter)
76
+ injectedElement = input
68
77
  }
69
78
 
70
- let toRemove = []
79
+ const formData = new FormData(form)
80
+ const toRemove = []
71
81
 
72
82
  formData.forEach((val, key, _index) => {
73
83
  if(val instanceof File){ toRemove.push(key) }
@@ -76,12 +86,20 @@ let serializeForm = (form, metadata, onlyNames = []) => {
76
86
  // Cleanup after building fileData
77
87
  toRemove.forEach(key => formData.delete(key))
78
88
 
79
- let params = new URLSearchParams()
89
+ const params = new URLSearchParams()
90
+
80
91
  for(let [key, val] of formData.entries()){
81
92
  if(onlyNames.length === 0 || onlyNames.indexOf(key) >= 0){
82
93
  params.append(key, val)
83
94
  }
84
95
  }
96
+
97
+ // remove the injected element again
98
+ // (it would be removed by the next dom patch anyway, but this is cleaner)
99
+ if(submitter && injectedElement){
100
+ submitter.parentElement.removeChild(injectedElement)
101
+ }
102
+
85
103
  for(let metaKey in meta){ params.append(metaKey, meta[metaKey]) }
86
104
 
87
105
  return params.toString()
@@ -303,6 +321,9 @@ export default class View {
303
321
  let fromEl = toEl.id && this.el.querySelector(`[id="${toEl.id}"]`)
304
322
  let phxStatic = fromEl && fromEl.getAttribute(PHX_STATIC)
305
323
  if(phxStatic){ toEl.setAttribute(PHX_STATIC, phxStatic) }
324
+ // set PHX_ROOT_ID to prevent events from being dispatched to the root view
325
+ // while the child join is still pending
326
+ if(fromEl){ fromEl.setAttribute(PHX_ROOT_ID, this.root.id) }
306
327
  return this.joinChild(toEl)
307
328
  })
308
329
 
@@ -389,6 +410,9 @@ export default class View {
389
410
 
390
411
  patch.after("added", el => {
391
412
  this.liveSocket.triggerDOM("onNodeAdded", [el])
413
+ let phxViewportTop = this.binding(PHX_VIEWPORT_TOP)
414
+ let phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM)
415
+ DOM.maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom)
392
416
  this.maybeAddNewHook(el)
393
417
  if(el.getAttribute){ this.maybeMounted(el) }
394
418
  })
@@ -750,12 +774,13 @@ export default class View {
750
774
 
751
775
  DOM.all(document, `[${PHX_REF_SRC}="${this.id}"][${PHX_REF}="${ref}"]`, el => {
752
776
  let disabledVal = el.getAttribute(PHX_DISABLED)
777
+ let readOnlyVal = el.getAttribute(PHX_READONLY)
753
778
  // remove refs
754
779
  el.removeAttribute(PHX_REF)
755
780
  el.removeAttribute(PHX_REF_SRC)
756
781
  // restore inputs
757
- if(el.getAttribute(PHX_READONLY) !== null){
758
- el.readOnly = false
782
+ if(readOnlyVal !== null){
783
+ el.readOnly = readOnlyVal === "true" ? true : false
759
784
  el.removeAttribute(PHX_READONLY)
760
785
  }
761
786
  if(disabledVal !== null){
@@ -795,6 +820,8 @@ export default class View {
795
820
  el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.innerText)
796
821
  }
797
822
  if(disableText !== ""){ el.innerText = disableText }
823
+ // PHX_DISABLED could have already been set in disableForm
824
+ el.setAttribute(PHX_DISABLED, el.getAttribute(PHX_DISABLED) || el.disabled)
798
825
  el.setAttribute("disabled", "")
799
826
  }
800
827
  })
@@ -895,6 +922,7 @@ export default class View {
895
922
  let refGenerator = () => this.putRef([inputEl, inputEl.form], "change", opts)
896
923
  let formData
897
924
  let meta = this.extractMeta(inputEl.form)
925
+ if(inputEl instanceof HTMLButtonElement){ meta.submitter = inputEl }
898
926
  if(inputEl.getAttribute(this.binding("change"))){
899
927
  formData = serializeForm(inputEl.form, {_target: opts._target, ...meta}, [inputEl.name])
900
928
  } else {
@@ -920,6 +948,7 @@ export default class View {
920
948
  this.uploadFiles(inputEl.form, targetCtx, ref, cid, (_uploads) => {
921
949
  callback && callback(resp)
922
950
  this.triggerAwaitingSubmit(inputEl.form)
951
+ this.undoRefs(ref)
923
952
  })
924
953
  }
925
954
  } else {
@@ -1064,7 +1093,7 @@ export default class View {
1064
1093
  }
1065
1094
 
1066
1095
  dispatchUploads(targetCtx, name, filesOrBlobs){
1067
- let targetElement = this.targetCtxElement(targetCtx) || this.el;
1096
+ let targetElement = this.targetCtxElement(targetCtx) || this.el
1068
1097
  let inputs = DOM.findUploadInputs(targetElement).filter(el => el.name === name)
1069
1098
  if(inputs.length === 0){ logError(`no live file inputs found matching the name "${name}"`) }
1070
1099
  else if(inputs.length > 1){ logError(`duplicate live file inputs found matching the name "${name}"`) }
@@ -1139,7 +1168,7 @@ export default class View {
1139
1168
  .map(form => {
1140
1169
  // attribute given via JS module needs to be escaped as it contains the symbols []",
1141
1170
  // which result in an invalid css selector otherwise.
1142
- const phxChangeValue = form.getAttribute(phxChange).replaceAll(/([\[\]"])/g, '\\$1')
1171
+ const phxChangeValue = CSS.escape(form.getAttribute(phxChange))
1143
1172
  let newForm = template.content.querySelector(`form[id="${form.id}"][${phxChange}="${phxChangeValue}"]`)
1144
1173
  if(newForm){
1145
1174
  return [form, newForm, this.targetComponentID(newForm)]
@@ -1152,17 +1181,18 @@ export default class View {
1152
1181
  }
1153
1182
 
1154
1183
  maybePushComponentsDestroyed(destroyedCIDs){
1155
- let willDestroyCIDs = destroyedCIDs.filter(cid => {
1184
+ let willDestroyCIDs = destroyedCIDs.concat(this.pruningCIDs).filter(cid => {
1156
1185
  return DOM.findComponentNodeList(this.el, cid).length === 0
1157
1186
  })
1187
+ // make sure this is a copy and not a reference
1188
+ this.pruningCIDs = willDestroyCIDs.concat([])
1189
+
1158
1190
  if(willDestroyCIDs.length > 0){
1159
- this.pruningCIDs.push(...willDestroyCIDs)
1191
+ // we must reset the render change tracking for cids that
1192
+ // could be added back from the server so we don't skip them
1193
+ willDestroyCIDs.forEach(cid => this.rendered.resetRender(cid))
1160
1194
 
1161
1195
  this.pushWithReply(null, "cids_will_destroy", {cids: willDestroyCIDs}, () => {
1162
- // The cids are either back on the page or they will be fully removed,
1163
- // so we can remove them from the pruningCIDs.
1164
- this.pruningCIDs = this.pruningCIDs.filter(cid => willDestroyCIDs.indexOf(cid) !== -1)
1165
-
1166
1196
  // See if any of the cids we wanted to destroy were added back,
1167
1197
  // if they were added back, we don't actually destroy them.
1168
1198
  let completelyDestroyCIDs = willDestroyCIDs.filter(cid => {
@@ -1171,6 +1201,7 @@ export default class View {
1171
1201
 
1172
1202
  if(completelyDestroyCIDs.length > 0){
1173
1203
  this.pushWithReply(null, "cids_destroyed", {cids: completelyDestroyCIDs}, (resp) => {
1204
+ this.pruningCIDs = this.pruningCIDs.filter(cid => resp.cids.indexOf(cid) === -1)
1174
1205
  this.rendered.pruneCIDs(resp.cids)
1175
1206
  })
1176
1207
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_live_view",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "description": "The Phoenix LiveView JavaScript client.",
5
5
  "license": "MIT",
6
6
  "repository": {},
@@ -10,15 +10,17 @@
10
10
  "test.watch": "jest --watch"
11
11
  },
12
12
  "dependencies": {
13
- "morphdom": "2.7.1"
13
+ "morphdom": "2.7.2"
14
14
  },
15
15
  "devDependencies": {
16
- "@babel/cli": "7.14.3",
17
- "@babel/core": "7.14.3",
18
- "@babel/preset-env": "7.14.2",
19
- "eslint": "7.27.0",
20
- "eslint-plugin-jest": "24.3.6",
21
- "jest": "^27.0.1",
22
- "phoenix": "1.5.9"
16
+ "@babel/cli": "7.23.4",
17
+ "@babel/core": "7.23.7",
18
+ "@babel/preset-env": "7.23.8",
19
+ "css.escape": "^1.5.1",
20
+ "eslint": "8.56.0",
21
+ "eslint-plugin-jest": "27.6.3",
22
+ "jest": "^29.7.0",
23
+ "jest-environment-jsdom": "^29.7.0",
24
+ "phoenix": "1.7.10"
23
25
  }
24
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_live_view",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "description": "The Phoenix LiveView JavaScript client.",
5
5
  "license": "MIT",
6
6
  "module": "./priv/static/phoenix_live_view.esm.js",
@@ -22,5 +22,12 @@
22
22
  "package.json",
23
23
  "priv/static/*",
24
24
  "assets/js/phoenix_live_view/*"
25
- ]
25
+ ],
26
+ "devDependencies": {
27
+ "@playwright/test": "^1.41.0"
28
+ },
29
+ "scripts": {
30
+ "e2e:server": "MIX_ENV=e2e mix run test/e2e/test_helper.exs",
31
+ "e2e:test": "mix assets.build && cd test/e2e && npx playwright test"
32
+ }
26
33
  }