Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lift the frame morphing logic up to FrameController.reload #1192

Merged
merged 25 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c03c893
Add new frame morphing refresh tests which fail
krschacht Feb 24, 2024
047a479
Get tests passing
krschacht Feb 25, 2024
e1fde68
Refactor code to remove duplication
krschacht Feb 25, 2024
5427b77
Remove comment
krschacht Feb 25, 2024
9fd1e9d
remove logging
krschacht Feb 25, 2024
a5a87e4
Remove testing server randNum hack
krschacht Feb 27, 2024
87c9553
Refactor to extract method & revert to class
krschacht Feb 29, 2024
07c3577
Refactor morphElements class
krschacht Mar 5, 2024
699f66f
Merge branch 'main' into frame-reload-uses-morphing
krschacht Aug 22, 2024
6abb120
wip
krschacht Aug 22, 2024
38e4d2c
Remove extraction added in another PR
krschacht Aug 22, 2024
8344e4d
Update reference to use new MorphingFrameRenderer
krschacht Aug 22, 2024
a342115
Reword test names
krschacht Aug 22, 2024
1ed1dc1
fixed typo
krschacht Aug 23, 2024
f738bdb
Only morph frame when using reload()
krschacht Aug 28, 2024
e956dd4
wip
krschacht Aug 28, 2024
fceb2cb
Fix a broken test
krschacht Aug 28, 2024
3af219d
Rewrite test fix to use playwright API
krschacht Aug 30, 2024
a0bf9a3
Fix unused reference
krschacht Aug 30, 2024
f45697c
Flip failing test to reflect product decision
krschacht Aug 31, 2024
55266c7
Merge branch 'main' into frame-reload-uses-morphing
krschacht Aug 31, 2024
8f0a949
Merge branch 'main' into frame-reload-uses-morphing
krschacht Aug 31, 2024
6801493
Revert "Flip failing test to reflect product decision"
krschacht Sep 3, 2024
795ab37
Correct logic for frame reloading
krschacht Sep 3, 2024
61bd384
revert unintended files
krschacht Sep 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/core/drive/morph_page_renderer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { dispatch } from "../../util"
import { PageRenderer } from "./page_renderer"
import { MorphElements } from "../morph_elements"

export class MorphPageRenderer extends PageRenderer {
async render() {
if (this.willRender) await this.#morphBody()
}

get renderMethod() {
return "morph"
}

// Private

async #morphBody() {
MorphElements.morph(this.currentElement, this.newElement)
this.#reloadRemoteFrames()

dispatch("turbo:morph", {
detail: {
currentElement: this.currentElement,
newElement: this.newElement
}
})
}

#reloadRemoteFrames() {
this.#remoteFrames().forEach((frame) => {
if (this.#isFrameReloadedWithMorph(frame)) {
frame.reload()
}
})
}

#remoteFrames() {
return Array.from(document.querySelectorAll('turbo-frame[src]')).filter(frame => {
return !frame.closest('[data-turbo-permanent]')
})
}

#isFrameReloadedWithMorph(element) {
return element.src && element.refresh === "morph"
}
}
118 changes: 0 additions & 118 deletions src/core/drive/morph_renderer.js

This file was deleted.

4 changes: 2 additions & 2 deletions src/core/drive/page_view.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { nextEventLoopTick } from "../../util"
import { View } from "../view"
import { ErrorRenderer } from "./error_renderer"
import { MorphRenderer } from "./morph_renderer"
import { MorphPageRenderer } from "./morph_page_renderer"
import { PageRenderer } from "./page_renderer"
import { PageSnapshot } from "./page_snapshot"
import { SnapshotCache } from "./snapshot_cache"
Expand All @@ -17,7 +17,7 @@ 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 ? MorphPageRenderer : PageRenderer

const renderer = new rendererClass(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender)

Expand Down
11 changes: 10 additions & 1 deletion src/core/frames/frame_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { FrameView } from "./frame_view"
import { LinkInterceptor } from "./link_interceptor"
import { FormLinkClickObserver } from "../../observers/form_link_click_observer"
import { FrameRenderer } from "./frame_renderer"
import { MorphFrameRenderer } from "./morph_frame_renderer"
import { session } from "../index"
import { StreamMessage } from "../streams/stream_message"
import { PageSnapshot } from "../drive/page_snapshot"
Expand Down Expand Up @@ -264,6 +265,7 @@ export class FrameController {
detail: { newFrame, ...options },
cancelable: true
})

const {
defaultPrevented,
detail: { render }
Expand Down Expand Up @@ -307,7 +309,14 @@ export class FrameController {

if (newFrameElement) {
const snapshot = new Snapshot(newFrameElement)
const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false)
let renderer

if (this.element.src && this.element.refresh === "morph") {
krschacht marked this conversation as resolved.
Show resolved Hide resolved
renderer = new MorphFrameRenderer(this, this.view.snapshot, snapshot, MorphFrameRenderer.renderElement, false, false)
} else {
renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false)
}

if (this.view.renderPromise) await this.view.renderPromise
this.changeHistory()

Expand Down
16 changes: 16 additions & 0 deletions src/core/frames/morph_frame_renderer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { FrameRenderer } from "./frame_renderer"
import { MorphElements } from "../morph_elements"
import { dispatch } from "../../util"

export class MorphFrameRenderer extends FrameRenderer {
static renderElement(currentElement, newParentElement) {
const newElement = newParentElement.children

dispatch("turbo:before-frame-morph", {
krschacht marked this conversation as resolved.
Show resolved Hide resolved
target: currentElement,
detail: { currentElement, newElement }
})

MorphElements.morph(currentElement, newElement, 'innerHTML')
}
}
66 changes: 66 additions & 0 deletions src/core/morph_elements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js"
import { dispatch } from "../util"

export class MorphElements {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these are all static methods, is there any benefit to defining exporting a class? Would a collection of exported functions serve the same purpose?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seanpdoyle Unlike some of the other utility files, I did it as a class because they're tightly coupled methods so on any use you'd need to import all methods. None of them stand alone. In this way, it's similar to the Cache utility class. But there is no actual state that is being instantiated so I did them static methods.

But I'm happy to change them to independent functions which are each exported. Would you like me to make that change?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it as a class because they're tightly coupled methods so on any use you'd need to import all methods

Reading through the diff in its current state, the MorphElements class is only ever interacted with by importing the class and invoking MorphElements.morph directly:

import { MorphElements } from "../morph_elements"

// ...
MorphElements.morph(...)

Since its consistently invoked with a single method, the surface area of the module's interface could be reduced to only include morph:

import { morph } from "../morph_elements"

morph(...)

Then, the module could be simplified to be a collection of module-private functions supporting a single exported morph function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, sorry, you're right!

Done

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seanpdoyle As I was doing some additional cleanup, I discovered that this change broke a random test in the test suite. Specifically, moving from a class to a collection of methods.

I spent a little while trying to track down the source of the bug and the only way I could solve it was to bring the class back.

The issue has something to do with this context as shared between methods. The morph() method sets this.isMorphingTurboFrame which is later referenced by shouldMorphElement(), but this is intentionally an arrow function because it's provided as a callback to Idiomorph. As I was banging my head against the wall trying to figure out another way around this or threading the context through as an argument, it occurred to me: this is the problem that classes solve! :) I just mean: sharing context between discrete method calls.

For now I reverted back to the class with static methods. The full test suite passes again. If you really think it shouldn't be a class, the solution needs to involve eliminating this shared state and somehow threading it through, but I don't understand the innerworkings of Idiomorph to easily do that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the morphing is stateful (uses this), you're correct: separate functions won't be suitable.

The original feedback was around the over-use of the static keyword. A class that's composed of all static keywords is functionally equivalent to a group of functions. Any "state" preserved through assigning to a this will be global. That's equivalent to a module-local variable.

Sharing a model-local variable across all morphing will lead to sharing that state across potentially concurrent morphs. For example, if this.isMorphingTurboFrame is true for a frame but false for a page-wide morph, that contention could cause unexpected behavior.

As a balance, what do you think of this set of changes:

diff --git a/src/core/drive/morph_page_renderer.js b/src/core/drive/morph_page_renderer.js
index 3e2dbf8..9418f44 100644
--- a/src/core/drive/morph_page_renderer.js
+++ b/src/core/drive/morph_page_renderer.js
@@ -1,6 +1,6 @@
 import { dispatch } from "../../util"
 import { PageRenderer } from "./page_renderer"
-import { MorphElements } from "../morph_elements"
+import { morphElements } from "../morph_elements"
 
 export class MorphPageRenderer extends PageRenderer {
   async render() {
@@ -14,7 +14,7 @@ export class MorphPageRenderer extends PageRenderer {
   // Private
 
   async #morphBody() {
-    MorphElements.morph(this.currentElement, this.newElement)
+    morphElements(this.currentElement, this.newElement)
     this.#reloadRemoteFrames()
 
     dispatch("turbo:morph", {
diff --git a/src/core/frames/morph_frame_renderer.js b/src/core/frames/morph_frame_renderer.js
index 83d8bf1..b8404e2 100644
--- a/src/core/frames/morph_frame_renderer.js
+++ b/src/core/frames/morph_frame_renderer.js
@@ -1,5 +1,5 @@
 import { FrameRenderer } from "./frame_renderer"
-import { MorphElements } from "../morph_elements"
+import { morphElements } from "../morph_elements"
 import { dispatch } from "../../util"
 
 export class MorphFrameRenderer extends FrameRenderer {
@@ -11,6 +11,6 @@ export class MorphFrameRenderer extends FrameRenderer {
       detail: { currentElement, newElement }
     })
 
-    MorphElements.morph(currentElement, newElement, "innerHTML")
+    morphElements(currentElement, newElement, "innerHTML")
   }
 }
diff --git a/src/core/morph_elements.js b/src/core/morph_elements.js
index 0761486..7b74248 100644
--- a/src/core/morph_elements.js
+++ b/src/core/morph_elements.js
@@ -2,8 +2,14 @@ import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js"
 import { dispatch } from "../util"
 import { FrameElement } from "../elements/frame_element"
 
-export class MorphElements {
-  static morph(currentElement, newElement, morphStyle = "outerHTML") {
+export function morphElements(currentElement, newElement, morphStyle = "outerHTML") {
+  const renderer = new Renderer()
+
+  renderer.morph(currentElement, newElement, morphStyle)
+}
+
+class Renderer {
+  morph(currentElement, newElement, morphStyle) {
     this.isMorphingTurboFrame = this.isFrameReloadedWithMorph(currentElement)
 
     Idiomorph.morph(currentElement, newElement, {
@@ -18,15 +24,15 @@ export class MorphElements {
     })
   }
 
-  static isFrameReloadedWithMorph(element) {
+  isFrameReloadedWithMorph(element) {
     return (element instanceof FrameElement) && element.shouldReloadWithMorph
   }
 
-  static shouldAddElement = (node) => {
+  shouldAddElement = (node) => {
     return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))
   }
 
-  static shouldMorphElement = (oldNode, newNode) => {
+  shouldMorphElement = (oldNode, newNode) => {
     if (!(oldNode instanceof HTMLElement)) return
 
     if (oldNode.hasAttribute("data-turbo-permanent")) return false
@@ -44,17 +50,17 @@ export class MorphElements {
     return !event.defaultPrevented
   }
 
-  static shouldUpdateAttribute = (attributeName, target, mutationType) => {
+  shouldUpdateAttribute = (attributeName, target, mutationType) => {
     const event = dispatch("turbo:before-morph-attribute", { cancelable: true, target, detail: { attributeName, mutationType } })
 
     return !event.defaultPrevented
   }
 
-  static shouldRemoveElement = (node) => {
+  shouldRemoveElement = (node) => {
     return this.shouldMorphElement(node)
   }
 
-  static didMorphElement = (oldNode, newNode) => {
+  didMorphElement = (oldNode, newNode) => {
     if (newNode instanceof HTMLElement) {
       dispatch("turbo:morph-element", {
         target: oldNode,

That diff removes the static lines in favor of a bonafide Class with instances. It also exports a single morphElements function for callers to use without any knowledge that it's implemented with a class instance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seanpdoyle Good call, I made this change. I confirmed that the tests all still pass.

My recommendation would be that we merge in this PR and circle back to the open "turbo:before-frame-morph" element later in a follow-up since this PR is not making any changes to events.

static morph(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
}
})
}

static isFrameReloadedWithMorph(element) {
return element.src && element.refresh === "morph"
}

static shouldAddElement = (node) => {
return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))
}

static shouldMorphElement = (oldNode, newNode) => {
if (!(oldNode instanceof HTMLElement)) return

if (oldNode.hasAttribute("data-turbo-permanent")) return false

if (!this.isMorphingTurboFrame && this.isFrameReloadedWithMorph(oldNode)) return false

const event = dispatch("turbo:before-morph-element", {
cancelable: true,
target: oldNode,
detail: {
newElement: newNode
}
})

return !event.defaultPrevented
}

static shouldUpdateAttribute = (attributeName, target, mutationType) => {
const event = dispatch("turbo:before-morph-attribute", { cancelable: true, target, detail: { attributeName, mutationType } })

return !event.defaultPrevented
}

static shouldRemoveElement = (node) => {
return this.shouldMorphElement(node)
}

static didMorphElement = (oldNode, newNode) => {
if (newNode instanceof HTMLElement) {
dispatch("turbo:morph-element", {
target: oldNode,
detail: {
newElement: newNode
}
})
}
}
}
12 changes: 11 additions & 1 deletion src/tests/fixtures/frames.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
target.closest("turbo-frame")?.setAttribute("data-turbo-action", "advance")
} else if (target.id == "remove-target-from-hello") {
document.getElementById("hello").removeAttribute("target")
} else if (target.id == "add-refresh-reload-to-frame") {
target.closest("turbo-frame")?.setAttribute("refresh", "reload")
} else if (target.id == "add-refresh-morph-to-frame") {
target.closest("turbo-frame")?.setAttribute("refresh", "morph")
} else if (target.id == "add-src-to-frame") {
target.closest("turbo-frame")?.setAttribute("src", "/__turbo/frames")
}
})
</script>
Expand All @@ -23,11 +29,15 @@ <h1>Frames</h1>

<turbo-frame id="frame" data-loaded-from="/src/tests/fixtures/frames.html">
<h2>Frames: #frame</h2>
<h3>This text always changes ##randNum##</h3>

<form action="/src/tests/fixtures/frames/frame.html">
<button id="frame-form-get-no-redirect">Navigate #frame without a redirect</button>
</form>
<button id="add-turbo-action-to-frame" type="button">Add [data-turbo-action="advance"] to #frame</button>
<button id="add-refresh-reload-to-frame" type="button">Add [refresh="reload"] to #frame</button>
<button id="add-refresh-morph-to-frame" type="button">Add [refresh="morph"] to #frame</button>
<button id="add-src-to-frame" type="button">Add [src="/src/tests/fixtures/frames.html"] to #frame so it can be reloaded</button>
<a id="link-frame" href="/src/tests/fixtures/frames/frame.html">Navigate #frame from within</a>
<a id="link-frame-with-search-params" href="/src/tests/fixtures/frames/frame.html?key=value">Navigate #frame with ?key=value</a>
<a id="link-nested-frame-action-advance" href="/src/tests/fixtures/frames/frame.html" data-turbo-action="advance">Navigate #frame from within with a[data-turbo-action="advance"]</a>
Expand Down Expand Up @@ -57,7 +67,7 @@ <h2>Frames: #frame</h2>
<turbo-frame id="hello" target="frame">
<h2>Frames: #hello</h2>

<a id="hello-link-frame" href="/src/tests/fixtures/frames/frame.html">Load #frame</a>
<a href="/src/tests/fixtures/frames/frame.html">Load #frame</a>
<button type="button" id="remove-target-from-hello">Remove #hello[target]</button>

</turbo-frame>
Expand Down
Loading