phoenix_live_view 0.18.18 → 0.19.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.
package/README.md CHANGED
@@ -2,11 +2,10 @@
2
2
 
3
3
  [![Actions Status](https://github.com/phoenixframework/phoenix_live_view/workflows/CI/badge.svg)](https://github.com/phoenixframework/phoenix_live_view/actions?query=workflow%3ACI) [![Hex.pm](https://img.shields.io/hexpm/v/phoenix_live_view.svg)](https://hex.pm/packages/phoenix_live_view) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/phoenix_live_view)
4
4
 
5
- Phoenix LiveView enables rich, real-time user experiences
6
- with server-rendered HTML.
5
+ Phoenix LiveView enables rich, real-time user experiences with server-rendered HTML.
7
6
 
8
- Visit the [https://livebeats.fly.dev](https://livebeats.fly.dev/) demo to see the kinds of applications
9
- you can build, or see a sneak peek below:
7
+ Visit the [https://livebeats.fly.dev](https://livebeats.fly.dev/) demo to see
8
+ the kinds of applications you can build, or see a sneak peek below:
10
9
 
11
10
  https://user-images.githubusercontent.com/576796/162234098-31b580fe-e424-47e6-b01d-cd2cfcf823a9.mp4
12
11
 
@@ -19,42 +18,34 @@ steps:
19
18
  $ mix archive.install hex phx_new
20
19
  $ mix phx.new demo
21
20
 
22
- ## Features
21
+ ## Feature highlights
23
22
 
24
- * Use a declarative model to render HTML on the server
25
- over WebSockets with optional LongPolling fallback
23
+ LiveView brings a unified experience to building web applications. You no longer
24
+ have to split work between client and server, across different toolings, layers, and
25
+ abstractions. Instead, LiveView enriches the server with a declarative and powerful
26
+ model while keeping your code closer to your data (and ultimately your source of truth):
26
27
 
27
- * A rich templating language, called HEEx, with support
28
- for function components, slots, HTML validation, and more
28
+ * **Declarative rendering:** Render HTML on the server over WebSockets with a declarative model, including an optional LongPolling fallback.
29
29
 
30
- * Smart change tracking - once connected, LiveView sends
31
- only what changed to the client, skipping the template
32
- markup and reducing the payload. This makes LiveView
33
- payloads much smaller than server-rendered HTML and on
34
- par with fine-tuned SPA applications
30
+ * **Rich templating language:** Enjoy HEEx: a templating language that supports function components, slots, HTML validation, verified routes, and more.
35
31
 
36
- * Live form validation with file upload support
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.
37
33
 
38
- * A rich integration API with the client with `phx-click`,
39
- `phx-focus`, `phx-blur`, `phx-submit`, etc. `phx-hook` is
40
- included for the cases where you have to write JavaScript
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
+
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.
41
37
 
42
- * Perform optimistic updates and transitions via JavaScript
43
- commands (`Phoenix.LiveView.JS`)
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.
44
39
 
45
- * Code reuse via stateful components, which break templates,
46
- state, and event handling into reusable bits, which is essential
47
- in large applications
40
+ * **Optimistic updates and transitions:** Perform optimistic updates and transitions with JavaScript commands via `Phoenix.LiveView.JS`.
48
41
 
49
- * Live navigation to enrich links and redirects to only load the
50
- minimum amount of content as users navigate between pages
42
+ * **Loose coupling:** Reuse more code via stateful components with loosely-coupled templates, state, and event handling a must for enterprise application development.
51
43
 
52
- * A latency simulator so you can emulate how slow clients will
53
- interact with your application
44
+ * **Live navigation:** Enriched links and redirects are just more ways LiveView keeps your app light and performant. Clients load the minimum amount of content needed as users navigate around your app without any compromise in user experience.
54
45
 
55
- * Testing tools that allow you to write a confident test suite
56
- without the complexity of running a whole browser alongside
57
- your tests
46
+ * **Latency simulator:** Emulate how slow clients will interact with your application with the latency simulator.
47
+
48
+ * **Robust test suite:** Write tests with confidence alongside Phoenix LiveView built-in testing tools. No more running a whole browser alongside your tests.
58
49
 
59
50
  ## Official announcements
60
51
 
@@ -72,11 +63,9 @@ See our existing comprehensive [docs](https://hexdocs.pm/phoenix_live_view) and
72
63
 
73
64
  ## Installation
74
65
 
75
- There are currently two methods for installing LiveView. For projects that
76
- require more stability, it is recommended that you install using the
77
- [installation guide on HexDocs](https://hexdocs.pm/phoenix_live_view/installation.html).
78
- If you want to use the latest features, you should follow the instructions
79
- given in the markdown file [here](guides/introduction/installation.md).
66
+ LiveView is included by default in all new Phoenix v1.6+ applications and
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).
80
69
 
81
70
  ## What makes LiveView unique?
82
71
 
@@ -115,7 +104,7 @@ anywhere else:
115
104
  the system. Do you want to notify a user that their best friend
116
105
  just connected? This is easily done without a single line of
117
106
  custom JavaScript and with no extra external dependencies
118
- (no extra databases, no extra message queues, etc.).
107
+ (no extra databases, no Redis, no extra message queues, etc.).
119
108
 
120
109
  * LiveView performs change tracking: whenever you change a value on
121
110
  the server, LiveView will send to the client only the values that
@@ -24,12 +24,16 @@ export const PHX_SKIP = "data-phx-skip"
24
24
  export const PHX_PRUNE = "data-phx-prune"
25
25
  export const PHX_PAGE_LOADING = "page-loading"
26
26
  export const PHX_CONNECTED_CLASS = "phx-connected"
27
- export const PHX_DISCONNECTED_CLASS = "phx-loading"
27
+ export const PHX_LOADING_CLASS = "phx-loading"
28
28
  export const PHX_NO_FEEDBACK_CLASS = "phx-no-feedback"
29
29
  export const PHX_ERROR_CLASS = "phx-error"
30
+ export const PHX_CLIENT_ERROR_CLASS = "phx-client-error"
31
+ export const PHX_SERVER_ERROR_CLASS = "phx-server-error"
30
32
  export const PHX_PARENT_ID = "data-phx-parent-id"
31
33
  export const PHX_MAIN = "data-phx-main"
32
34
  export const PHX_ROOT_ID = "data-phx-root-id"
35
+ export const PHX_VIEWPORT_TOP = "viewport-top"
36
+ export const PHX_VIEWPORT_BOTTOM = "viewport-bottom"
33
37
  export const PHX_TRIGGER_ACTION = "trigger-action"
34
38
  export const PHX_FEEDBACK_FOR = "feedback-for"
35
39
  export const PHX_HAS_FOCUSED = "phx-has-focused"
@@ -49,6 +53,7 @@ export const PHX_DEBOUNCE = "debounce"
49
53
  export const PHX_THROTTLE = "throttle"
50
54
  export const PHX_UPDATE = "update"
51
55
  export const PHX_STREAM = "stream"
56
+ export const PHX_STREAM_REF = "data-phx-stream"
52
57
  export const PHX_KEY = "key"
53
58
  export const PHX_PRIVATE = "phxPrivate"
54
59
  export const PHX_AUTO_RECOVER = "auto-recover"
@@ -60,7 +60,9 @@ let DOM = {
60
60
 
61
61
  wantsNewTab(e){
62
62
  let wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1)
63
- return wantsNewTab || e.target.getAttribute("target") === "_blank"
63
+ let isDownload = (e.target instanceof HTMLAnchorElement && e.target.hasAttribute("download"))
64
+ let isTargetBlank = e.target.getAttribute("target") === "_blank"
65
+ return wantsNewTab || isTargetBlank || isDownload
64
66
  },
65
67
 
66
68
  isUnloadableFormSubmit(e){
@@ -68,6 +70,8 @@ let DOM = {
68
70
  },
69
71
 
70
72
  isNewPageHref(href, currentLocation){
73
+ if(href.startsWith("mailto:") || href.startsWith("tel:")){ return false }
74
+
71
75
  let url
72
76
  try {
73
77
  url = new URL(href)
@@ -181,6 +185,7 @@ let DOM = {
181
185
  debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback){
182
186
  let debounce = el.getAttribute(phxDebounce)
183
187
  let throttle = el.getAttribute(phxThrottle)
188
+
184
189
  if(debounce === ""){ debounce = defaultDebounce }
185
190
  if(throttle === ""){ throttle = defaultThrottle }
186
191
  let value = debounce || throttle
@@ -259,14 +264,18 @@ let DOM = {
259
264
  return currentCycle
260
265
  },
261
266
 
262
- discardError(container, el, phxFeedbackFor){
263
- let field = el.getAttribute && el.getAttribute(phxFeedbackFor)
264
- // TODO: Remove id lookup after we update Phoenix to use input_name instead of input_id
265
- let input = field && container.querySelector(`[id="${field}"], [name="${field}"], [name="${field}[]"]`)
266
- if(!input){ return }
267
+ maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom){
268
+ if(el.hasAttribute && (el.hasAttribute(phxViewportTop) || el.hasAttribute(phxViewportBottom))){
269
+ el.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll")
270
+ }
271
+ },
267
272
 
273
+ maybeHideFeedback(container, input, phxFeedbackFor){
268
274
  if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){
269
- el.classList.add(PHX_NO_FEEDBACK_CLASS)
275
+ let feedbacks = [input.name]
276
+ if(input.name.endsWith("[]")){ feedbacks.push(input.name.slice(0, -2)) }
277
+ let selector = feedbacks.map(f => `[${phxFeedbackFor}="${f}"]`).join(", ")
278
+ DOM.all(container, selector, el => el.classList.add(PHX_NO_FEEDBACK_CLASS))
270
279
  }
271
280
  },
272
281
 
@@ -10,6 +10,9 @@ import {
10
10
  PHX_TRIGGER_ACTION,
11
11
  PHX_UPDATE,
12
12
  PHX_STREAM,
13
+ PHX_STREAM_REF,
14
+ PHX_VIEWPORT_TOP,
15
+ PHX_VIEWPORT_BOTTOM,
13
16
  } from "./constants"
14
17
 
15
18
  import {
@@ -83,8 +86,11 @@ export default class DOMPatch {
83
86
  let phxUpdate = liveSocket.binding(PHX_UPDATE)
84
87
  let phxFeedbackFor = liveSocket.binding(PHX_FEEDBACK_FOR)
85
88
  let disableWith = liveSocket.binding(PHX_DISABLE_WITH)
89
+ let phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP)
90
+ let phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM)
86
91
  let phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION)
87
92
  let added = []
93
+ let trackedInputs = []
88
94
  let updates = []
89
95
  let appendPrependUpdates = []
90
96
 
@@ -98,16 +104,18 @@ export default class DOMPatch {
98
104
  this.trackBefore("updated", container, container)
99
105
 
100
106
  liveSocket.time("morphdom", () => {
101
- this.streams.forEach(([inserts, deleteIds]) => {
102
- this.streamInserts = Object.assign(this.streamInserts, inserts)
107
+ this.streams.forEach(([ref, inserts, deleteIds, reset]) => {
108
+ Object.entries(inserts).forEach(([key, [streamAt, limit]]) => {
109
+ this.streamInserts[key] = {ref, streamAt, limit}
110
+ })
111
+ if(reset !== undefined){
112
+ DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, child => {
113
+ this.removeStreamChildElement(child)
114
+ })
115
+ }
103
116
  deleteIds.forEach(id => {
104
117
  let child = container.querySelector(`[id="${id}"]`)
105
- if(child){
106
- if(!this.maybePendingRemove(child)){
107
- child.remove()
108
- this.onNodeDiscarded(child)
109
- }
110
- }
118
+ if(child){ this.removeStreamChildElement(child) }
111
119
  })
112
120
  })
113
121
 
@@ -120,8 +128,10 @@ export default class DOMPatch {
120
128
  skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM },
121
129
  // tell morphdom how to add a child
122
130
  addChild: (parent, child) => {
123
- let streamAt = child.id ? this.streamInserts[child.id] : undefined
124
- if(streamAt === undefined) { return parent.appendChild(child) }
131
+ let {ref, streamAt, limit} = this.getStreamInsert(child)
132
+ if(ref === undefined) { return parent.appendChild(child) }
133
+
134
+ DOM.putSticky(child, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
125
135
 
126
136
  // streaming
127
137
  if(streamAt === 0){
@@ -132,12 +142,21 @@ export default class DOMPatch {
132
142
  let sibling = Array.from(parent.children)[streamAt]
133
143
  parent.insertBefore(child, sibling)
134
144
  }
145
+ let children = limit !== null && Array.from(parent.children)
146
+ if(limit && limit < 0 && children.length > limit * -1){
147
+ children.slice(0, children.length + limit).forEach(child => this.removeStreamChildElement(child))
148
+ } else if(limit && limit >= 0 && children.length > limit){
149
+ children.slice(limit).forEach(child => this.removeStreamChildElement(child))
150
+ }
135
151
  },
136
152
  onBeforeNodeAdded: (el) => {
153
+ DOM.maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom)
137
154
  this.trackBefore("added", el)
138
155
  return el
139
156
  },
140
157
  onNodeAdded: (el) => {
158
+ if(el.getAttribute){ this.maybeReOrderStream(el) }
159
+
141
160
  // hack to fix Safari handling of img srcset and video tags
142
161
  if(el instanceof HTMLImageElement && el.srcset){
143
162
  el.srcset = el.srcset
@@ -147,8 +166,10 @@ export default class DOMPatch {
147
166
  if(DOM.isNowTriggerFormExternal(el, phxTriggerExternal)){
148
167
  externalFormTriggered = el
149
168
  }
150
- //input handling
151
- DOM.discardError(targetContainer, el, phxFeedbackFor)
169
+
170
+ if(el.getAttribute && el.getAttribute("name")){
171
+ trackedInputs.push(el)
172
+ }
152
173
  // nested view handling
153
174
  if((DOM.isPhxChild(el) && view.ownsElement(el)) || DOM.isPhxSticky(el) && view.ownsElement(el.parentNode)){
154
175
  this.trackAfter("phxChildAdded", el)
@@ -159,7 +180,7 @@ export default class DOMPatch {
159
180
  onBeforeNodeDiscarded: (el) => {
160
181
  if(el.getAttribute && el.getAttribute(PHX_PRUNE) !== null){ return true }
161
182
  if(el.parentElement !== null && el.id &&
162
- DOM.isPhxUpdate(el.parentElement, phxUpdate, [PHX_STREAM, "append", "prepend"])){
183
+ DOM.isPhxUpdate(el.parentElement, phxUpdate, [PHX_STREAM, "append", "prepend"])){
163
184
  return false
164
185
  }
165
186
  if(this.maybePendingRemove(el)){ return false }
@@ -207,7 +228,6 @@ export default class DOMPatch {
207
228
 
208
229
  // input handling
209
230
  DOM.copyPrivates(toEl, fromEl)
210
- DOM.discardError(targetContainer, toEl, phxFeedbackFor)
211
231
 
212
232
  let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl)
213
233
  if(isFocusedFormEl && fromEl.type !== "hidden"){
@@ -216,13 +236,19 @@ export default class DOMPatch {
216
236
  DOM.syncAttrsToProps(fromEl)
217
237
  updates.push(fromEl)
218
238
  DOM.applyStickyOperations(fromEl)
239
+ trackedInputs.push(fromEl)
219
240
  return false
220
241
  } else {
221
242
  if(DOM.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])){
222
243
  appendPrependUpdates.push(new DOMPostMorphRestorer(fromEl, toEl, toEl.getAttribute(phxUpdate)))
223
244
  }
245
+
246
+ DOM.maybeAddPrivateHooks(toEl, phxViewportTop, phxViewportBottom)
224
247
  DOM.syncAttrsToProps(toEl)
225
248
  DOM.applyStickyOperations(toEl)
249
+ if(toEl.getAttribute("name")){
250
+ trackedInputs.push(toEl)
251
+ }
226
252
  this.trackBefore("updated", fromEl, toEl)
227
253
  return true
228
254
  }
@@ -238,6 +264,10 @@ export default class DOMPatch {
238
264
  })
239
265
  }
240
266
 
267
+ trackedInputs.forEach(input => {
268
+ DOM.maybeHideFeedback(targetContainer, input, phxFeedbackFor)
269
+ })
270
+
241
271
  liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
242
272
  DOM.dispatchEvent(document, "phx:update")
243
273
  added.forEach(el => this.trackAfter("added", el))
@@ -267,10 +297,25 @@ export default class DOMPatch {
267
297
  }
268
298
  }
269
299
 
300
+ removeStreamChildElement(child){
301
+ if(!this.maybePendingRemove(child)){
302
+ child.remove()
303
+ this.onNodeDiscarded(child)
304
+ }
305
+ }
306
+
307
+ getStreamInsert(el){
308
+ let insert = el.id ? this.streamInserts[el.id] : {}
309
+ return insert || {}
310
+ }
311
+
270
312
  maybeReOrderStream(el){
271
- let streamAt = el.id ? this.streamInserts[el.id] : undefined
313
+ let {ref, streamAt, limit} = this.getStreamInsert(el)
272
314
  if(streamAt === undefined){ return }
273
315
 
316
+ // we need to the PHX_STREAM_REF here as well as addChild is invoked only for parents
317
+ DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
318
+
274
319
  if(streamAt === 0){
275
320
  el.parentElement.insertBefore(el, el.parentElement.firstElementChild)
276
321
  } else if(streamAt > 0){
@@ -57,4 +57,111 @@ let Hooks = {
57
57
  }
58
58
  }
59
59
 
60
+ let scrollTop = () => document.documentElement.scrollTop || document.body.scrollTop
61
+ let winHeight = () => window.innerHeight || document.documentElement.clientHeight
62
+
63
+ let isAtViewportTop = (el) => {
64
+ let rect = el.getBoundingClientRect()
65
+ return rect.top >= 0 && rect.left >= 0 && rect.top <= winHeight()
66
+ }
67
+
68
+ let isAtViewportBottom = (el) => {
69
+ let rect = el.getBoundingClientRect()
70
+ return rect.right >= 0 && rect.left >= 0 && rect.bottom <= winHeight()
71
+ }
72
+
73
+ let isWithinViewport = (el) => {
74
+ let rect = el.getBoundingClientRect()
75
+ return rect.top >= 0 && rect.left >= 0 && rect.top <= winHeight()
76
+ }
77
+
78
+ Hooks.InfiniteScroll = {
79
+ mounted(){
80
+ let scrollBefore = scrollTop()
81
+ let topOverran = false
82
+ let throttleInterval = 500
83
+ let pendingOp = null
84
+
85
+ let onTopOverrun = this.throttle(throttleInterval, (topEvent, firstChild) => {
86
+ pendingOp = () => true
87
+ this.liveSocket.execJSHookPush(this.el, topEvent, {id: firstChild.id, _overran: true}, () => {
88
+ pendingOp = null
89
+ })
90
+ })
91
+
92
+ let onFirstChildAtTop = this.throttle(throttleInterval, (topEvent, firstChild) => {
93
+ pendingOp = () => firstChild.scrollIntoView({block: "start"})
94
+ this.liveSocket.execJSHookPush(this.el, topEvent, {id: firstChild.id}, () => {
95
+ pendingOp = null
96
+ if(!isWithinViewport(firstChild)){ firstChild.scrollIntoView({block: "start"}) }
97
+ })
98
+ })
99
+
100
+ let onLastChildAtBottom = this.throttle(throttleInterval, (bottomEvent, lastChild) => {
101
+ pendingOp = () => lastChild.scrollIntoView({block: "end"})
102
+ this.liveSocket.execJSHookPush(this.el, bottomEvent, {id: lastChild.id}, () => {
103
+ pendingOp = null
104
+ if(!isWithinViewport(lastChild)){ lastChild.scrollIntoView({block: "end"}) }
105
+ })
106
+ })
107
+
108
+ this.onScroll = (e) => {
109
+ let scrollNow = scrollTop()
110
+
111
+ if(pendingOp){
112
+ scrollBefore = scrollNow
113
+ return pendingOp()
114
+ }
115
+ let rect = this.el.getBoundingClientRect()
116
+ let topEvent = this.el.getAttribute(this.liveSocket.binding("viewport-top"))
117
+ let bottomEvent = this.el.getAttribute(this.liveSocket.binding("viewport-bottom"))
118
+ let lastChild = this.el.lastElementChild
119
+ let firstChild = this.el.firstElementChild
120
+ let isScrollingUp = scrollNow < scrollBefore
121
+ let isScrollingDown = scrollNow > scrollBefore
122
+
123
+ // el overran while scrolling up
124
+ if(isScrollingUp && topEvent && !topOverran && rect.top >= 0){
125
+ topOverran = true
126
+ onTopOverrun(topEvent, firstChild)
127
+ } else if(isScrollingDown && topOverran && rect.top <= 0){
128
+ topOverran = false
129
+ }
130
+
131
+ if(topEvent && isScrollingUp && isAtViewportTop(firstChild)){
132
+ onFirstChildAtTop(topEvent, firstChild)
133
+ } else if(bottomEvent && isScrollingDown && isAtViewportBottom(lastChild)){
134
+ onLastChildAtBottom(bottomEvent, lastChild)
135
+ }
136
+ scrollBefore = scrollNow
137
+ }
138
+ window.addEventListener("scroll", this.onScroll)
139
+ },
140
+ destroyed(){ window.removeEventListener("scroll", this.onScroll) },
141
+
142
+ throttle(interval, callback){
143
+ let lastCallAt = 0
144
+ let timer
145
+
146
+ return (...args) => {
147
+ let now = Date.now()
148
+ let remainingTime = interval - (now - lastCallAt)
149
+
150
+ if(remainingTime <= 0 || remainingTime > interval){
151
+ if(timer) {
152
+ clearTimeout(timer)
153
+ timer = null
154
+ }
155
+ lastCallAt = now
156
+ callback(...args)
157
+ } else if(!timer){
158
+ timer = setTimeout(() => {
159
+ lastCallAt = Date.now()
160
+ timer = null
161
+ callback(...args)
162
+ }, remainingTime)
163
+ }
164
+ }
165
+ }
166
+ }
60
167
  export default Hooks
@@ -5,13 +5,16 @@ let focusStack = null
5
5
 
6
6
  let JS = {
7
7
  exec(eventType, phxEvent, view, sourceEl, defaults){
8
- let [defaultKind, defaultArgs] = defaults || [null, {}]
8
+ let [defaultKind, defaultArgs] = defaults || [null, {callback: defaults && defaults.callback}]
9
9
  let commands = phxEvent.charAt(0) === "[" ?
10
10
  JSON.parse(phxEvent) : [[defaultKind, defaultArgs]]
11
11
 
12
+
13
+
12
14
  commands.forEach(([kind, args]) => {
13
15
  if(kind === defaultKind && defaultArgs.data){
14
16
  args.data = Object.assign(args.data || {}, defaultArgs.data)
17
+ args.callback = args.callback || defaultArgs.callback
15
18
  }
16
19
  this.filterToEls(sourceEl, args).forEach(el => {
17
20
  this[`exec_${kind}`](eventType, phxEvent, view, sourceEl, el, args)
@@ -45,21 +48,21 @@ let JS = {
45
48
  exec_push(eventType, phxEvent, view, sourceEl, el, args){
46
49
  if(!view.isConnected()){ return }
47
50
 
48
- let {event, data, target, page_loading, loading, value, dispatcher} = args
51
+ let {event, data, target, page_loading, loading, value, dispatcher, callback} = args
49
52
  let pushOpts = {loading, value, target, page_loading: !!page_loading}
50
53
  let targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl
51
54
  let phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc
52
55
  view.withinTargets(phxTarget, (targetView, targetCtx) => {
53
56
  if(eventType === "change"){
54
- let {newCid, _target, callback} = args
57
+ let {newCid, _target} = args
55
58
  _target = _target || (DOM.isFormInput(sourceEl) ? sourceEl.name : undefined)
56
59
  if(_target){ pushOpts._target = _target }
57
60
  targetView.pushInput(sourceEl, targetCtx, newCid, event || phxEvent, pushOpts, callback)
58
61
  } else if(eventType === "submit"){
59
62
  let {submitter} = args
60
- targetView.submitForm(sourceEl, targetCtx, event || phxEvent, submitter, pushOpts)
63
+ targetView.submitForm(sourceEl, targetCtx, event || phxEvent, submitter, pushOpts, callback)
61
64
  } else {
62
- targetView.pushEvent(eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts)
65
+ targetView.pushEvent(eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts, callback)
63
66
  }
64
67
  })
65
68
  },
@@ -98,6 +98,7 @@ import {
98
98
  PHX_FEEDBACK_FOR,
99
99
  RELOAD_JITTER_MIN,
100
100
  RELOAD_JITTER_MAX,
101
+ PHX_REF,
101
102
  } from "./constants"
102
103
 
103
104
  import {
@@ -239,6 +240,12 @@ export default class LiveSocket {
239
240
 
240
241
  // private
241
242
 
243
+ execJSHookPush(el, phxEvent, data, callback){
244
+ this.withinOwners(el, view => {
245
+ JS.exec("hook", phxEvent, view, el, ["push", {data, callback}])
246
+ })
247
+ }
248
+
242
249
  unload(){
243
250
  if(this.unloaded){ return }
244
251
  if(this.main && this.isConnected()){ this.log(this.main, "socket", () => ["disconnect for page nav"]) }
@@ -411,9 +418,7 @@ export default class LiveSocket {
411
418
  let removeAttr = this.binding("remove")
412
419
  elements = elements || DOM.all(document, `[${removeAttr}]`)
413
420
  elements.forEach(el => {
414
- if(document.body.contains(el)){ // skip children already removed
415
- this.execJS(el, el.getAttribute(removeAttr), "remove")
416
- }
421
+ this.execJS(el, el.getAttribute(removeAttr), "remove")
417
422
  })
418
423
  }
419
424
 
@@ -503,8 +508,6 @@ export default class LiveSocket {
503
508
  this.boundTopLevelEvents = true
504
509
  // enter failsafe reload if server has gone away intentionally, such as "disconnect" broadcast
505
510
  this.socket.onClose(event => {
506
- // unload when navigating href or form submit (such as for firefox)
507
- if(event && event.code === 1001){ return this.unload() }
508
511
  // failsafe reload if normal closure and we still have a main LV
509
512
  if(event && event.code === 1000 && this.main){ return this.reloadWithJitter(this.main) }
510
513
  })
@@ -642,6 +645,8 @@ export default class LiveSocket {
642
645
  return
643
646
  }
644
647
  if(target.getAttribute("href") === "#"){ e.preventDefault() }
648
+ // noop if we are in the middle of awaiting an ack for this el already
649
+ if(target.hasAttribute(PHX_REF)){ return }
645
650
 
646
651
  this.debounce(target, e, "click", () => {
647
652
  this.withinOwners(target, view => {
@@ -172,7 +172,7 @@ export default class Rendered {
172
172
 
173
173
  comprehensionToBuffer(rendered, templates, output){
174
174
  let {[DYNAMICS]: dynamics, [STATIC]: statics, [STREAM]: stream} = rendered
175
- let [_inserts, deleteIds] = stream || [{}, []]
175
+ let [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null]
176
176
  statics = this.templateStatic(statics, templates)
177
177
  let compTemplates = templates || rendered[TEMPLATES]
178
178
  for(let d = 0; d < dynamics.length; d++){
@@ -184,8 +184,8 @@ export default class Rendered {
184
184
  }
185
185
  }
186
186
 
187
- if(stream !== undefined && (rendered[DYNAMICS].length > 0 || deleteIds.length > 0)){
188
- rendered[DYNAMICS] = []
187
+ if(stream !== undefined && (rendered[DYNAMICS].length > 0 || deleteIds.length > 0 || reset)){
188
+ delete rendered[STREAM]
189
189
  output.streams.add(stream)
190
190
  }
191
191
  }