diff --git a/.prettierrc.json b/.prettierrc.json
index db5bf647b..ec8e5e030 100644
--- a/.prettierrc.json
+++ b/.prettierrc.json
@@ -1,5 +1,6 @@
{
"singleQuote": false,
"printWidth": 120,
- "semi": false
+ "semi": false,
+ "trailingComma" : "none"
}
diff --git a/package.json b/package.json
index 7488a73fd..28ef82bff 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@hotwired/turbo",
- "version": "8.0.4",
+ "version": "8.0.5",
"description": "The speed of a single-page web application without having to write any JavaScript",
"module": "dist/turbo.es2017-esm.js",
"main": "dist/turbo.es2017-umd.js",
diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js
deleted file mode 100644
index 2c5d14874..000000000
--- a/src/core/drive/morph_renderer.js
+++ /dev/null
@@ -1,118 +0,0 @@
-import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js"
-import { dispatch } from "../../util"
-import { PageRenderer } from "./page_renderer"
-
-export class MorphRenderer extends PageRenderer {
- async render() {
- if (this.willRender) await this.#morphBody()
- }
-
- get renderMethod() {
- return "morph"
- }
-
- // Private
-
- async #morphBody() {
- this.#morphElements(this.currentElement, this.newElement)
- this.#reloadRemoteFrames()
-
- dispatch("turbo:morph", {
- detail: {
- currentElement: this.currentElement,
- newElement: this.newElement
- }
- })
- }
-
- #morphElements(currentElement, newElement, morphStyle = "outerHTML") {
- this.isMorphingTurboFrame = this.#isFrameReloadedWithMorph(currentElement)
-
- Idiomorph.morph(currentElement, newElement, {
- morphStyle: morphStyle,
- callbacks: {
- beforeNodeAdded: this.#shouldAddElement,
- beforeNodeMorphed: this.#shouldMorphElement,
- beforeAttributeUpdated: this.#shouldUpdateAttribute,
- beforeNodeRemoved: this.#shouldRemoveElement,
- afterNodeMorphed: this.#didMorphElement
- }
- })
- }
-
- #shouldAddElement = (node) => {
- return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))
- }
-
- #shouldMorphElement = (oldNode, newNode) => {
- if (oldNode instanceof HTMLElement) {
- if (!oldNode.hasAttribute("data-turbo-permanent") && (this.isMorphingTurboFrame || !this.#isFrameReloadedWithMorph(oldNode))) {
- const event = dispatch("turbo:before-morph-element", {
- cancelable: true,
- target: oldNode,
- detail: {
- newElement: newNode
- }
- })
-
- return !event.defaultPrevented
- } else {
- return false
- }
- }
- }
-
- #shouldUpdateAttribute = (attributeName, target, mutationType) => {
- const event = dispatch("turbo:before-morph-attribute", { cancelable: true, target, detail: { attributeName, mutationType } })
-
- return !event.defaultPrevented
- }
-
- #didMorphElement = (oldNode, newNode) => {
- if (newNode instanceof HTMLElement) {
- dispatch("turbo:morph-element", {
- target: oldNode,
- detail: {
- newElement: newNode
- }
- })
- }
- }
-
- #shouldRemoveElement = (node) => {
- return this.#shouldMorphElement(node)
- }
-
- #reloadRemoteFrames() {
- this.#remoteFrames().forEach((frame) => {
- if (this.#isFrameReloadedWithMorph(frame)) {
- this.#renderFrameWithMorph(frame)
- frame.reload()
- }
- })
- }
-
- #renderFrameWithMorph(frame) {
- frame.addEventListener("turbo:before-frame-render", (event) => {
- event.detail.render = this.#morphFrameUpdate
- }, { once: true })
- }
-
- #morphFrameUpdate = (currentElement, newElement) => {
- dispatch("turbo:before-frame-morph", {
- target: currentElement,
- detail: { currentElement, newElement }
- })
- this.#morphElements(currentElement, newElement.children, "innerHTML")
- }
-
- #isFrameReloadedWithMorph(element) {
- return element.src && element.refresh === "morph"
- }
-
- #remoteFrames() {
- return Array.from(document.querySelectorAll('turbo-frame[src]')).filter(frame => {
- return !frame.closest('[data-turbo-permanent]')
- })
- }
-}
diff --git a/src/core/drive/morphing_page_renderer.js b/src/core/drive/morphing_page_renderer.js
new file mode 100644
index 000000000..2884a24fb
--- /dev/null
+++ b/src/core/drive/morphing_page_renderer.js
@@ -0,0 +1,48 @@
+import { FrameElement } from "../../elements/frame_element"
+import { MorphingFrameRenderer } from "../frames/morphing_frame_renderer"
+import { PageRenderer } from "./page_renderer"
+import { dispatch } from "../../util"
+import { morphElements } from "../morphing"
+
+export class MorphingPageRenderer extends PageRenderer {
+ static renderElement(currentElement, newElement) {
+ morphElements(currentElement, newElement, {
+ callbacks: {
+ beforeNodeMorphed: element => !canRefreshFrame(element)
+ }
+ })
+
+ for (const frame of currentElement.querySelectorAll("turbo-frame")) {
+ if (canRefreshFrame(frame)) refreshFrame(frame)
+ }
+
+ dispatch("turbo:morph", { detail: { currentElement, newElement } })
+ }
+
+ async preservingPermanentElements(callback) {
+ return await callback()
+ }
+
+ get renderMethod() {
+ return "morph"
+ }
+
+ get shouldAutofocus() {
+ return false
+ }
+}
+
+function canRefreshFrame(frame) {
+ return frame instanceof FrameElement &&
+ frame.src &&
+ frame.refresh === "morph" &&
+ !frame.closest("[data-turbo-permanent]")
+}
+
+function refreshFrame(frame) {
+ frame.addEventListener("turbo:before-frame-render", ({ detail }) => {
+ detail.render = MorphingFrameRenderer.renderElement
+ }, { once: true })
+
+ frame.reload()
+}
diff --git a/src/core/drive/navigator.js b/src/core/drive/navigator.js
index 8d8d3e0be..8210c7471 100644
--- a/src/core/drive/navigator.js
+++ b/src/core/drive/navigator.js
@@ -125,6 +125,7 @@ export class Navigator {
visitCompleted(visit) {
this.delegate.visitCompleted(visit)
+ delete this.currentVisit
}
locationWithActionIsSamePage(location, action) {
diff --git a/src/core/drive/page_view.js b/src/core/drive/page_view.js
index 1583f25a0..1bcb04134 100644
--- a/src/core/drive/page_view.js
+++ b/src/core/drive/page_view.js
@@ -1,7 +1,7 @@
import { nextEventLoopTick } from "../../util"
import { View } from "../view"
import { ErrorRenderer } from "./error_renderer"
-import { MorphRenderer } from "./morph_renderer"
+import { MorphingPageRenderer } from "./morphing_page_renderer"
import { PageRenderer } from "./page_renderer"
import { PageSnapshot } from "./page_snapshot"
import { SnapshotCache } from "./snapshot_cache"
@@ -17,9 +17,9 @@ export class PageView extends View {
renderPage(snapshot, isPreview = false, willRender = true, visit) {
const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage
- const rendererClass = shouldMorphPage ? MorphRenderer : PageRenderer
+ const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer
- const renderer = new rendererClass(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender)
+ const renderer = new rendererClass(this.snapshot, snapshot, rendererClass.renderElement, isPreview, willRender)
if (!renderer.shouldRender) {
this.forceReloaded = true
diff --git a/src/core/frames/link_interceptor.js b/src/core/frames/link_interceptor.js
index 9b9249544..fb025db58 100644
--- a/src/core/frames/link_interceptor.js
+++ b/src/core/frames/link_interceptor.js
@@ -1,3 +1,5 @@
+import { findLinkFromClickTarget } from "../../util"
+
export class LinkInterceptor {
constructor(delegate, element) {
this.delegate = delegate
@@ -17,7 +19,7 @@ export class LinkInterceptor {
}
clickBubbled = (event) => {
- if (this.respondsToEventTarget(event.target)) {
+ if (this.clickEventIsSignificant(event)) {
this.clickEvent = event
} else {
delete this.clickEvent
@@ -25,7 +27,7 @@ export class LinkInterceptor {
}
linkClicked = (event) => {
- if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) {
+ if (this.clickEvent && this.clickEventIsSignificant(event)) {
if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) {
this.clickEvent.preventDefault()
event.preventDefault()
@@ -39,8 +41,10 @@ export class LinkInterceptor {
delete this.clickEvent
}
- respondsToEventTarget(target) {
- const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null
- return element && element.closest("turbo-frame, html") == this.element
+ clickEventIsSignificant(event) {
+ const target = event.composed ? event.target?.parentElement : event.target
+ const element = findLinkFromClickTarget(target) || target
+
+ return element instanceof Element && element.closest("turbo-frame, html") == this.element
}
}
diff --git a/src/core/frames/morphing_frame_renderer.js b/src/core/frames/morphing_frame_renderer.js
new file mode 100644
index 000000000..fe42065d5
--- /dev/null
+++ b/src/core/frames/morphing_frame_renderer.js
@@ -0,0 +1,14 @@
+import { FrameRenderer } from "./frame_renderer"
+import { morphChildren } from "../morphing"
+import { dispatch } from "../../util"
+
+export class MorphingFrameRenderer extends FrameRenderer {
+ static renderElement(currentElement, newElement) {
+ dispatch("turbo:before-frame-morph", {
+ target: currentElement,
+ detail: { currentElement, newElement }
+ })
+
+ morphChildren(currentElement, newElement)
+ }
+}
diff --git a/src/core/morphing.js b/src/core/morphing.js
new file mode 100644
index 000000000..dfd21e839
--- /dev/null
+++ b/src/core/morphing.js
@@ -0,0 +1,66 @@
+import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js"
+import { dispatch } from "../util"
+
+export function morphElements(currentElement, newElement, { callbacks, ...options } = {}) {
+ Idiomorph.morph(currentElement, newElement, {
+ ...options,
+ callbacks: new DefaultIdiomorphCallbacks(callbacks)
+ })
+}
+
+export function morphChildren(currentElement, newElement) {
+ morphElements(currentElement, newElement.children, {
+ morphStyle: "innerHTML"
+ })
+}
+
+class DefaultIdiomorphCallbacks {
+ #beforeNodeMorphed
+
+ constructor({ beforeNodeMorphed } = {}) {
+ this.#beforeNodeMorphed = beforeNodeMorphed || (() => true)
+ }
+
+ beforeNodeAdded = (node) => {
+ return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))
+ }
+
+ beforeNodeMorphed = (currentElement, newElement) => {
+ if (currentElement instanceof Element) {
+ if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) {
+ const event = dispatch("turbo:before-morph-element", {
+ cancelable: true,
+ target: currentElement,
+ detail: { currentElement, newElement }
+ })
+
+ return !event.defaultPrevented
+ } else {
+ return false
+ }
+ }
+ }
+
+ beforeAttributeUpdated = (attributeName, target, mutationType) => {
+ const event = dispatch("turbo:before-morph-attribute", {
+ cancelable: true,
+ target,
+ detail: { attributeName, mutationType }
+ })
+
+ return !event.defaultPrevented
+ }
+
+ beforeNodeRemoved = (node) => {
+ return this.beforeNodeMorphed(node)
+ }
+
+ afterNodeMorphed = (currentElement, newElement) => {
+ if (currentElement instanceof Element) {
+ dispatch("turbo:morph-element", {
+ target: currentElement,
+ detail: { currentElement, newElement }
+ })
+ }
+ }
+}
diff --git a/src/core/renderer.js b/src/core/renderer.js
index 56e73983e..f0a02f581 100644
--- a/src/core/renderer.js
+++ b/src/core/renderer.js
@@ -16,6 +16,10 @@ export class Renderer {
return true
}
+ get shouldAutofocus() {
+ return true
+ }
+
get reloadReason() {
return
}
@@ -40,9 +44,11 @@ export class Renderer {
}
focusFirstAutofocusableElement() {
- const element = this.connectedSnapshot.firstAutofocusableElement
- if (element) {
- element.focus()
+ if (this.shouldAutofocus) {
+ const element = this.connectedSnapshot.firstAutofocusableElement
+ if (element) {
+ element.focus()
+ }
}
}
diff --git a/src/core/session.js b/src/core/session.js
index cdb978348..1047d4463 100644
--- a/src/core/session.js
+++ b/src/core/session.js
@@ -109,7 +109,7 @@ export class Session {
refresh(url, requestId) {
const isRecentRequest = requestId && this.recentRequests.has(requestId)
- if (!isRecentRequest) {
+ if (!isRecentRequest && !this.navigator.currentVisit) {
this.visit(url, { action: "replace", shouldCacheSnapshot: false })
}
}
diff --git a/src/core/streams/actions/morph.js b/src/core/streams/actions/morph.js
deleted file mode 100644
index d177bfd01..000000000
--- a/src/core/streams/actions/morph.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { Idiomorph } from "idiomorph/dist/idiomorph.esm"
-import { dispatch } from "../../../util"
-
-export default function morph(streamElement) {
- const morphStyle = streamElement.hasAttribute("children-only") ? "innerHTML" : "outerHTML"
- streamElement.targetElements.forEach((element) => {
- Idiomorph.morph(element, streamElement.templateContent, {
- morphStyle: morphStyle,
- callbacks: {
- beforeNodeAdded,
- beforeNodeMorphed,
- beforeAttributeUpdated,
- beforeNodeRemoved,
- afterNodeMorphed
- }
- })
- })
-}
-
-function beforeNodeAdded(node) {
- return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))
-}
-
-function beforeNodeRemoved(node) {
- return beforeNodeAdded(node)
-}
-
-function beforeNodeMorphed(target, newElement) {
- if (target instanceof HTMLElement && !target.hasAttribute("data-turbo-permanent")) {
- const event = dispatch("turbo:before-morph-element", {
- cancelable: true,
- target,
- detail: {
- newElement
- }
- })
- return !event.defaultPrevented
- }
- return false
-}
-
-function beforeAttributeUpdated(attributeName, target, mutationType) {
- const event = dispatch("turbo:before-morph-attribute", {
- cancelable: true,
- target,
- detail: {
- attributeName,
- mutationType
- }
- })
- return !event.defaultPrevented
-}
-
-function afterNodeMorphed(target, newElement) {
- if (newElement instanceof HTMLElement) {
- dispatch("turbo:morph-element", {
- target,
- detail: {
- newElement
- }
- })
- }
-}
diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js
index 486dc8566..a038add0d 100644
--- a/src/core/streams/stream_actions.js
+++ b/src/core/streams/stream_actions.js
@@ -1,5 +1,5 @@
import { session } from "../"
-import morph from "./actions/morph"
+import { morphElements, morphChildren } from "../morphing"
export const StreamActions = {
after() {
@@ -25,21 +25,31 @@ export const StreamActions = {
},
replace() {
- this.targetElements.forEach((e) => e.replaceWith(this.templateContent))
+ const method = this.getAttribute("method")
+
+ this.targetElements.forEach((targetElement) => {
+ if (method === "morph") {
+ morphElements(targetElement, this.templateContent)
+ } else {
+ targetElement.replaceWith(this.templateContent)
+ }
+ })
},
update() {
+ const method = this.getAttribute("method")
+
this.targetElements.forEach((targetElement) => {
- targetElement.innerHTML = ""
- targetElement.append(this.templateContent)
+ if (method === "morph") {
+ morphChildren(targetElement, this.templateContent)
+ } else {
+ targetElement.innerHTML = ""
+ targetElement.append(this.templateContent)
+ }
})
},
refresh() {
session.refresh(this.baseURI, this.requestId)
- },
-
- morph() {
- morph(this)
}
}
diff --git a/src/elements/stream_element.js b/src/elements/stream_element.js
index b9bcf35f0..77f447594 100644
--- a/src/elements/stream_element.js
+++ b/src/elements/stream_element.js
@@ -6,20 +6,22 @@ import { nextRepaint } from "../util"
/**
* Renders updates to the page from a stream of messages.
*
- * Using the `action` attribute, this can be configured one of four ways:
+ * Using the `action` attribute, this can be configured one of eight ways:
*
- * - `append` - appends the result to the container
- * - `prepend` - prepends the result to the container
- * - `replace` - replaces the contents of the container
- * - `remove` - removes the container
- * - `before` - inserts the result before the target
* - `after` - inserts the result after the target
+ * - `append` - appends the result to the target
+ * - `before` - inserts the result before the target
+ * - `prepend` - prepends the result to the target
+ * - `refresh` - initiates a page refresh
+ * - `remove` - removes the target
+ * - `replace` - replaces the outer HTML of the target
+ * - `update` - replaces the inner HTML of the target
*
* @customElement turbo-stream
* @example
*