-
Notifications
You must be signed in to change notification settings - Fork 429
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
Export PageRenderer #305
Export PageRenderer #305
Conversation
Please do look at turning that monkey patch into more direct flexibility. @domchristie was just mentioning this in #290. |
FYI, I've tackled this issue in chaskiq.io (intercom-like embed) by wrapping the embed div in a turbo-permanent. That will persist their state between navigation. |
@dhh Thanks for the merge! I've already tried wrapping the node with I came across the following issues that describe the problem (or similar/related problems):
Being able to intercept the rendering operation as described in #290 might be a viable solution to the problem if it'd allow you to switch the rendering target from @dhh just an idea, but perhaps being able to specify a different rendering target by simply adding an optional attribute (i.e. Example Layout HTML diff - <body>
- <%= yield %>
- <!-- code from various services such as Stripe, HelpScout, is often injected here -->
- </body>
+ <body>
+ <div data-turbo-drive-body>
+ <%= yield %>
+ </div>
+ <!-- code from various services such as Stripe, HelpScout, is often injected here -->
+ </body> Example PageRenderer diff - assignNewBody() {
- if (document.body && this.newElement instanceof HTMLBodyElement) {
- document.body.replaceWith(this.newElement)
- } else {
- document.documentElement.appendChild(this.newElement)
- }
- }
+ assignNewBody() {
+ if (this.turboBody && this.newTurboElement) {
+ this.turboBody.replaceWith(this.newTurboElement)
+ } else if (document.body && this.newElement instanceof HTMLBodyElement) {
+ document.body.replaceWith(this.newElement)
+ } else {
+ document.documentElement.appendChild(this.newElement)
+ }
+ }
+
+ get turboBody() {
+ return document.querySelector("[data-turbo-drive-body]")
+ }
+
+ get newTurboElement() {
+ return this.newElement.querySelector("[data-turbo-drive-body]")
+ } I haven't tried the above yet, but this is essentially what I had in mind. The default behavior stays the same (render into |
@mrrooijen Can you explain more the issue you're trying to solve? Why isn't data-turbo-permanent working for you? What's breaking and could we fix it? It is an interesting idea to basically invert data-turbo-permanent, such that everything NOT marked is permanent. But I'm not sure why we're able to make that work but not specifically marking something as data-turbo-permanent? Is it about detaching and attaching the nodes that trigger something? |
@dhh correct. When you detach an element containing an iframe, and then re-attach it, it'll cause the iframe to reload. This behavior is intended and there's no way around it as far as I know. See: https://bugs.webkit.org/show_bug.cgi?id=13574#c14 There apparently used to be a "magic iframe" that could work around this issue, but it's been removed due to security implications. Third party nodes and iframes (Stripe, HelpScout, etc) are commonly injected just before the closing Therefore, the only way that I currently know of that resolves this issue is to, rather than render your content into
Just to clarify, the solution that I proposed (the diffs) doesn't involve the use of |
I don't have a deep knowledge of the rendering process, but my feeling is that it won't be straightforward to just change the target render element, and that this might cause future maintenance issues. (It could become a reimplementation of Turbo frames :/) Given the uncertainty around how this might be used, my preference would be for a "canary" API using the pausable render feature. For example something like: addEventListener('turbo:before-render', function (event) {
event.preventDefault()
document.getElementById('root').replaceWith(event.detail.newBody.getElementById('root'))
event.detail.resume({ skipSnapshotRendering: true })
}) |
Our use case will require something similar. We are embedding Twilio Flex inside of an iframe in our CRM. The iframe reload during the visit causes state loss and breaks WebRTC connections which results in dropped calls. I made a fork that did exactly what @mrrooijen suggested with Something like what @domchristie suggests would definitely work for our use case. |
I have spiked an idea for this here: main...domchristie:custom_rendering Not tried it, but might be worth testing it out. |
Thanks @domchristie! Your spike does allow rendering to a child element instead of body without monkey patching PageRenderer. Unfortunately you lose the permanent element preservation and script activation that are handled by PageRenderer. For this particular use case, being able to configure the function that assigns the new body would be preferable:
I'm not sure if that's much better than monkey patching PageRenderer, though. Another option would be to extract those features from PageRenderer and allow people to use them in their custom renderers. That allows a high amount of flexibility without sacrificing some of turbo's great features in the process. Either way the problem with restoration visits still exists, but it looks like @tbo already discovered the cause of that. See #218 (comment) re: snapshotting mechanism. I would love to see that one line merged, it would allow us to switch off our fork. |
I suppose that if you were using a DOM diffing library like morphdom then you'd likely handle permanent elements yourself? Perhaps there's a way to implement both e.g. |
I'm not a morphdom expert, but I imagine there may be cases where you have some state that you would like to keep even though the server is returning a new body that contains some default state. I don't know if morphdom has its own way to express that.
Letting people provide their own PageRenderer is a potential solution. Instead of letting people monkey-patch it, you could write your own or extend the one that Turbo exports. Example:
|
I think this is possible with the addEventListener('turbo:before-render', async function (event) {
event.preventDefault()
morphdom(document.body, event.detail.newBody, {
onBeforeElUpdated: function (fromEl, toEl) {
const permanent = (
fromEl.dataset.turboPermanent &&
toEl.dataset.turboPermanent &&
fromEl.id === toEl.id
)
if (permanent) return false
return true
}
})
event.detail.resume({ skipSnapshotRendering: true })
})
I think I'm generally against this approach, as the entire |
That looks straightforward to me. I like the idea. Far better than making For our use case extra weight of morphdom isn't an issue, I'll give it a go with your branch and see if I can get script activation working as well. Thanks! |
Not tested extensively, but what you had there more or less worked, and adding Turbo-compatible script activation was easy as well. In case anybody is curious: function copyElementAttributes(destinationElement, sourceElement) {
for (const { name, value } of [ ...sourceElement.attributes ]) {
destinationElement.setAttribute(name, value)
}
}
function createScriptElement(element) {
if (element.getAttribute("data-turbo-eval") == "false") {
return element
} else {
const createdScriptElement = document.createElement("script")
const cspNonce = document.head.querySelector('meta[name="csp-nonce"]')?.getAttribute("content")
if (cspNonce) {
createdScriptElement.nonce = cspNonce
}
createdScriptElement.textContent = element.textContent
createdScriptElement.async = false
copyElementAttributes(createdScriptElement, element)
return createdScriptElement
}
}
addEventListener('turbo:before-render', async function (event) {
event.preventDefault()
morphdom(document.body, event.detail.newBody, {
onNodeAdded: function (node) {
if (node.nodeName === 'SCRIPT') {
const activatedScriptElement = createScriptElement(node)
node.replaceWith(activatedScriptElement)
}
},
onBeforeElUpdated: function (fromEl, toEl) {
const permanent = (
fromEl.dataset.turboPermanent !== undefined &&
toEl.dataset.turboPermanent !== undefined &&
fromEl.id === toEl.id
)
if (permanent) return false
if (fromEl.nodeName === "SCRIPT" && toEl.nodeName === "SCRIPT") {
const activatedScriptElement = createScriptElement(toEl)
fromEl.replaceWith(activatedScriptElement)
return false;
}
return true
}
})
event.detail.resume({ skipSnapshotRendering: true })
}) While adopting morphdom and copying some turbo functionality can work for us, I can see value in also having an official Turbo solution using @mrrooijen's suggestion of specifying a target for body assignment with something like In other words, I like both |
With PageRenderer now being exportable, the following works for me. import { PageRenderer } from "@hotwired/turbo"
PageRenderer.prototype.assignNewBody = function() {
const body = document.querySelector("[data-turbo-drive-body]")
const el = this.newElement.querySelector("[data-turbo-drive-body]")
if (body && el) {
body.replaceWith(el)
} else if (document.body && this.newElement instanceof HTMLBodyElement) {
document.body.replaceWith(this.newElement)
} else {
document.documentElement.appendChild(this.newElement)
}
} <head>
<meta content="no-cache" name="turbo-cache-control" />
</head>
<body>
<div data-turbo-drive-body>
<%= yield %>
</div>
</body> Page restorations appear to work for me because I'm not caching pages. Otherwise, as @internets pointed out, it will break. |
If you'd like to continue caching, you can eliminate the pause before cloning the snapshot. Here's the monkey patch: Turbo.navigator.view.cacheSnapshot = function() {
if (this.shouldCacheSnapshot) {
this.delegate.viewWillCacheSnapshot()
const { snapshot, lastRenderedLocation: location } = this
// await nextEventLoopTick()
this.snapshotCache.put(location, snapshot.clone())
}
} I'm not 100% sure yet, but I believe the |
Thanks for exporting this, this works great! import morphdom from "morphdom";
import { PageRenderer } from "@hotwired/turbo";
PageRenderer.prototype.assignNewBody = function() {
if (document.body && this.newElement instanceof HTMLBodyElement) {
morphdom(document.body, this.newElement, {
onBeforeElUpdated: function(fromEl, toEl) {
return !fromEl.isEqualNode(toEl);
}
});
} else {
document.documentElement.appendChild(this.newElement)
}
} |
For those coming across this issue, who might be looking for a more limited patch that still allows for caching to function as expected, I went about it by actually adding // copied since `util` is not exported
const nextEventLoopTick = () => new Promise((resolve) => {
setTimeout(() => resolve(), 0);
});
const findBodyElement = (body) => body.querySelector('[data-turbo-drive-body]') || body;
PageRenderer.prototype.assignNewBody = async function assignNewBody() {
await nextEventLoopTick();
if (document.body && this.newElement instanceof HTMLBodyElement) {
const currentBody = findBodyElement(document.body);
const newBody = findBodyElement(this.newElement);
currentBody.replaceWith(newBody);
} else {
document.documentElement.appendChild(this.newElement);
}
}; The order of execution without adding
By adding I believe the reason this isn't an issue if you replace all of |
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in hotwired#305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
While most of the time, replacing `<body>` makes perfect sense, when working with 3rd party integrations (i.e. Stripe), there are elements injected just before the closing `</body>` tag that should not be removed between page visits. There is more detail on this issue, particularly with injected `<iframe>` elements in #305 (comment). Now, if someone wants to customize the element that is replaced by Drive, they can add `data-turbo-drive-body` to an element, and only that element will be replaced between visits.
For anyone that takes inspiration from #305 (comment) please be aware that the code provided fixes the cache visits issue, but for non-cache visits, if the new body and old body contain |
@agrobbin @scottnicolson I'd like to point that implementing #305 (comment) causes |
- static preservingPermanentElements(delegate, permanentElementMap, callback) {
+ static async preservingPermanentElements(delegate, permanentElementMap, callback) {
const bardo = new this(delegate, permanentElementMap);
bardo.enter();
- callback();
+ await callback();
bardo.leave();
}
enter() {
@@ -1225,8 +1225,8 @@ class Renderer {
delete this.resolvingFunctions;
}
}
- preservingPermanentElements(callback) {
- Bardo.preservingPermanentElements(this, this.permanentElementMap, callback);
+ async preservingPermanentElements(callback) {
+ await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback);
}
focusFirstAutofocusableElement() {
const element = this.connectedSnapshot.firstAutofocusableElement;
@@ -2582,7 +2582,7 @@ class PageRenderer extends Renderer {
}
async render() {
if (this.willRender) {
- this.replaceBody();
+ await this.replaceBody();
}
}
finishRendering() {
@@ -2607,10 +2607,10 @@ class PageRenderer extends Renderer {
this.copyNewHeadProvisionalElements();
await newStylesheetElements;
}
- replaceBody() {
- this.preservingPermanentElements(() => {
+ async replaceBody() {
+ await this.preservingPermanentElements(async () => {
this.activateNewBody();
- this.assignNewBody();
+ await this.assignNewBody();
});
}
get trackedElementsAreIdentical() {
@@ -2649,8 +2649,8 @@ class PageRenderer extends Renderer {
inertScriptElement.replaceWith(activatedScriptElement);
}
}
- assignNewBody() {
- this.renderElement(this.currentElement, this.newElement);
+ async assignNewBody() {
+ await this.renderElement(this.currentElement, this.newElement);
} I think these are the minimal changes to make |
There are situations where you don't want the entire body to be replaced. One of the reasons would be when you're using 3rd party libraries that inject code (i.e. an iframe) such as Intercom or HelpScout, as this would result in the removal- and state-loss of their UI component every time you navigate using turbo drive.
Looking through previous issues it seems that there's no desire to add the ability to select a different node to render turbo drive responses into. So instead, it would be nice to be able to monkey-patch this functionality in so we can opt-out of this constraint. However, in order to do this we'll need access to PageRenderer, which currently isn't exported. This PR exports it.