-
Notifications
You must be signed in to change notification settings - Fork 430
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
Lift the frame morphing logic up to FrameController.reload #1192
Conversation
src/core/drive/morph_renderer.js
Outdated
|
||
// Private | ||
// Static methods — these helper methods are all static because morphing is done by turbo frames, which is outside of a PageView |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no change to the logic of any of these methods. They're simply converted to static methods so that they can be used by FrameController.
src/core/drive/morph_renderer.js
Outdated
#reloadRemoteFrames() { | ||
this.#remoteFrames().forEach((frame) => { | ||
frame.reload() | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This class no longer needs to check if the frame has morph set or not. It can blindly trigger a reload()
on each frame and trust that the frame will be smart — it knows if it needs to morph itself.
@@ -1,3 +1,4 @@ | |||
<turbo-frame id="refresh-morph"> | |||
<h2>Loaded morphed frame</h2> | |||
<h3>Does not change</h3> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding a piece of HTML which does not change made it easier for me to observe whether morphing is occurring or not within chrome inspector
@@ -654,7 +694,7 @@ test("navigating turbo-frame[data-turbo-action=advance] from within pushes URL s | |||
|
|||
test("navigating turbo-frame[data-turbo-action=advance] from outside with target pushes URL state", async ({ page }) => { | |||
await page.click("#add-turbo-action-to-frame") | |||
await page.click("#hello-link-frame") | |||
await page.click("#hello a") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every other test was referencing click("#hello a")
so I updated this one for consistency.
@seanpdoyle You reviewed my documentation PR yesterday, related to this change. I wanted to ping on this one in case you could also look at the bug fix. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@krschacht thanks for working on this.
Instead of reusing MorphRenderer
, which is a PageRenderer
for doing this, I'd consider adding a specialized FrameRenderer
that uses morphing. It could kick in when [refresh="morph"]
. I think this would also remove some clunkiness in the current MorphRenderer
, where it relies on an event to use morphing to refresh frames.
@jorgemanrubia Sure thing! I'm still trying to fully understand the architecture, but I think I follow what you're saying. That event logic was clunky. I'll get this cleaned up so we can get it merged in. Just to preview my plan in case you want to weigh in. I will plan to:
|
e429db7
to
e1fde68
Compare
@jorgemanrubia This PR is ready to go. I confirmed that all tests are passing and added good test coverage for the new cases. I also made a corresponding update to the turbo-frame docs here: hotwired/turbo-site#170 |
src/core/morph_elements.js
Outdated
import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js" | ||
import { dispatch } from "../util" | ||
|
||
export class MorphElements { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is great, it would help with quite a few things! I left minor comments.
src/elements/frame_element.js
Outdated
@@ -82,6 +82,10 @@ export class FrameElement extends HTMLElement { | |||
return this.getAttribute("refresh") | |||
} | |||
|
|||
get shouldReloadWithMorph() { | |||
this.src && this.refresh === "morph" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this have a return
statement?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, is it important to exclude initial <turbo-frame>
elements that don't have a [src]
attribute? Wouldn't morphing an initial HTTP request have a benefit?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're too fast for me! :) I actually mistakenly pushed before I was done. I'm doing a little more cleanup and making sure all tests pass before I went ahead and tagged anyone.
However, I don't understand your second comment. My mental model was that morphing for turbo frames only applied when there was a src. And I didn't introduce that extra condition, that's currently present within main.
When there is no src, then the full contents of the HTML must be supplied within the tags so that means it's part of the overall page. And so if the overall page morphs, then there is no special handling for turbo-frames — the contents within them just morphs along with all the rest of the HTML of that page. But I could be misunderstanding that.
That was my read of this original logic:
https://github.com/hotwired/turbo/blob/main/src/core/drive/morph_renderer.js#L95
The event binding hack was only being added when there was a src so I assume without that it's just regular HTML morphing of the page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you're good with that, this PR should be ready. I just did the additional refactor Bruno suggested and I know you still want Jorge to weigh in on removing that event (or maybe you meant keep the event but just omit it from public documentation, I'm good either way).
c237cb2
to
87c9553
Compare
This is probably the Turbo 8 feature I'm looking forward to the most at the moment, thank you @krschacht. I just wanted to say I've been testing this locally for the past week or so and it works very well, I haven't run into any gotchas or bugs. |
Follow-up to [][] Related to [hotwired#1192][] The `morphElements` function --- Introduce a new `src/core/morphing` module to expose a centralized and re-usable `morphElements(currentElement, newElement, delegate)` function to be invoked across the various morphing contexts. Next, move the logic from the `MorphRenderer` into a module-private `Morph` class. The `Morph` class (like its `MorphRenderer` predecessor) wraps a call to `Idiomorph` based on its own set of callbacks. The bulk of the logic remains in the `Morph` class, including checks for `[data-turbo-permanent]`. To serve as a seam for integration, the class retains a reference to a delegate responsible for: * providing options for the `Idiomorph` * determining whether or not a node should be skipped while morphing The `PageMorphRenderer` skips `<turbo-frame refresh="morph">` elements so that it can override their rendering to use morphing. Similarly, the `FrameMorphRenderer` provides the `morphStyle: "innerHTML"` option to morph its children. Changes to the renderers --- To integrate with the new module, first rename the `MorphRenderer` to `PageMorphRenderer` to set a new precedent that communicates the level of the document the morphing is scoped to. With that change in place, define the static `PageMorphRenderer.renderElement` to mirror the other existing renderer static functions (like [PageRenderer.renderElement][], [ErrorRenderer.renderElement][], and [FrameRenderer.renderElement][]). This integrates with the changes proposed in [hotwired#1028][]. Next, modify the rest of the `PageMorphRenderer` to integrate with its `PageRenderer` ancestor in a way that invokes the static `renderElement` function. This involves overriding the `preservingPermanentElements(callback)` method. In theory, morphing has implications on the concept of "permanence". In practice, morphing has the `[data-turbo-permanent]` attribute receive special treatment during morphing. Following the new precedent, introduce a new `FrameMorphRenderer` class to define the `FrameMorphRenderer.renderElement` function that invokes the `morphElements` function with `newElement.children` and `morphStyle: "innerHTML"`. Changes to the StreamActions --- The extraction of the `morphElements` function makes entirety of the `src/core/streams/actions/morph.js` module redundant. This commit removes that module and invokes `morphElements` directly within the `StreamActions.morph` function. Future possibilities --- In the future, additional changes could be made to expose the morphing capabilities as part of the `window.Turbo` interface. For example, applications could experiment with supporting [Page Refresh-style morphing for pages with different URL pathnames][] by overriding the rendering mechanism in `turbo:before-render`: ```js addEventListener("turbo:before-render", (event) => { const someCriteriaForMorphing = ... if (someCriteriaForMorphing) { event.detail.render = (currentElement, newElement) => { window.Turbo.morphElements(currentElement, newElement, { ... }) } } }) ``` [hotwired#1185]: hotwired#1185 (comment) [hotwired#1192]: hotwired#1192 [PageRenderer.renderElement]: https://github.com/hotwired/turbo/blob/9fb05e3ed3ebb15fe7b13f52941f25df425e3d15/src/core/drive/page_renderer.js#L5-L11 [ErrorRenderer.renderElement]: https://github.com/hotwired/turbo/blob/9fb05e3ed3ebb15fe7b13f52941f25df425e3d15/src/core/drive/error_renderer.js#L5-L9 [FrameRenderer.renderElement]: https://github.com/hotwired/turbo/blob/9fb05e3ed3ebb15fe7b13f52941f25df425e3d15/src/core/frames/frame_renderer.js#L5-L16 [hotwired#1028]: hotwired#1028 [hotwired#1177]: hotwired#1177
The lint failure is legit, the file is only 40 lines long. |
@brunoprietog Ah yes, you're right. I've fixed. But let me finish investigating this other test failure that I discovered around navigated frame refresh. I didn't touch the test and didn't expect it to break this behavior, but I'm still wrapping my mind around it to debug. Tbd |
@brunoprietog I could use your help on this test failure. I fully understand what's going on, and it's actually touching on a really interesting edge case. I think I need ya'll to make a clear product decision and then I can implement appropriately. Let me explain: I believe the current intended logic for turbo-frames is as follows:
Is this understanding all accurate? I'm not attempting to propose any changes in this paragraph, I'm just trying to articulate how I think it's supposed to work. Assuming that is correct, here is the edge case:
|
That's a good question. The morphing process has two stages. In the first, you go through all the elements. If we find a Turbo frame susceptible to be reloaded with morphing, we ignore it completely, i.e., it's not touched, no morphing is applied on it (yet). This manifests itself here: https://github.com/hotwired/turbo/blob/main/src/core/drive/morphing_page_renderer.js#L11 The other case where morphing is not done is if the element has the Once that process is complete, what we have are all new elements returned by the server, except for Turbo frames that are susceptible to morphing reloads and permanent elements, which remain in the state prior to the server's response. This is because we are interested in preserving the URL where the Turbo frame is located if the user has navigated through it. Otherwise, each page refresh would reset the Turbo frame to the initial state of the page, and that isn't what we want. Then comes the second stage. We go through all the Turbo frames, and if there's one that can be reloaded with morphing, we refresh and replace the current content with the new content of the Turbo frame returned by the server using morphing. This is the process we are refactoring here. So, to answer your question, yes, you can add Turbo frames dynamically and change their src. These won't be touched if they have the conditions for reloading using morphing. Only the Even on HEY Calendar we do something like this for pagination. We dynamically add Turbo frames for each new page. On each page refresh, the server doesn't actually return these Turbo frames, but since they already exist on the client, they remain there, and are reloaded with morphing. |
@@ -204,7 +204,7 @@ test("frames marked with refresh='morph' are excluded from full page morphing", | |||
await expect(page.locator("#refresh-morph")).toHaveText("Loaded morphed frame") | |||
}) | |||
|
|||
test("navigated frames without refresh attribute are reset after morphing", async ({ page }) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm sorry if I explained wrong. Turbo frames that aren't reloaded by morphing have to be reset correctly. This test should be fine.
Turbo frames that don't have the conditions to be reloaded with morphing are affected in the first round of morphing, therefore, they are morphed according to the changes coming from the server. Turbo frames that can be reloaded with morphing due to the presence of the src
attribute and refresh="morph"
are ignored in the first round, and then refreshed using morphing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was just about to send a long final comment saying I finished. :) Happy to do this either way. This test is a bit confusing so it took me a bit to fully make sense of. In your longer explanation, you said what my expectation would be. Specifically:
This is because we are interested in preserving the URL where the Turbo frame is located if the user has navigated through it. Otherwise, each page refresh would reset the Turbo frame to the initial state of the page, and that isn't what we want.
This test was enforcing the opposite, so I flipped it to reflect what you said here. ^ And it fits my intuition that if a user navigations a turbo frame, and it's implicitly reload="refresh" (not morph) then I would expect that navigation to stay. But you think we should revert it back since it started with no src when the page loaded?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, that's correct, but only for Turbo frames marked with refresh="morph"
attribute. Sorry if I wasn't clear enough.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I mean is that the goal of the test as it was before was correct. Turbo frames that don't contain the refresh="morph"
attribute are reset. We only retain the state of Turbo frames that are reloaded with morphing. The rest are morphed in the first round according to what the server returns in the request made at the time of the page refresh.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test has been reverted (so that it fails again) and the logic has been corrected (so now the test passes).
I think that's it and things are now ready. 🤞
This PR should be ready to merge in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work here @krschacht 👏, I'll merge shortly.
@brunoprietog I reverted the Guys — While I have your attention, I am using turbo-frames extensively in my app. I have discovered a bug with history and turbo frames. I have opened a new issue regarding this, others have confirmed the bug and there is a reliable repro and fix. I can't tell if the core team has had eyes on this yet so it's worth a quick look as people are sharing monkey patch hacks and teeing up different PRs attempting to fix it: #1300 |
Thanks @krschacht. We'll have a look at that one. |
Follow-up to [hotwired#1192][] Closes [hotwired#1303][] Remove the argument from all `Renderer` subclass constructor call sites. In its place, define the default value for the `Renderer.renderElement` property based on the `static renderElement(currentElement, newElement)` defined by the inheriting class. By automatically determining which `renderElement` implementation based on class, this commit enables a simpler Frame morphing strategy. Instead of attaching one-time event listeners, this commit maintains a private `#shouldMorphFrame` property that is set, then un-set during the rendering lifecycle of a frame. Similarly, this commit implements the `MorphingFrameRenderer.preservingPermanentElements` method in the same style as the `MorphingPageRenderer.preservingPermanentElements`. Since _instances_ of `MorphingFrameRenderer` are now being used instead of the static `MorphingFrameRenderer.renderElement`, we're able to utilize an instance method instead of passing around an additional anonymous function (which is the implementation that [hotwired#1303][] proposed). [hotwired#1192]: hotwired#1192 [hotwired#1303]: hotwired#1303 Co-authored-by: Micah Geisel <micah@botandrose.com>
Thank you for pushing this through @krschacht! I've modified #1028 to incorporate the changes made here. It also addresses the underlying issues highlighted by #1303. |
When I saw this PR get merged I was excited, but then I realized the original idea to expose the #1234 pointed out that by exposing things like Is there any plans to eventually expose |
@jordancoil While I can't speak to your question about exporting those classes, I wonder if you could accomplish what you describe with less coupling to Turbo's internals (and less of a public API commitment from Turbo) by hooking into the existing rendering events: https://turbo.hotwired.dev/reference/events |
@jordancoil I'm not sure that you've lost any flexibility. I think it should work for you to catch the event:
You might need to fiddle with |
@botandrose @krschacht thanks for the suggestions. Listening to the |
Fixes Issue #1161
Background:
turbo-frame
tags support an optional property ofrefresh="morph"
. It's used like this:When the full page morphs, this turbo-frame src is reloaded, and the presence of
morph
ensures the contents is morphed rather than replaced.Problem:
However, if you trigger an update of this frame by another means such as
document.getElementById('post').reload()
then the contents of the frame is replaced without morphing. @jorgemanrubia acknowledged in this comment that it would be nice for this behavior to be consistent.Solution within this PR:
This PR creates a newMorphFrameRenderer
which inherits fromFrameRenderer
. (Note:MorphRenderer
is renamed toMorphPageRenderer
for clarity.) And just likePageView
decides when to useMorphPageRenderer
, nowFrameController
makes the appropriate decision of when to useMorphFrameRenderer
.BecauseMorphFrame*
andMorphPage*
share so much logic this has been extracted into a utility classcore/morph_elements.js
Most of this was extracted into PR #1234. All that remains in this PR is (1) a new conditional to determine whether
FrameRenderer
orMorphingFrameRenderer
should be called on this frame, and (2) good test coverage for these new cases.I made a corresponding update to the turbo-frame docs here: hotwired/turbo-site#170