phoenix_live_view 1.0.2 → 1.0.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
@@ -193,19 +193,11 @@ $ npm run test
193
193
 
194
194
  Running the JavaScript unit tests:
195
195
 
196
- ```bash
197
- $ cd assets
198
- $ npm install
199
- $ npm run test
200
- # to automatically run tests for files that have been changed
201
- $ npm run test.watch
202
- ```
203
-
204
- or simply:
205
-
206
196
  ```bash
207
197
  $ npm run setup
208
198
  $ npm run js:test
199
+ # to automatically run tests for files that have been changed
200
+ $ npm run js:test.watch
209
201
  ```
210
202
 
211
203
  Running the JavaScript end-to-end tests:
@@ -10,6 +10,7 @@ import {
10
10
  PHX_PARENT_ID,
11
11
  PHX_PRIVATE,
12
12
  PHX_REF_SRC,
13
+ PHX_REF_LOCK,
13
14
  PHX_PENDING_ATTRS,
14
15
  PHX_ROOT_ID,
15
16
  PHX_SESSION,
@@ -148,7 +149,7 @@ let DOM = {
148
149
  cids.forEach(cid => {
149
150
  this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node).forEach(parent => {
150
151
  parentCids.add(cid)
151
- this.all(parent, `[${PHX_COMPONENT}]`)
152
+ this.filterWithinSameLiveView(this.all(parent, `[${PHX_COMPONENT}]`), parent)
152
153
  .map(el => parseInt(el.getAttribute(PHX_COMPONENT)))
153
154
  .forEach(childCID => childrenCids.add(childCID))
154
155
  })
@@ -545,6 +546,10 @@ let DOM = {
545
546
  if(!ops){ return }
546
547
 
547
548
  ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op))
549
+ },
550
+
551
+ isLocked(el){
552
+ return el.hasAttribute && el.hasAttribute(PHX_REF_LOCK)
548
553
  }
549
554
  }
550
555
 
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  import {
20
20
  detectDuplicateIds,
21
+ detectInvalidStreamInserts,
21
22
  isCid
22
23
  } from "./utils"
23
24
 
@@ -26,40 +27,7 @@ import DOMPostMorphRestorer from "./dom_post_morph_restorer"
26
27
  import morphdom from "morphdom"
27
28
 
28
29
  export default class DOMPatch {
29
- static patchWithClonedTree(container, clonedTree, liveSocket){
30
- let focused = liveSocket.getActiveElement()
31
- let {selectionStart, selectionEnd} = focused && DOM.hasSelectionRange(focused) ? focused : {}
32
- let phxUpdate = liveSocket.binding(PHX_UPDATE)
33
- let externalFormTriggered = null
34
-
35
- morphdom(container, clonedTree, {
36
- childrenOnly: false,
37
- onBeforeElUpdated: (fromEl, toEl) => {
38
- DOM.syncPendingAttrs(fromEl, toEl)
39
- // we cannot morph locked children
40
- if(!container.isSameNode(fromEl) && fromEl.hasAttribute(PHX_REF_LOCK)){ return false }
41
- if(DOM.isIgnored(fromEl, phxUpdate)){ return false }
42
- if(focused && focused.isSameNode(fromEl) && DOM.isFormInput(fromEl)){
43
- DOM.mergeFocusedInput(fromEl, toEl)
44
- return false
45
- }
46
- if(DOM.isNowTriggerFormExternal(toEl, liveSocket.binding(PHX_TRIGGER_ACTION))){
47
- externalFormTriggered = toEl
48
- }
49
- }
50
- })
51
-
52
- if(externalFormTriggered){
53
- liveSocket.unload()
54
- // use prototype's submit in case there's a form control with name or id of "submit"
55
- // https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit
56
- Object.getPrototypeOf(externalFormTriggered).submit.call(externalFormTriggered)
57
- }
58
-
59
- liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
60
- }
61
-
62
- constructor(view, container, id, html, streams, targetCID){
30
+ constructor(view, container, id, html, streams, targetCID, opts={}){
63
31
  this.view = view
64
32
  this.liveSocket = view.liveSocket
65
33
  this.container = container
@@ -79,6 +47,8 @@ export default class DOMPatch {
79
47
  afteradded: [], afterupdated: [], afterdiscarded: [], afterphxChildAdded: [],
80
48
  aftertransitionsDiscarded: []
81
49
  }
50
+ this.withChildren = opts.withChildren || opts.undoRef || false
51
+ this.undoRef = opts.undoRef
82
52
  }
83
53
 
84
54
  before(kind, callback){ this.callbacks[`before${kind}`].push(callback) }
@@ -115,7 +85,7 @@ export default class DOMPatch {
115
85
 
116
86
  let externalFormTriggered = null
117
87
 
118
- function morph(targetContainer, source, withChildren=false){
88
+ function morph(targetContainer, source, withChildren=this.withChildren){
119
89
  let morphCallbacks = {
120
90
  // normally, we are running with childrenOnly, as the patch HTML for a LV
121
91
  // does not include the LV attrs (data-phx-session, etc.)
@@ -247,7 +217,8 @@ export default class DOMPatch {
247
217
  // apply any changes that happened while the element was locked.
248
218
  let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl)
249
219
  let focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl)
250
- if(fromEl.hasAttribute(PHX_REF_SRC)){
220
+ // only perform the clone step if this is not a patch that unlocks
221
+ if(fromEl.hasAttribute(PHX_REF_SRC) && fromEl.getAttribute(PHX_REF_LOCK) != this.undoRef){
251
222
  if(DOM.isUploadInput(fromEl)){
252
223
  DOM.mergeAttrs(fromEl, toEl, {isIgnored: true})
253
224
  this.trackBefore("updated", fromEl, toEl)
@@ -341,6 +312,7 @@ export default class DOMPatch {
341
312
 
342
313
  if(liveSocket.isDebugEnabled()){
343
314
  detectDuplicateIds()
315
+ detectInvalidStreamInserts(this.streamInserts)
344
316
  // warn if there are any inputs named "id"
345
317
  Array.from(document.querySelectorAll("input[name=id]")).forEach(node => {
346
318
  if(node.form){
@@ -11,6 +11,15 @@ import {
11
11
  import DOM from "./dom"
12
12
 
13
13
  export default class ElementRef {
14
+ static onUnlock(el, callback){
15
+ if(!DOM.isLocked(el) && !el.closest(`[${PHX_REF_LOCK}]`)){ return callback() }
16
+ const closestLock = el.closest(`[${PHX_REF_LOCK}]`)
17
+ const ref = closestLock.closest(`[${PHX_REF_LOCK}]`).getAttribute(PHX_REF_LOCK)
18
+ closestLock.addEventListener(`phx:undo-lock:${ref}`, () => {
19
+ callback()
20
+ }, {once: true})
21
+ }
22
+
14
23
  constructor(el){
15
24
  this.el = el
16
25
  this.loadingRef = el.hasAttribute(PHX_REF_LOADING) ? parseInt(el.getAttribute(PHX_REF_LOADING), 10) : null
@@ -47,8 +47,26 @@ let Hooks = {
47
47
  mounted(){
48
48
  this.focusStart = this.el.firstElementChild
49
49
  this.focusEnd = this.el.lastElementChild
50
- this.focusStart.addEventListener("focus", () => ARIA.focusLast(this.el))
51
- this.focusEnd.addEventListener("focus", () => ARIA.focusFirst(this.el))
50
+ this.focusStart.addEventListener("focus", (e) => {
51
+ if(!e.relatedTarget || !this.el.contains(e.relatedTarget)){
52
+ // Handle focus entering from outside (e.g. Tab when body is focused)
53
+ // https://github.com/phoenixframework/phoenix_live_view/issues/3636
54
+ const nextFocus = e.target.nextElementSibling
55
+ ARIA.attemptFocus(nextFocus) || ARIA.focusFirst(nextFocus)
56
+ } else {
57
+ ARIA.focusLast(this.el)
58
+ }
59
+ })
60
+ this.focusEnd.addEventListener("focus", (e) => {
61
+ if(!e.relatedTarget || !this.el.contains(e.relatedTarget)){
62
+ // Handle focus entering from outside (e.g. Shift+Tab when body is focused)
63
+ // https://github.com/phoenixframework/phoenix_live_view/issues/3636
64
+ const nextFocus = e.target.previousElementSibling
65
+ ARIA.attemptFocus(nextFocus) || ARIA.focusLast(nextFocus)
66
+ } else {
67
+ ARIA.focusFirst(this.el)
68
+ }
69
+ })
52
70
  this.el.addEventListener("phx:show-end", () => this.el.focus())
53
71
  if(window.getComputedStyle(this.el).display !== "none"){
54
72
  ARIA.focusFirst(this.el)
@@ -375,7 +375,9 @@ export default class LiveSocket {
375
375
  DOM.all(document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, rootEl => {
376
376
  if(!this.getRootById(rootEl.id)){
377
377
  let view = this.newRootView(rootEl)
378
- view.setHref(this.getHref())
378
+ // stickies cannot be mounted at the router and therefore should not
379
+ // get a href set on them
380
+ if(!DOM.isPhxSticky(rootEl)){ view.setHref(this.getHref()) }
379
381
  view.join()
380
382
  if(rootEl.hasAttribute(PHX_MAIN)){ this.main = view }
381
383
  }
@@ -693,7 +695,7 @@ export default class LiveSocket {
693
695
  })
694
696
  window.addEventListener("popstate", event => {
695
697
  if(!this.registerNewLocation(window.location)){ return }
696
- let {type, backType, id, root, scroll, position} = event.state || {}
698
+ let {type, backType, id, scroll, position} = event.state || {}
697
699
  let href = window.location.href
698
700
 
699
701
  // Compare positions to determine direction
@@ -707,15 +709,11 @@ export default class LiveSocket {
707
709
 
708
710
  DOM.dispatchEvent(window, "phx:navigate", {detail: {href, patch: type === "patch", pop: true, direction: isForward ? "forward" : "backward"}})
709
711
  this.requestDOMUpdate(() => {
712
+ const callback = () => { this.maybeScroll(scroll) }
710
713
  if(this.main.isConnected() && (type === "patch" && id === this.main.id)){
711
- this.main.pushLinkPatch(event, href, null, () => {
712
- this.maybeScroll(scroll)
713
- })
714
+ this.main.pushLinkPatch(event, href, null, callback)
714
715
  } else {
715
- this.replaceMain(href, null, () => {
716
- if(root){ this.replaceRootHistory() }
717
- this.maybeScroll(scroll)
718
- })
716
+ this.replaceMain(href, null, callback)
719
717
  }
720
718
  })
721
719
  }, false)
@@ -836,15 +834,6 @@ export default class LiveSocket {
836
834
  })
837
835
  }
838
836
 
839
- replaceRootHistory(){
840
- Browser.pushState("replace", {
841
- root: true,
842
- type: "patch",
843
- id: this.main.id,
844
- position: this.currentHistoryPosition // Preserve current position
845
- })
846
- }
847
-
848
837
  registerNewLocation(newLocation){
849
838
  let {pathname, search} = this.currentLocation
850
839
  if(pathname + search === newLocation.pathname + newLocation.search){
@@ -23,6 +23,17 @@ export function detectDuplicateIds(){
23
23
  }
24
24
  }
25
25
 
26
+ export function detectInvalidStreamInserts(inserts){
27
+ const errors = new Set()
28
+ Object.keys(inserts).forEach((id) => {
29
+ const streamEl = document.getElementById(id)
30
+ if(streamEl && streamEl.parentElement && streamEl.parentElement.getAttribute("phx-update") !== "stream"){
31
+ errors.add(`The stream container with id "${streamEl.parentElement.id}" is missing the phx-update="stream" attribute. Ensure it is set for streams to work properly.`)
32
+ }
33
+ })
34
+ errors.forEach(error => console.error(error))
35
+ }
36
+
26
37
  export let debug = (view, kind, msg, obj) => {
27
38
  if(view.liveSocket.isDebugEnabled()){
28
39
  console.log(`${view.id} ${kind}: ${msg} - `, obj)
@@ -318,8 +318,12 @@ export default class View {
318
318
  this.formsForRecovery = this.getFormsForRecovery()
319
319
  }
320
320
  if(this.isMain() && window.history.state === null){
321
- // set initial history entry if this is the first page load
322
- this.liveSocket.replaceRootHistory()
321
+ // set initial history entry if this is the first page load (no history)
322
+ Browser.pushState("replace", {
323
+ type: "patch",
324
+ id: this.id,
325
+ position: this.liveSocket.currentHistoryPosition
326
+ })
323
327
  }
324
328
 
325
329
  if(liveview_version !== this.liveSocket.version()){
@@ -697,6 +701,9 @@ export default class View {
697
701
  addHook(el){
698
702
  let hookElId = ViewHook.elementID(el)
699
703
 
704
+ // only ever try to add hooks to elements owned by this view
705
+ if(el.getAttribute && !this.ownsElement(el)){ return }
706
+
700
707
  if(hookElId && !this.viewHooks[hookElId]){
701
708
  // hook created, but not attached (createHook for web component)
702
709
  let hook = DOM.getCustomElHook(el) || logError(`no hook found for custom element: ${el.id}`)
@@ -710,7 +717,6 @@ export default class View {
710
717
  } else {
711
718
  // new hook found with phx-hook attribute
712
719
  let hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK))
713
- if(hookName && !this.ownsElement(el)){ return }
714
720
  let callbacks = this.liveSocket.getHookCallbacks(hookName)
715
721
 
716
722
  if(callbacks){
@@ -725,9 +731,12 @@ export default class View {
725
731
  }
726
732
 
727
733
  destroyHook(hook){
734
+ // __destroyed clears the elementID from the hook, therefore
735
+ // we need to get it before calling __destroyed
736
+ const hookId = ViewHook.elementID(hook.el)
728
737
  hook.__destroyed()
729
738
  hook.__cleanup__()
730
- delete this.viewHooks[ViewHook.elementID(hook.el)]
739
+ delete this.viewHooks[hookId]
731
740
  }
732
741
 
733
742
  applyPendingUpdates(){
@@ -971,11 +980,12 @@ export default class View {
971
980
  let elRef = new ElementRef(el)
972
981
 
973
982
  elRef.maybeUndo(ref, phxEvent, clonedTree => {
974
- let hook = this.triggerBeforeUpdateHook(el, clonedTree)
975
- DOMPatch.patchWithClonedTree(el, clonedTree, this.liveSocket)
983
+ // we need to perform a full patch on unlocked elements
984
+ // to perform all the necessary logic (like calling updated for hooks, etc.)
985
+ let patch = new DOMPatch(this, el, this.id, clonedTree, [], null, {undoRef: ref})
986
+ const phxChildrenAdded = this.performPatch(patch, true)
976
987
  DOM.all(el, `[${PHX_REF_SRC}="${this.refSrc()}"]`, child => this.undoElRef(child, ref, phxEvent))
977
- this.execNewMounted(el)
978
- if(hook){ hook.__updated() }
988
+ if(phxChildrenAdded){ this.joinNewChildren() }
979
989
  })
980
990
  }
981
991
 
@@ -1182,15 +1192,20 @@ export default class View {
1182
1192
  }
1183
1193
  this.pushWithReply(refGenerator, "event", event).then(({resp}) => {
1184
1194
  if(DOM.isUploadInput(inputEl) && DOM.isAutoUpload(inputEl)){
1185
- if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){
1186
- let [ref, _els] = refGenerator()
1187
- this.undoRefs(ref, phxEvent, [inputEl.form])
1188
- this.uploadFiles(inputEl.form, phxEvent, targetCtx, ref, cid, (_uploads) => {
1189
- callback && callback(resp)
1190
- this.triggerAwaitingSubmit(inputEl.form, phxEvent)
1191
- this.undoRefs(ref, phxEvent)
1192
- })
1193
- }
1195
+ // the element could be inside a locked parent for other unrelated changes;
1196
+ // we can only start uploads when the tree is unlocked and the
1197
+ // necessary data attributes are set in the real DOM
1198
+ ElementRef.onUnlock(inputEl, () => {
1199
+ if(LiveUploader.filesAwaitingPreflight(inputEl).length > 0){
1200
+ let [ref, _els] = refGenerator()
1201
+ this.undoRefs(ref, phxEvent, [inputEl.form])
1202
+ this.uploadFiles(inputEl.form, phxEvent, targetCtx, ref, cid, (_uploads) => {
1203
+ callback && callback(resp)
1204
+ this.triggerAwaitingSubmit(inputEl.form, phxEvent)
1205
+ this.undoRefs(ref, phxEvent)
1206
+ })
1207
+ }
1208
+ })
1194
1209
  } else {
1195
1210
  callback && callback(resp)
1196
1211
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_live_view",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "The Phoenix LiveView JavaScript client.",
5
5
  "license": "MIT",
6
6
  "module": "./priv/static/phoenix_live_view.esm.js",
@@ -23,17 +23,35 @@
23
23
  "priv/static/*",
24
24
  "assets/js/phoenix_live_view/*"
25
25
  ],
26
+ "dependencies": {
27
+ "morphdom": "2.7.4"
28
+ },
26
29
  "devDependencies": {
27
- "@eslint/js": "^9.10.0",
28
- "@playwright/test": "^1.47.1",
30
+ "@babel/cli": "7.26.4",
31
+ "@babel/core": "7.26.0",
32
+ "@babel/preset-env": "7.26.0",
33
+ "@eslint/js": "^9.18.0",
34
+ "@playwright/test": "^1.49.1",
35
+ "@stylistic/eslint-plugin-js": "^2.12.1",
36
+ "css.escape": "^1.5.1",
37
+ "eslint": "9.18.0",
38
+ "eslint-plugin-jest": "28.10.0",
29
39
  "eslint-plugin-playwright": "^2.1.0",
30
- "monocart-reporter": "^2.8.0"
40
+ "globals": "^15.14.0",
41
+ "jest": "^29.7.0",
42
+ "jest-environment-jsdom": "^29.7.0",
43
+ "jest-monocart-coverage": "^1.1.1",
44
+ "monocart-reporter": "^2.9.13",
45
+ "phoenix": "1.7.18"
31
46
  },
32
47
  "scripts": {
33
- "setup": "mix deps.get && npm install && cd assets && npm install",
48
+ "setup": "mix deps.get && npm install",
34
49
  "e2e:server": "MIX_ENV=e2e mix test --cover --export-coverage e2e test/e2e/test_helper.exs",
35
50
  "e2e:test": "mix assets.build && cd test/e2e && npx playwright install && npx playwright test",
36
- "js:test": "cd assets && npm install && npm run test",
51
+ "js:test": "jest",
52
+ "js:test.coverage": "jest --coverage",
53
+ "js:test.watch": "jest --watch",
54
+ "js:lint": "eslint --fix && cd assets && eslint --fix",
37
55
  "test": "npm run js:test && npm run e2e:test",
38
56
  "cover:merge": "node test/e2e/merge-coverage.mjs",
39
57
  "cover": "npm run test && npm run cover:merge",