phoenix_live_view 0.20.1 → 0.20.3

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/README.md CHANGED
@@ -29,10 +29,10 @@ model while keeping your code closer to your data (and ultimately your source of
29
29
 
30
30
  * **Rich templating language:** Enjoy HEEx: a templating language that supports function components, slots, HTML validation, verified routes, and more.
31
31
 
32
- * **Small payloads:** LiveView is smart enough to track changes so it only sends what the client needs, making LiveView payloads much smaller than server-rendered HTML.
32
+ * **Diffs over the wire:** Instead of sending "HTML over the wire", LiveView knows exactly which parts of your templates change, sending minimal diffs over the wire after the initial render, reducing latency and bandwidth usage. The client leverages this information and optimizes the browser with 5-10x faster updates, compared to solutions that replace whole HTML fragments.
33
33
 
34
34
  * **Live form validation:** LiveView supports real-time form validation out of the box. Create rich user interfaces with features like uploads, nested inputs, and [specialized recovery](https://hexdocs.pm/phoenix_live_view/form-bindings.html#recovery-following-crashes-or-disconnects).
35
-
35
+
36
36
  * **File uploads:** Real-time file uploads with progress indicators and image previews. Process your uploads on the fly or submit them to your desired cloud service.
37
37
 
38
38
  * **Rich integration API:** Use the rich integration API to interact with the client, with `phx-click`, `phx-focus`, `phx-blur`, `phx-submit`, and `phx-hook` included for cases where you have to write JavaScript.
@@ -47,9 +47,13 @@ model while keeping your code closer to your data (and ultimately your source of
47
47
 
48
48
  * **Robust test suite:** Write tests with confidence alongside Phoenix LiveView built-in testing tools. No more running a whole browser alongside your tests.
49
49
 
50
- ## Official announcements
50
+ ## Learning
51
+
52
+ Check our [comprehensive docs](https://hexdocs.pm/phoenix_live_view) to get started.
53
+
54
+ The Phoenix framework documentation also keeps a list of [community resources](https://hexdocs.pm/phoenix/community.html), including books, videos, and other materials, and some include LiveView too.
51
55
 
52
- News from the Phoenix team on LiveView:
56
+ Also follow these announcements from the Phoenix team on LiveView for more examples and rationale:
53
57
 
54
58
  * [LiveBeats: Building a Social Music App With Phoenix LiveView](https://fly.io/blog/livebeats/)
55
59
 
@@ -57,15 +61,11 @@ News from the Phoenix team on LiveView:
57
61
 
58
62
  * [Initial announcement](https://dockyard.com/blog/2018/12/12/phoenix-liveview-interactive-real-time-apps-no-need-to-write-javascript)
59
63
 
60
- ## Learning
61
-
62
- See our existing comprehensive [docs](https://hexdocs.pm/phoenix_live_view) and [guides](https://hexdocs.pm/phoenix_live_view/api-reference.html) for more information.
63
-
64
64
  ## Installation
65
65
 
66
66
  LiveView is included by default in all new Phoenix v1.6+ applications and
67
67
  later. If you have an older existing Phoenix app and you wish to add
68
- LiveView, see [the installation guide on HexDocs](https://hexdocs.pm/phoenix_live_view/installation.html).
68
+ LiveView, see [the previous installation guide](https://github.com/phoenixframework/phoenix_live_view/blob/v0.20.1/guides/introduction/installation.md).
69
69
 
70
70
  ## What makes LiveView unique?
71
71
 
@@ -167,6 +167,7 @@ $ mix test
167
167
  ```
168
168
 
169
169
  Running the Javascript tests:
170
+
170
171
  ```bash
171
172
  $ cd assets
172
173
  $ npm run test
@@ -17,7 +17,7 @@ let ARIA = {
17
17
  (el instanceof HTMLAreaElement && el.href !== undefined) ||
18
18
  (!el.disabled && (this.anyOf(el, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement]))) ||
19
19
  (el instanceof HTMLIFrameElement) ||
20
- (el.tabIndex > 0 || (!interactiveOnly && el.tabIndex === 0 && el.getAttribute("tabindex") !== null && el.getAttribute("aria-hidden") !== "true"))
20
+ (el.tabIndex > 0 || (!interactiveOnly && el.getAttribute("tabindex") !== null && el.getAttribute("aria-hidden") !== "true"))
21
21
  )
22
22
  },
23
23
 
@@ -21,6 +21,7 @@ export const PHX_DROP_TARGET = "drop-target"
21
21
  export const PHX_ACTIVE_ENTRY_REFS = "data-phx-active-refs"
22
22
  export const PHX_LIVE_FILE_UPDATED = "phx:live-file:updated"
23
23
  export const PHX_SKIP = "data-phx-skip"
24
+ export const PHX_MAGIC_ID = "data-phx-id"
24
25
  export const PHX_PRUNE = "data-phx-prune"
25
26
  export const PHX_PAGE_LOADING = "page-loading"
26
27
  export const PHX_CONNECTED_CLASS = "phx-connected"
@@ -79,9 +80,10 @@ export const DEFAULTS = {
79
80
  // Rendered
80
81
  export const DYNAMICS = "d"
81
82
  export const STATIC = "s"
83
+ export const ROOT = "r"
82
84
  export const COMPONENTS = "c"
83
85
  export const EVENTS = "e"
84
86
  export const REPLY = "r"
85
87
  export const TITLE = "t"
86
88
  export const TEMPLATES = "p"
87
- export const STREAM = "stream"
89
+ export const STREAM = "stream"
@@ -285,10 +285,15 @@ let DOM = {
285
285
  }
286
286
  },
287
287
 
288
- maybeHideFeedback(container, input, phxFeedbackFor){
289
- if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){
290
- let feedbacks = [input.name]
291
- if(input.name.endsWith("[]")){ feedbacks.push(input.name.slice(0, -2)) }
288
+ maybeHideFeedback(container, inputs, phxFeedbackFor){
289
+ let feedbacks = []
290
+ inputs.forEach(input => {
291
+ if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){
292
+ feedbacks.push(input.name)
293
+ if(input.name.endsWith("[]")){ feedbacks.push(input.name.slice(0, -2)) }
294
+ }
295
+ })
296
+ if(feedbacks.length > 0){
292
297
  let selector = feedbacks.map(f => `[${phxFeedbackFor}="${f}"]`).join(", ")
293
298
  DOM.all(container, selector, el => el.classList.add(PHX_NO_FEEDBACK_CLASS))
294
299
  }
@@ -358,7 +363,7 @@ let DOM = {
358
363
  for(let i = targetAttrs.length - 1; i >= 0; i--){
359
364
  let name = targetAttrs[i].name
360
365
  if(isIgnored){
361
- if(name.startsWith("data-") && !source.hasAttribute(name)){ target.removeAttribute(name) }
366
+ if(name.startsWith("data-") && !source.hasAttribute(name) && ![PHX_REF, PHX_REF_SRC].includes(name)){ target.removeAttribute(name) }
362
367
  } else {
363
368
  if(!source.hasAttribute(name)){ target.removeAttribute(name) }
364
369
  }
@@ -366,8 +371,8 @@ let DOM = {
366
371
  },
367
372
 
368
373
  mergeFocusedInput(target, source){
369
- // skip selects because FF will reset highlighted index for any setAttribute
370
- if(!(target instanceof HTMLSelectElement)){ DOM.mergeAttrs(target, source, {exclude: ["value"]}) }
374
+ DOM.mergeAttrs(target, source, {exclude: ["value"]})
375
+
371
376
  if(source.readOnly){
372
377
  target.setAttribute("readonly", true)
373
378
  } else {
@@ -6,6 +6,7 @@ import {
6
6
  PHX_ROOT_ID,
7
7
  PHX_SESSION,
8
8
  PHX_SKIP,
9
+ PHX_MAGIC_ID,
9
10
  PHX_STATIC,
10
11
  PHX_TRIGGER_ACTION,
11
12
  PHX_UPDATE,
@@ -76,7 +77,7 @@ export default class DOMPatch {
76
77
  })
77
78
  }
78
79
 
79
- perform(){
80
+ perform(isJoinPatch){
80
81
  let {view, liveSocket, container, html} = this
81
82
  let targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container
82
83
  if(this.isCIDPatch() && !targetContainer){ return }
@@ -96,21 +97,19 @@ export default class DOMPatch {
96
97
 
97
98
  let externalFormTriggered = null
98
99
 
99
- let diffHTML = liveSocket.time("premorph container prep", () => {
100
- return this.buildDiffHTML(container, html, phxUpdate, targetContainer)
101
- })
102
-
103
100
  this.trackBefore("added", container)
104
101
  this.trackBefore("updated", container, container)
105
102
 
106
103
  liveSocket.time("morphdom", () => {
107
104
  this.streams.forEach(([ref, inserts, deleteIds, reset]) => {
108
105
  Object.entries(inserts).forEach(([key, [streamAt, limit]]) => {
109
- this.streamInserts[key] = {ref, streamAt, limit}
106
+ this.streamInserts[key] = {ref, streamAt, limit, resetKept: false}
110
107
  })
111
108
  if(reset !== undefined){
112
109
  DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, child => {
113
- if(!inserts[child.id]){
110
+ if(inserts[child.id]){
111
+ this.streamInserts[child.id].resetKept = true
112
+ } else {
114
113
  this.removeStreamChildElement(child)
115
114
  }
116
115
  })
@@ -121,10 +120,14 @@ export default class DOMPatch {
121
120
  })
122
121
  })
123
122
 
124
- morphdom(targetContainer, diffHTML, {
123
+ morphdom(targetContainer, html, {
125
124
  childrenOnly: targetContainer.getAttribute(PHX_COMPONENT) === null,
126
125
  getNodeKey: (node) => {
127
- return DOM.isPhxDestroyed(node) ? null : node.id
126
+ if(DOM.isPhxDestroyed(node)){ return null }
127
+ // If we have a join patch, then by definition there was no PHX_MAGIC_ID.
128
+ // This is important to reduce the amount of elements morphdom discards.
129
+ if(isJoinPatch){ return node.id }
130
+ return node.id || (node.getAttribute && node.getAttribute(PHX_MAGIC_ID))
128
131
  },
129
132
  // skip indexing from children when container is stream
130
133
  skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM },
@@ -164,7 +167,7 @@ export default class DOMPatch {
164
167
  return el
165
168
  },
166
169
  onNodeAdded: (el) => {
167
- if(el.getAttribute){ this.maybeReOrderStream(el) }
170
+ if(el.getAttribute){ this.maybeReOrderStream(el, true) }
168
171
 
169
172
  // hack to fix Safari handling of img srcset and video tags
170
173
  if(el instanceof HTMLImageElement && el.srcset){
@@ -185,6 +188,19 @@ export default class DOMPatch {
185
188
  }
186
189
  added.push(el)
187
190
  },
191
+ onBeforeElChildrenUpdated: (fromEl, toEl) => {
192
+ // before we update the children, we need to set existing stream children
193
+ // into the new order from the server if they were kept during a stream reset
194
+ if(fromEl.getAttribute(phxUpdate) === PHX_STREAM){
195
+ let toIds = Array.from(toEl.children).map(child => child.id)
196
+ Array.from(fromEl.children).filter(child => {
197
+ let {resetKept} = this.getStreamInsert(child)
198
+ return resetKept
199
+ }).forEach((child) => {
200
+ this.streamInserts[child.id].streamAt = toIds.indexOf(child.id)
201
+ })
202
+ }
203
+ },
188
204
  onNodeDiscarded: (el) => this.onNodeDiscarded(el),
189
205
  onBeforeNodeDiscarded: (el) => {
190
206
  if(el.getAttribute && el.getAttribute(PHX_PRUNE) !== null){ return true }
@@ -202,7 +218,7 @@ export default class DOMPatch {
202
218
  externalFormTriggered = el
203
219
  }
204
220
  updates.push(el)
205
- this.maybeReOrderStream(el)
221
+ this.maybeReOrderStream(el, false)
206
222
  },
207
223
  onBeforeElUpdated: (fromEl, toEl) => {
208
224
  DOM.maybeAddPrivateHooks(toEl, phxViewportTop, phxViewportBottom)
@@ -273,9 +289,7 @@ export default class DOMPatch {
273
289
  })
274
290
  }
275
291
 
276
- trackedInputs.forEach(input => {
277
- DOM.maybeHideFeedback(targetContainer, input, phxFeedbackFor)
278
- })
292
+ DOM.maybeHideFeedback(targetContainer, trackedInputs, phxFeedbackFor)
279
293
 
280
294
  liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
281
295
  DOM.dispatchEvent(document, "phx:update")
@@ -320,9 +334,9 @@ export default class DOMPatch {
320
334
  return insert || {}
321
335
  }
322
336
 
323
- maybeReOrderStream(el){
337
+ maybeReOrderStream(el, isNew){
324
338
  let {ref, streamAt, limit} = this.getStreamInsert(el)
325
- if(streamAt === undefined){ return }
339
+ if(streamAt === undefined || (streamAt === 0 && !isNew)){ return }
326
340
 
327
341
  // we need to the PHX_STREAM_REF here as well as addChild is invoked only for parents
328
342
  DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
@@ -363,7 +377,7 @@ export default class DOMPatch {
363
377
  isCIDPatch(){ return this.cidPatch }
364
378
 
365
379
  skipCIDSibling(el){
366
- return el.nodeType === Node.ELEMENT_NODE && el.getAttribute(PHX_SKIP) !== null
380
+ return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP)
367
381
  }
368
382
 
369
383
  targetCIDContainer(html){
@@ -376,37 +390,5 @@ export default class DOMPatch {
376
390
  }
377
391
  }
378
392
 
379
- // builds HTML for morphdom patch
380
- // - for full patches of LiveView or a component with a single
381
- // root node, simply returns the HTML
382
- // - for patches of a component with multiple root nodes, the
383
- // parent node becomes the target container and non-component
384
- // siblings are marked as skip.
385
- buildDiffHTML(container, html, phxUpdate, targetContainer){
386
- let isCIDPatch = this.isCIDPatch()
387
- let isCIDWithSingleRoot = isCIDPatch && targetContainer.getAttribute(PHX_COMPONENT) === this.targetCID.toString()
388
- if(!isCIDPatch || isCIDWithSingleRoot){
389
- return html
390
- } else {
391
- // component patch with multiple CID roots
392
- let diffContainer = null
393
- let template = document.createElement("template")
394
- diffContainer = DOM.cloneNode(targetContainer)
395
- let [firstComponent, ...rest] = DOM.findComponentNodeList(diffContainer, this.targetCID)
396
- template.innerHTML = html
397
- rest.forEach(el => el.remove())
398
- Array.from(diffContainer.childNodes).forEach(child => {
399
- // we can only skip trackable nodes with an ID
400
- if(child.id && child.nodeType === Node.ELEMENT_NODE && child.getAttribute(PHX_COMPONENT) !== this.targetCID.toString()){
401
- child.setAttribute(PHX_SKIP, "")
402
- child.innerHTML = ""
403
- }
404
- })
405
- Array.from(template.content.childNodes).forEach(el => diffContainer.insertBefore(el, firstComponent))
406
- firstComponent.remove()
407
- return diffContainer.outerHTML
408
- }
409
- }
410
-
411
393
  indexOf(parent, child){ return Array.from(parent.children).indexOf(child) }
412
394
  }
@@ -15,6 +15,7 @@ export default class EntryUploader {
15
15
 
16
16
  error(reason){
17
17
  if(this.errored){ return }
18
+ this.uploadChannel.leave()
18
19
  this.errored = true
19
20
  clearTimeout(this.chunkTimer)
20
21
  this.entry.error(reason)
@@ -9,8 +9,6 @@ let JS = {
9
9
  let commands = phxEvent.charAt(0) === "[" ?
10
10
  JSON.parse(phxEvent) : [[defaultKind, defaultArgs]]
11
11
 
12
-
13
-
14
12
  commands.forEach(([kind, args]) => {
15
13
  if(kind === defaultKind && defaultArgs.data){
16
14
  args.data = Object.assign(args.data || {}, defaultArgs.data)
@@ -26,6 +24,16 @@ let JS = {
26
24
  return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0)
27
25
  },
28
26
 
27
+ isInViewport(el){
28
+ const rect = el.getBoundingClientRect()
29
+ return (
30
+ rect.top >= 0 &&
31
+ rect.left >= 0 &&
32
+ rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
33
+ rect.right <= (window.innerWidth || document.documentElement.clientWidth)
34
+ )
35
+ },
36
+
29
37
  // private
30
38
 
31
39
  // commands
@@ -46,13 +54,12 @@ let JS = {
46
54
  },
47
55
 
48
56
  exec_push(eventType, phxEvent, view, sourceEl, el, args){
49
- if(!view.isConnected()){ return }
50
-
51
57
  let {event, data, target, page_loading, loading, value, dispatcher, callback} = args
52
58
  let pushOpts = {loading, value, target, page_loading: !!page_loading}
53
59
  let targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl
54
60
  let phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc
55
61
  view.withinTargets(phxTarget, (targetView, targetCtx) => {
62
+ if(!targetView.isConnected()){ return }
56
63
  if(eventType === "change"){
57
64
  let {newCid, _target} = args
58
65
  _target = _target || (DOM.isFormInput(sourceEl) ? sourceEl.name : undefined)
@@ -102,6 +109,10 @@ let JS = {
102
109
  this.addOrRemoveClasses(el, [], names, transition, time, view)
103
110
  },
104
111
 
112
+ exec_toggle_class(eventType, phxEvent, view, sourceEl, el, {to, names, transition, time}){
113
+ this.toggleClasses(el, names, transition, view)
114
+ },
115
+
105
116
  exec_transition(eventType, phxEvent, view, sourceEl, el, {time, transition}){
106
117
  this.addOrRemoveClasses(el, [], [], transition, time, view)
107
118
  },
@@ -193,6 +204,15 @@ let JS = {
193
204
  }
194
205
  },
195
206
 
207
+ toggleClasses(el, classes, transition, time, view){
208
+ window.requestAnimationFrame(() => {
209
+ let [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []])
210
+ let newAdds = classes.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name))
211
+ let newRemoves = classes.filter(name => prevRemoves.indexOf(name) < 0 && el.classList.contains(name))
212
+ this.addOrRemoveClasses(el, newAdds, newRemoves, transition, time, view)
213
+ })
214
+ },
215
+
196
216
  addOrRemoveClasses(el, adds, removes, transition, time, view){
197
217
  let [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []]
198
218
  if(transitionRun.length > 0){
@@ -522,7 +522,7 @@ export default class LiveSocket {
522
522
  if(!dead){ this.bindNav() }
523
523
  this.bindClicks()
524
524
  if(!dead){ this.bindForms() }
525
- this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, eventTarget) => {
525
+ this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, phxTarget) => {
526
526
  let matchKey = targetEl.getAttribute(this.binding(PHX_KEY))
527
527
  let pressedKey = e.key && e.key.toLowerCase() // chrome clicked autocompletes send a keydown without key
528
528
  if(matchKey && matchKey.toLowerCase() !== pressedKey){ return }
@@ -530,13 +530,13 @@ export default class LiveSocket {
530
530
  let data = {key: e.key, ...this.eventMeta(type, e, targetEl)}
531
531
  JS.exec(type, phxEvent, view, targetEl, ["push", {data}])
532
532
  })
533
- this.bind({blur: "focusout", focus: "focusin"}, (e, type, view, targetEl, phxEvent, eventTarget) => {
534
- if(!eventTarget){
533
+ this.bind({blur: "focusout", focus: "focusin"}, (e, type, view, targetEl, phxEvent, phxTarget) => {
534
+ if(!phxTarget){
535
535
  let data = {key: e.key, ...this.eventMeta(type, e, targetEl)}
536
536
  JS.exec(type, phxEvent, view, targetEl, ["push", {data}])
537
537
  }
538
538
  })
539
- this.bind({blur: "blur", focus: "focus"}, (e, type, view, targetEl, targetCtx, phxEvent, phxTarget) => {
539
+ this.bind({blur: "blur", focus: "focus"}, (e, type, view, targetEl, phxEvent, phxTarget) => {
540
540
  // blur and focus are triggered on document and window. Discard one to avoid dups
541
541
  if(phxTarget === "window"){
542
542
  let data = this.eventMeta(type, e, targetEl)
@@ -619,7 +619,7 @@ export default class LiveSocket {
619
619
  }
620
620
 
621
621
  bindClicks(){
622
- window.addEventListener("click", e => this.clickStartedAtTarget = e.target)
622
+ window.addEventListener("mousedown", e => this.clickStartedAtTarget = e.target)
623
623
  this.bindClick("click", "click", false)
624
624
  this.bindClick("mousedown", "capture-click", true)
625
625
  }
@@ -661,7 +661,7 @@ export default class LiveSocket {
661
661
  if(!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))){
662
662
  this.withinOwners(e.target, view => {
663
663
  let phxEvent = el.getAttribute(phxClickAway)
664
- if(JS.isVisible(el)){
664
+ if(JS.isVisible(el) && JS.isInViewport(el)){
665
665
  JS.exec("click", phxEvent, view, el, ["push", {data: this.eventMeta("click", e, e.target)}])
666
666
  }
667
667
  })
@@ -703,7 +703,9 @@ export default class LiveSocket {
703
703
  let type = target && target.getAttribute(PHX_LIVE_LINK)
704
704
  if(!type || !this.isConnected() || !this.main || DOM.wantsNewTab(e)){ return }
705
705
 
706
- let href = target.href
706
+ // When wrapping an SVG element in an anchor tag, the href can be an SVGAnimatedString
707
+ let href = target.href instanceof SVGAnimatedString ? target.href.baseVal : target.href
708
+
707
709
  let linkState = target.getAttribute(PHX_LINK_STATE)
708
710
  e.preventDefault()
709
711
  e.stopImmediatePropagation() // do not bubble click to regular phx-click bindings