phoenix_live_view 1.2.0-rc.2 → 1.2.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.
Files changed (27) hide show
  1. package/README.md +5 -5
  2. package/assets/js/phoenix_live_view/README.md +3 -0
  3. package/assets/js/phoenix_live_view/{aria.js → aria.ts} +18 -10
  4. package/assets/js/phoenix_live_view/{browser.js → browser.ts} +12 -8
  5. package/assets/js/phoenix_live_view/{dom.js → dom.ts} +107 -34
  6. package/assets/js/phoenix_live_view/{dom_patch.js → dom_patch.ts} +187 -124
  7. package/assets/js/phoenix_live_view/{dom_post_morph_restorer.js → dom_post_morph_restorer.ts} +17 -2
  8. package/assets/js/phoenix_live_view/{element_ref.js → element_ref.ts} +17 -11
  9. package/assets/js/phoenix_live_view/entry_uploader.js +4 -4
  10. package/assets/js/phoenix_live_view/{hooks.js → hooks.ts} +108 -91
  11. package/assets/js/phoenix_live_view/index.ts +14 -301
  12. package/assets/js/phoenix_live_view/js.js +2 -1
  13. package/assets/js/phoenix_live_view/js_commands.ts +12 -9
  14. package/assets/js/phoenix_live_view/{live_socket.js → live_socket.ts} +582 -114
  15. package/assets/js/phoenix_live_view/live_uploader.js +1 -1
  16. package/assets/js/phoenix_live_view/rendered.js +3 -0
  17. package/assets/js/phoenix_live_view/{utils.js → utils.ts} +35 -6
  18. package/assets/js/phoenix_live_view/{view.js → view.ts} +221 -110
  19. package/assets/js/phoenix_live_view/view_hook.ts +92 -32
  20. package/package.json +5 -2
  21. package/priv/static/phoenix_live_view.cjs.js +577 -314
  22. package/priv/static/phoenix_live_view.cjs.js.map +4 -4
  23. package/priv/static/phoenix_live_view.esm.js +577 -314
  24. package/priv/static/phoenix_live_view.esm.js.map +4 -4
  25. package/priv/static/phoenix_live_view.js +584 -314
  26. package/priv/static/phoenix_live_view.min.js +7 -7
  27. /package/assets/js/phoenix_live_view/{constants.js → constants.ts} +0 -0
@@ -1,3 +1,5 @@
1
+ import { Channel } from "phoenix";
2
+
1
3
  import {
2
4
  BEFORE_UNLOAD_LOADER_TIMEOUT,
3
5
  CHECKABLE_INPUTS,
@@ -55,7 +57,7 @@ import {
55
57
  } from "./utils";
56
58
 
57
59
  import Browser from "./browser";
58
- import DOM from "./dom";
60
+ import DOM, { FormInputLike, QueryableNode } from "./dom";
59
61
  import ElementRef from "./element_ref";
60
62
  import DOMPatch from "./dom_patch";
61
63
  import LiveUploader from "./live_uploader";
@@ -64,6 +66,7 @@ import { ViewHook } from "./view_hook";
64
66
  import JS from "./js";
65
67
 
66
68
  import morphdom from "morphdom";
69
+ import LiveSocket from "./live_socket";
67
70
 
68
71
  export const prependFormDataKey = (key, prefix) => {
69
72
  const isArray = key.endsWith("[]");
@@ -78,13 +81,52 @@ export const prependFormDataKey = (key, prefix) => {
78
81
  return baseKey;
79
82
  };
80
83
 
84
+ /** @internal */
81
85
  export default class View {
82
86
  static closestView(el) {
83
87
  const liveViewEl = el.closest(PHX_VIEW_SELECTOR);
84
88
  return liveViewEl ? DOM.private(liveViewEl, "view") : null;
85
89
  }
86
90
 
87
- constructor(el, liveSocket, parentView, flash, liveReferer) {
91
+ liveSocket: LiveSocket;
92
+ id: string;
93
+ el: Element;
94
+ isDead: boolean;
95
+ root: View;
96
+ portalElementIds: Set<string>;
97
+ private channel: Channel;
98
+ private rendered: Rendered | null;
99
+ private flash: string | null;
100
+ private parent: View | null;
101
+ private ref: number;
102
+ private lastAckRef: number | null;
103
+ private childJoins: number;
104
+ private loaderTimer: ReturnType<typeof setTimeout> | null;
105
+ private disconnectedTimer: ReturnType<typeof setTimeout> | null;
106
+ private pendingDiffs: any[];
107
+ private redirect: boolean;
108
+ private href: string | null;
109
+ private joinCount: number;
110
+ private joinAttempts: number;
111
+ private joinPending: boolean;
112
+ private destroyed: boolean;
113
+ private joinCallback: (onDone?: () => void) => void;
114
+ private stopCallback: () => void;
115
+ private pendingJoinOps: any[];
116
+ private viewHooks: Record<string, ViewHook>;
117
+ private formSubmits: any[];
118
+ private children: Record<string, Record<string, View>> | null;
119
+ private pendingForms: Set<string>;
120
+ private formsForRecovery: Record<string, HTMLFormElement>;
121
+
122
+ constructor(
123
+ el: Element,
124
+ liveSocket: LiveSocket,
125
+ parentView: View | null,
126
+ flash: string | null = null,
127
+ liveReferer: string | null = null,
128
+ ) {
129
+ this.rendered = null;
88
130
  this.isDead = false;
89
131
  this.liveSocket = liveSocket;
90
132
  this.flash = flash;
@@ -146,7 +188,7 @@ export default class View {
146
188
  this.viewHooks = {};
147
189
  this.formSubmits = [];
148
190
  this.children = this.parent ? null : {};
149
- this.root.children[this.id] = {};
191
+ this.root.children![this.id] = {};
150
192
  this.formsForRecovery = {};
151
193
  this.channel = this.liveSocket.channel(`lv:${this.id}`, () => {
152
194
  const url = this.href && this.expandURL(this.href);
@@ -156,7 +198,7 @@ export default class View {
156
198
  params: this.connectParams(liveReferer),
157
199
  session: this.getSession(),
158
200
  static: this.getStatic(),
159
- flash: this.flash,
201
+ flash: this.flash ?? undefined,
160
202
  sticky: this.el.hasAttribute(PHX_STICKY),
161
203
  };
162
204
  });
@@ -179,7 +221,9 @@ export default class View {
179
221
  connectParams(liveReferer) {
180
222
  const params = this.liveSocket.params(this.el);
181
223
  const manifest = DOM.all(document, `[${this.binding(PHX_TRACK_STATIC)}]`)
182
- .map((node) => node.src || node.href)
224
+ .map(
225
+ (node) => ("src" in node && node.src) || ("href" in node && node.href),
226
+ )
183
227
  .filter((url) => typeof url === "string");
184
228
 
185
229
  if (manifest.length > 0) {
@@ -187,21 +231,22 @@ export default class View {
187
231
  }
188
232
  params["_mounts"] = this.joinCount;
189
233
  params["_mount_attempts"] = this.joinAttempts;
190
- params["_live_referer"] = liveReferer;
234
+ params["_live_referer"] = liveReferer ?? undefined;
191
235
  this.joinAttempts++;
192
236
 
193
237
  return params;
194
238
  }
195
239
 
196
240
  isConnected() {
197
- return this.channel.canPush();
241
+ // TODO: canPush is private in phoenix
242
+ return (this.channel as any).canPush();
198
243
  }
199
244
 
200
- getSession() {
201
- return this.el.getAttribute(PHX_SESSION);
245
+ getSession(): string {
246
+ return this.el.getAttribute(PHX_SESSION)!;
202
247
  }
203
248
 
204
- getStatic() {
249
+ getStatic(): string | null {
205
250
  const val = this.el.getAttribute(PHX_STATIC);
206
251
  return val === "" ? null : val;
207
252
  }
@@ -211,11 +256,11 @@ export default class View {
211
256
  this.destroyPortalElements();
212
257
  this.destroyed = true;
213
258
  DOM.deletePrivate(this.el, "view");
214
- delete this.root.children[this.id];
259
+ delete this.root.children![this.id];
215
260
  if (this.parent) {
216
- delete this.root.children[this.parent.id][this.id];
261
+ delete this.root.children![this.parent.id][this.id];
217
262
  }
218
- clearTimeout(this.loaderTimer);
263
+ this.loaderTimer != null && clearTimeout(this.loaderTimer);
219
264
  const onFinished = () => {
220
265
  callback();
221
266
  for (const id in this.viewHooks) {
@@ -244,8 +289,8 @@ export default class View {
244
289
  this.el.classList.add(...classes);
245
290
  }
246
291
 
247
- showLoader(timeout) {
248
- clearTimeout(this.loaderTimer);
292
+ showLoader(timeout?: number) {
293
+ this.loaderTimer != null && clearTimeout(this.loaderTimer);
249
294
  if (timeout) {
250
295
  this.loaderTimer = setTimeout(() => this.showLoader(), timeout);
251
296
  } else {
@@ -258,13 +303,13 @@ export default class View {
258
303
 
259
304
  execAll(binding) {
260
305
  DOM.all(this.el, `[${binding}]`, (el) =>
261
- this.liveSocket.execJS(el, el.getAttribute(binding)),
306
+ this.liveSocket.execJS(el, el.getAttribute(binding)!),
262
307
  );
263
308
  }
264
309
 
265
310
  hideLoader() {
266
- clearTimeout(this.loaderTimer);
267
- clearTimeout(this.disconnectedTimer);
311
+ this.loaderTimer != null && clearTimeout(this.loaderTimer);
312
+ this.disconnectedTimer != null && clearTimeout(this.disconnectedTimer);
268
313
  this.setContainerClasses(PHX_CONNECTED_CLASS);
269
314
  this.execAll(this.binding("connected"));
270
315
  }
@@ -289,7 +334,7 @@ export default class View {
289
334
  // * a CID (Component ID), then we first search the component's element in the DOM
290
335
  // * a selector, then we search the selector in the DOM and call the callback
291
336
  // for each element found with the corresponding owner view
292
- withinTargets(phxTarget, callback, dom = document) {
337
+ withinTargets(phxTarget, callback, dom: QueryableNode = document) {
293
338
  // in the form recovery case we search in a template fragment instead of
294
339
  // the real dom, therefore we optionally pass dom and viewEl
295
340
 
@@ -300,11 +345,14 @@ export default class View {
300
345
  }
301
346
 
302
347
  if (isCid(phxTarget)) {
303
- const targets = DOM.findComponentNodeList(this.id, phxTarget, dom);
304
- if (targets.length === 0) {
348
+ const target = DOM.findComponent(this.id, phxTarget, dom);
349
+ if (!target) {
305
350
  logError(`no component found matching phx-target of ${phxTarget}`);
306
351
  } else {
307
- callback(this, parseInt(phxTarget));
352
+ callback(
353
+ this,
354
+ typeof phxTarget === "number" ? phxTarget : parseInt(phxTarget),
355
+ );
308
356
  }
309
357
  } else {
310
358
  const targets = Array.from(dom.querySelectorAll(phxTarget));
@@ -464,7 +512,11 @@ export default class View {
464
512
  }
465
513
 
466
514
  attachTrueDocEl() {
467
- this.el = DOM.byId(this.id);
515
+ const el = DOM.byId(this.id);
516
+ if (!el) {
517
+ throw new Error("unable to find root element for view");
518
+ }
519
+ this.el = el;
468
520
  this.el.setAttribute(PHX_ROOT_ID, this.root.id);
469
521
  }
470
522
 
@@ -518,7 +570,7 @@ export default class View {
518
570
  }
519
571
  }
520
572
  this.attachTrueDocEl();
521
- const patch = new DOMPatch(this, this.el, this.id, html, streams, null);
573
+ const patch = new DOMPatch(this, this.el, html, streams, null);
522
574
  patch.markPrunableContentForRemoval();
523
575
  this.performPatch(patch, false, true);
524
576
  this.joinNewChildren();
@@ -570,13 +622,13 @@ export default class View {
570
622
  }
571
623
 
572
624
  performPatch(patch, pruneCids, isJoinPatch = false) {
573
- const removedEls = [];
625
+ const removedEls: Array<Element> = [];
574
626
  let phxChildrenAdded = false;
575
627
  const updatedHookIds = new Set();
576
628
 
577
629
  this.liveSocket.triggerDOM("onPatchStart", [patch.targetContainer]);
578
630
 
579
- patch.after("added", (el) => {
631
+ patch.afterAdded((el) => {
580
632
  this.liveSocket.triggerDOM("onNodeAdded", [el]);
581
633
  const phxViewportTop = this.binding(PHX_VIEWPORT_TOP);
582
634
  const phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM);
@@ -587,7 +639,7 @@ export default class View {
587
639
  }
588
640
  });
589
641
 
590
- patch.after("phxChildAdded", (el) => {
642
+ patch.afterPhxChildAdded((el) => {
591
643
  if (DOM.isPhxSticky(el)) {
592
644
  this.liveSocket.joinRootViews();
593
645
  } else {
@@ -595,7 +647,7 @@ export default class View {
595
647
  }
596
648
  });
597
649
 
598
- patch.before("updated", (fromEl, toEl) => {
650
+ patch.beforeUpdated((fromEl, toEl) => {
599
651
  const hook = this.triggerBeforeUpdateHook(fromEl, toEl);
600
652
  if (hook) {
601
653
  updatedHookIds.add(fromEl.id);
@@ -604,20 +656,20 @@ export default class View {
604
656
  JS.onBeforeElUpdated(fromEl, toEl);
605
657
  });
606
658
 
607
- patch.after("updated", (el) => {
659
+ patch.afterUpdated((el) => {
608
660
  if (updatedHookIds.has(el.id)) {
609
661
  const hook = this.getHook(el);
610
662
  hook && hook.__updated();
611
663
  }
612
664
  });
613
665
 
614
- patch.after("discarded", (el) => {
666
+ patch.afterDiscarded((el) => {
615
667
  if (el.nodeType === Node.ELEMENT_NODE) {
616
668
  removedEls.push(el);
617
669
  }
618
670
  });
619
671
 
620
- patch.after("transitionsDiscarded", (els) =>
672
+ patch.afterTransitionsDiscarded((els) =>
621
673
  this.afterElementsRemoved(els, pruneCids),
622
674
  );
623
675
  patch.perform(isJoinPatch);
@@ -628,7 +680,7 @@ export default class View {
628
680
  }
629
681
 
630
682
  afterElementsRemoved(elements, pruneCids) {
631
- const destroyedCIDs = [];
683
+ const destroyedCIDs: Array<number> = [];
632
684
  elements.forEach((parent) => {
633
685
  const components = DOM.all(
634
686
  parent,
@@ -678,22 +730,29 @@ export default class View {
678
730
  const template = document.createElement("template");
679
731
  template.innerHTML = html;
680
732
 
733
+ if (!template.content.firstElementChild) {
734
+ return;
735
+ }
736
+
681
737
  // we special case <.portal> here and teleport it into our temporary DOM for recovery
682
738
  // as we'd otherwise not find teleported forms
683
739
  DOM.all(template.content, `[${PHX_PORTAL}]`).forEach((portalTemplate) => {
684
- template.content.firstElementChild.appendChild(
685
- portalTemplate.content.firstElementChild,
740
+ if (!(portalTemplate instanceof HTMLTemplateElement)) {
741
+ return;
742
+ }
743
+ template.content.firstElementChild?.appendChild(
744
+ portalTemplate.content.firstElementChild!,
686
745
  );
687
746
  });
688
747
 
689
748
  // because we work with a template element, we must manually copy the attributes
690
749
  // otherwise the owner / target helpers don't work properly
691
- const rootEl = template.content.firstElementChild;
750
+ const rootEl = template.content.firstElementChild!;
692
751
  rootEl.id = this.id;
693
752
  rootEl.setAttribute(PHX_ROOT_ID, this.root.id);
694
753
  rootEl.setAttribute(PHX_SESSION, this.getSession());
695
- rootEl.setAttribute(PHX_STATIC, this.getStatic());
696
- rootEl.setAttribute(PHX_PARENT_ID, this.parent ? this.parent.id : null);
754
+ rootEl.setAttribute(PHX_STATIC, this.getStatic() ?? "");
755
+ this.parent && rootEl.setAttribute(PHX_PARENT_ID, this.parent.id);
697
756
 
698
757
  // we go over all form elements in the new HTML for the LV
699
758
  // and look for old forms in the `formsForRecovery` object;
@@ -701,7 +760,7 @@ export default class View {
701
760
  const formsToRecover =
702
761
  // we go over all forms in the new DOM; because this is only the HTML for the current
703
762
  // view, we can be sure that all forms are owned by this view:
704
- DOM.all(template.content, "form")
763
+ (DOM.all(template.content, "form") as HTMLFormElement[])
705
764
  // only recover forms that have an id and are in the old DOM
706
765
  .filter((newForm) => newForm.id && oldForms[newForm.id])
707
766
  // abandon forms we already tried to recover to prevent looping a failed state
@@ -729,7 +788,7 @@ export default class View {
729
788
  this.pushFormRecovery(
730
789
  oldForm,
731
790
  newForm,
732
- template.content.firstElementChild,
791
+ template.content.firstElementChild!,
733
792
  () => {
734
793
  this.pendingForms.delete(newForm.id);
735
794
  // we only call the callback once all forms have been recovered
@@ -742,14 +801,16 @@ export default class View {
742
801
  }
743
802
 
744
803
  getChildById(id) {
745
- return this.root.children[this.id][id];
804
+ return this.root.children![this.id][id];
746
805
  }
747
806
 
748
807
  getDescendentByEl(el) {
749
808
  if (el.id === this.id) {
750
809
  return this;
751
810
  } else {
752
- return this.children[el.getAttribute(PHX_PARENT_ID)]?.[el.id];
811
+ return (
812
+ this.children && this.children[el.getAttribute(PHX_PARENT_ID)]?.[el.id]
813
+ );
753
814
  }
754
815
  }
755
816
 
@@ -767,7 +828,7 @@ export default class View {
767
828
  const child = this.getChildById(el.id);
768
829
  if (!child) {
769
830
  const view = new View(el, this.liveSocket, this);
770
- this.root.children[this.id][view.id] = view;
831
+ this.root.children![this.id][view.id] = view;
771
832
  view.join();
772
833
  this.childJoins++;
773
834
  return true;
@@ -818,22 +879,22 @@ export default class View {
818
879
  return false;
819
880
  }
820
881
 
821
- this.rendered.mergeDiff(diff);
882
+ this.rendered!.mergeDiff(diff);
822
883
  let phxChildrenAdded = false;
823
884
 
824
885
  // When the diff only contains component diffs, then walk components
825
886
  // and patch only the parent component containers found in the diff.
826
887
  // Otherwise, patch entire LV container.
827
- if (this.rendered.isComponentOnlyDiff(diff)) {
888
+ if (this.rendered!.isComponentOnlyDiff(diff)) {
828
889
  this.liveSocket.time("component patch complete", () => {
829
890
  const parentCids = DOM.findExistingParentCIDs(
830
891
  this.id,
831
- this.rendered.componentCIDs(diff),
892
+ this.rendered!.componentCIDs(diff),
832
893
  );
833
894
  parentCids.forEach((parentCID) => {
834
895
  if (
835
896
  this.componentPatch(
836
- this.rendered.getComponent(diff, parentCID),
897
+ this.rendered!.getComponent(diff, parentCID),
837
898
  parentCID,
838
899
  )
839
900
  ) {
@@ -844,7 +905,7 @@ export default class View {
844
905
  } else if (!isEmpty(diff)) {
845
906
  this.liveSocket.time("full patch complete", () => {
846
907
  const [html, streams] = this.renderContainer(diff, "update");
847
- const patch = new DOMPatch(this, this.el, this.id, html, streams, null);
908
+ const patch = new DOMPatch(this, this.el, html, streams, null);
848
909
  phxChildrenAdded = this.performPatch(patch, true);
849
910
  });
850
911
  }
@@ -862,16 +923,16 @@ export default class View {
862
923
  const tag = this.el.tagName;
863
924
  // Don't skip any component in the diff nor any marked as pruned
864
925
  // (as they may have been added back)
865
- const cids = diff ? this.rendered.componentCIDs(diff) : null;
866
- const { buffer: html, streams } = this.rendered.toString(cids);
926
+ const cids = diff ? this.rendered!.componentCIDs(diff) : null;
927
+ const { buffer: html, streams } = this.rendered!.toString(cids);
867
928
  return [`<${tag}>${html}</${tag}>`, streams];
868
929
  });
869
930
  }
870
931
 
871
932
  componentPatch(diff, cid) {
872
933
  if (isEmpty(diff)) return false;
873
- const { buffer: html, streams } = this.rendered.componentToString(cid);
874
- const patch = new DOMPatch(this, this.el, this.id, html, streams, cid);
934
+ const { buffer: html, streams } = this.rendered!.componentToString(cid);
935
+ const patch = new DOMPatch(this, this.el, html, streams, cid);
875
936
  const childrenAdded = this.performPatch(patch, true);
876
937
  return childrenAdded;
877
938
  }
@@ -984,7 +1045,7 @@ export default class View {
984
1045
  }
985
1046
 
986
1047
  eachChild(callback) {
987
- const children = this.root.children[this.id] || {};
1048
+ const children = this.root.children![this.id] || {};
988
1049
  for (const id in children) {
989
1050
  callback(this.getChildById(id));
990
1051
  }
@@ -1049,11 +1110,16 @@ export default class View {
1049
1110
  : to;
1050
1111
  }
1051
1112
 
1052
- /**
1053
- * @param {{to: string, flash?: string, reloadToken?: string}} redirect
1054
- */
1055
- onRedirect({ to, flash, reloadToken }) {
1056
- this.liveSocket.redirect(to, flash, reloadToken);
1113
+ onRedirect({
1114
+ to,
1115
+ flash,
1116
+ reloadToken,
1117
+ }: {
1118
+ to: string;
1119
+ flash?: string | null;
1120
+ reloadToken?: string;
1121
+ }) {
1122
+ this.liveSocket.redirect(to, flash ?? null, reloadToken ?? null);
1057
1123
  }
1058
1124
 
1059
1125
  isDestroyed() {
@@ -1064,12 +1130,7 @@ export default class View {
1064
1130
  this.isDead = true;
1065
1131
  }
1066
1132
 
1067
- joinPush() {
1068
- this.joinPush = this.joinPush || this.channel.join();
1069
- return this.joinPush;
1070
- }
1071
-
1072
- join(callback) {
1133
+ join(callback?) {
1073
1134
  this.showLoader(this.liveSocket.loaderTimeout);
1074
1135
  this.bindChannel();
1075
1136
  if (this.isMain()) {
@@ -1091,13 +1152,17 @@ export default class View {
1091
1152
  }
1092
1153
 
1093
1154
  onJoinError(resp) {
1155
+ if (resp.events) {
1156
+ this.liveSocket.dispatchEvents(resp.events);
1157
+ }
1158
+
1094
1159
  if (resp.reason === "reload") {
1095
1160
  this.log("error", () => [
1096
1161
  `failed mount with ${resp.status}. Falling back to page reload`,
1097
1162
  resp,
1098
1163
  ]);
1099
1164
  this.onRedirect({
1100
- to: this.liveSocket.main.href,
1165
+ to: this.liveSocket.main!.href!,
1101
1166
  reloadToken: resp.token,
1102
1167
  });
1103
1168
  return;
@@ -1106,7 +1171,7 @@ export default class View {
1106
1171
  "unauthorized live_redirect. Falling back to page request",
1107
1172
  resp,
1108
1173
  ]);
1109
- this.onRedirect({ to: this.liveSocket.main.href, flash: this.flash });
1174
+ this.onRedirect({ to: this.liveSocket.main!.href!, flash: this.flash });
1110
1175
  return;
1111
1176
  }
1112
1177
  if (resp.redirect || resp.live_redirect) {
@@ -1230,7 +1295,11 @@ export default class View {
1230
1295
  });
1231
1296
  }
1232
1297
 
1233
- pushWithReply(refGenerator, event, payload) {
1298
+ pushWithReply(
1299
+ refGenerator,
1300
+ event,
1301
+ payload,
1302
+ ): Promise<{ resp: any; reply: any; ref: number | null }> {
1234
1303
  if (!this.isConnected()) {
1235
1304
  return Promise.reject(new Error("no connection"));
1236
1305
  }
@@ -1303,7 +1372,7 @@ export default class View {
1303
1372
  });
1304
1373
  }
1305
1374
 
1306
- undoRefs(ref, phxEvent, onlyEls) {
1375
+ undoRefs(ref, phxEvent, onlyEls?) {
1307
1376
  if (!this.isConnected()) {
1308
1377
  return;
1309
1378
  } // exit if external form triggered
@@ -1332,7 +1401,7 @@ export default class View {
1332
1401
  elRef.maybeUndo(ref, phxEvent, (clonedTree) => {
1333
1402
  // we need to perform a full patch on unlocked elements
1334
1403
  // to perform all the necessary logic (like calling updated for hooks, etc.)
1335
- const patch = new DOMPatch(this, el, this.id, clonedTree, [], null, {
1404
+ const patch = new DOMPatch(this, el, clonedTree, new Set(), null, {
1336
1405
  undoRef: ref,
1337
1406
  });
1338
1407
  const phxChildrenAdded = this.performPatch(patch, true);
@@ -1349,7 +1418,12 @@ export default class View {
1349
1418
  return this.el.id;
1350
1419
  }
1351
1420
 
1352
- putRef(elements, phxEvent, eventType, opts = {}) {
1421
+ putRef(
1422
+ elements: Array<{ el: Element; lock: boolean; loading: boolean }>,
1423
+ phxEvent: string | null,
1424
+ eventType: string,
1425
+ opts: { [key: string]: any } = {},
1426
+ ) {
1353
1427
  const newRef = this.ref++;
1354
1428
  const disableWith = this.binding(PHX_DISABLE_WITH);
1355
1429
  if (opts.loading) {
@@ -1365,10 +1439,10 @@ export default class View {
1365
1439
  }
1366
1440
  el.setAttribute(PHX_REF_SRC, this.refSrc());
1367
1441
  if (loading) {
1368
- el.setAttribute(PHX_REF_LOADING, newRef);
1442
+ el.setAttribute(PHX_REF_LOADING, newRef.toString());
1369
1443
  }
1370
1444
  if (lock) {
1371
- el.setAttribute(PHX_REF_LOCK, newRef);
1445
+ el.setAttribute(PHX_REF_LOCK, newRef.toString());
1372
1446
  }
1373
1447
 
1374
1448
  if (
@@ -1396,7 +1470,7 @@ export default class View {
1396
1470
  const disableText = el.getAttribute(disableWith);
1397
1471
  if (disableText !== null) {
1398
1472
  if (!el.getAttribute(PHX_DISABLE_WITH_RESTORE)) {
1399
- el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.textContent);
1473
+ el.setAttribute(PHX_DISABLE_WITH_RESTORE, el.textContent || "");
1400
1474
  }
1401
1475
  if (disableText !== "") {
1402
1476
  el.textContent = disableText;
@@ -1404,7 +1478,8 @@ export default class View {
1404
1478
  // PHX_DISABLED could have already been set in disableForm
1405
1479
  el.setAttribute(
1406
1480
  PHX_DISABLED,
1407
- el.getAttribute(PHX_DISABLED) || el.disabled,
1481
+ el.getAttribute(PHX_DISABLED) ||
1482
+ ("disabled" in el ? String(el.disabled) : ""),
1408
1483
  );
1409
1484
  el.setAttribute("disabled", "");
1410
1485
  }
@@ -1473,12 +1548,12 @@ export default class View {
1473
1548
  return this.lastAckRef !== null && this.lastAckRef >= ref;
1474
1549
  }
1475
1550
 
1476
- componentID(el) {
1551
+ componentID(el): number | null {
1477
1552
  const cid = el.getAttribute && el.getAttribute(PHX_COMPONENT);
1478
1553
  return cid ? parseInt(cid) : null;
1479
1554
  }
1480
1555
 
1481
- targetComponentID(target, targetCtx, opts = {}) {
1556
+ targetComponentID(target, targetCtx, opts: { [key: string]: any } = {}) {
1482
1557
  if (isCid(targetCtx)) {
1483
1558
  return targetCtx;
1484
1559
  }
@@ -1486,7 +1561,9 @@ export default class View {
1486
1561
  const cidOrSelector =
1487
1562
  opts.target || target.getAttribute(this.binding("target"));
1488
1563
  if (isCid(cidOrSelector)) {
1489
- return parseInt(cidOrSelector);
1564
+ return typeof cidOrSelector === "number"
1565
+ ? cidOrSelector
1566
+ : parseInt(cidOrSelector);
1490
1567
  } else if (targetCtx && (cidOrSelector !== null || opts.target)) {
1491
1568
  return this.closestComponentID(targetCtx);
1492
1569
  } else {
@@ -1521,7 +1598,12 @@ export default class View {
1521
1598
  }
1522
1599
  }
1523
1600
 
1524
- pushHookEvent(el, targetCtx, event, payload) {
1601
+ pushHookEvent(
1602
+ el,
1603
+ targetCtx,
1604
+ event,
1605
+ payload,
1606
+ ): Promise<{ reply: any; ref: number }> {
1525
1607
  if (!this.isConnected()) {
1526
1608
  this.log("hook", () => [
1527
1609
  "unable to push hook event. LiveView not connected",
@@ -1544,7 +1626,10 @@ export default class View {
1544
1626
  event: event,
1545
1627
  value: payload,
1546
1628
  cid: this.closestComponentID(targetCtx),
1547
- }).then(({ resp: _resp, reply, ref }) => ({ reply, ref }));
1629
+ }).then(
1630
+ ({ resp: _resp, reply, ref }) =>
1631
+ ({ reply, ref }) as { reply: any; ref: number },
1632
+ );
1548
1633
  }
1549
1634
 
1550
1635
  extractMeta(el, meta, value) {
@@ -1583,7 +1668,11 @@ export default class View {
1583
1668
  return meta;
1584
1669
  }
1585
1670
 
1586
- serializeForm(form, opts, onlyNames = []) {
1671
+ serializeForm(
1672
+ form: HTMLFormElement,
1673
+ opts: { [key: string]: any } = {},
1674
+ onlyNames: string[] = [],
1675
+ ) {
1587
1676
  const { submitter } = opts;
1588
1677
 
1589
1678
  // We must inject the submitter in the order that it exists in the DOM
@@ -1605,7 +1694,7 @@ export default class View {
1605
1694
  }
1606
1695
 
1607
1696
  const formData = new FormData(form);
1608
- const toRemove = [];
1697
+ const toRemove: string[] = [];
1609
1698
 
1610
1699
  formData.forEach((val, key, _index) => {
1611
1700
  if (val instanceof File) {
@@ -1620,6 +1709,10 @@ export default class View {
1620
1709
 
1621
1710
  const { inputsUnused, onlyHiddenInputs } = Array.from(form.elements).reduce(
1622
1711
  (acc, input) => {
1712
+ if (!DOM.isFormAssociated(input)) {
1713
+ return acc;
1714
+ }
1715
+
1623
1716
  const { inputsUnused, onlyHiddenInputs } = acc;
1624
1717
  const key = input.name;
1625
1718
  if (!key) {
@@ -1684,9 +1777,17 @@ export default class View {
1684
1777
  return params.toString();
1685
1778
  }
1686
1779
 
1687
- pushEvent(type, el, targetCtx, phxEvent, meta, opts = {}, onReply) {
1780
+ pushEvent(
1781
+ type,
1782
+ el,
1783
+ targetCtx,
1784
+ phxEvent,
1785
+ meta,
1786
+ opts: { [key: string]: any } = {},
1787
+ onReply?,
1788
+ ) {
1688
1789
  this.pushWithReply(
1689
- (maybePayload) =>
1790
+ (maybePayload?) =>
1690
1791
  this.putRef([{ el, loading: true, lock: true }], phxEvent, type, {
1691
1792
  ...opts,
1692
1793
  payload: maybePayload?.payload,
@@ -1718,7 +1819,7 @@ export default class View {
1718
1819
  });
1719
1820
  }
1720
1821
 
1721
- pushInput(inputEl, targetCtx, forceCid, phxEvent, opts, callback) {
1822
+ pushInput(inputEl, targetCtx, forceCid, phxEvent, opts, callback?) {
1722
1823
  if (!inputEl.form) {
1723
1824
  throw new Error("form events require the input to be inside a form");
1724
1825
  }
@@ -1727,7 +1828,7 @@ export default class View {
1727
1828
  const cid = isCid(forceCid)
1728
1829
  ? forceCid
1729
1830
  : this.targetComponentID(inputEl.form, targetCtx, opts);
1730
- const refGenerator = (maybePayload) => {
1831
+ const refGenerator = (maybePayload?) => {
1731
1832
  return this.putRef(
1732
1833
  [
1733
1834
  { el: inputEl, loading: true, lock: true },
@@ -1740,7 +1841,7 @@ export default class View {
1740
1841
  };
1741
1842
  let formData;
1742
1843
  const meta = this.extractMeta(inputEl.form, {}, opts.value);
1743
- const serializeOpts = {};
1844
+ const serializeOpts: { submitter?: HTMLButtonElement } = {};
1744
1845
  if (inputEl instanceof HTMLButtonElement) {
1745
1846
  serializeOpts.submitter = inputEl;
1746
1847
  }
@@ -1841,7 +1942,7 @@ export default class View {
1841
1942
  );
1842
1943
  }
1843
1944
 
1844
- disableForm(formEl, phxEvent, opts = {}) {
1945
+ disableForm(formEl: HTMLFormElement, phxEvent: string, opts = {}) {
1845
1946
  const filterIgnored = (el) => {
1846
1947
  const userIgnored = closestPhxBinding(
1847
1948
  el,
@@ -1855,10 +1956,11 @@ export default class View {
1855
1956
  const filterDisables = (el) => {
1856
1957
  return el.hasAttribute(this.binding(PHX_DISABLE_WITH));
1857
1958
  };
1858
- const filterButton = (el) => el.tagName == "BUTTON";
1959
+ const filterButton = (el): el is HTMLButtonElement =>
1960
+ el.tagName == "BUTTON";
1859
1961
 
1860
- const filterInput = (el) =>
1861
- ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
1962
+ const filterInput = (el): el is HTMLInputElement | HTMLTextAreaElement =>
1963
+ ["INPUT", "TEXTAREA"].includes(el.tagName);
1862
1964
 
1863
1965
  const formElements = Array.from(formEl.elements);
1864
1966
  const disables = formElements.filter(filterDisables);
@@ -1866,14 +1968,14 @@ export default class View {
1866
1968
  const inputs = formElements.filter(filterInput).filter(filterIgnored);
1867
1969
 
1868
1970
  buttons.forEach((button) => {
1869
- button.setAttribute(PHX_DISABLED, button.disabled);
1971
+ button.setAttribute(PHX_DISABLED, button.disabled.toString());
1870
1972
  button.disabled = true;
1871
1973
  });
1872
1974
  inputs.forEach((input) => {
1873
- input.setAttribute(PHX_READONLY, input.readOnly);
1975
+ input.setAttribute(PHX_READONLY, input.readOnly.toString());
1874
1976
  input.readOnly = true;
1875
- if (input.files) {
1876
- input.setAttribute(PHX_DISABLED, input.disabled);
1977
+ if (input instanceof HTMLInputElement && input.files) {
1978
+ input.setAttribute(PHX_DISABLED, input.disabled.toString());
1877
1979
  input.disabled = true;
1878
1980
  }
1879
1981
  });
@@ -1886,14 +1988,15 @@ export default class View {
1886
1988
 
1887
1989
  // we reverse the order so form children are already locked by the time
1888
1990
  // the form is locked
1889
- const els = [{ el: formEl, loading: true, lock: false }]
1890
- .concat(formEls)
1891
- .reverse();
1991
+ const els = [
1992
+ { el: formEl, loading: true, lock: false },
1993
+ ...formEls,
1994
+ ].reverse();
1892
1995
  return this.putRef(els, phxEvent, "submit", opts);
1893
1996
  }
1894
1997
 
1895
1998
  pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply) {
1896
- const refGenerator = (maybePayload) =>
1999
+ const refGenerator = (maybePayload?) =>
1897
2000
  this.disableForm(formEl, phxEvent, {
1898
2001
  ...opts,
1899
2002
  form: formEl,
@@ -2058,7 +2161,7 @@ export default class View {
2058
2161
 
2059
2162
  targetCtxElement(targetCtx) {
2060
2163
  if (isCid(targetCtx)) {
2061
- const [target] = DOM.findComponentNodeList(this.id, targetCtx);
2164
+ const target = DOM.findComponent(this.id, targetCtx);
2062
2165
  return target;
2063
2166
  } else if (targetCtx) {
2064
2167
  return targetCtx;
@@ -2067,7 +2170,12 @@ export default class View {
2067
2170
  }
2068
2171
  }
2069
2172
 
2070
- pushFormRecovery(oldForm, newForm, templateDom, callback) {
2173
+ pushFormRecovery(
2174
+ oldForm: HTMLFormElement,
2175
+ newForm: HTMLFormElement,
2176
+ templateDom: Element | DocumentFragment,
2177
+ callback: () => void,
2178
+ ) {
2071
2179
  // we are only recovering forms inside the current view, therefore it is safe to
2072
2180
  // skip withinOwners here and always use this when referring to the view
2073
2181
  const phxChange = this.binding("change");
@@ -2076,8 +2184,9 @@ export default class View {
2076
2184
  newForm.getAttribute(this.binding(PHX_AUTO_RECOVER)) ||
2077
2185
  newForm.getAttribute(this.binding("change"));
2078
2186
  const inputs = Array.from(oldForm.elements).filter(
2079
- (el) => DOM.isFormInput(el) && el.name && !el.hasAttribute(phxChange),
2080
- );
2187
+ (el) =>
2188
+ DOM.isFormAssociated(el) && el.name && !el.hasAttribute(phxChange),
2189
+ ) as Array<FormInputLike>;
2081
2190
  if (inputs.length === 0) {
2082
2191
  callback();
2083
2192
  return;
@@ -2137,7 +2246,8 @@ export default class View {
2137
2246
  "click",
2138
2247
  )
2139
2248
  : null;
2140
- const fallback = () => this.liveSocket.redirect(window.location.href);
2249
+ const fallback = () =>
2250
+ this.liveSocket.redirect(window.location.href, null, null);
2141
2251
  const url = href.startsWith("/")
2142
2252
  ? `${location.protocol}//${location.host}${href}`
2143
2253
  : href;
@@ -2187,6 +2297,7 @@ export default class View {
2187
2297
  document,
2188
2298
  `#${CSS.escape(this.id)} form[${phxChange}], [${PHX_TELEPORTED_REF}="${CSS.escape(this.id)}"] form[${phxChange}]`,
2189
2299
  )
2300
+ .filter((form) => form instanceof HTMLFormElement)
2190
2301
  .filter((form) => form.id)
2191
2302
  .filter((form) => form.elements.length > 0)
2192
2303
  .filter(
@@ -2202,14 +2313,14 @@ export default class View {
2202
2313
  // and form.elements returns both the fieldset and the input separately.
2203
2314
  // Because the fieldset is disabled, the input should NOT be sent though.
2204
2315
  // We can only reliably serialize the form by cloning it fully.
2205
- const clonedForm = form.cloneNode(true);
2316
+ const clonedForm = form.cloneNode(true) as HTMLFormElement;
2206
2317
  // we call morphdom to copy any special state
2207
2318
  // like the selected option of a <select> element;
2208
2319
  // any also copy over privates (which contain information about touched fields)
2209
2320
  morphdom(clonedForm, form, {
2210
2321
  onBeforeElUpdated: (fromEl, toEl) => {
2211
2322
  DOM.copyPrivates(fromEl, toEl);
2212
- if (fromEl.getAttribute("form") === form.id) {
2323
+ if (fromEl.getAttribute("form") === form.id && fromEl.parentNode) {
2213
2324
  // In case the form contains an element with form="id" pointing
2214
2325
  // to the form itself, firefox still associates the element with the
2215
2326
  // original form element. This is not fixed by removing the parameter,
@@ -2227,7 +2338,7 @@ export default class View {
2227
2338
  `[form="${CSS.escape(form.id)}"]`,
2228
2339
  );
2229
2340
  Array.from(externalElements).forEach((el) => {
2230
- const clonedEl = /** @type {HTMLElement} */ (el.cloneNode(true));
2341
+ const clonedEl = el.cloneNode(true) as Element;
2231
2342
  morphdom(clonedEl, el);
2232
2343
  DOM.copyPrivates(clonedEl, el);
2233
2344
  // See https://github.com/phoenixframework/phoenix_live_view/issues/4021
@@ -2244,7 +2355,7 @@ export default class View {
2244
2355
 
2245
2356
  maybePushComponentsDestroyed(destroyedCIDs) {
2246
2357
  let willDestroyCIDs = destroyedCIDs.filter((cid) => {
2247
- return DOM.findComponentNodeList(this.id, cid).length === 0;
2358
+ return DOM.findComponent(this.id, cid) === null;
2248
2359
  });
2249
2360
 
2250
2361
  const onError = (error) => {
@@ -2256,7 +2367,7 @@ export default class View {
2256
2367
  if (willDestroyCIDs.length > 0) {
2257
2368
  // we must reset the render change tracking for cids that
2258
2369
  // could be added back from the server so we don't skip them
2259
- willDestroyCIDs.forEach((cid) => this.rendered.resetRender(cid));
2370
+ willDestroyCIDs.forEach((cid) => this.rendered!.resetRender(cid));
2260
2371
 
2261
2372
  this.pushWithReply(null, "cids_will_destroy", { cids: willDestroyCIDs })
2262
2373
  .then(() => {
@@ -2266,7 +2377,7 @@ export default class View {
2266
2377
  // See if any of the cids we wanted to destroy were added back,
2267
2378
  // if they were added back, we don't actually destroy them.
2268
2379
  let completelyDestroyCIDs = willDestroyCIDs.filter((cid) => {
2269
- return DOM.findComponentNodeList(this.id, cid).length === 0;
2380
+ return DOM.findComponent(this.id, cid) === null;
2270
2381
  });
2271
2382
 
2272
2383
  if (completelyDestroyCIDs.length > 0) {
@@ -2274,7 +2385,7 @@ export default class View {
2274
2385
  cids: completelyDestroyCIDs,
2275
2386
  })
2276
2387
  .then(({ resp }) => {
2277
- this.rendered.pruneCIDs(resp.cids);
2388
+ this.rendered!.pruneCIDs(resp.cids);
2278
2389
  })
2279
2390
  .catch(onError);
2280
2391
  }
@@ -2297,7 +2408,7 @@ export default class View {
2297
2408
  DOM.putPrivate(form, PHX_HAS_SUBMITTED, true);
2298
2409
  const inputs = Array.from(form.elements);
2299
2410
  inputs.forEach((input) => DOM.putPrivate(input, PHX_HAS_SUBMITTED, true));
2300
- this.liveSocket.blurActiveElement(this);
2411
+ this.liveSocket.blurActiveElement();
2301
2412
  this.pushFormSubmit(form, targetCtx, phxEvent, submitter, opts, () => {
2302
2413
  this.liveSocket.restorePreviouslyActiveFocus();
2303
2414
  });