phoenix_live_view 0.17.7 → 0.17.8

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.8 (2022-04-06)
4
+
5
+ ### Enhancements
6
+ - Add HEEx formatter
7
+ - Support `phx-change` on individual inputs
8
+ - Dispatch `MouseEvent` on client
9
+ - Add `:bubbles` option to `JS.dispatch` to control event bubbling
10
+ - Expose underlying `liveSocket` instance on hooks
11
+ - Enable client debug by default on localhost
12
+
13
+ ### Bug fixes
14
+ - Fix hook and sticky LiveView issues caused by back-to-back live redirects from mount
15
+ - Fix hook destroyed callback failing to be invoked for children of phx-remove in some cases
16
+ - Do not failsafe reload the page on push timeout if disconnected
17
+ - Do not bubble navigation click events to regular phx-click's
18
+
3
19
  ## 0.17.7 (2022-02-07)
4
20
 
5
21
  ### Enhancements
@@ -200,7 +216,7 @@ Some functionality that was previously deprecated has been removed:
200
216
 
201
217
  ## 0.16.0 (2021-08-10)
202
218
 
203
- ## # Security Considerations Upgrading from 0.15
219
+ ### Security Considerations Upgrading from 0.15
204
220
 
205
221
  LiveView v0.16 optimizes live redirects by supporting navigation purely
206
222
  over the existing WebSocket connection. This is accomplished by the new
@@ -615,7 +631,7 @@ as LiveView introduces a macro with that name and it is special cased by the und
615
631
  - No longer send event metadata by default. Metadata is now opt-in and user defined at the `LiveSocket` level.
616
632
  To maintain backwards compatibility with pre-0.13 behaviour, you can provide the following metadata option:
617
633
 
618
- ```javascript
634
+ ```
619
635
  let liveSocket = new LiveSocket("/live", Socket, {
620
636
  params: {_csrf_token: csrfToken},
621
637
  metadata: {
@@ -714,7 +730,7 @@ The new implementation will check there is a button at `#term .buttons a`, with
714
730
  - `Phoenix.LiveViewTest.assert_remove/3` has been removed. If the LiveView crashes, it will cause the test to crash too
715
731
  - Passing a path with DOM IDs to `render_*` test functions is deprecated. Furthermore, they now require a `phx-target="<%= @id %>"` on the given DOM ID:
716
732
 
717
- ```html
733
+ ```heex
718
734
  <div id="component-id" phx-target="component-id">
719
735
  ...
720
736
  </div>
@@ -931,14 +947,14 @@ The steps are:
931
947
 
932
948
  4) You should define the CSRF meta tag inside <head> in your layout, before `app.js` is included:
933
949
 
934
- ```html
950
+ ```heex
935
951
  <%= csrf_meta_tag() %>
936
952
  <script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
937
953
  ```
938
954
 
939
955
  5) Then in your app.js:
940
956
 
941
- ```javascript
957
+ ```
942
958
  let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
943
959
  let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}});
944
960
  ```
@@ -1019,7 +1035,7 @@ Also note that **the session from now on will have string keys**. LiveView will
1019
1035
  - All `phx-update` containers now require a unique ID
1020
1036
  - `LiveSocket` JavaScript constructor now requires explicit dependency injection of Phoenix Socket constructor. For example:
1021
1037
 
1022
- ```javascript
1038
+ ```
1023
1039
  import {Socket} from "phoenix"
1024
1040
  import LiveSocket from "phoenix_live_view"
1025
1041
 
@@ -1035,7 +1051,7 @@ let liveSocket = new LiveSocket("/live", Socket, {...})
1035
1051
  - Fix params failing to update on re-mounts after live_redirect
1036
1052
  - Fix blur event metadata being sent with type of `"focus"`
1037
1053
 
1038
- ## 0.1.2
1054
+ ## 0.1.2 (2019-08-28)
1039
1055
 
1040
1056
  ### Backwards incompatible changes
1041
1057
  - `phx-value` has no effect, use `phx-value-*` instead
package/README.md CHANGED
@@ -125,7 +125,7 @@ $ npm install --save --prefix assets mdn-polyfills url-search-params-polyfill fo
125
125
 
126
126
  Note: The `shim-keyboard-event-key` polyfill is also required for [MS Edge 12-18](https://caniuse.com/#feat=keyboardevent-key).
127
127
 
128
- ```javascript
128
+ ```
129
129
  // assets/js/app.js
130
130
  import "mdn-polyfills/Object.assign"
131
131
  import "mdn-polyfills/CustomEvent"
@@ -250,8 +250,10 @@ let DOM = {
250
250
  return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]
251
251
  },
252
252
 
253
- dispatchEvent(target, eventString, detail = {}){
254
- let event = new CustomEvent(eventString, {bubbles: true, cancelable: true, detail: detail})
253
+ dispatchEvent(target, name, opts = {}){
254
+ let bubbles = opts.bubbles === undefined ? true : !!opts.bubbles
255
+ let eventOpts = {bubbles: bubbles, cancelable: true, detail: opts.detail || {}}
256
+ let event = name === "click" ? new MouseEvent("click", eventOpts) : new CustomEvent(name, eventOpts)
255
257
  target.dispatchEvent(event)
256
258
  },
257
259
 
@@ -287,7 +289,7 @@ let DOM = {
287
289
 
288
290
  mergeFocusedInput(target, source){
289
291
  // skip selects because FF will reset highlighted index for any setAttribute
290
- if(!(target instanceof HTMLSelectElement)){ DOM.mergeAttrs(target, source, {except: ["value"]}) }
292
+ if(!(target instanceof HTMLSelectElement)){ DOM.mergeAttrs(target, source, {exclude: ["value"]}) }
291
293
  if(source.readOnly){
292
294
  target.setAttribute("readonly", true)
293
295
  } else {
@@ -24,18 +24,23 @@ let JS = {
24
24
 
25
25
  // commands
26
26
 
27
- exec_dispatch(eventType, phxEvent, view, sourceEl, el, {to, event, detail}){
28
- DOM.dispatchEvent(el, event, detail)
27
+ exec_dispatch(eventType, phxEvent, view, sourceEl, el, {to, event, detail, bubbles}){
28
+ detail = detail || {}
29
+ detail.dispatcher = sourceEl
30
+ DOM.dispatchEvent(el, event, {detail, bubbles})
29
31
  },
30
32
 
31
33
  exec_push(eventType, phxEvent, view, sourceEl, el, args){
32
- let {event, data, target, page_loading, loading, value} = args
34
+ if(!view.isConnected()){ return }
35
+
36
+ let {event, data, target, page_loading, loading, value, dispatcher} = args
33
37
  let pushOpts = {loading, value, target, page_loading: !!page_loading}
34
- let targetSrc = eventType === "change" ? sourceEl.form : sourceEl
38
+ let targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl
35
39
  let phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc
36
40
  view.withinTargets(phxTarget, (targetView, targetCtx) => {
37
41
  if(eventType === "change"){
38
42
  let {newCid, _target, callback} = args
43
+ _target = _target || (sourceEl instanceof HTMLInputElement ? sourceEl.name : undefined)
39
44
  if(_target){ pushOpts._target = _target }
40
45
  targetView.pushInput(sourceEl, targetCtx, newCid, event || phxEvent, pushOpts, callback)
41
46
  } else if(eventType === "submit"){
@@ -170,10 +175,10 @@ let JS = {
170
175
 
171
176
  setOrRemoveAttrs(el, sets, removes){
172
177
  let [prevSets, prevRemoves] = DOM.getSticky(el, "attrs", [[], []])
173
- let keepSets = sets.filter(([attr, _val]) => !this.hasSet(prevSets, attr) && !el.attributes.getNamedItem(attr))
174
- let keepRemoves = removes.filter(attr => prevRemoves.indexOf(attr) < 0 && el.attributes.getNamedItem(attr))
175
- let newSets = prevSets.filter(([attr, _val]) => removes.indexOf(attr) < 0).concat(keepSets)
176
- let newRemoves = prevRemoves.filter(attr => !this.hasSet(sets, attr)).concat(keepRemoves)
178
+
179
+ let alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes);
180
+ let newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets);
181
+ let newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes);
177
182
 
178
183
  DOM.putSticky(el, "attrs", currentEl => {
179
184
  newRemoves.forEach(attr => currentEl.removeAttribute(attr))
@@ -182,8 +187,6 @@ let JS = {
182
187
  })
183
188
  },
184
189
 
185
- hasSet(sets, nameSearch){ return sets.find(([name, val]) => name === nameSearch) },
186
-
187
190
  hasAllClasses(el, classes){ return classes.every(name => el.classList.contains(name)) },
188
191
 
189
192
  isToggledOut(el, outClasses){
@@ -138,8 +138,9 @@ export default class LiveSocket {
138
138
  this.prevActive = null
139
139
  this.silenced = false
140
140
  this.main = null
141
+ this.outgoingMainEl = null
142
+ this.clickStartedAtTarget = null
141
143
  this.linkRef = 1
142
- this.clickRef = 1
143
144
  this.roots = {}
144
145
  this.href = window.location.href
145
146
  this.pendingLink = null
@@ -173,11 +174,13 @@ export default class LiveSocket {
173
174
 
174
175
  isDebugEnabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true" }
175
176
 
177
+ isDebugDisabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "false" }
178
+
176
179
  enableDebug(){ this.sessionStorage.setItem(PHX_LV_DEBUG, "true") }
177
180
 
178
181
  enableProfiling(){ this.sessionStorage.setItem(PHX_LV_PROFILE, "true") }
179
182
 
180
- disableDebug(){ this.sessionStorage.removeItem(PHX_LV_DEBUG) }
183
+ disableDebug(){ this.sessionStorage.setItem(PHX_LV_DEBUG, "false") }
181
184
 
182
185
  disableProfiling(){ this.sessionStorage.removeItem(PHX_LV_PROFILE) }
183
186
 
@@ -197,6 +200,8 @@ export default class LiveSocket {
197
200
  getSocket(){ return this.socket }
198
201
 
199
202
  connect(){
203
+ // enable debug by default if on localhost and not explicitly disabled
204
+ if(window.location.hostname === "localhost" && !this.isDebugDisabled()){ this.enableDebug() }
200
205
  let doConnect = () => {
201
206
  if(this.joinRootViews()){
202
207
  this.bindTopLevelEvents()
@@ -262,7 +267,7 @@ export default class LiveSocket {
262
267
  let latency = this.getLatencySim()
263
268
  let oldJoinCount = view.joinCount
264
269
  if(!latency){
265
- if(opts.timeout){
270
+ if(this.isConnected() && opts.timeout){
266
271
  return push().receive("timeout", () => {
267
272
  if(view.joinCount === oldJoinCount && !view.isDestroyed()){
268
273
  this.reloadWithJitter(view, () => {
@@ -342,8 +347,8 @@ export default class LiveSocket {
342
347
  }
343
348
 
344
349
  replaceMain(href, flash, callback = null, linkRef = this.setPendingLink(href)){
345
- let oldMainEl = this.main.el
346
- let newMainEl = DOM.cloneNode(oldMainEl, "")
350
+ this.outgoingMainEl = this.outgoingMainEl || this.main.el
351
+ let newMainEl = DOM.cloneNode(this.outgoingMainEl, "")
347
352
  this.main.showLoader(this.loaderTimeout)
348
353
  this.main.destroy()
349
354
 
@@ -354,7 +359,8 @@ export default class LiveSocket {
354
359
  if(joinCount === 1 && this.commitPendingLink(linkRef)){
355
360
  this.requestDOMUpdate(() => {
356
361
  DOM.findPhxSticky(document).forEach(el => newMainEl.appendChild(el))
357
- oldMainEl.replaceWith(newMainEl)
362
+ this.outgoingMainEl.replaceWith(newMainEl)
363
+ this.outgoingMainEl = null
358
364
  callback && callback()
359
365
  onDone()
360
366
  })
@@ -457,7 +463,7 @@ export default class LiveSocket {
457
463
  this.boundTopLevelEvents = true
458
464
  // enter failsafe reload if server has gone away intentionally, such as "disconnect" broadcast
459
465
  this.socket.onClose(event => {
460
- if(event.code === 1000 && this.main){
466
+ if(event && event.code === 1000 && this.main){
461
467
  this.reloadWithJitter(this.main)
462
468
  }
463
469
  })
@@ -569,6 +575,7 @@ export default class LiveSocket {
569
575
  }
570
576
 
571
577
  bindClicks(){
578
+ window.addEventListener("mousedown", e => this.clickStartedAtTarget = e.target)
572
579
  this.bindClick("click", "click", false)
573
580
  this.bindClick("mousedown", "capture-click", true)
574
581
  }
@@ -576,15 +583,14 @@ export default class LiveSocket {
576
583
  bindClick(eventName, bindingName, capture){
577
584
  let click = this.binding(bindingName)
578
585
  window.addEventListener(eventName, e => {
579
- if(!this.isConnected()){ return }
580
- this.clickRef++
581
- let clickRefWas = this.clickRef
582
586
  let target = null
583
587
  if(capture){
584
588
  target = e.target.matches(`[${click}]`) ? e.target : e.target.querySelector(`[${click}]`)
585
589
  } else {
586
- target = closestPhxBinding(e.target, click)
587
- this.dispatchClickAway(e, clickRefWas)
590
+ let clickStartedAtTarget = this.clickStartedAtTarget || e.target
591
+ target = closestPhxBinding(clickStartedAtTarget, click)
592
+ this.dispatchClickAway(e, clickStartedAtTarget)
593
+ this.clickStartedAtTarget = null
588
594
  }
589
595
  let phxEvent = target && target.getAttribute(click)
590
596
  if(!phxEvent){ return }
@@ -598,15 +604,13 @@ export default class LiveSocket {
598
604
  }, capture)
599
605
  }
600
606
 
601
- dispatchClickAway(e, clickRefWas){
607
+ dispatchClickAway(e, clickStartedAt){
602
608
  let phxClickAway = this.binding("click-away")
603
- let phxClick = this.binding("click")
604
609
  DOM.all(document, `[${phxClickAway}]`, el => {
605
- if(!(el.isSameNode(e.target) || el.contains(e.target))){
610
+ if(!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))){
606
611
  this.withinOwners(e.target, view => {
607
612
  let phxEvent = el.getAttribute(phxClickAway)
608
613
  if(JS.isVisible(el)){
609
- let target = e.target.closest(`[${phxClick}]`) || e.target
610
614
  JS.exec("click", phxEvent, view, el, ["push", {data: this.eventMeta("click", e, e.target)}])
611
615
  }
612
616
  })
@@ -649,9 +653,11 @@ export default class LiveSocket {
649
653
  let type = target && target.getAttribute(PHX_LIVE_LINK)
650
654
  let wantsNewTab = e.metaKey || e.ctrlKey || e.button === 1
651
655
  if(!type || !this.isConnected() || !this.main || wantsNewTab){ return }
656
+
652
657
  let href = target.href
653
658
  let linkState = target.getAttribute(PHX_LINK_STATE)
654
659
  e.preventDefault()
660
+ e.stopImmediatePropagation() // do not bubble click to regular phx-click bindings
655
661
  if(this.pendingLink === href){ return }
656
662
 
657
663
  this.requestDOMUpdate(() => {
@@ -667,7 +673,7 @@ export default class LiveSocket {
667
673
  }
668
674
 
669
675
  dispatchEvent(event, payload = {}){
670
- DOM.dispatchEvent(window, `phx:${event}`, payload)
676
+ DOM.dispatchEvent(window, `phx:${event}`, {detail: payload})
671
677
  }
672
678
 
673
679
  dispatchEvents(events){
@@ -675,8 +681,8 @@ export default class LiveSocket {
675
681
  }
676
682
 
677
683
  withPageLoading(info, callback){
678
- DOM.dispatchEvent(window, "phx:page-loading-start", info)
679
- let done = () => DOM.dispatchEvent(window, "phx:page-loading-stop", info)
684
+ DOM.dispatchEvent(window, "phx:page-loading-start", {detail: info})
685
+ let done = () => DOM.dispatchEvent(window, "phx:page-loading-stop", {detail: info})
680
686
  return callback ? callback(done) : done
681
687
  }
682
688
 
@@ -735,10 +741,15 @@ export default class LiveSocket {
735
741
 
736
742
  for(let type of ["change", "input"]){
737
743
  this.on(type, e => {
744
+ let phxChange = this.binding("change")
738
745
  let input = e.target
739
- let phxEvent = input.form && input.form.getAttribute(this.binding("change"))
746
+ let inputEvent = input.getAttribute(phxChange)
747
+ let formEvent = input.form && input.form.getAttribute(phxChange)
748
+ let phxEvent = inputEvent || formEvent
740
749
  if(!phxEvent){ return }
741
750
  if(input.type === "number" && input.validity && input.validity.badInput){ return }
751
+
752
+ let dispatcher = inputEvent ? input : input.form
742
753
  let currentIterations = iterations
743
754
  iterations++
744
755
  let {at: at, type: lastType} = DOM.private(input, "prev-iteration") || {}
@@ -748,12 +759,12 @@ export default class LiveSocket {
748
759
  DOM.putPrivate(input, "prev-iteration", {at: currentIterations, type: type})
749
760
 
750
761
  this.debounce(input, e, () => {
751
- this.withinOwners(input.form, view => {
762
+ this.withinOwners(dispatcher, view => {
752
763
  DOM.putPrivate(input, PHX_HAS_FOCUSED, true)
753
764
  if(!DOM.isTextualInput(input)){
754
765
  this.setActiveElement(input)
755
766
  }
756
- JS.exec("change", phxEvent, view, input, ["push", {_target: e.target.name}])
767
+ JS.exec("change", phxEvent, view, input, ["push", {_target: e.target.name, dispatcher: dispatcher}])
757
768
  })
758
769
  })
759
770
  }, false)
@@ -50,7 +50,7 @@ import Rendered from "./rendered"
50
50
  import ViewHook from "./view_hook"
51
51
  import JS from "./js"
52
52
 
53
- let serializeForm = (form, meta = {}) => {
53
+ let serializeForm = (form, meta, onlyNames = []) => {
54
54
  let formData = new FormData(form)
55
55
  let toRemove = []
56
56
 
@@ -62,7 +62,11 @@ let serializeForm = (form, meta = {}) => {
62
62
  toRemove.forEach(key => formData.delete(key))
63
63
 
64
64
  let params = new URLSearchParams()
65
- for(let [key, val] of formData.entries()){ params.append(key, val) }
65
+ for(let [key, val] of formData.entries()){
66
+ if(onlyNames.length === 0 || onlyNames.indexOf(key) >= 0){
67
+ params.append(key, val)
68
+ }
69
+ }
66
70
  for(let metaKey in meta){ params.append(metaKey, meta[metaKey]) }
67
71
 
68
72
  return params.toString()
@@ -377,10 +381,13 @@ export default class View {
377
381
  let destroyedCIDs = []
378
382
  elements.forEach(parent => {
379
383
  let components = DOM.all(parent, `[${PHX_COMPONENT}]`)
380
- components.concat(parent).forEach(el => {
384
+ let hooks = DOM.all(parent, `[${this.binding(PHX_HOOK)}]`)
385
+ components.concat(parent).forEach(el => {
381
386
  let cid = this.componentID(el)
382
387
  if(isCid(cid) && destroyedCIDs.indexOf(cid) === -1){ destroyedCIDs.push(cid) }
383
- let hook = this.getHook(el)
388
+ })
389
+ hooks.concat(parent).forEach(hookEl => {
390
+ let hook = this.getHook(hookEl)
384
391
  hook && this.destroyHook(hook)
385
392
  })
386
393
  })
@@ -635,7 +642,7 @@ export default class View {
635
642
  }
636
643
 
637
644
  displayError(){
638
- if(this.isMain()){ DOM.dispatchEvent(window, "phx:page-loading-start", {to: this.href, kind: "error"}) }
645
+ if(this.isMain()){ DOM.dispatchEvent(window, "phx:page-loading-start", {detail: {to: this.href, kind: "error"}}) }
639
646
  this.showLoader()
640
647
  this.setContainerClasses(PHX_DISCONNECTED_CLASS, PHX_ERROR_CLASS)
641
648
  }
@@ -821,7 +828,12 @@ export default class View {
821
828
  let uploads
822
829
  let cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx)
823
830
  let refGenerator = () => this.putRef([inputEl, inputEl.form], "change", opts)
824
- let formData = serializeForm(inputEl.form, {_target: opts._target})
831
+ let formData
832
+ if(inputEl.getAttribute(this.binding("change"))){
833
+ formData = serializeForm(inputEl.form, {_target: opts._target}, [inputEl.name])
834
+ } else {
835
+ formData = serializeForm(inputEl.form, {_target: opts._target})
836
+ }
825
837
  if(DOM.isUploadInput(inputEl) && inputEl.files && inputEl.files.length > 0){
826
838
  LiveUploader.trackFiles(inputEl, Array.from(inputEl.files))
827
839
  }
@@ -930,7 +942,7 @@ export default class View {
930
942
  }, onReply)
931
943
  })
932
944
  } else {
933
- let formData = serializeForm(formEl)
945
+ let formData = serializeForm(formEl, {})
934
946
  this.pushWithReply(refGenerator, "event", {
935
947
  type: "form",
936
948
  event: phxEvent,
@@ -985,7 +997,7 @@ export default class View {
985
997
  let inputs = DOM.findUploadInputs(this.el).filter(el => el.name === name)
986
998
  if(inputs.length === 0){ logError(`no live file inputs found matching the name "${name}"`) }
987
999
  else if(inputs.length > 1){ logError(`duplicate live file inputs found matching the name "${name}"`) }
988
- else { DOM.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, {files: filesOrBlobs}) }
1000
+ else { DOM.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, {detail: {files: filesOrBlobs}}) }
989
1001
  }
990
1002
 
991
1003
  pushFormRecovery(form, newCid, callback){
@@ -5,7 +5,7 @@ export default class ViewHook {
5
5
 
6
6
  constructor(view, el, callbacks){
7
7
  this.__view = view
8
- this.__liveSocket = view.liveSocket
8
+ this.liveSocket = view.liveSocket
9
9
  this.__callbacks = callbacks
10
10
  this.__listeners = new Set()
11
11
  this.__isDisconnected = false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_live_view",
3
- "version": "0.17.7",
3
+ "version": "0.17.8",
4
4
  "description": "The Phoenix LiveView JavaScript client.",
5
5
  "license": "MIT",
6
6
  "module": "./priv/static/phoenix_live_view.esm.js",