phoenix_live_view 1.0.4 → 1.0.6

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.
@@ -67,6 +67,7 @@ export const PHX_RELOAD_STATUS = "__phoenix_reload_status__"
67
67
  export const LOADER_TIMEOUT = 1
68
68
  export const MAX_CHILD_JOIN_ATTEMPTS = 3
69
69
  export const BEFORE_UNLOAD_LOADER_TIMEOUT = 200
70
+ export const DISCONNECTED_TIMEOUT = 500
70
71
  export const BINDING_PREFIX = "phx-"
71
72
  export const PUSH_TIMEOUT = 30000
72
73
  export const LINK_HEADER = "x-requested-with"
@@ -234,10 +234,11 @@ let DOM = {
234
234
  case null: return callback()
235
235
 
236
236
  case "blur":
237
+ this.incCycle(el, "debounce-blur-cycle", () => {
238
+ if(asyncFilter()){ callback() }
239
+ })
237
240
  if(this.once(el, "debounce-blur")){
238
- el.addEventListener("blur", () => {
239
- if(asyncFilter()){ callback() }
240
- })
241
+ el.addEventListener("blur", () => this.triggerCycle(el, "debounce-blur-cycle"))
241
242
  }
242
243
  return
243
244
 
@@ -245,7 +245,11 @@ export default class DOMPatch {
245
245
  return false
246
246
  }
247
247
 
248
- // input handling
248
+ // if we are undoing a lock, copy potentially nested clones over
249
+ if(this.undoRef && DOM.private(toEl, PHX_REF_LOCK)){
250
+ DOM.putPrivate(fromEl, PHX_REF_LOCK, DOM.private(toEl, PHX_REF_LOCK))
251
+ }
252
+ // now copy regular DOM.private data
249
253
  DOM.copyPrivates(toEl, fromEl)
250
254
 
251
255
  // skip patching focused inputs unless focus is a select that has changed options
@@ -295,14 +299,8 @@ export default class DOMPatch {
295
299
  // clear stream items from the dead render if they are not inserted again
296
300
  if(isJoinPatch){
297
301
  DOM.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`, el => {
298
- // make sure to only remove elements owned by the current view
299
- // see https://github.com/phoenixframework/phoenix_live_view/issues/3047
300
- this.liveSocket.owner(el, (view) => {
301
- if(view === this.view){
302
- Array.from(el.children).forEach(child => {
303
- this.removeStreamChildElement(child)
304
- })
305
- }
302
+ Array.from(el.children).forEach(child => {
303
+ this.removeStreamChildElement(child)
306
304
  })
307
305
  })
308
306
  }
@@ -359,6 +357,11 @@ export default class DOMPatch {
359
357
  }
360
358
 
361
359
  removeStreamChildElement(child){
360
+ // make sure to only remove elements owned by the current view
361
+ // see https://github.com/phoenixframework/phoenix_live_view/issues/3047
362
+ // and https://github.com/phoenixframework/phoenix_live_view/issues/3681
363
+ if(!this.view.ownsElement(child)){ return }
364
+
362
365
  // we need to store the node if it is actually re-added in the same patch
363
366
  // we do NOT want to execute phx-remove, we do NOT want to call onNodeDiscarded
364
367
  if(this.streamInserts[child.id]){
@@ -67,12 +67,9 @@ let Hooks = {
67
67
  ARIA.focusFirst(this.el)
68
68
  }
69
69
  })
70
- // only try to change the focus if it is not already inside
71
- if(!this.el.contains(document.activeElement)){
72
- this.el.addEventListener("phx:show-end", () => this.el.focus())
73
- if(window.getComputedStyle(this.el).display !== "none"){
74
- ARIA.focusFirst(this.el)
75
- }
70
+ this.el.addEventListener("phx:show-end", () => this.el.focus())
71
+ if(window.getComputedStyle(this.el).display !== "none"){
72
+ ARIA.focusFirst(this.el)
76
73
  }
77
74
  }
78
75
  }
@@ -46,12 +46,9 @@ let JS = {
46
46
  // commands
47
47
 
48
48
  exec_exec(e, eventType, phxEvent, view, sourceEl, el, {attr, to}){
49
- let nodes = to ? DOM.all(document, to) : [sourceEl]
50
- nodes.forEach(node => {
51
- let encodedJS = node.getAttribute(attr)
52
- if(!encodedJS){ throw new Error(`expected ${attr} to contain JS command on "${to}"`) }
53
- view.liveSocket.execJS(node, encodedJS, eventType)
54
- })
49
+ let encodedJS = el.getAttribute(attr)
50
+ if(!encodedJS){ throw new Error(`expected ${attr} to contain JS command on "${to}"`) }
51
+ view.liveSocket.execJS(el, encodedJS, eventType)
55
52
  },
56
53
 
57
54
  exec_dispatch(e, eventType, phxEvent, view, sourceEl, el, {event, detail, bubbles}){
@@ -98,21 +95,35 @@ let JS = {
98
95
 
99
96
  exec_focus(e, eventType, phxEvent, view, sourceEl, el){
100
97
  ARIA.attemptFocus(el)
98
+ // in case the JS.focus command is in a JS.show/hide/toggle chain, for show we need
99
+ // to wait for JS.show to have updated the element's display property (see exec_toggle)
100
+ // but that run in nested animation frames, therefore we need to use them here as well
101
+ window.requestAnimationFrame(() => {
102
+ window.requestAnimationFrame(() => ARIA.attemptFocus(el))
103
+ })
101
104
  },
102
105
 
103
106
  exec_focus_first(e, eventType, phxEvent, view, sourceEl, el){
104
107
  ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el)
108
+ // if you wonder about the nested animation frames, see exec_focus
109
+ window.requestAnimationFrame(() => {
110
+ window.requestAnimationFrame(() => ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el))
111
+ })
105
112
  },
106
113
 
107
114
  exec_push_focus(e, eventType, phxEvent, view, sourceEl, el){
108
- window.requestAnimationFrame(() => focusStack.push(el || sourceEl))
115
+ focusStack.push(el || sourceEl)
109
116
  },
110
117
 
111
118
  exec_pop_focus(_e, _eventType, _phxEvent, _view, _sourceEl, _el){
112
- window.requestAnimationFrame(() => {
113
- const el = focusStack.pop()
114
- if(el){ el.focus() }
115
- })
119
+ const el = focusStack.pop()
120
+ if(el){
121
+ el.focus()
122
+ // if you wonder about the nested animation frames, see exec_focus
123
+ window.requestAnimationFrame(() => {
124
+ window.requestAnimationFrame(() => el.focus())
125
+ })
126
+ }
116
127
  },
117
128
 
118
129
  exec_add_class(e, eventType, phxEvent, view, sourceEl, el, {names, transition, time, blocking}){
@@ -198,11 +209,19 @@ let JS = {
198
209
  if(eventType === "remove"){ return }
199
210
  let onStart = () => {
200
211
  this.addOrRemoveClasses(el, inStartClasses, outClasses.concat(outStartClasses).concat(outEndClasses))
201
- let stickyDisplay = display || this.defaultDisplay(el)
202
- DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay)
212
+ const stickyDisplay = display || this.defaultDisplay(el)
203
213
  window.requestAnimationFrame(() => {
214
+ // first add the starting + active class, THEN make the element visible
215
+ // otherwise if we toggled the visibility earlier css animations
216
+ // would flicker, as the element becomes visible before the active animation
217
+ // class is set (see https://github.com/phoenixframework/phoenix_live_view/issues/3456)
204
218
  this.addOrRemoveClasses(el, inClasses, [])
205
- window.requestAnimationFrame(() => this.addOrRemoveClasses(el, inEndClasses, inStartClasses))
219
+ // addOrRemoveClasses uses a requestAnimationFrame itself, therefore we need to move the putSticky
220
+ // into the next requestAnimationFrame...
221
+ window.requestAnimationFrame(() => {
222
+ DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay)
223
+ this.addOrRemoveClasses(el, inEndClasses, inStartClasses)
224
+ })
206
225
  })
207
226
  }
208
227
  let onEnd = () => {
@@ -219,14 +238,18 @@ let JS = {
219
238
  }
220
239
  } else {
221
240
  if(this.isVisible(el)){
222
- el.dispatchEvent(new Event("phx:hide-start"))
223
- DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = "none")
224
- el.dispatchEvent(new Event("phx:hide-end"))
241
+ window.requestAnimationFrame(() => {
242
+ el.dispatchEvent(new Event("phx:hide-start"))
243
+ DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = "none")
244
+ el.dispatchEvent(new Event("phx:hide-end"))
245
+ })
225
246
  } else {
226
- el.dispatchEvent(new Event("phx:show-start"))
227
- let stickyDisplay = display || this.defaultDisplay(el)
228
- DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay)
229
- el.dispatchEvent(new Event("phx:show-end"))
247
+ window.requestAnimationFrame(() => {
248
+ el.dispatchEvent(new Event("phx:show-start"))
249
+ let stickyDisplay = display || this.defaultDisplay(el)
250
+ DOM.putSticky(el, "toggle", currentEl => currentEl.style.display = stickyDisplay)
251
+ el.dispatchEvent(new Event("phx:show-end"))
252
+ })
230
253
  }
231
254
  }
232
255
  },
@@ -28,6 +28,8 @@
28
28
  * @param {Object} [opts.uploaders] - The optional object for referencing LiveView uploader callbacks.
29
29
  * @param {integer} [opts.loaderTimeout] - The optional delay in milliseconds to wait before apply
30
30
  * loading states.
31
+ * @param {integer} [opts.disconnectedTimeout] - The delay in milliseconds to wait before
32
+ * executing phx-disconnected commands. Defaults to 500.
31
33
  * @param {integer} [opts.maxReloads] - The maximum reloads before entering failsafe mode.
32
34
  * @param {integer} [opts.reloadJitterMin] - The minimum time between normal reload attempts.
33
35
  * @param {integer} [opts.reloadJitterMax] - The maximum time between normal reload attempts.
@@ -78,6 +80,7 @@ import {
78
80
  DEFAULTS,
79
81
  FAILSAFE_JITTER,
80
82
  LOADER_TIMEOUT,
83
+ DISCONNECTED_TIMEOUT,
81
84
  MAX_RELOADS,
82
85
  PHX_DEBOUNCE,
83
86
  PHX_DROP_TARGET,
@@ -152,6 +155,7 @@ export default class LiveSocket {
152
155
  this.hooks = opts.hooks || {}
153
156
  this.uploaders = opts.uploaders || {}
154
157
  this.loaderTimeout = opts.loaderTimeout || LOADER_TIMEOUT
158
+ this.disconnectedTimeout = opts.disconnectedTimeout || DISCONNECTED_TIMEOUT
155
159
  this.reloadWithJitterTimer = null
156
160
  this.maxReloads = opts.maxReloads || MAX_RELOADS
157
161
  this.reloadJitterMin = opts.reloadJitterMin || RELOAD_JITTER_MIN
@@ -67,8 +67,8 @@ export let prependFormDataKey = (key, prefix) => {
67
67
  return baseKey
68
68
  }
69
69
 
70
- let serializeForm = (form, metadata, onlyNames = []) => {
71
- const {submitter, ...meta} = metadata
70
+ let serializeForm = (form, opts, onlyNames = []) => {
71
+ const {submitter} = opts
72
72
 
73
73
  // We must inject the submitter in the order that it exists in the DOM
74
74
  // relative to other inputs. For example, for checkbox groups, the order must be maintained.
@@ -100,12 +100,26 @@ let serializeForm = (form, metadata, onlyNames = []) => {
100
100
 
101
101
  const params = new URLSearchParams()
102
102
 
103
- let elements = Array.from(form.elements)
103
+ const {inputsUnused, onlyHiddenInputs} = Array.from(form.elements).reduce((acc, input) => {
104
+ const {inputsUnused, onlyHiddenInputs} = acc
105
+ const key = input.name
106
+ if(!key){ return acc }
107
+
108
+ if(inputsUnused[key] === undefined){ inputsUnused[key] = true }
109
+ if(onlyHiddenInputs[key] === undefined){ onlyHiddenInputs[key] = true }
110
+
111
+ const isUsed = DOM.private(input, PHX_HAS_FOCUSED) || DOM.private(input, PHX_HAS_SUBMITTED)
112
+ const isHidden = input.type === "hidden"
113
+ inputsUnused[key] = inputsUnused[key] && !isUsed
114
+ onlyHiddenInputs[key] = onlyHiddenInputs[key] && isHidden
115
+
116
+ return acc
117
+ }, {inputsUnused: {}, onlyHiddenInputs: {}})
118
+
104
119
  for(let [key, val] of formData.entries()){
105
120
  if(onlyNames.length === 0 || onlyNames.indexOf(key) >= 0){
106
- let inputs = elements.filter(input => input.name === key)
107
- let isUnused = !inputs.some(input => (DOM.private(input, PHX_HAS_FOCUSED) || DOM.private(input, PHX_HAS_SUBMITTED)))
108
- let hidden = inputs.every(input => input.type === "hidden")
121
+ let isUnused = inputsUnused[key]
122
+ let hidden = onlyHiddenInputs[key]
109
123
  if(isUnused && !(submitter && submitter.name == key) && !hidden){
110
124
  params.append(prependFormDataKey(key, "_unused_"), "")
111
125
  }
@@ -119,8 +133,6 @@ let serializeForm = (form, metadata, onlyNames = []) => {
119
133
  submitter.parentElement.removeChild(injectedElement)
120
134
  }
121
135
 
122
- for(let metaKey in meta){ params.append(metaKey, meta[metaKey]) }
123
-
124
136
  return params.toString()
125
137
  }
126
138
 
@@ -143,6 +155,7 @@ export default class View {
143
155
  this.lastAckRef = null
144
156
  this.childJoins = 0
145
157
  this.loaderTimer = null
158
+ this.disconnectedTimer = null
146
159
  this.pendingDiffs = []
147
160
  this.pendingForms = new Set()
148
161
  this.redirect = false
@@ -254,6 +267,7 @@ export default class View {
254
267
 
255
268
  hideLoader(){
256
269
  clearTimeout(this.loaderTimer)
270
+ clearTimeout(this.disconnectedTimer)
257
271
  this.setContainerClasses(PHX_CONNECTED_CLASS)
258
272
  this.execAll(this.binding("connected"))
259
273
  }
@@ -740,6 +754,12 @@ export default class View {
740
754
  }
741
755
 
742
756
  applyPendingUpdates(){
757
+ // prevent race conditions where we might still be pending a new
758
+ // navigation after applying the current one;
759
+ // if we call update and a pendingDiff is not applied, it would
760
+ // be silently dropped otherwise, as update would push it back to
761
+ // pendingDiffs, but we clear it immediately after
762
+ if(this.liveSocket.hasPendingLink() && this.root.isMain()){ return }
743
763
  this.pendingDiffs.forEach(({diff, events}) => this.update(diff, events))
744
764
  this.pendingDiffs = []
745
765
  this.eachChild(child => child.applyPendingUpdates())
@@ -891,7 +911,13 @@ export default class View {
891
911
  if(this.isMain()){ DOM.dispatchEvent(window, "phx:page-loading-start", {detail: {to: this.href, kind: "error"}}) }
892
912
  this.showLoader()
893
913
  this.setContainerClasses(...classes)
894
- this.execAll(this.binding("disconnected"))
914
+ this.delayedDisconnected()
915
+ }
916
+
917
+ delayedDisconnected(){
918
+ this.disconnectedTimer = setTimeout(() => {
919
+ this.execAll(this.binding("disconnected"))
920
+ }, this.liveSocket.disconnectedTimeout)
895
921
  }
896
922
 
897
923
  wrapPush(callerPush, receives){
@@ -1171,12 +1197,13 @@ export default class View {
1171
1197
  ], phxEvent, "change", opts)
1172
1198
  }
1173
1199
  let formData
1174
- let meta = this.extractMeta(inputEl.form)
1175
- if(inputEl instanceof HTMLButtonElement){ meta.submitter = inputEl }
1200
+ let meta = this.extractMeta(inputEl.form, {}, opts.value)
1201
+ let serializeOpts = {}
1202
+ if(inputEl instanceof HTMLButtonElement){ serializeOpts.submitter = inputEl }
1176
1203
  if(inputEl.getAttribute(this.binding("change"))){
1177
- formData = serializeForm(inputEl.form, {_target: opts._target, ...meta}, [inputEl.name])
1204
+ formData = serializeForm(inputEl.form, serializeOpts, [inputEl.name])
1178
1205
  } else {
1179
- formData = serializeForm(inputEl.form, {_target: opts._target, ...meta})
1206
+ formData = serializeForm(inputEl.form, serializeOpts)
1180
1207
  }
1181
1208
  if(DOM.isUploadInput(inputEl) && inputEl.files && inputEl.files.length > 0){
1182
1209
  LiveUploader.trackFiles(inputEl, Array.from(inputEl.files))
@@ -1187,6 +1214,7 @@ export default class View {
1187
1214
  type: "form",
1188
1215
  event: phxEvent,
1189
1216
  value: formData,
1217
+ meta: {_target: opts._target, ...meta},
1190
1218
  uploads: uploads,
1191
1219
  cid: cid
1192
1220
  }
@@ -1300,22 +1328,24 @@ export default class View {
1300
1328
  if(LiveUploader.inputsAwaitingPreflight(formEl).length > 0){
1301
1329
  return this.undoRefs(ref, phxEvent)
1302
1330
  }
1303
- let meta = this.extractMeta(formEl)
1304
- let formData = serializeForm(formEl, {submitter, ...meta})
1331
+ let meta = this.extractMeta(formEl, {}, opts.value)
1332
+ let formData = serializeForm(formEl, {submitter})
1305
1333
  this.pushWithReply(proxyRefGen, "event", {
1306
1334
  type: "form",
1307
1335
  event: phxEvent,
1308
1336
  value: formData,
1337
+ meta: meta,
1309
1338
  cid: cid
1310
1339
  }).then(({resp}) => onReply(resp))
1311
1340
  })
1312
1341
  } else if(!(formEl.hasAttribute(PHX_REF_SRC) && formEl.classList.contains("phx-submit-loading"))){
1313
- let meta = this.extractMeta(formEl)
1314
- let formData = serializeForm(formEl, {submitter, ...meta})
1342
+ let meta = this.extractMeta(formEl, {}, opts.value)
1343
+ let formData = serializeForm(formEl, {submitter})
1315
1344
  this.pushWithReply(refGenerator, "event", {
1316
1345
  type: "form",
1317
1346
  event: phxEvent,
1318
1347
  value: formData,
1348
+ meta: meta,
1319
1349
  cid: cid
1320
1350
  }).then(({resp}) => onReply(resp))
1321
1351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_live_view",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "The Phoenix LiveView JavaScript client.",
5
5
  "license": "MIT",
6
6
  "module": "./priv/static/phoenix_live_view.esm.js",