phoenix_live_view 0.18.18 → 0.19.1

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,15 +60,22 @@ 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){
67
69
  return !e.defaultPrevented && !this.wantsNewTab(e)
68
70
  },
69
71
 
70
- isNewPageHref(href, currentLocation){
72
+ isNewPageClick(e, currentLocation){
73
+ let href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null
71
74
  let url
75
+
76
+ if(e.defaultPrevented || href === null || this.wantsNewTab(e)){ return false }
77
+ if(href.startsWith("mailto:") || href.startsWith("tel:")){ return false }
78
+
72
79
  try {
73
80
  url = new URL(href)
74
81
  } catch(e) {
@@ -181,6 +188,7 @@ let DOM = {
181
188
  debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback){
182
189
  let debounce = el.getAttribute(phxDebounce)
183
190
  let throttle = el.getAttribute(phxThrottle)
191
+
184
192
  if(debounce === ""){ debounce = defaultDebounce }
185
193
  if(throttle === ""){ throttle = defaultThrottle }
186
194
  let value = debounce || throttle
@@ -259,14 +267,18 @@ let DOM = {
259
267
  return currentCycle
260
268
  },
261
269
 
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 }
270
+ maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom){
271
+ if(el.hasAttribute && (el.hasAttribute(phxViewportTop) || el.hasAttribute(phxViewportBottom))){
272
+ el.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll")
273
+ }
274
+ },
267
275
 
276
+ maybeHideFeedback(container, input, phxFeedbackFor){
268
277
  if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){
269
- el.classList.add(PHX_NO_FEEDBACK_CLASS)
278
+ let feedbacks = [input.name]
279
+ if(input.name.endsWith("[]")){ feedbacks.push(input.name.slice(0, -2)) }
280
+ let selector = feedbacks.map(f => `[${phxFeedbackFor}="${f}"]`).join(", ")
281
+ DOM.all(container, selector, el => el.classList.add(PHX_NO_FEEDBACK_CLASS))
270
282
  }
271
283
  },
272
284
 
@@ -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,28 @@ 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
+ let childrenToRemove = []
147
+ if(limit && limit < 0 && children.length > limit * -1){
148
+ childrenToRemove = children.slice(0, children.length + limit)
149
+ } else if(limit && limit >= 0 && children.length > limit){
150
+ childrenToRemove = children.slice(limit)
151
+ }
152
+ childrenToRemove.forEach(removeChild => {
153
+ // do not remove child as part of limit if we are re-adding it
154
+ if(!this.streamInserts[removeChild.id]){
155
+ this.removeStreamChildElement(removeChild)
156
+ }
157
+ })
135
158
  },
136
159
  onBeforeNodeAdded: (el) => {
160
+ DOM.maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom)
137
161
  this.trackBefore("added", el)
138
162
  return el
139
163
  },
140
164
  onNodeAdded: (el) => {
165
+ if(el.getAttribute){ this.maybeReOrderStream(el) }
166
+
141
167
  // hack to fix Safari handling of img srcset and video tags
142
168
  if(el instanceof HTMLImageElement && el.srcset){
143
169
  el.srcset = el.srcset
@@ -147,8 +173,10 @@ export default class DOMPatch {
147
173
  if(DOM.isNowTriggerFormExternal(el, phxTriggerExternal)){
148
174
  externalFormTriggered = el
149
175
  }
150
- //input handling
151
- DOM.discardError(targetContainer, el, phxFeedbackFor)
176
+
177
+ if(el.getAttribute && el.getAttribute("name") && DOM.isFormInput(el)){
178
+ trackedInputs.push(el)
179
+ }
152
180
  // nested view handling
153
181
  if((DOM.isPhxChild(el) && view.ownsElement(el)) || DOM.isPhxSticky(el) && view.ownsElement(el.parentNode)){
154
182
  this.trackAfter("phxChildAdded", el)
@@ -159,7 +187,7 @@ export default class DOMPatch {
159
187
  onBeforeNodeDiscarded: (el) => {
160
188
  if(el.getAttribute && el.getAttribute(PHX_PRUNE) !== null){ return true }
161
189
  if(el.parentElement !== null && el.id &&
162
- DOM.isPhxUpdate(el.parentElement, phxUpdate, [PHX_STREAM, "append", "prepend"])){
190
+ DOM.isPhxUpdate(el.parentElement, phxUpdate, [PHX_STREAM, "append", "prepend"])){
163
191
  return false
164
192
  }
165
193
  if(this.maybePendingRemove(el)){ return false }
@@ -207,7 +235,6 @@ export default class DOMPatch {
207
235
 
208
236
  // input handling
209
237
  DOM.copyPrivates(toEl, fromEl)
210
- DOM.discardError(targetContainer, toEl, phxFeedbackFor)
211
238
 
212
239
  let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl)
213
240
  if(isFocusedFormEl && fromEl.type !== "hidden"){
@@ -216,13 +243,19 @@ export default class DOMPatch {
216
243
  DOM.syncAttrsToProps(fromEl)
217
244
  updates.push(fromEl)
218
245
  DOM.applyStickyOperations(fromEl)
246
+ trackedInputs.push(fromEl)
219
247
  return false
220
248
  } else {
221
249
  if(DOM.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])){
222
250
  appendPrependUpdates.push(new DOMPostMorphRestorer(fromEl, toEl, toEl.getAttribute(phxUpdate)))
223
251
  }
252
+
253
+ DOM.maybeAddPrivateHooks(toEl, phxViewportTop, phxViewportBottom)
224
254
  DOM.syncAttrsToProps(toEl)
225
255
  DOM.applyStickyOperations(toEl)
256
+ if(toEl.getAttribute("name") && DOM.isFormInput(toEl)){
257
+ trackedInputs.push(toEl)
258
+ }
226
259
  this.trackBefore("updated", fromEl, toEl)
227
260
  return true
228
261
  }
@@ -238,6 +271,10 @@ export default class DOMPatch {
238
271
  })
239
272
  }
240
273
 
274
+ trackedInputs.forEach(input => {
275
+ DOM.maybeHideFeedback(targetContainer, input, phxFeedbackFor)
276
+ })
277
+
241
278
  liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
242
279
  DOM.dispatchEvent(document, "phx:update")
243
280
  added.forEach(el => this.trackAfter("added", el))
@@ -267,10 +304,25 @@ export default class DOMPatch {
267
304
  }
268
305
  }
269
306
 
307
+ removeStreamChildElement(child){
308
+ if(!this.maybePendingRemove(child)){
309
+ child.remove()
310
+ this.onNodeDiscarded(child)
311
+ }
312
+ }
313
+
314
+ getStreamInsert(el){
315
+ let insert = el.id ? this.streamInserts[el.id] : {}
316
+ return insert || {}
317
+ }
318
+
270
319
  maybeReOrderStream(el){
271
- let streamAt = el.id ? this.streamInserts[el.id] : undefined
320
+ let {ref, streamAt, limit} = this.getStreamInsert(el)
272
321
  if(streamAt === undefined){ return }
273
322
 
323
+ // we need to the PHX_STREAM_REF here as well as addChild is invoked only for parents
324
+ DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
325
+
274
326
  if(streamAt === 0){
275
327
  el.parentElement.insertBefore(el, el.parentElement.firstElementChild)
276
328
  } 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
  })
@@ -635,14 +638,15 @@ export default class LiveSocket {
635
638
  }
636
639
  let phxEvent = target && target.getAttribute(click)
637
640
  if(!phxEvent){
638
- let href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null
639
- if(!capture && href !== null && !DOM.wantsNewTab(e) && DOM.isNewPageHref(href, window.location)){
640
- this.unload()
641
- }
641
+ if(!capture && DOM.isNewPageClick(e, window.location)){ this.unload() }
642
642
  return
643
643
  }
644
+
644
645
  if(target.getAttribute("href") === "#"){ e.preventDefault() }
645
646
 
647
+ // noop if we are in the middle of awaiting an ack for this el already
648
+ if(target.hasAttribute(PHX_REF)){ return }
649
+
646
650
  this.debounce(target, e, "click", () => {
647
651
  this.withinOwners(target, view => {
648
652
  JS.exec("click", phxEvent, view, target, ["push", {data: this.eventMeta("click", e, target)}])
@@ -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
  }