diff --git a/assets/js/phoenix_live_view/aria.js b/assets/js/phoenix_live_view/aria.js
index 963f680181..f099390293 100644
--- a/assets/js/phoenix_live_view/aria.js
+++ b/assets/js/phoenix_live_view/aria.js
@@ -1,4 +1,10 @@
+/**
+ * Utilities for accessibility behaviors and affordances
+ */
 let ARIA = {
+  /**
+   * Focus a main element of the page
+   */
   focusMain(){
     let target = document.querySelector("main h1, main, h1")
     if(target){
@@ -9,10 +15,22 @@ let ARIA = {
     }
   },
 
+  /**
+   * Find the first class in the collection that the given object is an instance of.
+   * @param {object} instance 
+   * @param {object[]} classes 
+   * @returns {object | null}
+   */
   anyOf(instance, classes){ return classes.find(name => instance instanceof name) },
 
+  /**
+   * Can the element be focused?
+   * @param {Element} el 
+   * @param {boolean} [interactiveOnly] 
+   * @returns {boolean}
+   */
   isFocusable(el, interactiveOnly){
-    return(
+    return (
       (el instanceof HTMLAnchorElement && el.rel !== "ignore") ||
       (el instanceof HTMLAreaElement && el.href !== undefined) ||
       (!el.disabled && (this.anyOf(el, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement]))) ||
@@ -21,11 +39,23 @@ let ARIA = {
     )
   },
 
+  /**
+   * Focus the given element, reporting the result.
+   * @param {Element} el 
+   * @param {boolean} [interactiveOnly] 
+   * @returns {boolean} Is the given element now focused?
+   */
   attemptFocus(el, interactiveOnly){
-    if(this.isFocusable(el, interactiveOnly)){ try{ el.focus() } catch(e){} }
+    /* eslint-disable-next-line no-empty */
+    if(this.isFocusable(el, interactiveOnly)){ try { el.focus() } catch (e){} }
     return !!document.activeElement && document.activeElement.isSameNode(el)
   },
 
+  /**
+   * Focus the first interactive child element; depth-first search
+   * @param {Element} el 
+   * @returns {boolean}
+   */
   focusFirstInteractive(el){
     let child = el.firstElementChild
     while(child){
@@ -36,6 +66,11 @@ let ARIA = {
     }
   },
 
+  /**
+   * Focus the first child element; depth-first search
+   * @param {Element} el 
+   * @returns {boolean} Is the given element now focused?
+   */
   focusFirst(el){
     let child = el.firstElementChild
     while(child){
@@ -46,6 +81,11 @@ let ARIA = {
     }
   },
 
+  /**
+   * Focus the last child; depth-first search
+   * @param {Element} el 
+   * @returns {boolean} Is the given element now focused?
+   */
   focusLast(el){
     let child = el.lastElementChild
     while(child){
@@ -56,4 +96,5 @@ let ARIA = {
     }
   }
 }
-export default ARIA
\ No newline at end of file
+
+export default ARIA
diff --git a/assets/js/phoenix_live_view/browser.js b/assets/js/phoenix_live_view/browser.js
index 6d4aeca14d..a8bae405a5 100644
--- a/assets/js/phoenix_live_view/browser.js
+++ b/assets/js/phoenix_live_view/browser.js
@@ -1,10 +1,30 @@
 let Browser = {
+  /**
+   * Does browser support pushState feature of the History API?
+   * @returns {boolean}
+   */
   canPushState(){ return (typeof (history.pushState) !== "undefined") },
 
+  /**
+   * Remove item from local storage
+   * @param {Storage} localStorage 
+   * @param {string} namespace 
+   * @param {string} subkey 
+   */
   dropLocal(localStorage, namespace, subkey){
     return localStorage.removeItem(this.localKey(namespace, subkey))
   },
 
+  /**
+   * Update item in local storage
+   * @template {T}
+   * @param {Storage} localStorage 
+   * @param {string} namespace 
+   * @param {string} subkey 
+   * @param {T} initial - value to set if first save
+   * @param {function} func - updating function; receives current, JSON-parsed value of store item and saves the JSON-stringified return value;
+   * @returns {T}
+   */
   updateLocal(localStorage, namespace, subkey, initial, func){
     let current = this.getLocal(localStorage, namespace, subkey)
     let key = this.localKey(namespace, subkey)
@@ -13,15 +33,32 @@ let Browser = {
     return newVal
   },
 
+  /**
+   * Read item from local storage. NOTE: will parse as JSON; might throw
+   * @param {Storage} localStorage 
+   * @param {string} namespace 
+   * @param {string} subkey 
+   * @returns {any}
+   */
   getLocal(localStorage, namespace, subkey){
     return JSON.parse(localStorage.getItem(this.localKey(namespace, subkey)))
   },
 
+  /**
+   * Replace current history state data without navigating 
+   * @param {function} callback 
+   */
   updateCurrentState(callback){
     if(!this.canPushState()){ return }
     history.replaceState(callback(history.state || {}), "", window.location.href)
   },
 
+  /**
+   * Perform history state change to URL
+   * @param {("push"|"replace")} kind 
+   * @param {{type: string, scroll: number|undefined, root: boolean, id: string}} meta 
+   * @param {string} to - URL of destination
+   */
   pushState(kind, meta, to){
     if(this.canPushState()){
       if(to !== window.location.href){
@@ -47,21 +84,47 @@ let Browser = {
     }
   },
 
+  /**
+   * Set document cookie value
+   * @param {string} name 
+   * @param {string} value 
+   */
   setCookie(name, value){
     document.cookie = `${name}=${value}`
   },
 
+  /**
+   * Get value from cookie
+   * @param {string} name 
+   * @returns {string}
+   */
   getCookie(name){
     return document.cookie.replace(new RegExp(`(?:(?:^|.*;\s*)${name}\s*\=\s*([^;]*).*$)|^.*$`), "$1")
   },
 
+  /**
+   * Redirect to URL and set flash message if given
+   * @param {string} toURL 
+   * @param {string} [flash] 
+   */
   redirect(toURL, flash){
     if(flash){ Browser.setCookie("__phoenix_flash__", flash + "; max-age=60000; path=/") }
     window.location = toURL
   },
 
+  /**
+   * Get the namespaced key for use in browser Storage API
+   * @param {string} namespace 
+   * @param {string} subkey 
+   * @returns {string}
+   */
   localKey(namespace, subkey){ return `${namespace}-${subkey}` },
 
+  /**
+   * Find element target of the URL hash segment, if it exists
+   * @param {string} maybeHash 
+   * @returns {HTMLElement|null}
+   */
   getHashTargetEl(maybeHash){
     let hash = maybeHash.toString().substring(1)
     if(hash === ""){ return }
diff --git a/assets/js/phoenix_live_view/constants.js b/assets/js/phoenix_live_view/constants.js
index 04cca88352..5e0a53d987 100644
--- a/assets/js/phoenix_live_view/constants.js
+++ b/assets/js/phoenix_live_view/constants.js
@@ -86,4 +86,4 @@ export const EVENTS = "e"
 export const REPLY = "r"
 export const TITLE = "t"
 export const TEMPLATES = "p"
-export const STREAM = "stream"
\ No newline at end of file
+export const STREAM = "stream"
diff --git a/assets/js/phoenix_live_view/dom.js b/assets/js/phoenix_live_view/dom.js
index 6cdac7fd2d..7e3dc1086a 100644
--- a/assets/js/phoenix_live_view/dom.js
+++ b/assets/js/phoenix_live_view/dom.js
@@ -26,40 +26,102 @@ import {
   logError
 } from "./utils"
 
+/**
+ * Wrappers around common DOM APIs for traversal and mutation
+ */
 let DOM = {
+  /**
+   * Find Element by ID
+   * @param {string} id 
+   * @returns {HTMLElement|null}
+   */
   byId(id){ return document.getElementById(id) || logError(`no id found for ${id}`) },
 
+  /**
+   * Remove class from element
+   * @param {HTMLElement} el 
+   * @param {string} className 
+   */
   removeClass(el, className){
     el.classList.remove(className)
     if(el.classList.length === 0){ el.removeAttribute("class") }
   },
 
+  /**
+   * @typedef {(value: Element, index: number, array: Element[])=>void} ElementCallback
+   */
+
+  /**
+   * Execute callback for each child of node matching CSS selector
+   * 
+   * @template {ElementCallback|undefined} T
+   * @param {ParentNode} node - a DOM node to search underneath
+   * @param {string} query - a CSS selector
+   * @param {T} callback - the callback to operate
+   * @returns {[T] extends [ElementCallback] ? undefined : HTMLElement[] }
+   */
   all(node, query, callback){
     if(!node){ return [] }
     let array = Array.from(node.querySelectorAll(query))
     return callback ? array.forEach(callback) : array
   },
 
+  /**
+   * Count the child nodes of HTML fragment
+   * @param {string} html - HTML fragment
+   * @returns {number}
+   */
   childNodeLength(html){
     let template = document.createElement("template")
     template.innerHTML = html
     return template.content.childElementCount
   },
 
+  /**
+   * Is this input a tracked file upload input?
+   * @param {HTMLInputElement} el 
+   * @returns {boolean}
+   */
   isUploadInput(el){ return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null },
 
+  /**
+   * Is this input a phoenix auto-uploading input?
+   * @param {HTMLInputElement} inputEl 
+   * @returns {boolean}
+   */
   isAutoUpload(inputEl){ return inputEl.hasAttribute("data-phx-auto-upload") },
 
+  /**
+   * Select all child nodes that are file inputs
+   * @param {HTMLElement} node 
+   * @returns {HTMLInputElement[]}
+   */
   findUploadInputs(node){ return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`) },
 
+  /**
+   * Find nodes within component
+   * @param {HTMLElement} node 
+   * @param {number|string} cid 
+   * @returns {HTMLElement[]}
+   */
   findComponentNodeList(node, cid){
     return this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node)
   },
 
+  /**
+   * Has this node been marked by phoenix as destroyed?
+   * @param {HTMLElement} node 
+   * @returns {boolean}
+   */
   isPhxDestroyed(node){
     return node.id && DOM.private(node, "destroyed") ? true : false
   },
 
+  /**
+   * Did the user perform a well-known gesture to open a link in a new tab?
+   * @param {Event} e 
+   * @returns {boolean}
+   */
   wantsNewTab(e){
     let wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1)
     let isDownload = (e.target instanceof HTMLAnchorElement && e.target.hasAttribute("download"))
@@ -67,6 +129,11 @@ let DOM = {
     return wantsNewTab || isTargetBlank || isDownload
   },
 
+  /**
+   * Did the user submit a form that is unloadable?
+   * @param {Event} e 
+   * @returns {boolean}
+   */
   isUnloadableFormSubmit(e){
     // Ignore form submissions intended to close a native <dialog> element
     // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#usage_notes
@@ -80,6 +147,12 @@ let DOM = {
     }
   },
 
+  /**
+   * Did the user click a link that navigates somewhere else?
+   * @param {Event} e 
+   * @param {Location} currentLocation 
+   * @returns {boolean}
+   */
   isNewPageClick(e, currentLocation){
     let href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null
     let url
@@ -90,10 +163,10 @@ let DOM = {
 
     try {
       url = new URL(href)
-    } catch(e) {
+    } catch (e){
       try {
         url = new URL(href, currentLocation)
-      } catch(e) {
+      } catch (e){
         // bad URL, fallback to let browser try it as external
         return true
       }
@@ -107,31 +180,71 @@ let DOM = {
     return url.protocol.startsWith("http")
   },
 
+  /**
+   * Mark element as phoenix destroyed
+   * @param {HTMLElement} el 
+   */
   markPhxChildDestroyed(el){
     if(this.isPhxChild(el)){ el.setAttribute(PHX_SESSION, "") }
     this.putPrivate(el, "destroyed", true)
   },
 
+  /**
+   * Find all children of a specific phoenix view in the given HTML fragment
+   * @param {string} html 
+   * @param {string} parentId 
+   * @returns 
+   */
   findPhxChildrenInFragment(html, parentId){
     let template = document.createElement("template")
     template.innerHTML = html
     return this.findPhxChildren(template.content, parentId)
   },
 
+  /**
+   * Is this node ignored from phoenix updates?
+   * @param {HTMLElement} el 
+   * @param {string} phxUpdate 
+   * @returns {boolean}
+   */
   isIgnored(el, phxUpdate){
     return (el.getAttribute(phxUpdate) || el.getAttribute("data-phx-update")) === "ignore"
   },
 
+  /**
+   * Is this node marked for phoenix updates?
+   * @param {HTMLElement} el 
+   * @param {string} phxUpdate 
+   * @param {string[]} updateTypes 
+   * @returns 
+   */
   isPhxUpdate(el, phxUpdate, updateTypes){
     return el.getAttribute && updateTypes.indexOf(el.getAttribute(phxUpdate)) >= 0
   },
 
+  /**
+   * Find child elements marked as phx-sticky
+   * @param {HTMLElement} el 
+   * @returns {HTMLElement[]}
+   */
   findPhxSticky(el){ return this.all(el, `[${PHX_STICKY}]`) },
 
+  /**
+   * Find all children of a given phoenix view's element
+   * @param {HTMLElement} el 
+   * @param {string} parentId 
+   * @returns {HTMLElement[]}
+   */
   findPhxChildren(el, parentId){
     return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`)
   },
 
+  /**
+   * Find all IDs for parent components of this node
+   * @param {parentNode} node 
+   * @param {(string|number)[]} cids 
+   * @returns {Set<string|number>}
+   */
   findParentCIDs(node, cids){
     let initial = new Set(cids)
     let parentCids =
@@ -148,6 +261,12 @@ let DOM = {
     return parentCids.size === 0 ? new Set(cids) : parentCids
   },
 
+  /**
+   * Filter the given element nodes to only those within the given parent
+   * @param {HTMLElement[]} nodes 
+   * @param {HTMLElement} parent 
+   * @returns {HTMLElement[]}
+   */
   filterWithinSameLiveView(nodes, parent){
     if(parent.querySelector(PHX_VIEW_SELECTOR)){
       return nodes.filter(el => this.withinSameLiveView(el, parent))
@@ -156,6 +275,12 @@ let DOM = {
     }
   },
 
+  /**
+   * Is given node within the given parent?
+   * @param {HTMLElement} node 
+   * @param {HTMLElement} parent 
+   * @returns {boolean}
+   */
   withinSameLiveView(node, parent){
     while(node = node.parentNode){
       if(node.isSameNode(parent)){ return true }
@@ -163,15 +288,40 @@ let DOM = {
     }
   },
 
+  /**
+   * Get the value stored in the phoenix private data on the DOM node
+   * @param {HTMLElement} el 
+   * @param {string} key 
+   * @returns {any}
+   */
   private(el, key){ return el[PHX_PRIVATE] && el[PHX_PRIVATE][key] },
 
+  /**
+   * Delete the value stored in the phoenix private data on the DOM node
+   * @param {HTMLElement} el 
+   * @param {string} key 
+   * @returns {any}
+   */
   deletePrivate(el, key){ el[PHX_PRIVATE] && delete (el[PHX_PRIVATE][key]) },
 
+  /**
+   * Set the value at key in the phoenix private data on the DOM node
+   * @param {HTMLElement} el 
+   * @param {string} key 
+   * @param {any} value 
+   */
   putPrivate(el, key, value){
     if(!el[PHX_PRIVATE]){ el[PHX_PRIVATE] = {} }
     el[PHX_PRIVATE][key] = value
   },
 
+  /**
+   * Update the value at key in the phoenix private data on the DOM node with a function
+   * @param {HTMLElement} el 
+   * @param {string} key 
+   * @param {any} defaultVal 
+   * @param {function} updateFunc 
+   */
   updatePrivate(el, key, defaultVal, updateFunc){
     let existing = this.private(el, key)
     if(existing === undefined){
@@ -181,12 +331,21 @@ let DOM = {
     }
   },
 
+  /**
+   * Copy private phoenix data bag from one element to another
+   * @param {HTMLElement} target 
+   * @param {HTMLElement} source 
+   */
   copyPrivates(target, source){
     if(source[PHX_PRIVATE]){
       target[PHX_PRIVATE] = source[PHX_PRIVATE]
     }
   },
 
+  /**
+   * Update the document title
+   * @param {string} str 
+   */
   putTitle(str){
     let titleEl = document.querySelector("title")
     if(titleEl){
@@ -197,6 +356,17 @@ let DOM = {
     }
   },
 
+  /**
+   * Apply an event debounce and/or throttle for callback
+   * @param {HTMLElement} el 
+   * @param {Event} event 
+   * @param {string} phxDebounce 
+   * @param {number} defaultDebounce 
+   * @param {string} phxThrottle 
+   * @param {number} defaultThrottle 
+   * @param {function} asyncFilter 
+   * @param {function} callback 
+   */
   debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback){
     let debounce = el.getAttribute(phxDebounce)
     let throttle = el.getAttribute(phxThrottle)
@@ -257,6 +427,11 @@ let DOM = {
     }
   },
 
+  /**
+   * @param {HTMLElement} el 
+   * @param {string} key 
+   * @param {any} currentCycle 
+   */
   triggerCycle(el, key, currentCycle){
     let [cycle, trigger] = this.private(el, key)
     if(!currentCycle){ currentCycle = cycle }
@@ -266,12 +441,24 @@ let DOM = {
     }
   },
 
+  /**
+   * Set element private data key to true
+   * @param {HTMLElement} el 
+   * @param {string} key 
+   * @returns {boolean} - false if was already set; true if key was unset
+   */
   once(el, key){
     if(this.private(el, key) === true){ return false }
     this.putPrivate(el, key, true)
     return true
   },
 
+  /**
+   * @param {HTMLElement} el 
+   * @param {string} key 
+   * @param {function} trigger 
+   * @returns {any}
+   */
   incCycle(el, key, trigger = function (){ }){
     let [currentCycle] = this.private(el, key) || [0, trigger]
     currentCycle++
@@ -279,12 +466,24 @@ let DOM = {
     return currentCycle
   },
 
+  /**
+   * Add private hooks if element attributes dictate
+   * @param {HTMLElement} el 
+   * @param {string} phxViewportTop 
+   * @param {string} phxViewportBottom 
+   */
   maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom){
     if(el.hasAttribute && (el.hasAttribute(phxViewportTop) || el.hasAttribute(phxViewportBottom))){
       el.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll")
     }
   },
 
+  /**
+   * Hide input feedback nodes if appropriate
+   * @param {HTMLElement} container 
+   * @param {HTMLInputElement} input 
+   * @param {string} phxFeedbackFor 
+   */
   maybeHideFeedback(container, input, phxFeedbackFor){
     if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){
       let feedbacks = [input.name]
@@ -294,6 +493,11 @@ let DOM = {
     }
   },
 
+  /**
+   * Reset phoenix data (focus, submission state, feedback, etc.) of the given form's inputs
+   * @param {HTMLFormElement} form 
+   * @param {string} phxFeedbackFor 
+   */
   resetForm(form, phxFeedbackFor){
     Array.from(form.elements).forEach(input => {
       let query = `[${phxFeedbackFor}="${input.id}"],
@@ -308,6 +512,11 @@ let DOM = {
     })
   },
 
+  /**
+   * Unhide feedback elements for this input
+   * @param {HTMLElement} inputEl 
+   * @param {string} phxFeedbackFor 
+   */
   showError(inputEl, phxFeedbackFor){
     if(inputEl.id || inputEl.name){
       this.all(inputEl.form, `[${phxFeedbackFor}="${inputEl.id}"], [${phxFeedbackFor}="${inputEl.name}"]`, (el) => {
@@ -316,18 +525,41 @@ let DOM = {
     }
   },
 
+  /**
+   * @param {HTMLElement} node 
+   * @returns {boolean}
+   */
   isPhxChild(node){
     return node.getAttribute && node.getAttribute(PHX_PARENT_ID)
   },
 
+  /**
+   * @param {HTMLElement} node 
+   * @returns {boolean}
+   */
   isPhxSticky(node){
     return node.getAttribute && node.getAttribute(PHX_STICKY) !== null
   },
 
+  /**
+   * @param {HTMLElement} el 
+   * @returns {HTMLElement|null}
+   */
   firstPhxChild(el){
     return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]
   },
 
+  /**
+   * Create and dispatch a custom event to the target element
+   * 
+   * Options:
+   *   - bubbles: does the event bubble? Defaults to true (NOTE: this is different from standard CustomEvent behavior)
+   *   - detail: a value associated with the event that can be accessed by the handler
+   * 
+   * @param {HTMLElement} target 
+   * @param {string} name 
+   * @param {{bubbles?: boolean, detail?: object}} [opts] 
+   */
   dispatchEvent(target, name, opts = {}){
     let bubbles = opts.bubbles === undefined ? true : !!opts.bubbles
     let eventOpts = {bubbles: bubbles, cancelable: true, detail: opts.detail || {}}
@@ -335,6 +567,12 @@ let DOM = {
     target.dispatchEvent(event)
   },
 
+  /**
+   * Clone node and optionally replace inner HTML with given HTML fragment
+   * @param {Node} node 
+   * @param {string} [html] 
+   * @returns {Node}
+   */
   cloneNode(node, html){
     if(typeof (html) === "undefined"){
       return node.cloneNode(true)
@@ -345,6 +583,18 @@ let DOM = {
     }
   },
 
+  /**
+   * Copy attributes from source to target, deleting attributes on target not
+   * found in source.
+   * 
+   * Options:
+   *   - exclude: List of attribute names on source to skip
+   *   - isIgnored: Only remove extra attributes on target if they start with 'data-
+   * 
+   * @param {HTMLElement} target 
+   * @param {HTMLElement} source 
+   * @param {{exclude?: string[], isIgnored?: boolean}} [opts] 
+   */
   mergeAttrs(target, source, opts = {}){
     let exclude = opts.exclude || []
     let isIgnored = opts.isIgnored
@@ -365,6 +615,11 @@ let DOM = {
     }
   },
 
+  /**
+   * Copy attributes from focusable input source to target
+   * @param {HTMLElement} target 
+   * @param {HTMLElement} source 
+   */
   mergeFocusedInput(target, source){
     // skip selects because FF will reset highlighted index for any setAttribute
     if(!(target instanceof HTMLSelectElement)){ DOM.mergeAttrs(target, source, {exclude: ["value"]}) }
@@ -375,10 +630,21 @@ let DOM = {
     }
   },
 
+  /**
+   * Does element have a selection range?
+   * @param {HTMLElement} el 
+   * @returns {boolean}
+   */
   hasSelectionRange(el){
     return el.setSelectionRange && (el.type === "text" || el.type === "textarea")
   },
 
+  /**
+   * Restore focus to the element, along with any selection range
+   * @param {HTMLElement} focused
+   * @param {number} [selectionStart] 
+   * @param {number} [selectionEnd] 
+   */
   restoreFocus(focused, selectionStart, selectionEnd){
     if(!DOM.isTextualInput(focused)){ return }
     let wasFocused = focused.matches(":focus")
@@ -389,20 +655,45 @@ let DOM = {
     }
   },
 
+  /**
+   * Is the element a form input?
+   * @param {HTMLElement} el 
+   * @returns {boolean}
+   */
   isFormInput(el){ return /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== "button" },
 
+  /**
+   * Copy checkable input elements "checked" attributes to "checked" property
+   * @param {HTMLElement} el 
+   */
   syncAttrsToProps(el){
     if(el instanceof HTMLInputElement && CHECKABLE_INPUTS.indexOf(el.type.toLocaleLowerCase()) >= 0){
       el.checked = el.getAttribute("checked") !== null
     }
   },
 
+  /**
+   * Is the element one of the focusable textual input elements?
+   * @param {HTMLElement} el 
+   * @returns {boolean}
+   */
   isTextualInput(el){ return FOCUSABLE_INPUTS.indexOf(el.type) >= 0 },
 
+  /**
+   * @param {HTMLElement} el 
+   * @param {string} phxTriggerExternal 
+   * @returns {boolean}
+   */
   isNowTriggerFormExternal(el, phxTriggerExternal){
     return el.getAttribute && el.getAttribute(phxTriggerExternal) !== null
   },
 
+  /**
+   * @param {HTMLElement} fromEl 
+   * @param {HTMLElement} toEl 
+   * @param {string} disableWith 
+   * @returns {boolean}
+   */
   syncPendingRef(fromEl, toEl, disableWith){
     let ref = fromEl.getAttribute(PHX_REF)
     if(ref === null){ return true }
@@ -422,6 +713,11 @@ let DOM = {
     }
   },
 
+  /**
+   * Remove child nodes missing ID's.
+   * @param {HTMLElement} container 
+   * @param {string} phxUpdate 
+   */
   cleanChildNodes(container, phxUpdate){
     if(DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend"])){
       let toRemove = []
@@ -440,6 +736,14 @@ let DOM = {
     }
   },
 
+  /**
+   * Replace the root container element and replacing attributes with given set
+   * (while stilll retaining phx tracking attributes).
+   * @param {HTMLElement} container 
+   * @param {string} tagName 
+   * @param {object} attrs 
+   * @returns {HTMLElement}
+   */
   replaceRootContainer(container, tagName, attrs){
     let retainedAttrs = new Set(["id", PHX_SESSION, PHX_STATIC, PHX_MAIN, PHX_ROOT_ID])
     if(container.tagName.toLowerCase() === tagName.toLowerCase()){
@@ -463,8 +767,16 @@ let DOM = {
     }
   },
 
+
+  /**
+   * Get the stashed result for the element's private "sticky" operation matching given name; if not found, return default value
+   * @param {HTMLElement} el 
+   * @param {string} name - name of sticky op to search for
+   * @param {any} defaultVal - if function, it will be called and result returned
+   * @returns {any} stashed result of op or defaultVal
+   */
   getSticky(el, name, defaultVal){
-    let op = (DOM.private(el, "sticky") || []).find(([existingName, ]) => name === existingName)
+    let op = (DOM.private(el, "sticky") || []).find(([existingName]) => name === existingName)
     if(op){
       let [_name, _op, stashedResult] = op
       return stashedResult
@@ -473,16 +785,27 @@ let DOM = {
     }
   },
 
+  /**
+   * Delete the named "sticky" operation from element's private data
+   * @param {HTMLElement} el 
+   * @param {string} name 
+   */
   deleteSticky(el, name){
     this.updatePrivate(el, "sticky", [], ops => {
       return ops.filter(([existingName, _]) => existingName !== name)
     })
   },
 
+  /**
+   * Store a named "sticky" operation and it's result to the element's private data
+   * @param {HTMLElement} el 
+   * @param {string} name 
+   * @param {function} op 
+   */
   putSticky(el, name, op){
     let stashedResult = op(el)
     this.updatePrivate(el, "sticky", [], ops => {
-      let existingIndex = ops.findIndex(([existingName, ]) => name === existingName)
+      let existingIndex = ops.findIndex(([existingName]) => name === existingName)
       if(existingIndex >= 0){
         ops[existingIndex] = [name, op, stashedResult]
       } else {
@@ -492,6 +815,10 @@ let DOM = {
     })
   },
 
+  /**
+   * Re-run all sticky operations for this element and store latest stashed results
+   * @param {HTMLElement} el 
+   */
   applyStickyOperations(el){
     let ops = DOM.private(el, "sticky")
     if(!ops){ return }
diff --git a/assets/js/phoenix_live_view/dom_patch.js b/assets/js/phoenix_live_view/dom_patch.js
index 5adf9afc9c..ecf60d69d3 100644
--- a/assets/js/phoenix_live_view/dom_patch.js
+++ b/assets/js/phoenix_live_view/dom_patch.js
@@ -1,3 +1,8 @@
+/**
+ * Module Dependencies:
+ * 
+ * @typedef {import('./rendered.js').Stream} Stream
+ */
 import {
   PHX_COMPONENT,
   PHX_DISABLE_WITH,
@@ -25,6 +30,15 @@ import DOM from "./dom"
 import DOMPostMorphRestorer from "./dom_post_morph_restorer"
 import morphdom from "morphdom"
 
+/**
+ * Definition of a stream insert object
+ * @typedef {object} StreamInsert 
+ * @property {string} ref
+ * @property {number} streamAt
+ * @property {number} limit
+ * @property {boolean} resetKept
+ */
+
 export default class DOMPatch {
   static patchEl(fromEl, toEl, activeElement){
     morphdom(fromEl, toEl, {
@@ -38,6 +52,15 @@ export default class DOMPatch {
     })
   }
 
+  /**
+   * Constructor - DOM Patch to be implemented by Morphdom
+   * @param {import('./view').default} view 
+   * @param {HTMLElement} container 
+   * @param {string} id 
+   * @param {string} html 
+   * @param {Stream[]} streams 
+   * @param {string|number|null} [targetCID] 
+   */
   constructor(view, container, id, html, streams, targetCID){
     this.view = view
     this.liveSocket = view.liveSocket
@@ -46,6 +69,7 @@ export default class DOMPatch {
     this.rootID = view.root.id
     this.html = html
     this.streams = streams
+    /** @type {{[key: string]: StreamInsert}}  - keyed by element ID */
     this.streamInserts = {}
     this.targetCID = targetCID
     this.cidPatch = isCid(this.targetCID)
@@ -58,17 +82,41 @@ export default class DOMPatch {
     }
   }
 
+  /**
+   * Register "before" callback
+   * @param {string} kind 
+   * @param {(...any) => void} callback 
+   */
   before(kind, callback){ this.callbacks[`before${kind}`].push(callback) }
+
+  /**
+   * Register "after" callback
+   * @param {string} kind 
+   * @param {(...any) => void} callback 
+   */
   after(kind, callback){ this.callbacks[`after${kind}`].push(callback) }
 
+  /**
+   * Run all "before" callbacks matching kind
+   * @param {string} kind 
+   * @param  {...any} [args] - given to each executed callback
+   */
   trackBefore(kind, ...args){
     this.callbacks[`before${kind}`].forEach(callback => callback(...args))
   }
 
+  /**
+   * Run all "after" callbacks matching kind
+   * @param {string} kind 
+   * @param  {...any} [args] - given to each executed callback
+   */
   trackAfter(kind, ...args){
     this.callbacks[`after${kind}`].forEach(callback => callback(...args))
   }
 
+  /**
+   * Add the prune attribute to prunable nodes in the container
+   */
   markPrunableContentForRemoval(){
     let phxUpdate = this.liveSocket.binding(PHX_UPDATE)
     DOM.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`, el => el.innerHTML = "")
@@ -77,6 +125,9 @@ export default class DOMPatch {
     })
   }
 
+  /**
+   * Run the patch via morphdom
+   */
   perform(){
     let {view, liveSocket, container, html} = this
     let targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container
@@ -90,9 +141,14 @@ export default class DOMPatch {
     let phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP)
     let phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM)
     let phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION)
+
+    /** @type {HTMLElement[]} */
     let added = []
+    /** @type {HTMLElement[]} */
     let trackedInputs = []
+    /** @type {HTMLElement[]} */
     let updates = []
+    /** @type {DOMPostMorphRestorer[]} */
     let appendPrependUpdates = []
 
     let externalFormTriggered = null
@@ -131,7 +187,7 @@ export default class DOMPatch {
         // tell morphdom how to add a child
         addChild: (parent, child) => {
           let {ref, streamAt, limit} = this.getStreamInsert(child)
-          if(ref === undefined) { return parent.appendChild(child) }
+          if(ref === undefined){ return parent.appendChild(child) }
 
           DOM.putSticky(child, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
 
@@ -168,6 +224,7 @@ export default class DOMPatch {
 
           // hack to fix Safari handling of img srcset and video tags
           if(el instanceof HTMLImageElement && el.srcset){
+            /* eslint-disable-next-line no-self-assign */
             el.srcset = el.srcset
           } else if(el instanceof HTMLVideoElement && el.autoplay){
             el.play()
@@ -314,12 +371,20 @@ export default class DOMPatch {
     return true
   }
 
+  /**
+   * Notify liveSocket and callbacks of node discard
+   * @param {Element} el 
+   */
   onNodeDiscarded(el){
     // nested view handling
     if(DOM.isPhxChild(el) || DOM.isPhxSticky(el)){ this.liveSocket.destroyViewByEl(el) }
     this.trackAfter("discarded", el)
   }
 
+  /**
+   * @param {Element} node 
+   * @returns {boolean} true if added to pending removals
+   */
   maybePendingRemove(node){
     if(node.getAttribute && node.getAttribute(this.phxRemove) !== null){
       this.pendingRemoves.push(node)
@@ -329,6 +394,10 @@ export default class DOMPatch {
     }
   }
 
+  /**
+   * Remove the child immediately or within a transition
+   * @param {Element} child 
+   */
   removeStreamChildElement(child){
     if(!this.maybePendingRemove(child)){
       child.remove()
@@ -336,12 +405,22 @@ export default class DOMPatch {
     }
   }
 
+  /**
+   * Get stream insert matching Element ID
+   * @param {Element} el 
+   * @returns {StreamInsert}
+   */
   getStreamInsert(el){
     let insert = el.id ? this.streamInserts[el.id] : {}
     return insert || {}
   }
 
+  /**
+   * Insert nodes that are part of the stream, reordering their position if it changed
+   * @param {Element} el 
+   */
   maybeReOrderStream(el){
+    // eslint-disable-next-line no-unused-vars
     let {ref, streamAt, limit} = this.getStreamInsert(el)
     if(streamAt === undefined){ return }
 
@@ -366,6 +445,9 @@ export default class DOMPatch {
     }
   }
 
+  /**
+   * Run pending node removals within a transition
+   */
   transitionPendingRemoves(){
     let {pendingRemoves, liveSocket} = this
     if(pendingRemoves.length > 0){
@@ -381,12 +463,26 @@ export default class DOMPatch {
     }
   }
 
+  /**
+   * Is this patch targeting a component?
+   * @returns {boolean}
+   */
   isCIDPatch(){ return this.cidPatch }
 
+  /**
+   * Is this an element that was indicated to be skipped?
+   * @param {Node} el 
+   * @returns {boolean}
+   */
   skipCIDSibling(el){
     return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP)
   }
 
+  /**
+   * Get the container of the target CID
+   * @param {string} html 
+   * @returns {HTMLElement|undefined} undefined if target is not a component
+   */
   targetCIDContainer(html){
     if(!this.isCIDPatch()){ return }
     let [first, ...rest] = DOM.findComponentNodeList(this.container, this.targetCID)
@@ -397,5 +493,11 @@ export default class DOMPatch {
     }
   }
 
+  /**
+   * Find indexOf child within parent
+   * @param {Element} parent 
+   * @param {Element} child 
+   * @returns {number}
+   */
   indexOf(parent, child){ return Array.from(parent.children).indexOf(child) }
-}
\ No newline at end of file
+}
diff --git a/assets/js/phoenix_live_view/dom_post_morph_restorer.js b/assets/js/phoenix_live_view/dom_post_morph_restorer.js
index 2fa2ea429b..4f71af57c6 100644
--- a/assets/js/phoenix_live_view/dom_post_morph_restorer.js
+++ b/assets/js/phoenix_live_view/dom_post_morph_restorer.js
@@ -5,10 +5,19 @@ import {
 import DOM from "./dom"
 
 export default class DOMPostMorphRestorer {
+  /**
+   * Constructor
+   * @param {Element} containerBefore 
+   * @param {Element} containerAfter 
+   * @param {string} updateType 
+   */
   constructor(containerBefore, containerAfter, updateType){
+    /** @type {Set<string>} */
     let idsBefore = new Set()
+    /** @type {Set<string>} */
     let idsAfter = new Set([...containerAfter.children].map(child => child.id))
 
+    /** @type {{elementId: string, previousElementId: string}[]} */
     let elementsToModify = []
 
     Array.from(containerBefore.children).forEach(child => {
@@ -27,12 +36,14 @@ export default class DOMPostMorphRestorer {
     this.elementIdsToAdd = [...idsAfter].filter(id => !idsBefore.has(id))
   }
 
-  // We do the following to optimize append/prepend operations:
-  //   1) Track ids of modified elements & of new elements
-  //   2) All the modified elements are put back in the correct position in the DOM tree
-  //      by storing the id of their previous sibling
-  //   3) New elements are going to be put in the right place by morphdom during append.
-  //      For prepend, we move them to the first position in the container
+  /**
+   * We do the following to optimize append/prepend operations:
+   *   1) Track ids of modified elements & of new elements
+   *   2) All the modified elements are put back in the correct position in the DOM tree
+   *      by storing the id of their previous sibling
+   *   3) New elements are going to be put in the right place by morphdom during append.
+   *      For prepend, we move them to the first position in the container
+   */
   perform(){
     let container = DOM.byId(this.containerId)
     this.elementsToModify.forEach(elementToModify => {
diff --git a/assets/js/phoenix_live_view/entry_uploader.js b/assets/js/phoenix_live_view/entry_uploader.js
index e442dc854e..39ad473cfb 100644
--- a/assets/js/phoenix_live_view/entry_uploader.js
+++ b/assets/js/phoenix_live_view/entry_uploader.js
@@ -1,8 +1,19 @@
+/**
+ * Module Type Dependencies:
+ * @typedef {import('./live_socket.js').default} LiveSocket
+ * @typedef {import('./upload_entry').default} UploadEntry
+ */
 import {
   logError
 } from "./utils"
 
 export default class EntryUploader {
+  /**
+   * Constructor
+   * @param {UploadEntry} entry 
+   * @param {number} chunkSize 
+   * @param {LiveSocket} liveSocket 
+   */
   constructor(entry, chunkSize, liveSocket){
     this.liveSocket = liveSocket
     this.entry = entry
@@ -13,6 +24,11 @@ export default class EntryUploader {
     this.uploadChannel = liveSocket.channel(`lvu:${entry.ref}`, {token: entry.metadata()})
   }
 
+  /**
+   * Mark this entry upload as an error
+   * @private
+   * @param {string} [reason] 
+   */
   error(reason){
     if(this.errored){ return }
     this.errored = true
@@ -20,6 +36,10 @@ export default class EntryUploader {
     this.entry.error(reason)
   }
 
+  /**
+   * Perform upload over channel
+   * @public
+   */
   upload(){
     this.uploadChannel.onError(reason => this.error(reason))
     this.uploadChannel.join()
@@ -27,8 +47,17 @@ export default class EntryUploader {
       .receive("error", reason => this.error(reason))
   }
 
+  /**
+   * Have all file chunks finished uploading?
+   * @public
+   * @returns {boolean}
+   */
   isDone(){ return this.offset >= this.entry.file.size }
 
+  /**
+   * Read and upload next file chunk
+   * @private
+   */
   readNextChunk(){
     let reader = new window.FileReader()
     let blob = this.entry.file.slice(this.offset, this.chunkSize + this.offset)
@@ -43,6 +72,11 @@ export default class EntryUploader {
     reader.readAsArrayBuffer(blob)
   }
 
+  /**
+   * Perform file chunk upload over channel
+   * @private
+   * @param {string | ArrayBuffer | null} chunk 
+   */
   pushChunk(chunk){
     if(!this.uploadChannel.isJoined()){ return }
     this.uploadChannel.push("chunk", chunk)
diff --git a/assets/js/phoenix_live_view/hooks.js b/assets/js/phoenix_live_view/hooks.js
index 59521baad1..dc4a0b0382 100644
--- a/assets/js/phoenix_live_view/hooks.js
+++ b/assets/js/phoenix_live_view/hooks.js
@@ -8,7 +8,13 @@ import {
 import LiveUploader from "./live_uploader"
 import ARIA from "./aria"
 
+/** @typedef {import('./view_hook').HookCallbacks} HookCallbacks */
+
 let Hooks = {
+  /**
+   * Phoenix hook for handling live file uploads with progress tracking
+   * @satisfies {HookCallbacks}
+   */
   LiveFileUpload: {
     activeRefs(){ return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS) },
 
@@ -30,6 +36,10 @@ let Hooks = {
     }
   },
 
+  /**
+   * Phoenix hook for image previewing
+   * @satisfies {HookCallbacks}
+   */
   LiveImgPreview: {
     mounted(){
       this.ref = this.el.getAttribute("data-phx-entry-ref")
@@ -43,6 +53,11 @@ let Hooks = {
       URL.revokeObjectURL(this.url)
     }
   },
+  
+  /**
+   * Phoenix hook for wrapping focus on a container element
+   * @satisfies {HookCallbacks}
+   */
   FocusWrap: {
     mounted(){
       this.focusStart = this.el.firstElementChild
@@ -75,6 +90,9 @@ let isWithinViewport = (el) => {
   return rect.top >= 0 && rect.left >= 0 && rect.top <= winHeight()
 }
 
+/**
+ * Phoenix hook for inifinitely scrolling a container and loading new data on-demand
+ */
 Hooks.InfiniteScroll = {
   mounted(){
     let scrollBefore = scrollTop()
@@ -105,6 +123,7 @@ Hooks.InfiniteScroll = {
       })
     })
 
+    // eslint-disable-next-line no-unused-vars
     this.onScroll = (e) => {
       let scrollNow = scrollTop()
 
@@ -148,7 +167,7 @@ Hooks.InfiniteScroll = {
       let remainingTime = interval - (now - lastCallAt)
 
       if(remainingTime <= 0 || remainingTime > interval){
-        if(timer) {
+        if(timer){
           clearTimeout(timer)
           timer = null
         }
diff --git a/assets/js/phoenix_live_view/js.js b/assets/js/phoenix_live_view/js.js
index 8535a53237..51a6926d00 100644
--- a/assets/js/phoenix_live_view/js.js
+++ b/assets/js/phoenix_live_view/js.js
@@ -1,9 +1,21 @@
 import DOM from "./dom"
 import ARIA from "./aria"
 
+/**
+ * @typedef {import('./view.js').default} View
+ */
+
 let focusStack = null
 
 let JS = {
+  /**
+   * Execute JS command - main entrypoint
+   * @param {string|null} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {HTMLElement} sourceEl 
+   * @param {[defaultKind: string, defaultArgs: any]} [defaults] 
+   */
   exec(eventType, phxEvent, view, sourceEl, defaults){
     let [defaultKind, defaultArgs] = defaults || [null, {callback: defaults && defaults.callback}]
     let commands = phxEvent.charAt(0) === "[" ?
@@ -22,6 +34,11 @@ let JS = {
     })
   },
 
+  /**
+   * Is element visible and not scrolled off-screen?
+   * @param {Element} el 
+   * @returns {boolean}
+   */
   isVisible(el){
     return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0)
   },
@@ -30,6 +47,16 @@ let JS = {
 
   // commands
 
+  /**
+   * Exec JS command for all targeted DOM nodes
+   * @private
+   * @param {string} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {Element} sourceEl 
+   * @param {Element} el 
+   * @param {[attrWithCommand: string, toTargetsSelector: string]} args 
+   */
   exec_exec(eventType, phxEvent, view, sourceEl, el, [attr, to]){
     let nodes = to ? DOM.all(document, to) : [sourceEl]
     nodes.forEach(node => {
@@ -39,12 +66,47 @@ let JS = {
     })
   },
 
+  /**
+   * Exec dispatch command for event
+   * @private
+   * @param {string} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {Element} sourceEl 
+   * @param {Element} el 
+   * @param {object} args 
+   * @param {string} [args.to]
+   * @param {string} [args.event] 
+   * @param {boolean} [args.bubbles] 
+   * @param {object} [args.detail]
+   */
+  // eslint-disable-next-line no-unused-vars
   exec_dispatch(eventType, phxEvent, view, sourceEl, el, {to, event, detail, bubbles}){
     detail = detail || {}
     detail.dispatcher = sourceEl
     DOM.dispatchEvent(el, event, {detail, bubbles})
   },
 
+  /**
+   * Push an event over livesocket
+   * @private
+   * @param {string} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {Element} sourceEl 
+   * @param {Element} el 
+   * @param {object} args 
+   * @param {string} [args.event]
+   * @param {any} [args.submitter]
+   * @param {string|number|Element} [args.target]
+   * @param {boolean} [args.page_loading]
+   * @param {any} [args.loading]
+   * @param {any} [args.value]
+   * @param {Element} [args.dispatcher]
+   * @param {function} [args.callback]
+   * @param {string|number} [args.newCid]
+   * @param {string} [args._target]
+   */
   exec_push(eventType, phxEvent, view, sourceEl, el, args){
     if(!view.isConnected()){ return }
 
@@ -67,26 +129,78 @@ let JS = {
     })
   },
 
+  /**
+   * @private
+   * @param {string} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {Element} sourceEl 
+   * @param {Element} el 
+   * @param {{href: string, replace?: boolean}} args 
+   */
   exec_navigate(eventType, phxEvent, view, sourceEl, el, {href, replace}){
     view.liveSocket.historyRedirect(href, replace ? "replace" : "push")
   },
 
+  /**
+   * @private
+   * @param {string} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {Element} sourceEl 
+   * @param {Element} el 
+   * @param {{href: string, replace: boolean}} args 
+   */
   exec_patch(eventType, phxEvent, view, sourceEl, el, {href, replace}){
     view.liveSocket.pushHistoryPatch(href, replace ? "replace" : "push", sourceEl)
   },
 
+
+  /**
+   * @private
+   * @param {string} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {Element} sourceEl 
+   * @param {Element} el 
+   */
   exec_focus(eventType, phxEvent, view, sourceEl, el){
     window.requestAnimationFrame(() => ARIA.attemptFocus(el))
   },
 
+  /**
+   * @private
+   * @param {string} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {Element} sourceEl 
+   * @param {Element} el 
+   */
   exec_focus_first(eventType, phxEvent, view, sourceEl, el){
     window.requestAnimationFrame(() => ARIA.focusFirstInteractive(el) || ARIA.focusFirst(el))
   },
 
+  /**
+   * @private
+   * @param {string} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {Element} sourceEl 
+   * @param {Element} el 
+   */
   exec_push_focus(eventType, phxEvent, view, sourceEl, el){
     window.requestAnimationFrame(() => focusStack = el || sourceEl)
   },
 
+  /**
+   * @private
+   * @param {string} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {Element} sourceEl 
+   * @param {Element} el 
+   */
+  // eslint-disable-next-line no-unused-vars
   exec_pop_focus(eventType, phxEvent, view, sourceEl, el){
     window.requestAnimationFrame(() => {
       if(focusStack){ focusStack.focus() }
@@ -94,6 +208,18 @@ let JS = {
     })
   },
 
+  /**
+   * @private
+   * @param {string} eventType 
+   * @param {string} phxEvent 
+   * @param {View} view 
+   * @param {Element} sourceEl 
+   * @param {Element} el 
+   * @param {object} args
+   * @param {object} args.names
+   * @param {object} args.transition
+   * @param {object} args.time
+   */
   exec_add_class(eventType, phxEvent, view, sourceEl, el, {names, transition, time}){
     this.addOrRemoveClasses(el, names, [], transition, time, view)
   },
@@ -193,6 +319,15 @@ let JS = {
     }
   },
 
+  /**
+   * @param {Element} el 
+   * @param {string[]} adds 
+   * @param {string[]} removes 
+   * @param {[runs: string[], starts: string[], ends: string[]]} [transition] 
+   * @param {number} time 
+   * @param {View} view 
+   * @returns 
+   */
   addOrRemoveClasses(el, adds, removes, transition, time, view){
     let [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []]
     if(transitionRun.length > 0){
@@ -208,6 +343,7 @@ let JS = {
     }
 
     window.requestAnimationFrame(() => {
+      /** @type {[string[], string[]]} */
       let [prevAdds, prevRemoves] = DOM.getSticky(el, "classes", [[], []])
       let keepAdds = adds.filter(name => prevAdds.indexOf(name) < 0 && !el.classList.contains(name))
       let keepRemoves = removes.filter(name => prevRemoves.indexOf(name) < 0 && el.classList.contains(name))
@@ -222,7 +358,13 @@ let JS = {
     })
   },
 
+  /**
+   * @param {Element} el 
+   * @param {[attr: string, val: string][]} sets 
+   * @param {string[]} removes 
+   */
   setOrRemoveAttrs(el, sets, removes){
+    /** @type {[[attr: string, val: string], string[]]} */
     let [prevSets, prevRemoves] = DOM.getSticky(el, "attrs", [[], []])
 
     let alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes);
@@ -236,19 +378,40 @@ let JS = {
     })
   },
 
+  /**
+   * @param {HTMLElement} el 
+   * @param {string[]} classes 
+   * @returns {boolean}
+   */
   hasAllClasses(el, classes){ return classes.every(name => el.classList.contains(name)) },
 
+  /**
+   * @param {HTMLElement} el 
+   * @param {string[]} outClasses 
+   * @returns {boolean}
+   */
   isToggledOut(el, outClasses){
     return !this.isVisible(el) || this.hasAllClasses(el, outClasses)
   },
 
+  /**
+   * @private
+   * @param {HTMLElement} sourceEl 
+   * @param {{to?: string}} args 
+   * @returns 
+   */
   filterToEls(sourceEl, {to}){
     return to ? DOM.all(document, to) : [sourceEl]
   },
 
+  /**
+   * @private
+   * @param {HTMLElement} el 
+   * @returns {"table-row"|"table-cell"|"block"}
+   */
   defaultDisplay(el){
     return {tr: "table-row", td: "table-cell"}[el.tagName.toLowerCase()] || "block"
   }
 }
 
-export default JS
\ No newline at end of file
+export default JS
diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js
index fe830f302c..2281450528 100644
--- a/assets/js/phoenix_live_view/live_socket.js
+++ b/assets/js/phoenix_live_view/live_socket.js
@@ -1,76 +1,16 @@
-/** Initializes the LiveSocket
- *
- *
- * @param {string} endPoint - The string WebSocket endpoint, ie, `"wss://example.com/live"`,
- *                                               `"/live"` (inherited host & protocol)
- * @param {Phoenix.Socket} socket - the required Phoenix Socket class imported from "phoenix". For example:
- *
- *     import {Socket} from "phoenix"
- *     import {LiveSocket} from "phoenix_live_view"
- *     let liveSocket = new LiveSocket("/live", Socket, {...})
- *
- * @param {Object} [opts] - Optional configuration. Outside of keys listed below, all
- * configuration is passed directly to the Phoenix Socket constructor.
- * @param {Object} [opts.defaults] - The optional defaults to use for various bindings,
- * such as `phx-debounce`. Supports the following keys:
- *
- *   - debounce - the millisecond phx-debounce time. Defaults 300
- *   - throttle - the millisecond phx-throttle time. Defaults 300
- *
- * @param {Function} [opts.params] - The optional function for passing connect params.
- * The function receives the element associated with a given LiveView. For example:
- *
- *     (el) => {view: el.getAttribute("data-my-view-name", token: window.myToken}
- *
- * @param {string} [opts.bindingPrefix] - The optional prefix to use for all phx DOM annotations.
- * Defaults to "phx-".
- * @param {Object} [opts.hooks] - The optional object for referencing LiveView hook callbacks.
- * @param {Object} [opts.uploaders] - The optional object for referencing LiveView uploader callbacks.
- * @param {integer} [opts.loaderTimeout] - The optional delay in milliseconds to wait before apply
- * loading states.
- * @param {integer} [opts.maxReloads] - The maximum reloads before entering failsafe mode.
- * @param {integer} [opts.reloadJitterMin] - The minimum time between normal reload attempts.
- * @param {integer} [opts.reloadJitterMax] - The maximum time between normal reload attempts.
- * @param {integer} [opts.failsafeJitter] - The time between reload attempts in failsafe mode.
- * @param {Function} [opts.viewLogger] - The optional function to log debug information. For example:
- *
- *     (view, kind, msg, obj) => console.log(`${view.id} ${kind}: ${msg} - `, obj)
- *
- * @param {Object} [opts.metadata] - The optional object mapping event names to functions for
- * populating event metadata. For example:
- *
- *     metadata: {
- *       click: (e, el) => {
- *         return {
- *           ctrlKey: e.ctrlKey,
- *           metaKey: e.metaKey,
- *           detail: e.detail || 1,
- *         }
- *       },
- *       keydown: (e, el) => {
- *         return {
- *           key: e.key,
- *           ctrlKey: e.ctrlKey,
- *           metaKey: e.metaKey,
- *           shiftKey: e.shiftKey
- *         }
- *       }
- *     }
- * @param {Object} [opts.sessionStorage] - An optional Storage compatible object
- * Useful when LiveView won't have access to `sessionStorage`.  For example, This could
- * happen if a site loads a cross-domain LiveView in an iframe.  Example usage:
- *
- *     class InMemoryStorage {
- *       constructor() { this.storage = {} }
- *       getItem(keyName) { return this.storage[keyName] || null }
- *       removeItem(keyName) { delete this.storage[keyName] }
- *       setItem(keyName, keyValue) { this.storage[keyName] = keyValue }
- *     }
- *
- * @param {Object} [opts.localStorage] - An optional Storage compatible object
- * Useful for when LiveView won't have access to `localStorage`.
- * See `opts.sessionStorage` for examples.
-*/
+/**
+ * Module dependencies
+ * 
+ * Phoenix:
+ * @typedef {import('phoenix').Socket} Socket
+ * @typedef {typeof import('phoenix').Socket} SocketCls
+ * @typedef {import('phoenix').Channel} Channel
+ * @typedef {import('phoenix').Push} Push
+ * 
+ * Local:
+ * @typedef {import('./view_hook.js').default} ViewHook
+ * @typedef {import('./view_hook.js').HookCallbacks} HookCallbacks
+ */
 
 import {
   BINDING_PREFIX,
@@ -106,7 +46,6 @@ import {
   closestPhxBinding,
   closure,
   debug,
-  isObject,
   maybe
 } from "./utils"
 
@@ -118,6 +57,78 @@ import View from "./view"
 import JS from "./js"
 
 export default class LiveSocket {
+  /** Constructor - Initializes the LiveSocket
+   *
+   * @param {string} url - The WebSocket endpoint, ie, `"wss://example.com/live"`, `"/live"` (inherited host & protocol)
+   * @param {SocketCls} phxSocket - The required Phoenix Socket class imported from "phoenix". For example:
+   *
+   *     import {Socket} from "phoenix"
+   *     import {LiveSocket} from "phoenix_live_view"
+   *     let liveSocket = new LiveSocket("/live", Socket, {...})
+   * 
+   * @param {Object} opts - Optional configuration. Outside of keys listed below, all
+   * configuration is passed directly to the Phoenix Socket constructor.
+   * 
+   * @param {{debounce: number, throttle: number}} [opts.defaults] - The optional defaults to use for various bindings,
+   * such as `phx-debounce`. Supports the following keys:
+   *
+   *   - debounce - the millisecond phx-debounce time. Defaults 300
+   *   - throttle - the millisecond phx-throttle time. Defaults 300
+   *
+   * @param {Function} [opts.params] - The optional function for passing connect params.
+   * The function receives the element associated with a given LiveView. For example:
+   *
+   *     (el) => {view: el.getAttribute("data-my-view-name", token: window.myToken}
+   *
+   * @param {string} [opts.bindingPrefix] - The optional prefix to use for all phx DOM annotations.
+   * Defaults to "phx-".
+   * @param {{[key:string]: HookCallbacks}} [opts.hooks] - The optional object for referencing LiveView hook callbacks.
+   * @param {{[key:string]: function}} [opts.uploaders] - The optional object for referencing LiveView uploader callbacks.
+   * @param {integer} [opts.loaderTimeout] - The optional delay in milliseconds to wait before apply
+   * loading states.
+   * @param {integer} [opts.maxReloads] - The maximum reloads before entering failsafe mode.
+   * @param {integer} [opts.reloadJitterMin] - The minimum time between normal reload attempts.
+   * @param {integer} [opts.reloadJitterMax] - The maximum time between normal reload attempts.
+   * @param {integer} [opts.failsafeJitter] - The time between reload attempts in failsafe mode.
+   * @param {(view: View, kind: string, msg: string, obj: any) => void} [opts.viewLogger] - The optional function to log debug information. For example:
+   *
+   *     (view, kind, msg, obj) => console.log(`${view.id} ${kind}: ${msg} - `, obj)
+   *
+   * @param {{[key:string]: (e: Event, targetEl: Element) => any}} [opts.metadata] - The optional object mapping event names to functions for
+   * populating event metadata. For example:
+   *
+   *     metadata: {
+   *       click: (e, el) => {
+   *         return {
+   *           ctrlKey: e.ctrlKey,
+   *           metaKey: e.metaKey,
+   *           detail: e.detail || 1,
+   *         }
+   *       },
+   *       keydown: (e, el) => {
+   *         return {
+   *           key: e.key,
+   *           ctrlKey: e.ctrlKey,
+   *           metaKey: e.metaKey,
+   *           shiftKey: e.shiftKey
+   *         }
+   *       }
+   *     }
+   * @param {Object} [opts.sessionStorage] - An optional Storage compatible object
+   * Useful when LiveView won't have access to `sessionStorage`.  For example, This could
+   * happen if a site loads a cross-domain LiveView in an iframe.  Example usage:
+   *
+   *     class InMemoryStorage {
+   *       constructor() { this.storage = {} }
+   *       getItem(keyName) { return this.storage[keyName] || null }
+   *       removeItem(keyName) { delete this.storage[keyName] }
+   *       setItem(keyName, keyValue) { this.storage[keyName] = keyValue }
+   *     }
+   *
+   * @param {Object} [opts.localStorage] - An optional Storage compatible object
+   * Useful for when LiveView won't have access to `localStorage`.
+   * See `opts.sessionStorage` for examples.
+   */
   constructor(url, phxSocket, opts = {}){
     this.unloaded = false
     if(!phxSocket || phxSocket.constructor.name === "Object"){
@@ -143,10 +154,12 @@ export default class LiveSocket {
     this.outgoingMainEl = null
     this.clickStartedAtTarget = null
     this.linkRef = 1
+    /** @type {{[key: string]: View}} */
     this.roots = {}
     this.href = window.location.href
     this.pendingLink = null
     this.currentLocation = clone(window.location)
+    /** @type {{[key: string]: HookCallbacks}} */
     this.hooks = opts.hooks || {}
     this.uploaders = opts.uploaders || {}
     this.loaderTimeout = opts.loaderTimeout || LOADER_TIMEOUT
@@ -173,35 +186,77 @@ export default class LiveSocket {
 
   // public
 
+  /**
+   * Is profiling mode enabled?
+   * @returns {boolean}
+   */
   isProfileEnabled(){ return this.sessionStorage.getItem(PHX_LV_PROFILE) === "true" }
 
+  /**
+   * Is debug mode enabled?
+   * @returns {boolean}
+   */
   isDebugEnabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true" }
 
+  /**
+   * Is debug mode disabled?
+   * @returns {boolean}
+   */
   isDebugDisabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "false" }
 
+  /**
+   * Enable debug mode
+   */
   enableDebug(){ this.sessionStorage.setItem(PHX_LV_DEBUG, "true") }
 
+  /**
+   * Enable profiling mode
+   */
   enableProfiling(){ this.sessionStorage.setItem(PHX_LV_PROFILE, "true") }
 
+  /**
+   * Disable debug mode
+   */
   disableDebug(){ this.sessionStorage.setItem(PHX_LV_DEBUG, "false") }
 
+  /**
+   * Disable profiling mode
+   */
   disableProfiling(){ this.sessionStorage.removeItem(PHX_LV_PROFILE) }
 
+
+  /**
+   * Enable latency simulation mode
+   * @param {number} upperBoundMs 
+   */
   enableLatencySim(upperBoundMs){
     this.enableDebug()
     console.log("latency simulator enabled for the duration of this browser session. Call disableLatencySim() to disable")
     this.sessionStorage.setItem(PHX_LV_LATENCY_SIM, upperBoundMs)
   }
 
+  /**
+   * Disable latency simulation mode
+   */
   disableLatencySim(){ this.sessionStorage.removeItem(PHX_LV_LATENCY_SIM) }
 
+  /**
+   * Get simulated latency value if set
+   * @returns {number|null}
+   */
   getLatencySim(){
     let str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM)
     return str ? parseInt(str) : null
   }
 
+  /**
+   * Get the underlying socket object
+   */
   getSocket(){ return this.socket }
 
+  /**
+   * Connect on socket and join views
+   */
   connect(){
     // enable debug by default if on localhost and not explicitly disabled
     if(window.location.hostname === "localhost" && !this.isDebugDisabled()){ this.enableDebug() }
@@ -223,29 +278,52 @@ export default class LiveSocket {
     }
   }
 
+  /**
+   * Disconnect
+   * @param {() => void | Promise<void>} callback 
+   */
   disconnect(callback){
     clearTimeout(this.reloadWithJitterTimer)
     this.socket.disconnect(callback)
   }
 
+  /**
+   * Replace socket transport object
+   * @param {new(endpoint: string) => object} transport - Class/Constructor implementing transport interface
+   */
   replaceTransport(transport){
     clearTimeout(this.reloadWithJitterTimer)
     this.socket.replaceTransport(transport)
     this.connect()
   }
 
+  /**
+   * Execute JS against element
+   * @param {Element} el 
+   * @param {string} encodedJS 
+   * @param {string} [eventType] 
+   */
   execJS(el, encodedJS, eventType = null){
     this.owner(el, view => JS.exec(eventType, encodedJS, view, el))
   }
 
   // private
 
+  /**
+   * @param {HTMLElement} el 
+   * @param {string} phxEvent 
+   * @param {object} data 
+   * @param {() => void} callback 
+   */
   execJSHookPush(el, phxEvent, data, callback){
     this.withinOwners(el, view => {
       JS.exec("hook", phxEvent, view, el, ["push", {data, callback}])
     })
   }
 
+  /**
+   * Disconnect socket, unload, and destroy all views
+   */
   unload(){
     if(this.unloaded){ return }
     if(this.main && this.isConnected()){ this.log(this.main, "socket", () => ["disconnect for page nav"]) }
@@ -254,8 +332,20 @@ export default class LiveSocket {
     this.disconnect()
   }
 
+  /**
+   * Run registered DOM callbacks matching kind
+   * @param {string} kind 
+   * @param {any[]} args 
+   */
   triggerDOM(kind, args){ this.domCallbacks[kind](...args) }
 
+  /**
+   * Execute the given function in a timer and log to the console
+   * @template T
+   * @param {string} name 
+   * @param {() => T} func 
+   * @returns {T}
+   */
   time(name, func){
     if(!this.isProfileEnabled() || !console.time){ return func() }
     console.time(name)
@@ -264,6 +354,12 @@ export default class LiveSocket {
     return result
   }
 
+  /**
+   * Debug log
+   * @param {View} view 
+   * @param {string} kind 
+   * @param {() => [string, any]} msgCallback - only called if opt.viewLogger given or isDebugEnabled
+   */
   log(view, kind, msgCallback){
     if(this.viewLogger){
       let [msg, obj] = msgCallback()
@@ -274,14 +370,30 @@ export default class LiveSocket {
     }
   }
 
+  /**
+   * Execute callback after next DOM update transition finishes
+   * @param {() => void} callback 
+   */
   requestDOMUpdate(callback){
     this.transitions.after(callback)
   }
 
+  /**
+   * Add a managed transition
+   * @param {number} time 
+   * @param {() => void} onStart 
+   * @param {() => void} [onDone] 
+   */
   transition(time, onStart, onDone = function(){}){
     this.transitions.addTransition(time, onStart, onDone)
   }
 
+  /**
+   * Subscribe to event on channel
+   * @param {Channel} channel - channel to listen on 
+   * @param {string} event - event to listen for
+   * @param {(response?: object) => void} cb - callback to invoke on each event
+   */
   onChannel(channel, event, cb){
     channel.on(event, data => {
       let latency = this.getLatencySim()
@@ -293,6 +405,13 @@ export default class LiveSocket {
     })
   }
 
+  /**
+   * Wrap Channel Push with additional management behavior
+   * @param {View} view 
+   * @param {{timeout: boolean}} opts 
+   * @param {() => Push} push 
+   * @returns {Push}
+   */
   wrapPush(view, opts, push){
     let latency = this.getLatencySim()
     let oldJoinCount = view.joinCount
@@ -321,6 +440,11 @@ export default class LiveSocket {
     return fakePush
   }
 
+  /**
+   * Reload page with simulated network jitter
+   * @param {View} view 
+   * @param {() => void} [log] 
+   */
   reloadWithJitter(view, log){
     clearTimeout(this.reloadWithJitterTimer)
     this.disconnect()
@@ -347,20 +471,51 @@ export default class LiveSocket {
     }, afterMs)
   }
 
+  /**
+   * Lookup hook
+   * @param {string} [name] 
+   * @returns {HookCallbacks|undefined}
+   */
   getHookCallbacks(name){
     return name && name.startsWith("Phoenix.") ? Hooks[name.split(".")[1]] : this.hooks[name]
   }
 
+  /**
+   * Is the socket unloaded?
+   * @returns {boolean}
+   */
   isUnloaded(){ return this.unloaded }
 
+  /**
+   * Is the socket connected?
+   * @returns {boolean}
+   */
   isConnected(){ return this.socket.isConnected() }
 
+  /**
+   * Get the prefix for attribute bindings
+   * @returns {string}
+   */
   getBindingPrefix(){ return this.bindingPrefix }
 
+  /**
+   * Create prefixed binding name
+   * @param {string} kind 
+   * @returns {string}
+   */
   binding(kind){ return `${this.getBindingPrefix()}${kind}` }
 
+  /**
+   * Get socket channel for this topic
+   * @param {string} topic 
+   * @param {object} [params] 
+   * @returns {Channel}
+   */
   channel(topic, params){ return this.socket.channel(topic, params) }
 
+  /**
+   * If no live root views, join the dead views
+   */
   joinDeadView(){
     let body = document.body
     if(body && !this.isPhxView(body) && !this.isPhxView(document.firstElementChild)){
@@ -372,6 +527,10 @@ export default class LiveSocket {
     }
   }
 
+  /**
+   * Find all root views and join()
+   * @returns {boolean} were root views found?
+   */
   joinRootViews(){
     let rootsFound = false
     DOM.all(document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, rootEl => {
@@ -386,11 +545,23 @@ export default class LiveSocket {
     return rootsFound
   }
 
+  /**
+   * Execute browser redirect
+   * @param {string} to 
+   * @param {string|null} flash 
+   */
   redirect(to, flash){
     this.unload()
     Browser.redirect(to, flash)
   }
 
+  /**
+   * Replace a new root view and main element
+   * @param {string} href 
+   * @param {string|null} flash 
+   * @param {(linkRef: number) => void} [callback] 
+   * @param {number} [linkRef] 
+   */
   replaceMain(href, flash, callback = null, linkRef = this.setPendingLink(href)){
     let liveReferer = this.currentLocation.href
     this.outgoingMainEl = this.outgoingMainEl || this.main.el
@@ -414,6 +585,10 @@ export default class LiveSocket {
     })
   }
 
+  /**
+   * Dispatch remove JS events for given elements or elements with "remove" binding
+   * @param {Element[]} [elements] 
+   */
   transitionRemoves(elements){
     let removeAttr = this.binding("remove")
     elements = elements || DOM.all(document, `[${removeAttr}]`)
@@ -422,30 +597,66 @@ export default class LiveSocket {
     })
   }
 
+  /**
+   * Is element part of a view?
+   * @param {Element} el 
+   * @returns {boolean}
+   */
   isPhxView(el){ return el.getAttribute && el.getAttribute(PHX_SESSION) !== null }
 
+  /**
+   * Create a root view
+   * @param {HTMLElement} el 
+   * @param {*} flash 
+   * @param {string} liveReferer - URL of location that initiated the new view
+   * @returns {View}
+   */
   newRootView(el, flash, liveReferer){
     let view = new View(el, this, null, flash, liveReferer)
     this.roots[view.id] = view
     return view
   }
 
+  /**
+   * Run callback with owning View
+   * @param {HTMLElement} childEl 
+   * @param {(view: View) => void} callback 
+   */
   owner(childEl, callback){
     let view = maybe(childEl.closest(PHX_VIEW_SELECTOR), el => this.getViewByEl(el)) || this.main
     if(view){ callback(view) }
   }
 
+  /**
+   * Execute callback for view owning given element
+   * @template {HTMLElement} T
+   * @param {T} childEl 
+   * @param {(view: View, childEl: T) => void} callback 
+   */
   withinOwners(childEl, callback){
     this.owner(childEl, view => callback(view, childEl))
   }
 
+  /**
+   * Get view owning the element
+   * @param {Element} el 
+   * @returns {View}
+   */
   getViewByEl(el){
     let rootId = el.getAttribute(PHX_ROOT_ID)
     return maybe(this.getRootById(rootId), root => root.getDescendentByEl(el))
   }
 
+  /**
+   * Get root view by ID
+   * @param {string} id 
+   * @returns {View}
+   */
   getRootById(id){ return this.roots[id] }
 
+  /**
+   * Destroy all root views
+   */
   destroyAllViews(){
     for(let id in this.roots){
       this.roots[id].destroy()
@@ -454,6 +665,10 @@ export default class LiveSocket {
     this.main = null
   }
 
+  /**
+   * Destroy root view for element
+   * @param {Element} el 
+   */
   destroyViewByEl(el){
     let root = this.getRootById(el.getAttribute(PHX_ROOT_ID))
     if(root && root.id === el.id){
@@ -464,6 +679,10 @@ export default class LiveSocket {
     }
   }
 
+  /**
+   * Set target as the new active element
+   * @param {Element} target 
+   */
   setActiveElement(target){
     if(this.activeElement === target){ return }
     this.activeElement = target
@@ -476,6 +695,10 @@ export default class LiveSocket {
     target.addEventListener("touchend", cancel)
   }
 
+  /**
+   * Get document's active element
+   * @returns {Element}
+   */
   getActiveElement(){
     if(document.activeElement === document.body){
       return this.activeElement || document.activeElement
@@ -485,23 +708,37 @@ export default class LiveSocket {
     }
   }
 
+  /**
+   * Unset the existing active element if it's owned by given view
+   * @param {View} view 
+   */
   dropActiveElement(view){
     if(this.prevActive && view.ownsElement(this.prevActive)){
       this.prevActive = null
     }
   }
 
+  /**
+   * Restore focus to the previously active element
+   */
   restorePreviouslyActiveFocus(){
     if(this.prevActive && this.prevActive !== document.body){
       this.prevActive.focus()
     }
   }
 
+  /**
+   * Blue the active element after tracking it for potential future focus
+   * restore.
+   */
   blurActiveElement(){
     this.prevActive = this.getActiveElement()
     if(this.prevActive !== document.body){ this.prevActive.blur() }
   }
 
+  /**
+   * @param {{dead?: boolean}} args 
+   */
   bindTopLevelEvents({dead} = {}){
     if(this.boundTopLevelEvents){ return }
 
@@ -522,7 +759,7 @@ export default class LiveSocket {
     if(!dead){ this.bindNav() }
     this.bindClicks()
     if(!dead){ this.bindForms() }
-    this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, eventTarget) => {
+    this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, _eventTarget) => {
       let matchKey = targetEl.getAttribute(this.binding(PHX_KEY))
       let pressedKey = e.key && e.key.toLowerCase() // chrome clicked autocompletes send a keydown without key
       if(matchKey && matchKey.toLowerCase() !== pressedKey){ return }
@@ -565,17 +802,31 @@ export default class LiveSocket {
     })
   }
 
+  /**
+   * @param {string} eventName 
+   * @param {Event} e 
+   * @param {Element} targetEl 
+   * @returns {object}
+   */
   eventMeta(eventName, e, targetEl){
     let callback = this.metadataCallbacks[eventName]
     return callback ? callback(e, targetEl) : {}
   }
 
+  /**
+   * @param {string} href 
+   * @returns {number}
+   */
   setPendingLink(href){
     this.linkRef++
     this.pendingLink = href
     return this.linkRef
   }
 
+  /**
+   * @param {number} linkRef 
+   * @returns {boolean}
+   */
   commitPendingLink(linkRef){
     if(this.linkRef !== linkRef){
       return false
@@ -586,10 +837,21 @@ export default class LiveSocket {
     }
   }
 
+  /**
+   * @returns {string}
+   */
   getHref(){ return this.href }
 
+  /**
+   * @returns {boolean}
+   */
   hasPendingLink(){ return !!this.pendingLink }
 
+  /**
+   * Bind handler to multiple browser events
+   * @param {{[key:string]: string}} events 
+   * @param {(e: Event, eventType: string, view: View, targetEl: Element, phxEvent: string, eventTarget: Element) => void} callback 
+   */
   bind(events, callback){
     for(let event in events){
       let browserEventName = events[event]
@@ -618,12 +880,20 @@ export default class LiveSocket {
     }
   }
 
+  /**
+   * Bind to all window click events to dispatch internally
+   */
   bindClicks(){
     window.addEventListener("click", e => this.clickStartedAtTarget = e.target)
     this.bindClick("click", "click", false)
     this.bindClick("mousedown", "capture-click", true)
   }
 
+  /**
+   * @param {string} eventName 
+   * @param {string} bindingName 
+   * @param {boolean} capture 
+   */
   bindClick(eventName, bindingName, capture){
     let click = this.binding(bindingName)
     window.addEventListener(eventName, e => {
@@ -655,6 +925,11 @@ export default class LiveSocket {
     }, capture)
   }
 
+  /**
+   * Dispatch click-away events
+   * @param {Event} e 
+   * @param {Element} clickStartedAt 
+   */
   dispatchClickAway(e, clickStartedAt){
     let phxClickAway = this.binding("click-away")
     DOM.all(document, `[${phxClickAway}]`, el => {
@@ -669,6 +944,11 @@ export default class LiveSocket {
     })
   }
 
+  /**
+   * Bind navigation events to dispatch internally
+   * 
+   * NOTE: tracking scrolls to restore scroll when necessary
+   */
   bindNav(){
     if(!Browser.canPushState()){ return }
     if(history.scrollRestoration){ history.scrollRestoration = "manual" }
@@ -725,7 +1005,11 @@ export default class LiveSocket {
     }, false)
   }
 
-  maybeScroll(scroll) {
+  /**
+   * Scroll if value given
+   * @param {number} [scroll] 
+   */
+  maybeScroll(scroll){
     if(typeof(scroll) === "number"){
       requestAnimationFrame(() => {
         window.scrollTo(0, scroll)
@@ -733,20 +1017,42 @@ export default class LiveSocket {
     }
   }
 
+  /**
+   * Dispatch event on window
+   * @param {string} event 
+   * @param {{[key:string]: any}} [payload] 
+   */
   dispatchEvent(event, payload = {}){
     DOM.dispatchEvent(window, `phx:${event}`, {detail: payload})
   }
 
+  /**
+   * Dispatch each event on window
+   * @param {[eventName: string, payload: any][]} events 
+   */
   dispatchEvents(events){
     events.forEach(([event, payload]) => this.dispatchEvent(event, payload))
   }
 
+  /**
+   * Dispatch phx:page-loading-start and return callback to end loading
+   * @template T
+   * @param {any} [info] - optional data to provide in event detail
+   * @param {(stopLoadingCb: ()=>void) => T} [callback] - if given, call with callback that dispatches stop-loading event
+   * @returns {T|()=>void} either a callback to dispatch stop-loading event, or result of callback if given
+   */
   withPageLoading(info, callback){
     DOM.dispatchEvent(window, "phx:page-loading-start", {detail: info})
     let done = () => DOM.dispatchEvent(window, "phx:page-loading-stop", {detail: info})
     return callback ? callback(done) : done
   }
 
+  /**
+   * Push a location history patch
+   * @param {string} href 
+   * @param {("push"|"replace")} linkState 
+   * @param {HTMLElement} targetEl 
+   */
   pushHistoryPatch(href, linkState, targetEl){
     if(!this.isConnected()){ return Browser.redirect(href) }
 
@@ -758,6 +1064,12 @@ export default class LiveSocket {
     })
   }
 
+  /**
+   * Perform browser history update for patching
+   * @param {string} href 
+   * @param {("push"|"replace")} linkState 
+   * @param {number} linkRef 
+   */
   historyPatch(href, linkState, linkRef = this.setPendingLink(href)){
     if(!this.commitPendingLink(linkRef)){ return }
 
@@ -766,6 +1078,12 @@ export default class LiveSocket {
     this.registerNewLocation(window.location)
   }
 
+  /**
+   * Perform browser history update for redirect
+   * @param {string} href 
+   * @param {("push"|"replace")} linkState 
+   * @param {string|null} flash 
+   */
   historyRedirect(href, linkState, flash){
     // convert to full href if only path prefix
     if(!this.isConnected()){ return Browser.redirect(href, flash) }
@@ -786,10 +1104,18 @@ export default class LiveSocket {
     })
   }
 
+  /**
+   * Replace root in browser history state
+   */
   replaceRootHistory(){
     Browser.pushState("replace", {root: true, type: "patch", id: this.main.id})
   }
 
+  /**
+   * Set new location as current location
+   * @param {Location} newLocation 
+   * @returns {boolean}
+   */
   registerNewLocation(newLocation){
     let {pathname, search} = this.currentLocation
     if(pathname + search === newLocation.pathname + newLocation.search){
@@ -800,6 +1126,9 @@ export default class LiveSocket {
     }
   }
 
+  /**
+   * Bind Forms
+   */
   bindForms(){
     let iterations = 0
     let externalFormSubmitted = false
@@ -878,6 +1207,13 @@ export default class LiveSocket {
     })
   }
 
+  /**
+   * @private
+   * @param {Element} el 
+   * @param {Event} event 
+   * @param {string} eventType 
+   * @param {string} callback 
+   */
   debounce(el, event, eventType, callback){
     if(eventType === "blur" || eventType === "focusout"){ return callback() }
 
@@ -894,12 +1230,21 @@ export default class LiveSocket {
     })
   }
 
+  /**
+   * Disable event listeners, execute callback, enable event listeners
+   * @param {() => void} callback 
+   */
   silenceEvents(callback){
     this.silenced = true
     callback()
     this.silenced = false
   }
 
+  /**
+   * Attach handler for event
+   * @param {string} event 
+   * @param {(e: Event) => void} callback 
+   */
   on(event, callback){
     window.addEventListener(event, e => {
       if(!this.silenced){ callback(e) }
@@ -909,10 +1254,15 @@ export default class LiveSocket {
 
 class TransitionSet {
   constructor(){
+    /** @type {Set<number>} @private */
     this.transitions = new Set()
+    /** @type {function[]} */
     this.pendingOps = []
   }
 
+  /**
+   * Clear all transitions and flush any pending operations
+   */
   reset(){
     this.transitions.forEach(timer => {
       clearTimeout(timer)
@@ -921,6 +1271,11 @@ class TransitionSet {
     this.flushPendingOps()
   }
 
+  /**
+   * Register given callback for execution after next transition finishes.
+   * Executes immediately if no transition is in-flight.
+   * @param {() => void} callback 
+   */
   after(callback){
     if(this.size() === 0){
       callback()
@@ -929,6 +1284,12 @@ class TransitionSet {
     }
   }
 
+  /**
+   * Add a transition.
+   * @param {number} time 
+   * @param {() => void} onStart 
+   * @param {() => void} onDone 
+   */
   addTransition(time, onStart, onDone){
     onStart()
     let timer = setTimeout(() => {
@@ -939,10 +1300,23 @@ class TransitionSet {
     this.transitions.add(timer)
   }
 
+  /**
+   * Register operation for execution after next transition
+   * @private
+   * @param {() => void} op 
+   */
   pushPendingOp(op){ this.pendingOps.push(op) }
 
+  /**
+   * Get the size of current transition set.
+   * @returns {number}
+   */
   size(){ return this.transitions.size }
 
+  /**
+   * Execute all registered pending operations
+   * @private
+   */
   flushPendingOps(){
     if(this.size() > 0){ return }
     let op = this.pendingOps.shift()
diff --git a/assets/js/phoenix_live_view/live_uploader.js b/assets/js/phoenix_live_view/live_uploader.js
index 64df45a230..3021218aff 100644
--- a/assets/js/phoenix_live_view/live_uploader.js
+++ b/assets/js/phoenix_live_view/live_uploader.js
@@ -1,3 +1,8 @@
+/**
+ * Module Type Dependencies:
+ * @typedef {import('./view.js').default} View
+ * @typedef {import('./live_socket.js').default} LiveSocket
+ */
 import {
   PHX_DONE_REFS,
   PHX_PREFLIGHTED_REFS,
@@ -13,6 +18,11 @@ import UploadEntry from "./upload_entry"
 let liveUploaderFileRef = 0
 
 export default class LiveUploader {
+  /**
+   * Generate a unique reference for this file
+   * @param {File} file 
+   * @returns {string} the file ref
+   */
   static genFileRef(file){
     let ref = file._phxRef
     if(ref !== undefined){
@@ -23,11 +33,22 @@ export default class LiveUploader {
     }
   }
 
+  /**
+   * Create URL and pass to the callback
+   * @param {HTMLInputElement} inputEl 
+   * @param {string} ref 
+   * @param {function} callback 
+   */
   static getEntryDataURL(inputEl, ref, callback){
     let file = this.activeFiles(inputEl).find(file => this.genFileRef(file) === ref)
     callback(URL.createObjectURL(file))
   }
 
+  /**
+   * Are any file uploads still in-flight for the given form?
+   * @param {HTMLFormElement} formEl 
+   * @returns {boolean}
+   */
   static hasUploadsInProgress(formEl){
     let active = 0
     DOM.findUploadInputs(formEl).forEach(input => {
@@ -38,6 +59,11 @@ export default class LiveUploader {
     return active > 0
   }
 
+  /**
+   * 
+   * @param {HTMLInputElement} inputEl 
+   * @returns {object} map of file refs to array of entries
+   */
   static serializeUploads(inputEl){
     let files = this.activeFiles(inputEl)
     let fileData = {}
@@ -57,16 +83,31 @@ export default class LiveUploader {
     return fileData
   }
 
+  /**
+   * Clear upload refs on given file upload input
+   * @param {HTMLInputElement} inputEl 
+   */
   static clearFiles(inputEl){
     inputEl.value = null
     inputEl.removeAttribute(PHX_UPLOAD_REF)
     DOM.putPrivate(inputEl, "files", [])
   }
 
+  /**
+   * Untrack file upload for input
+   * @param {HTMLInputElement} inputEl 
+   * @param {File} file 
+   */
   static untrackFile(inputEl, file){
     DOM.putPrivate(inputEl, "files", DOM.private(inputEl, "files").filter(f => !Object.is(f, file)))
   }
 
+  /**
+   * Track file uploads for the given input
+   * @param {HTMLInputElement} inputEl 
+   * @param {File[]} files 
+   * @param {object=} dataTransfer 
+   */
   static trackFiles(inputEl, files, dataTransfer){
     if(inputEl.getAttribute("multiple") !== null){
       let newFiles = files.filter(file => !this.activeFiles(inputEl).find(f => Object.is(f, file)))
@@ -79,24 +120,50 @@ export default class LiveUploader {
     }
   }
 
+  /**
+   * Select a list of all file inputs with active uploads
+   * @param {HTMLFormElement} formEl 
+   * @returns {HTMLInputElement[]}
+   */
   static activeFileInputs(formEl){
     let fileInputs = DOM.findUploadInputs(formEl)
     return Array.from(fileInputs).filter(el => el.files && this.activeFiles(el).length > 0)
   }
 
+  /**
+   * Select a list of all files from this input being actively uploaded
+   * @param {HTMLInputElement} input 
+   * @returns {File[]}
+   */
   static activeFiles(input){
     return (DOM.private(input, "files") || []).filter(f => UploadEntry.isActive(input, f))
   }
 
+  /**
+   * Select a list of all file inputs with files still awaiting preflight
+   * @param {HTMLFormElement} formEl 
+   * @returns {HTMLInputElement[]}
+   */
   static inputsAwaitingPreflight(formEl){
     let fileInputs = DOM.findUploadInputs(formEl)
     return Array.from(fileInputs).filter(input => this.filesAwaitingPreflight(input).length > 0)
   }
 
+  /**
+   * Select a list of all files from this input still awaiting preflight 
+   * @param {HTMLInputElement} input 
+   * @returns {File[]}
+   */
   static filesAwaitingPreflight(input){
     return this.activeFiles(input).filter(f => !UploadEntry.isPreflighted(input, f))
   }
 
+  /**
+   * Constructor
+   * @param {HTMLInputElement} inputEl 
+   * @param {View} view 
+   * @param {function} onComplete 
+   */
   constructor(inputEl, view, onComplete){
     this.view = view
     this.onComplete = onComplete
@@ -107,8 +174,18 @@ export default class LiveUploader {
     this.numEntriesInProgress = this._entries.length
   }
 
+  /**
+   * Get upload entries list
+   * @returns {UploadEntry[]}
+   */
   entries(){ return this._entries }
 
+  /**
+   * Initialize the upload process
+   * @param {any} resp 
+   * @param {function} onError 
+   * @param {LiveSocket} liveSocket 
+   */
   initAdapterUpload(resp, onError, liveSocket){
     this._entries =
       this._entries.map(entry => {
diff --git a/assets/js/phoenix_live_view/rendered.js b/assets/js/phoenix_live_view/rendered.js
index 8ad1e7adaa..437fee2824 100644
--- a/assets/js/phoenix_live_view/rendered.js
+++ b/assets/js/phoenix_live_view/rendered.js
@@ -19,6 +19,27 @@ import {
   isCid,
 } from "./utils"
 
+/** 
+ * @typedef {[number, number]} Insert
+ * 
+ * @typedef {[
+ *   ref: string, 
+ *   inserts: {[key: string]: Insert}, 
+ *   deleteIds: string[], 
+ *   reset: boolean|undefined
+ * ]} Stream 
+ *
+ * @typedef {{
+ *   [key: string]: string | number | string[] | RenderedDiffNode,
+ * }} RenderedDiffNode
+ * 
+ * @typedef {object} Output
+ * @property {string} buffer
+ * @property {Set<Stream>} streams
+ * @property {RenderedDiffNode} [components]
+ * @property {Set<string|number>} [onlyCids]
+ */
+
 const VOID_TAGS = new Set([
   "area",
   "base",
@@ -39,6 +60,13 @@ const VOID_TAGS = new Set([
 ])
 const endingTagNameChars = new Set([">", "/", " ", "\n", "\t", "\r"])
 
+/**
+ * Modify root html
+ * @param {string} html 
+ * @param {{[key: string]: boolean|string}} attrs 
+ * @param {boolean} [clearInnerHTML] - skip
+ * @returns {[newHTML: string, beforeTag: string, afterTag: string]}
+ */
 export let modifyRoot = (html, attrs, clearInnerHTML) => {
   let i = 0
   let insideComment = false
@@ -94,8 +122,8 @@ export let modifyRoot = (html, attrs, clearInnerHTML) => {
 
   let attrsStr =
     Object.keys(attrs)
-    .map(attr => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`)
-    .join(" ")
+      .map(attr => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`)
+      .join(" ")
 
   if(clearInnerHTML){
     if(VOID_TAGS.has(tag)){
@@ -111,7 +139,13 @@ export let modifyRoot = (html, attrs, clearInnerHTML) => {
   return [newHTML, beforeTag, afterTag]
 }
 
+
 export default class Rendered {
+  /**
+   * Extract the important top-level elements from the diff
+   * @param {RenderedDiff} diff 
+   * @returns {{diff: RenderedDiffNode, title?: string, reply: number|null, events: any[]}}
+   */
   static extract(diff){
     let {[REPLY]: reply, [EVENTS]: events, [TITLE]: title} = diff
     delete diff[REPLY]
@@ -120,20 +154,45 @@ export default class Rendered {
     return {diff, title, reply: reply || null, events: events || []}
   }
 
+  /**
+   * Constructor - Handle the LiveView wire protocol render trees with their
+   * statics, dynamics, components, streams, and templates.
+   * 
+   * @param {string} viewId 
+   * @param {RenderedDiffNode} rendered 
+   */
   constructor(viewId, rendered){
     this.viewId = viewId
+    /** @type {RenderedDiffNode} */
     this.rendered = {}
     this.magicId = 0
     this.mergeDiff(rendered)
   }
 
+  /**
+   * Get ID of view that created
+   * @returns {string}
+   */
   parentViewId(){ return this.viewId }
 
+  /**
+   * @param {(string|number)[]} [onlyCids] 
+   * @returns {[string, streams: Set<Stream>]}
+   */
   toString(onlyCids){
     let [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, true, {})
     return [str, streams]
   }
 
+  /**
+   * @private
+   * @param {RenderedDiffNode} rendered 
+   * @param {RenderedDiffNode} [components] 
+   * @param {(string|number)[]} onlyCids 
+   * @param {boolean} changeTracking 
+   * @param {{[key: string]: string}} [rootAttrs] 
+   * @returns {[buffer: Output['buffer'], streams: Output['streams']]}
+   */
   recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids, changeTracking, rootAttrs){
     onlyCids = onlyCids ? new Set(onlyCids) : null
     let output = {buffer: "", components: components, onlyCids: onlyCids, streams: new Set()}
@@ -141,15 +200,35 @@ export default class Rendered {
     return [output.buffer, output.streams]
   }
 
+  /**
+   * Extract component IDs from diff
+   * @param {{[key: string]: any}} diff 
+   * @returns {number[]} list of all CIDs parsed to ints
+   */
   componentCIDs(diff){ return Object.keys(diff[COMPONENTS] || {}).map(i => parseInt(i)) }
 
+  /**
+   * Does diff only contain COMPONENTS segment?
+   * @param {RenderedDiffNode} diff
+   * @returns {boolean}
+   */
   isComponentOnlyDiff(diff){
     if(!diff[COMPONENTS]){ return false }
     return Object.keys(diff).length === 1
   }
 
+  /**
+   * Lookup COMPONENT node with CID from diff 
+   * @param {RenderedDiffNode} diff 
+   * @param {number|string} cid 
+   * @returns {RenderedDiffNode}
+   */
   getComponent(diff, cid){ return diff[COMPONENTS][cid] }
 
+  /**
+   * Merge given diff with 
+   * @param {RenderedDiffNode} diff 
+   */
   mergeDiff(diff){
     let newc = diff[COMPONENTS]
     let cache = {}
@@ -169,6 +248,16 @@ export default class Rendered {
     }
   }
 
+  /**
+   * Lookup Component node
+   * @private
+   * @param {string|number} cid 
+   * @param {RenderedDiffNode} cdiff 
+   * @param {RenderedDiffNode} oldc 
+   * @param {RenderedDiffNode} newc 
+   * @param {{[key: string]: any}} cache 
+   * @returns {RenderedDiffNode}
+   */
   cachedFindComponent(cid, cdiff, oldc, newc, cache){
     if(cache[cid]){
       return cache[cid]
@@ -197,6 +286,12 @@ export default class Rendered {
     }
   }
 
+  /**
+   * @private
+   * @param {object} target 
+   * @param {object} source 
+   * @returns {object}
+   */
   mutableMerge(target, source){
     if(source[STATIC] !== undefined){
       return source
@@ -206,6 +301,11 @@ export default class Rendered {
     }
   }
 
+  /**
+   * @private
+   * @param {object} target 
+   * @param {object} source 
+   */
   doMutableMerge(target, source){
     for(let key in source){
       let val = source[key]
@@ -222,14 +322,19 @@ export default class Rendered {
     }
   }
 
-  // Merges cid trees together, copying statics from source tree.
-  //
-  // The `pruneMagicId` is passed to control pruning the magicId of the
-  // target. We must always prune the magicId when we are sharing statics
-  // from another component. If not pruning, we replicate the logic from
-  // mutableMerge, where we set newRender to true if there is a root
-  // (effectively forcing the new version to be rendered instead of skipped)
-  //
+  /**
+   * Merges cid trees together, copying statics from source tree.
+   *
+   * The `pruneMagicId` is passed to control pruning the magicId of the
+   * target. We must always prune the magicId when we are sharing statics
+   * from another component. If not pruning, we replicate the logic from
+   * mutableMerge, where we set newRender to true if there is a root
+   * (effectively forcing the new version to be rendered instead of skipped)
+   * @param {RenderedDiffNode} target 
+   * @param {RenderedDiffNode} source 
+   * @param {boolean} pruneMagicId 
+   * @returns {RenderedDiffNode} merged CID tree
+   */
   cloneMerge(target, source, pruneMagicId){
     let merged = {...target, ...source}
     for(let key in merged){
@@ -248,40 +353,77 @@ export default class Rendered {
     return merged
   }
 
+  /**
+   * Get stripped HTML for component with ID
+   * @param {string} cid 
+   * @returns {[strippedHTML: string, streams: string | Set<any>]}
+   */
   componentToString(cid){
     let [str, streams] = this.recursiveCIDToString(this.rendered[COMPONENTS], cid, null)
     let [strippedHTML, _before, _after] = modifyRoot(str, {})
     return [strippedHTML, streams]
   }
 
+
+
   pruneCIDs(cids){
     cids.forEach(cid => delete this.rendered[COMPONENTS][cid])
   }
 
   // private
 
+  /**
+   * Get the current diff tree
+   * @private
+   * @returns {RenderedDiffNode}
+   */
   get(){ return this.rendered }
 
+  /**
+   * Is the diff a new fingerprint?
+   * @private
+   * @param {RenderedDiffNode} [diff] 
+   * @returns {boolean}
+   */
   isNewFingerprint(diff = {}){ return !!diff[STATIC] }
 
+  /**
+   * Template statics into 
+   * @private
+   * @param {string[]|number} part 
+   * @param {any} templates 
+   * @returns {any}
+   */
   templateStatic(part, templates){
-    if(typeof (part) === "number") {
+    if(typeof (part) === "number"){
       return templates[part]
     } else {
       return part
     }
   }
 
+  /**
+   * @private
+   * @returns {string} unique magic ID
+   */
   nextMagicID(){
     this.magicId++
     return `${this.parentViewId()}-${this.magicId}`
   }
 
-  // Converts rendered tree to output buffer.
-  //
-  // changeTracking controls if we can apply the PHX_SKIP optimization.
-  // It is disabled for comprehensions since we must re-render the entire collection
-  // and no invidial element is tracked inside the comprehension.
+  /**
+   * Converts rendered tree to output buffer.
+   *
+   * changeTracking controls if we can apply the PHX_SKIP optimization.
+   * It is disabled for comprehensions since we must re-render the entire collection
+   * and no invidial element is tracked inside the comprehension.
+   * @private
+   * @param {RenderedDiffNode} rendered 
+   * @param {{[key: string]: string[]}} [templates] 
+   * @param {Output} output 
+   * @param {boolean} changeTracking 
+   * @param {{[key: string]: string}} [rootAttrs] 
+   */
   toOutputBuffer(rendered, templates, output, changeTracking, rootAttrs = {}){
     if(rendered[DYNAMICS]){ return this.comprehensionToBuffer(rendered, templates, output) }
     let {[STATIC]: statics} = rendered
@@ -321,6 +463,12 @@ export default class Rendered {
     }
   }
 
+  /**
+   * @private
+   * @param {RenderedDiffNode} rendered 
+   * @param {{[key: string]: string[]}} [templates] 
+   * @param {Output} output 
+   */
   comprehensionToBuffer(rendered, templates, output){
     let {[DYNAMICS]: dynamics, [STATIC]: statics, [STREAM]: stream} = rendered
     let [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null]
@@ -347,6 +495,13 @@ export default class Rendered {
     }
   }
 
+  /**
+   * @private
+   * @param {RenderedDiffNode} rendered 
+   * @param {{[key: string]: string[]}} [templates] 
+   * @param {Output} output 
+   * @param {boolean} changeTracking 
+   */
   dynamicToBuffer(rendered, templates, output, changeTracking){
     if(typeof (rendered) === "number"){
       let [str, streams] = this.recursiveCIDToString(output.components, rendered, output.onlyCids)
@@ -359,6 +514,13 @@ export default class Rendered {
     }
   }
 
+  /**
+   * @private
+   * @param {RenderedDiffNode} components 
+   * @param {number|string} cid 
+   * @param {Set<number|string>} [onlyCids] 
+   * @returns {[html: string, streams: Set<Stream>]}
+   */
   recursiveCIDToString(components, cid, onlyCids){
     let component = components[cid] || logError(`no component for CID ${cid}`, components)
     let attrs = {[PHX_COMPONENT]: cid}
@@ -383,4 +545,4 @@ export default class Rendered {
 
     return [html, streams]
   }
-}
\ No newline at end of file
+}
diff --git a/assets/js/phoenix_live_view/upload_entry.js b/assets/js/phoenix_live_view/upload_entry.js
index c4b31ffe24..54bf12cbe7 100644
--- a/assets/js/phoenix_live_view/upload_entry.js
+++ b/assets/js/phoenix_live_view/upload_entry.js
@@ -1,3 +1,9 @@
+/**
+ * Module Type Dependencies:
+ * @typedef {import('./view.js').default} View
+ * @typedef {import('./live_socket.js').default} LiveSocket
+ */
+
 import {
   PHX_ACTIVE_ENTRY_REFS,
   PHX_LIVE_FILE_UPDATED,
@@ -13,6 +19,12 @@ import LiveUploader from "./live_uploader"
 import DOM from "./dom"
 
 export default class UploadEntry {
+  /**
+   * Is the file for this input still being uploaded? 
+   * @param {HTMLInputElement} fileEl 
+   * @param {File} file 
+   * @returns {boolean}
+   */
   static isActive(fileEl, file){
     let isNew = file._phxRef === undefined
     let activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",")
@@ -20,29 +32,59 @@ export default class UploadEntry {
     return file.size > 0 && (isNew || isActive)
   }
 
+  /**
+   * Is the file for this input active and in preflight?
+   * @param {HTMLInputElement} fileEl 
+   * @param {File} file 
+   * @returns {boolean}
+   */
   static isPreflighted(fileEl, file){
     let preflightedRefs = fileEl.getAttribute(PHX_PREFLIGHTED_REFS).split(",")
     let isPreflighted = preflightedRefs.indexOf(LiveUploader.genFileRef(file)) >= 0
     return isPreflighted && this.isActive(fileEl, file)
   }
 
+  /**
+   * Constructor
+   * @param {HTMLInputElement} fileEl 
+   * @param {File} file 
+   * @param {View} view 
+   */
   constructor(fileEl, file, view){
+    /** @readonly */
     this.ref = LiveUploader.genFileRef(file)
+
     this.fileEl = fileEl
     this.file = file
     this.view = view
     this.meta = null
+    
+    /** @private */
     this._isCancelled = false
+    /** @private */
     this._isDone = false
+    /** @private */
     this._progress = 0
+    /** @private */
     this._lastProgressSent = -1
+    /** @private */
     this._onDone = function (){ }
+    /** @private */
     this._onElUpdated = this.onElUpdated.bind(this)
+
     this.fileEl.addEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated)
   }
 
+  /**
+   * Get metadata for this upload entry
+   * @returns {{uploader: LiveUploader}|null}
+   */
   metadata(){ return this.meta }
 
+  /**
+   * Push file progress to the view
+   * @param {number} progress 
+   */
   progress(progress){
     this._progress = Math.floor(progress)
     if(this._progress > this._lastProgressSent){
@@ -61,22 +103,35 @@ export default class UploadEntry {
     }
   }
 
+  /**
+   * Cancel the upload for this entry
+   */
   cancel(){
     this._isCancelled = true
     this._isDone = true
     this._onDone()
   }
 
+  /**
+   * @returns {boolean}
+   */
   isDone(){ return this._isDone }
 
+  /**
+   * Mark upload as an error and update view file progress
+   * @param {string} [reason] 
+   */
   error(reason = "failed"){
     this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated)
     this.view.pushFileProgress(this.fileEl, this.ref, {error: reason})
     if(!DOM.isAutoUpload(this.fileEl)){ LiveUploader.clearFiles(this.fileEl) }
   }
 
-  //private
-
+  /**
+   * Set a callback for when upload is done
+   * @private
+   * @param {() => void} callback 
+   */
   onDone(callback){
     this._onDone = () => {
       this.fileEl.removeEventListener(PHX_LIVE_FILE_UPDATED, this._onElUpdated)
@@ -84,11 +139,19 @@ export default class UploadEntry {
     }
   }
 
+  /**
+   * @private
+   */
   onElUpdated(){
     let activeRefs = this.fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",")
     if(activeRefs.indexOf(this.ref) === -1){ this.cancel() }
   }
 
+  /**
+   * Generate the preflight payload data
+   * @private
+   * @returns
+   */
   toPreflightPayload(){
     return {
       last_modified: this.file.lastModified,
@@ -101,6 +164,12 @@ export default class UploadEntry {
     }
   }
 
+  /**
+   * Lookup the uploader for this specific entry
+   * @private
+   * @param {{[key: string]: function}} uploaders
+   * @returns {{name: string, callback: function}}
+   */
   uploader(uploaders){
     if(this.meta.uploader){
       let callback = uploaders[this.meta.uploader] || logError(`no uploader configured for ${this.meta.uploader}`)
@@ -110,6 +179,11 @@ export default class UploadEntry {
     }
   }
 
+  /**
+   * Update metadata from response
+   * @private
+   * @param {{entries: {[key: string]: object}}} resp 
+   */
   zipPostFlight(resp){
     this.meta = resp.entries[this.ref]
     if(!this.meta){ logError(`no preflight upload response returned with ref ${this.ref}`, {input: this.fileEl, response: resp}) }
diff --git a/assets/js/phoenix_live_view/utils.js b/assets/js/phoenix_live_view/utils.js
index 97e5faec14..86af14a2d4 100644
--- a/assets/js/phoenix_live_view/utils.js
+++ b/assets/js/phoenix_live_view/utils.js
@@ -1,16 +1,40 @@
+/**
+ * General utility functions.
+ * 
+ * Module Type Dependencies:
+ * @typedef {import('./view.js').default} View
+ * @typedef {import('./live_socket.js').default} LiveSocket
+ * @typedef {import('./upload_entry').default} UploadEntry
+ */
+
 import {
   PHX_VIEW_SELECTOR
 } from "./constants"
 
 import EntryUploader from "./entry_uploader"
 
+
+/**
+ * Write string to browser error console
+ * @param {string} msg 
+ * @param {object} obj 
+ * @returns 
+ */
 export let logError = (msg, obj) => console.error && console.error(msg, obj)
 
+/**
+ * Is the value a component ID? e.g. "0", "1", 32, "543", 8
+ * @param {string | number} cid 
+ * @returns {boolean}
+ */
 export let isCid = (cid) => {
   let type = typeof(cid)
   return type === "number" || (type === "string" && /^(0|[1-9]\d*)$/.test(cid))
 }
 
+/**
+ * Check all Element IDs for any duplicates and log a warning if found.
+ */
 export function detectDuplicateIds(){
   let ids = new Set()
   let elems = document.querySelectorAll("*[id]")
@@ -23,17 +47,42 @@ export function detectDuplicateIds(){
   }
 }
 
+
+/**
+ * If debug is enabled, log debug messages.
+ * @param {View} view 
+ * @param {string} kind
+ * @param {string} msg 
+ * @param {object} obj 
+ */
 export let debug = (view, kind, msg, obj) => {
   if(view.liveSocket.isDebugEnabled()){
     console.log(`${view.id} ${kind}: ${msg} - `, obj)
   }
 }
 
-// wraps value in closure or returns closure
+/**
+ * Wrap given value in closure or returns closure
+ * @param {any} val 
+ * @returns {function}
+ */
 export let closure = (val) => typeof val === "function" ? val : function (){ return val }
 
+/**
+ * Deep-clone a given object.
+ * @template T
+ * @param {T} obj 
+ * @returns {T} cloned obj
+ */
 export let clone = (obj) => { return JSON.parse(JSON.stringify(obj)) }
 
+/**
+ * Lookup the closest element with the given phoenix binding attribute
+ * @param {HTMLElement} el 
+ * @param {string} binding 
+ * @param {HTMLElement} [borderEl]
+ * @returns {HTMLElement | null}
+ */
 export let closestPhxBinding = (el, binding, borderEl) => {
   do {
     if(el.matches(`[${binding}]`) && !el.disabled){ return el }
@@ -42,19 +91,49 @@ export let closestPhxBinding = (el, binding, borderEl) => {
   return null
 }
 
+/**
+ * Is the given value an object (but not an array)?
+ * @param {any} obj 
+ * @returns {boolean}
+ */
 export let isObject = (obj) => {
   return obj !== null && typeof obj === "object" && !(obj instanceof Array)
 }
 
+/**
+ * Deep equality check
+ * @param {any} obj1 
+ * @param {any} obj2 
+ * @returns {boolean}
+ */
 export let isEqualObj = (obj1, obj2) => JSON.stringify(obj1) === JSON.stringify(obj2)
 
+/**
+ * Is object/array empty?
+ * @param {object|array} obj 
+ * @returns {boolean}
+ */
 export let isEmpty = (obj) => {
   for(let x in obj){ return false }
   return true
 }
 
+/**
+ * If given value is truthy, call the callback with that value
+ * @template T, V
+ * @param {T} el 
+ * @param {(el: T) => V} callback
+ * @returns {V}
+ */
 export let maybe = (el, callback) => el && callback(el)
 
+/**
+ * Create and run an uploader for all given upload entries
+ * @param {UploadEntry[]} entries 
+ * @param {function} onError 
+ * @param {object} resp
+ * @param {LiveSocket} liveSocket 
+ */
 export let channelUploader = function (entries, onError, resp, liveSocket){
   entries.forEach(entry => {
     let entryUploader = new EntryUploader(entry, resp.config.chunk_size, liveSocket)
diff --git a/assets/js/phoenix_live_view/view.js b/assets/js/phoenix_live_view/view.js
index f93c632235..2f63c20b99 100644
--- a/assets/js/phoenix_live_view/view.js
+++ b/assets/js/phoenix_live_view/view.js
@@ -55,6 +55,15 @@ import Rendered from "./rendered"
 import ViewHook from "./view_hook"
 import JS from "./js"
 
+/** @typedef {import('./rendered').RenderedDiff} RenderedDiff */
+
+/**
+ * URL-safe encoding of data in the given form element
+ * @param {HTMLFormElement} form 
+ * @param {{submitter: HTMLElement | null | undefined, _target: string}} metadata 
+ * @param {string[]} onlyNames 
+ * @returns {string} URL-encoded data
+ */
 let serializeForm = (form, metadata, onlyNames = []) => {
   let {submitter, ...meta} = metadata
 
@@ -88,11 +97,20 @@ let serializeForm = (form, metadata, onlyNames = []) => {
 }
 
 export default class View {
+  /**
+   * Constructor
+   * @param {HTMLElement} el 
+   * @param {import('./live_socket').default} liveSocket 
+   * @param {View|null} parentView 
+   * @param {string|null} flash 
+   * @param {string} liveReferer 
+   */
   constructor(el, liveSocket, parentView, flash, liveReferer){
     this.isDead = false
     this.liveSocket = liveSocket
     this.flash = flash
     this.parent = parentView
+    /** @type {View} */
     this.root = parentView ? parentView.root : this
     this.el = el
     this.id = this.el.id
@@ -111,7 +129,10 @@ export default class View {
     this.pendingJoinOps = this.parent ? null : []
     this.viewHooks = {}
     this.uploaders = {}
+
+    /** @type {Array<[el: HTMLFormElement, ref: number, opts: object, cb: () => void]>} */
     this.formSubmits = []
+    /** @type {{[key: string]: View}|null} */
     this.children = this.parent ? null : {}
     this.root.children[this.id] = {}
     this.channel = this.liveSocket.channel(`lv:${this.id}`, () => {
@@ -127,15 +148,32 @@ export default class View {
     })
   }
 
+  /**
+   * Setter for href
+   * @param {string} href 
+   */
   setHref(href){ this.href = href }
 
+  /**
+   * Set href and enable redirect
+   * @param {string} href 
+   */
   setRedirect(href){
     this.redirect = true
     this.href = href
   }
 
+  /**
+   * Is this view/element the main liveview container 
+   * @returns {boolean}
+   */
   isMain(){ return this.el.hasAttribute(PHX_MAIN) }
 
+  /**
+   * Set socket connection params
+   * @param {string} liveReferer 
+   * @returns {object}
+   */
   connectParams(liveReferer){
     let params = this.liveSocket.params(this.el)
     let manifest =
@@ -149,15 +187,31 @@ export default class View {
     return params
   }
 
+  /**
+   * Is the socket channel connected?
+   * @returns {boolean}
+   */
   isConnected(){ return this.channel.canPush() }
 
+  /**
+   * Lookup the phoenix session
+   * @returns {string}
+   */
   getSession(){ return this.el.getAttribute(PHX_SESSION) }
 
+  /**
+   * Lookup the phoenix static information
+   * @returns {string|null}
+   */
   getStatic(){
     let val = this.el.getAttribute(PHX_STATIC)
     return val === "" ? null : val
   }
 
+  /**
+   * Destroy view, all child views, and hooks
+   * @param {() => void} [callback] - will execute callback on completion
+   */
   destroy(callback = function (){ }){
     this.destroyAllChildren()
     this.destroyed = true
@@ -180,6 +234,11 @@ export default class View {
       .receive("timeout", onFinished)
   }
 
+  /**
+   * Add CSS classes to the container element.
+   * NOTE: Will remove liveview-managed transient classes for errors, loading, etc.
+   * @param  {...string} classes 
+   */
   setContainerClasses(...classes){
     this.el.classList.remove(
       PHX_CONNECTED_CLASS,
@@ -191,6 +250,10 @@ export default class View {
     this.el.classList.add(...classes)
   }
 
+  /**
+   * Set loading classes and call disconnect on hooks
+   * @param {number} [timeout] 
+   */
   showLoader(timeout){
     clearTimeout(this.loaderTimer)
     if(timeout){
@@ -201,28 +264,54 @@ export default class View {
     }
   }
 
+  /**
+   * Run execJS for associated binding
+   * @param {string} binding 
+   */
   execAll(binding){
     DOM.all(this.el, `[${binding}]`, el => this.liveSocket.execJS(el, el.getAttribute(binding)))
   }
 
+  /**
+   * Remove the loader and transition to connected
+   */
   hideLoader(){
     clearTimeout(this.loaderTimer)
     this.setContainerClasses(PHX_CONNECTED_CLASS)
     this.execAll(this.binding("connected"))
   }
 
+  /**
+   * Call reconnected callback for viewHooks
+   */
   triggerReconnected(){
     for(let id in this.viewHooks){ this.viewHooks[id].__reconnected() }
   }
 
+  /**
+   * Log over the LiveSocket
+   * @param {string} kind 
+   * @param {() => [msg: string, obj: any]} msgCallback 
+   */
   log(kind, msgCallback){
     this.liveSocket.log(this, kind, msgCallback)
   }
 
+  /**
+   * Add a managed transition
+   * @param {number} time 
+   * @param {() => void} onStart 
+   * @param {() => void} [onDone] 
+   */
   transition(time, onStart, onDone = function(){}){
     this.liveSocket.transition(time, onStart, onDone)
   }
 
+  /**
+   * Execute callback within the context of the view owning the target
+   * @param {Element|string|number} phxTarget 
+   * @param {(view: View, targetCtx: number|Element) => void} callback 
+   */
   withinTargets(phxTarget, callback){
     if(phxTarget instanceof HTMLElement || phxTarget instanceof SVGElement){
       return this.liveSocket.owner(phxTarget, view => callback(view, phxTarget))
@@ -242,6 +331,12 @@ export default class View {
     }
   }
 
+  /**
+   * Apply the given raw diff
+   * @param {"mount"|"update"} type 
+   * @param {RenderedDiff} rawDiff - The LiveView diff wire protocol
+   * @param {({diff: RenderedDiff, reply: number|null, events: any[]}) => void} callback 
+   */
   applyDiff(type, rawDiff, callback){
     this.log(type, () => ["", clone(rawDiff)])
     let {diff, reply, events, title} = Rendered.extract(rawDiff)
@@ -249,6 +344,10 @@ export default class View {
     if(title){ window.requestAnimationFrame(() => DOM.putTitle(title)) }
   }
 
+  /**
+   * Handle successful channel join
+   * @param {{rendered: RenderedDiff, container?: [string, object]}} resp 
+   */
   onJoin(resp){
     let {rendered, container} = resp
     if(container){
@@ -268,6 +367,7 @@ export default class View {
       this.joinCount++
 
       if(forms.length > 0){
+        // eslint-disable-next-line no-unused-vars
         forms.forEach(([form, newForm, newCid], i) => {
           this.pushFormRecovery(form, newCid, resp => {
             if(i === forms.length - 1){
@@ -281,6 +381,9 @@ export default class View {
     })
   }
 
+  /**
+   * Remove all pending element ref attrs on page
+   */
   dropPendingRefs(){
     DOM.all(document, `[${PHX_REF_SRC}="${this.id}"][${PHX_REF}]`, el => {
       el.removeAttribute(PHX_REF)
@@ -288,6 +391,14 @@ export default class View {
     })
   }
 
+  /**
+   * Handle successful join result
+   * @param {object} resp 
+   * @param {any} resp.live_patch
+   * @param {string} html 
+   * @param {any[]} streams 
+   * @param {any[]} events 
+   */
   onJoinComplete({live_patch}, html, streams, events){
     // In order to provide a better experience, we want to join
     // all LiveViews first and only then apply their patches.
@@ -319,11 +430,17 @@ export default class View {
     }
   }
 
+  /**
+   * Lookup element with ID and set on self
+   */
   attachTrueDocEl(){
     this.el = DOM.byId(this.id)
     this.el.setAttribute(PHX_ROOT_ID, this.root.id)
   }
 
+  /**
+   * Add and mount a hook if appropriate
+   */
   execNewMounted(){
     let phxViewportTop = this.binding(PHX_VIEWPORT_TOP)
     let phxViewportBottom = this.binding(PHX_VIEWPORT_BOTTOM)
@@ -337,6 +454,13 @@ export default class View {
     DOM.all(this.el, `[${this.binding(PHX_MOUNTED)}]`, el => this.maybeMounted(el))
   }
 
+  /**
+   * Apply initial patch after socket join completes
+   * @param {{kind: "push"|"replace", to: string}} [live_patch] 
+   * @param {string} html 
+   * @param {Stream[]} streams 
+   * @param {any[]} events 
+   */
   applyJoinPatch(live_patch, html, streams, events){
     this.attachTrueDocEl()
     let patch = new DOMPatch(this, this.el, this.id, html, streams, null)
@@ -358,6 +482,12 @@ export default class View {
     this.stopCallback()
   }
 
+  /**
+   * Trigger beforeUpdate hook lifecycle 
+   * @param {Element} fromEl 
+   * @param {Element} toEl 
+   * @returns {ViewHook|undefined} if hook found for fromEl, return it
+   */
   triggerBeforeUpdateHook(fromEl, toEl){
     this.liveSocket.triggerDOM("onBeforeElUpdated", [fromEl, toEl])
     let hook = this.getHook(fromEl)
@@ -368,6 +498,10 @@ export default class View {
     }
   }
 
+  /**
+   * Maybe run mount JS
+   * @param {Element} el 
+   */
   maybeMounted(el){
     let phxMounted = el.getAttribute(this.binding(PHX_MOUNTED))
     let hasBeenInvoked = phxMounted && DOM.private(el, "mounted")
@@ -377,11 +511,22 @@ export default class View {
     }
   }
 
+  /**
+   * @param {Element} el 
+   * @param {boolean} [force] 
+   */
+  // eslint-disable-next-line no-unused-vars
   maybeAddNewHook(el, force){
     let newHook = this.addHook(el)
     if(newHook){ newHook.__mounted() }
   }
 
+  /**
+   * Perform DOM patch
+   * @param {DOMPatch} patch 
+   * @param {boolean} pruneCids - prune components associated in patch?
+   * @returns {boolean} were children added?
+   */
   performPatch(patch, pruneCids){
     let removedEls = []
     let phxChildrenAdded = false
@@ -421,6 +566,11 @@ export default class View {
     return phxChildrenAdded
   }
 
+  /**
+   * Remove elements and destroy their components and hooks if applicable 
+   * @param {Element[]} elements 
+   * @param {boolean} pruneCids 
+   */
   afterElementsRemoved(elements, pruneCids){
     let destroyedCIDs = []
     elements.forEach(parent => {
@@ -443,12 +593,25 @@ export default class View {
     }
   }
 
+  /**
+   * Call jioinChild
+   */
   joinNewChildren(){
     DOM.findPhxChildren(this.el, this.id).forEach(el => this.joinChild(el))
   }
 
+  /**
+   * Get child views by ID
+   * @param {string} id 
+   * @returns {View|undefined}
+   */
   getChildById(id){ return this.root.children[this.id][id] }
 
+  /**
+   * Lookup a child view (or self) by element
+   * @param {HTMLElement} el 
+   * @returns {View}
+   */
   getDescendentByEl(el){
     if(el.id === this.id){
       return this
@@ -457,6 +620,10 @@ export default class View {
     }
   }
 
+  /**
+   * Destroy child view matching ID
+   * @param {string} id 
+   */
   destroyDescendent(id){
     for(let parentId in this.root.children){
       for(let childId in this.root.children[parentId]){
@@ -465,6 +632,11 @@ export default class View {
     }
   }
 
+  /**
+   * Looks up a child
+   * @param {HTMLElement} el 
+   * @returns {boolean} true if child did
+   */
   joinChild(el){
     let child = this.getChildById(el.id)
     if(!child){
@@ -529,6 +701,12 @@ export default class View {
     if(phxChildrenAdded){ this.joinNewChildren() }
   }
 
+  /**
+   * 
+   * @param {*} diff 
+   * @param {string} kind 
+   * @returns {[string, object]}
+   */
   renderContainer(diff, kind){
     return this.liveSocket.time(`toString diff (${kind})`, () => {
       let tag = this.el.tagName
@@ -550,6 +728,11 @@ export default class View {
 
   getHook(el){ return this.viewHooks[ViewHook.elementID(el)] }
 
+  /**
+   * Add a ViewHook for this element if one was specified
+   * @param {Element} el 
+   * @returns {ViewHook|undefind}
+   */
   addHook(el){
     if(ViewHook.elementID(el) || !el.getAttribute){ return }
     let hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK))
@@ -566,23 +749,39 @@ export default class View {
     }
   }
 
+  /**
+   * Call ViewHook teardown functions and delete from view
+   * @param {ViewHook} hook 
+   */
   destroyHook(hook){
     hook.__destroyed()
     hook.__cleanup__()
     delete this.viewHooks[ViewHook.elementID(hook.el)]
   }
 
+  /**
+   * Apply pending diff updates for self and each child
+   */
   applyPendingUpdates(){
     this.pendingDiffs.forEach(({diff, events}) => this.update(diff, events))
     this.pendingDiffs = []
     this.eachChild(child => child.applyPendingUpdates())
   }
 
+  /**
+   * Run callback for each child view
+   * @param {(view: View|undefined) => void} callback 
+   */
   eachChild(callback){
     let children = this.root.children[this.id] || {}
     for(let id in children){ callback(this.getChildById(id)) }
   }
 
+  /**
+   * Register callback for events on channel
+   * @param {string} event - events to listen for on channel
+   * @param {(resp: any) => void} cb - callback to execute on each channel response
+   */
   onChannel(event, cb){
     this.liveSocket.onChannel(this.channel, event, resp => {
       if(this.isJoinPending()){
@@ -593,6 +792,9 @@ export default class View {
     })
   }
 
+  /**
+   * Bind handlers to important liveview events
+   */
   bindChannel(){
     // The diff event should be handled by the regular update operations.
     // All other operations are queued to be applied only after join.
@@ -608,30 +810,61 @@ export default class View {
     this.channel.onClose(reason => this.onClose(reason))
   }
 
+  /**
+   * Destroy all child nodes
+   */
   destroyAllChildren(){ this.eachChild(child => child.destroy()) }
 
+  /**
+   * Handle live redirect navigation
+   * @param {{to: string, kind: ("push"|"replace"), flash: string|null}} redir 
+   */
   onLiveRedirect(redir){
     let {to, kind, flash} = redir
     let url = this.expandURL(to)
     this.liveSocket.historyRedirect(url, kind, flash)
   }
 
+  /**
+   * Handle live patch navigation
+   * @param {{to: string, kind: ("push"|"replace")}} redir 
+   */
   onLivePatch(redir){
     let {to, kind} = redir
     this.href = this.expandURL(to)
     this.liveSocket.historyPatch(to, kind)
   }
 
+  /**
+   * Ensure relative URLs are expanded to full URLs
+   * @param {string} to 
+   * @returns {string}
+   */
   expandURL(to){
     return to.startsWith("/") ? `${window.location.protocol}//${window.location.host}${to}` : to
   }
 
+  /**
+   * Handle full browser redirect events (not Live redirects)
+   * @param {{to: string, flash: string|null}} resp
+   */
   onRedirect({to, flash}){ this.liveSocket.redirect(to, flash) }
 
+  /**
+   * Is this view destroyed?
+   * @returns {boolean}
+   */
   isDestroyed(){ return this.destroyed }
 
+  /**
+   * Mark this view dead
+   */
   joinDead(){ this.isDead = true }
 
+  /**
+   * Join the websocket channel
+   * @param {(joinCount: number, onDone: () => void) => void} [callback] 
+   */
   join(callback){
     this.showLoader(this.liveSocket.loaderTimeout)
     this.bindChannel()
@@ -654,6 +887,10 @@ export default class View {
     })
   }
 
+  /**
+   * Handle failed channel join
+   * @param {{reason?: string, redirect?: string, live_redirect?: object}} resp 
+   */
   onJoinError(resp){
     if(resp.reason === "reload"){
       this.log("error", () => [`failed mount with ${resp.status}. Falling back to page request`, resp])
@@ -675,6 +912,10 @@ export default class View {
     if(this.liveSocket.isConnected()){ this.liveSocket.reloadWithJitter(this) }
   }
 
+  /**
+   * Callback to clean-up resources on close
+   * @param {string} reason 
+   */
   onClose(reason){
     if(this.isDestroyed()){ return }
     if(this.liveSocket.hasPendingLink() && reason !== "leave"){
@@ -689,6 +930,10 @@ export default class View {
     }
   }
 
+  /**
+   * Callback to clean-up resources on error and display error message
+   * @param {string} reason 
+   */
   onError(reason){
     this.onClose(reason)
     if(this.liveSocket.isConnected()){ this.log("error", () => ["view crashed", reason]) }
@@ -701,6 +946,10 @@ export default class View {
     }
   }
 
+  /**
+   * Dispatch events for error state and disconnected bindings and update page styles
+   * @param {string[]} classes - CSS classes to set on the container
+   */
   displayError(classes){
     if(this.isMain()){ DOM.dispatchEvent(window, "phx:page-loading-start", {detail: {to: this.href, kind: "error"}}) }
     this.showLoader()
@@ -708,6 +957,13 @@ export default class View {
     this.execAll(this.binding("disconnected"))
   }
 
+  /**
+   * Push event with payload over channel
+   * @param {() => [number|null, HTMLElement[], object]} refGenerator 
+   * @param {string} event 
+   * @param {object} payload 
+   * @param {(resp: any, hookReply: any) => void} [onReply] 
+   */
   pushWithReply(refGenerator, event, payload, onReply = function (){ }){
     if(!this.isConnected()){ return }
 
@@ -745,6 +1001,10 @@ export default class View {
     )
   }
 
+  /**
+   * Unset ref attrs from all child elements with matching ref set
+   * @param {number} ref 
+   */
   undoRefs(ref){
     if(!this.isConnected()){ return } // exit if external form triggered
 
@@ -780,6 +1040,13 @@ export default class View {
     })
   }
 
+  /**
+   * Put ref as attribute on elements
+   * @param {HTMLElement[]} elements 
+   * @param {string} event 
+   * @param {{loading?: boolean}} opts 
+   * @returns {[newRef: number, elements: HTMLElement[], opts: object]}
+   */
   putRef(elements, event, opts = {}){
     let newRef = this.ref++
     let disableWith = this.binding(PHX_DISABLE_WITH)
@@ -801,11 +1068,23 @@ export default class View {
     return [newRef, elements, opts]
   }
 
+  /**
+   * Get component ID from element if it is set
+   * @param {HTMLElement} el 
+   * @returns {number|null}
+   */
   componentID(el){
     let cid = el.getAttribute && el.getAttribute(PHX_COMPONENT)
     return cid ? parseInt(cid) : null
   }
 
+  /**
+   * Find component ID of target
+   * @param {HTMLElement} target 
+   * @param {string|number|HTMLElement} targetCtx 
+   * @param {{target?: number|string}} opts 
+   * @returns {number|string}
+   */
   targetComponentID(target, targetCtx, opts = {}){
     if(isCid(targetCtx)){ return targetCtx }
 
@@ -819,6 +1098,11 @@ export default class View {
     }
   }
 
+  /**
+   * Find closest component ID to the target: self or closest parent
+   * @param {number|string|HTMLElement} targetCtx 
+   * @returns {number|string|null}
+   */
   closestComponentID(targetCtx){
     if(isCid(targetCtx)){
       return targetCtx
@@ -829,6 +1113,15 @@ export default class View {
     }
   }
 
+  /**
+   * Push hook event and handle reply
+   * @param {HTMLElement} el 
+   * @param {number|string|HTMLElement} targetCtx 
+   * @param {string} event 
+   * @param {object} payload 
+   * @param {(reply: any, ref: number) => void} onReply 
+   * @returns {number} ref
+   */
   pushHookEvent(el, targetCtx, event, payload, onReply){
     if(!this.isConnected()){
       this.log("hook", () => ["unable to push hook event. LiveView not connected", event, payload])
@@ -845,6 +1138,14 @@ export default class View {
     return ref
   }
 
+  /**
+   * Extract metadata from element attributes
+   * @template {{[key: string]: any}} T
+   * @param {HTMLElement} el - element to check attrs for metadata
+   * @param {T} [meta] - initial meta object to mutate by adding data from el attrs
+   * @param {T} [value] - copy value properties to meta object
+   * @returns {T} meta
+   */
   extractMeta(el, meta, value){
     let prefix = this.binding("value-")
     for(let i = 0; i < el.attributes.length; i++){
@@ -868,6 +1169,16 @@ export default class View {
   }
 
 
+  /**
+   * Push event and optionally handle reply
+   * @param {string} type 
+   * @param {HTMLElement} el 
+   * @param {string|number|HTMLElement} targetCtx 
+   * @param {string} phxEvent 
+   * @param {{[key: string]: anu}} meta 
+   * @param {{loading?: boolean, value?: object, target?: string | number}} opts 
+   * @param {(reply: any) => void} [onReply] 
+   */
   pushEvent(type, el, targetCtx, phxEvent, meta, opts = {}, onReply){
     this.pushWithReply(() => this.putRef([el], type, opts), "event", {
       type: type,
@@ -877,6 +1188,13 @@ export default class View {
     }, (resp, reply) => onReply && onReply(reply))
   }
 
+  /**
+   * Push file progress event and optionally handle reply
+   * @param {HTMLInputElement} fileEl 
+   * @param {string} entryRef 
+   * @param {number|{error: string}} progress 
+   * @param {(resp: any, reply: any) => void} [onReply] 
+   */
   pushFileProgress(fileEl, entryRef, progress, onReply = function (){ }){
     this.liveSocket.withinOwners(fileEl.form, (view, targetCtx) => {
       view.pushWithReply(null, "progress", {
@@ -889,6 +1207,19 @@ export default class View {
     })
   }
 
+  /**
+   * Push form input data over channel
+   * @param {HTMLElement} inputEl 
+   * @param {string|number|HTMLElement} targetCtx 
+   * @param {string|number|null} forceCid 
+   * @param {string} phxEvent 
+   * @param {object} opts 
+   * @param {boolean} [opts.loading]
+   * @param {HTMLElement | null} [opts.submitter]
+   * @param {string|number} [opts.target]
+   * @param {string} [opts._target]
+   * @param {(resp: any) => void} [callback] 
+   */
   pushInput(inputEl, targetCtx, forceCid, phxEvent, opts, callback){
     let uploads
     let cid = isCid(forceCid) ? forceCid : this.targetComponentID(inputEl.form, targetCtx, opts)
@@ -928,6 +1259,10 @@ export default class View {
     })
   }
 
+  /**
+   * Lookup and execute any previously scheduled submission callbacks for this form
+   * @param {HTMLFormElement} formEl 
+   */
   triggerAwaitingSubmit(formEl){
     let awaitingSubmit = this.getScheduledSubmit(formEl)
     if(awaitingSubmit){
@@ -937,15 +1272,32 @@ export default class View {
     }
   }
 
+  /**
+   * Lookup any previously scheduled form submission
+   * @param {HTMLFormElement} formEl 
+   * @returns {[el: HTMLElement, ref: number, opts: any, cb: () => void] | undefined}
+   */
   getScheduledSubmit(formEl){
     return this.formSubmits.find(([el, _ref, _opts, _callback]) => el.isSameNode(formEl))
   }
 
+  /**
+   * Schedule a form submission callback for future execution (only schedules once per element)
+   * @param {HTMLFormElement} formEl 
+   * @param {number} ref 
+   * @param {object} opts 
+   * @param {() => void} [callback] 
+   * @returns {boolean} True if form already scheduled to submit
+   */
   scheduleSubmit(formEl, ref, opts, callback){
     if(this.getScheduledSubmit(formEl)){ return true }
     this.formSubmits.push([formEl, ref, opts, callback])
   }
 
+  /**
+   * Cancel a form's scheduled submission callback
+   * @param {HTMLFormElement} formEl 
+   */
   cancelSubmit(formEl){
     this.formSubmits = this.formSubmits.filter(([el, ref, _callback]) => {
       if(el.isSameNode(formEl)){
@@ -957,6 +1309,13 @@ export default class View {
     })
   }
 
+  /**
+   * Disable form elements 
+   * @param {HTMLFormElement} formEl 
+   * @param {object} opts 
+   * @param {boolean} [opts.loading]
+   * @returns {[newRef: number, elements: HTMLElement[], opts: any]}
+   */
   disableForm(formEl, opts = {}){
     let filterIgnored = el => {
       let userIgnored = closestPhxBinding(el, `${this.binding(PHX_UPDATE)}=ignore`, el.form)
@@ -990,6 +1349,16 @@ export default class View {
     return this.putRef([formEl].concat(disables).concat(buttons).concat(inputs), "submit", opts)
   }
 
+  /**
+   * Push for submission data; will wait for any file uploads to finish
+   * @param {HTMLFormElement} formEl 
+   * @param {string|number|HTMLElement} targetCtx 
+   * @param {string} phxEvent 
+   * @param {HTMLElement | null | undefined} submitter 
+   * @param {object} opts 
+   * @param {function} [onReply] 
+   * @returns {boolean}
+   */
   pushFormSubmit(formEl, targetCtx, phxEvent, submitter, opts, onReply){
     let refGenerator = () => this.disableForm(formEl, opts)
     let cid = this.targetComponentID(formEl, targetCtx)
@@ -1022,6 +1391,14 @@ export default class View {
     }
   }
 
+  /**
+   * Perform all file uploads for inputs in this form
+   * @param {HTMLFormElement} formEl 
+   * @param {string|number|HTMLElement} targetCtx 
+   * @param {number} ref 
+   * @param {string|number} cid 
+   * @param {() => void} onComplete 
+   */
   uploadFiles(formEl, targetCtx, ref, cid, onComplete){
     let joinCountAtUpload = this.joinCount
     let inputEls = LiveUploader.activeFileInputs(formEl)
@@ -1032,7 +1409,7 @@ export default class View {
       let uploader = new LiveUploader(inputEl, this, () => {
         numFileInputsInProgress--
         if(numFileInputsInProgress === 0){ onComplete() }
-      });
+      })
 
       this.uploaders[inputEl] = uploader
       let entries = uploader.entries().map(entry => entry.toPreflightPayload())
@@ -1063,26 +1440,44 @@ export default class View {
     })
   }
 
+  /**
+   * Dispatch custom upload event to file input matching name
+   * @param {string|number|HTMLElement} targetCtx 
+   * @param {string} name 
+   * @param {(File|Blob)[]} filesOrBlobs 
+   */
   dispatchUploads(targetCtx, name, filesOrBlobs){
-    let targetElement = this.targetCtxElement(targetCtx) || this.el;
+    let targetElement = this.targetCtxElement(targetCtx) || this.el
     let inputs = DOM.findUploadInputs(targetElement).filter(el => el.name === name)
     if(inputs.length === 0){ logError(`no live file inputs found matching the name "${name}"`) }
     else if(inputs.length > 1){ logError(`duplicate live file inputs found matching the name "${name}"`) }
     else { DOM.dispatchEvent(inputs[0], PHX_TRACK_UPLOADS, {detail: {files: filesOrBlobs}}) }
   }
 
-  targetCtxElement(targetCtx) {
+  /**
+   * Get element for target element
+   * @param {string|number|HTMLElement} targetCtx 
+   * @returns {HTMLElement|null}
+   */
+  targetCtxElement(targetCtx){
     if(isCid(targetCtx)){
       let [target] = DOM.findComponentNodeList(this.el, targetCtx)
       return target
-    } else if(targetCtx) {
+    } else if(targetCtx){
       return targetCtx
     } else {
       return null
     }
   }
 
+  /**
+   * Push form recovery
+   * @param {HTMLFormElement} form 
+   * @param {string|number} newCid 
+   * @param {function} callback 
+   */
   pushFormRecovery(form, newCid, callback){
+    // eslint-disable-next-line no-unused-vars
     this.liveSocket.withinOwners(form, (view, targetCtx) => {
       let phxChange = this.binding("change")
       let inputs = Array.from(form.elements).filter(el => DOM.isFormInput(el) && el.name && !el.hasAttribute(phxChange))
@@ -1097,6 +1492,12 @@ export default class View {
     })
   }
 
+  /**
+   * Push live patch for link
+   * @param {string} href 
+   * @param {HTMLElement} targetEl 
+   * @param {(linkRef: number) => void} callback 
+   */
   pushLinkPatch(href, targetEl, callback){
     let linkRef = this.liveSocket.setPendingLink(href)
     let refGen = targetEl ? () => this.putRef([targetEl], "click") : null
@@ -1124,6 +1525,11 @@ export default class View {
     }
   }
 
+  /**
+   * Select forms for recovery within the element bound to this view
+   * @param {string} html 
+   * @returns {HTMLFormElement[]}
+   */
   formsForRecovery(html){
     if(this.joinCount === 0){ return [] }
 
@@ -1139,7 +1545,7 @@ export default class View {
         .map(form => {
           // attribute given via JS module needs to be escaped as it contains the symbols []",
           // which result in an invalid css selector otherwise.
-          const phxChangeValue = form.getAttribute(phxChange).replaceAll(/([\[\]"])/g, '\\$1')
+          const phxChangeValue = form.getAttribute(phxChange).replaceAll(/([\[\]"])/g, "\\$1")
           let newForm = template.content.querySelector(`form[id="${form.id}"][${phxChange}="${phxChangeValue}"]`)
           if(newForm){
             return [form, newForm, this.targetComponentID(newForm)]
@@ -1147,10 +1553,16 @@ export default class View {
             return [form, form, this.targetComponentID(form)]
           }
         })
+        // eslint-disable-next-line no-unused-vars
         .filter(([form, newForm, newCid]) => newForm)
     )
   }
 
+  /**
+   * Find child components from given component ID list and push the collection of found IDs
+   * @param {(string|number)[]} destroyedCIDs 
+   * @returns {boolean|undefined}
+   */
   maybePushComponentsDestroyed(destroyedCIDs){
     let willDestroyCIDs = destroyedCIDs.filter(cid => {
       return DOM.findComponentNodeList(this.el, cid).length === 0
@@ -1178,6 +1590,11 @@ export default class View {
     }
   }
 
+  /**
+   * Does this view own the given element?
+   * @param {Element} el 
+   * @returns {boolean}
+   */
   ownsElement(el){
     let parentViewEl = el.closest(PHX_VIEW_SELECTOR)
     return el.getAttribute(PHX_PARENT_ID) === this.id ||
@@ -1185,6 +1602,19 @@ export default class View {
       (!parentViewEl && this.isDead)
   }
 
+  /**
+   * Submit the data for this form element over livesocket
+   * @param {HTMLFormElement} form 
+   * @param {string|number|Element} targetCtx 
+   * @param {string} phxEvent 
+   * @param {HTMLElement|null|undefined} submitter 
+   * @param {object} [opts] 
+   * @param {boolean} [opts.loading] 
+   * @param {boolean} [opts.page_loading] 
+   * @param {any} [opts.value] 
+   * @param {string|Element} [opts.target] 
+   * @param {string} [opts._target] 
+   */
   submitForm(form, targetCtx, phxEvent, submitter, opts = {}){
     DOM.putPrivate(form, PHX_HAS_SUBMITTED, true)
     let phxFeedback = this.liveSocket.binding(PHX_FEEDBACK_FOR)
@@ -1197,5 +1627,10 @@ export default class View {
     })
   }
 
+  /**
+   * Get the binding matching string
+   * @param {string} kind 
+   * @returns {string}
+   */
   binding(kind){ return this.liveSocket.binding(kind) }
 }
diff --git a/assets/js/phoenix_live_view/view_hook.js b/assets/js/phoenix_live_view/view_hook.js
index b939b8a007..759cfc81e2 100644
--- a/assets/js/phoenix_live_view/view_hook.js
+++ b/assets/js/phoenix_live_view/view_hook.js
@@ -1,8 +1,40 @@
+/**
+ * @typedef {object} HookCallbacks - The type interface of user-defined hook callback objects
+ * @property {(this: ViewHook) => void} [mounted] - Called when the element has been added to the DOM and its server LiveView has finished mounting.
+ * @property {(this: ViewHook) => void} [destroyed] - Called when element has been removed from the page (either parent update or parent removal).
+ * @property {(this: ViewHook) => void} [beforeDestroy] - Called when the element is about to be removed from the DOM.
+ * @property {(this: ViewHook) => void} [updated] - Called when the element has been updated in the DOM.
+ * @property {(this: ViewHook) => void} [beforeUpdate] - Called when the element is about to be updated in the DOM.
+ * @property {(this: ViewHook) => void} [disconnected] - Called when the element's parent view has disconnected from the server.
+ * @property {(this: ViewHook) => void} [reconnected] - Called when the element's parent view has reconnected to the server.
+ */
+
 let viewHookID = 1
+
 export default class ViewHook {
+  /**
+   * Create a hook ID
+   * @returns {number} ID unique to this browser tab
+   */
   static makeID(){ return viewHookID++ }
+
+  /**
+   * Get ID for the hook bound to this element
+   * @param {Element} el 
+   * @returns {number|undefined}
+   */
   static elementID(el){ return el.phxHookId }
 
+  /**
+   * Constructor - Wraps user-defined hook callbacks with a consistent interface.
+   *
+   * The callbacks will run with the scope of an instance of this class: thus,
+   * all methods and attributes on ViewHook will be availble to the user-defined
+   * callbacks.
+   * @param {import('./view.js').default} view 
+   * @param {Element} el - attribute referencing the bound DOM node
+   * @param {HookCallbacks} callbacks 
+   */
   constructor(view, el, callbacks){
     this.__view = view
     this.liveSocket = view.liveSocket
@@ -14,31 +46,91 @@ export default class ViewHook {
     for(let key in this.__callbacks){ this[key] = this.__callbacks[key] }
   }
 
+  /**
+   * Call the user-provided mounted() callback, if defined
+   * @public
+   */
   __mounted(){ this.mounted && this.mounted() }
+
+  /**
+   * Call the user-provided updated() callback, if defined
+   * @public
+   */
   __updated(){ this.updated && this.updated() }
+
+  /**
+   * Call the user-provided beforeUpdate() callback, if defined
+   * @public
+   */
   __beforeUpdate(){ this.beforeUpdate && this.beforeUpdate() }
+
+  /**
+   * Call the user-provided destroyed() callback, if defined
+   * @public
+   */
   __destroyed(){ this.destroyed && this.destroyed() }
+
+  /**
+   * Call the user-provided reconnected() callback, if defined
+   * @public
+   */
   __reconnected(){
     if(this.__isDisconnected){
       this.__isDisconnected = false
       this.reconnected && this.reconnected()
     }
   }
+
+  /**
+   * Call the user-provided disconnected() callback, if defined
+   * @public
+   */
   __disconnected(){
     this.__isDisconnected = true
     this.disconnected && this.disconnected()
   }
 
+  /**
+   * Push an event to the server
+   * @public
+   * @param {string} event 
+   * @param {any} payload 
+   * @param {(reply: any, ref: number) => void} [onReply] 
+   * @returns {number} ref
+   */
   pushEvent(event, payload = {}, onReply = function (){ }){
     return this.__view.pushHookEvent(this.el, null, event, payload, onReply)
   }
 
+  /**
+   * Push targeted events from the client to LiveViews and LiveComponents. It
+   * sends the event to the LiveComponent or LiveView the phxTarget is defined
+   * in, where its value can be either a query selector or an actual DOM
+   * element. 
+   *
+   * NOTE: If the query selector returns more than one element it will send the
+   * event to all of them, even if all the elements are in the same
+   * LiveComponent or LiveView.
+   * @public
+   * @param {string|Element} phxTarget 
+   * @param {string} event 
+   * @param {any} payload 
+   * @param {(reply: any, ref: number) => void} [onReply] 
+   * @returns 
+   */
   pushEventTo(phxTarget, event, payload = {}, onReply = function (){ }){
     return this.__view.withinTargets(phxTarget, (view, targetCtx) => {
       return view.pushHookEvent(this.el, targetCtx, event, payload, onReply)
     })
   }
 
+  /**
+   * Register callback to handle phoenix events
+   * @public
+   * @param {string} event 
+   * @param {(payload: any) => void} callback - receives as payload the CustomEvent's detail
+   * @returns {(payload: any, bypass?: boolean) => string|void} the registered callback reference; can be used to remove the event
+   */
   handleEvent(event, callback){
     let callbackRef = (customEvent, bypass) => bypass ? event : callback(customEvent.detail)
     window.addEventListener(`phx:${event}`, callbackRef)
@@ -46,22 +138,52 @@ export default class ViewHook {
     return callbackRef
   }
 
+  /**
+   * Remove a registered phoenix event callback
+   * @public
+   * @param {(payload: any, bypass?: boolean) => string|void} callbackRef 
+   */
   removeHandleEvent(callbackRef){
     let event = callbackRef(null, true)
     window.removeEventListener(`phx:${event}`, callbackRef)
     this.__listeners.delete(callbackRef)
   }
 
+  /**
+   * Inject a list of file-like objects into the uploader.
+   * @public
+   * @param {string} name 
+   * @param {File[]} files 
+   */
   upload(name, files){
     return this.__view.dispatchUploads(null, name, files)
   }
 
+  /**
+   * Inject a list of file-like objects into an uploader. The hook
+   * will send the files to the uploader with name defined by allow_upload/3 on
+   * the server-side. Dispatching new uploads triggers an input change event
+   * which will be sent to the LiveComponent or LiveView the phxTarget is
+   * defined in, where its value can be either a query selector or an actual DOM
+   * element. 
+   * 
+   * NOTE: If the query selector returns more than one live file input, an
+   * error will be logged.
+   * @public
+   * @param {string|Element} phxTarget 
+   * @param {string} name 
+   * @param {File[]} files 
+   */
   uploadTo(phxTarget, name, files){
     return this.__view.withinTargets(phxTarget, (view, targetCtx) => {
       view.dispatchUploads(targetCtx, name, files)
     })
   }
 
+  /**
+   * Teardown resources for hook and callbacks
+   * @public
+   */
   __cleanup__(){
     this.__listeners.forEach(callbackRef => this.removeHandleEvent(callbackRef))
   }