From abab1cf0e000c333457e764e556e0301d7f95f2f Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sat, 2 Dec 2023 19:07:35 -0500 Subject: [PATCH] Introduce `turbo:{before-,}morph-{element,attribute}` events Follow-up to [9944490][] Related to [#1083] Related to [@hotwired/turbo-rails#533][] The problem --- Some client-side plugins are losing their state when elements are morphed. Without resorting to `MutationObserver` instances to determine when a node is morphed, uses of those plugins don't have the ability to prevent (without `[data-turbo-permanent]`) or respond to the morphing. The proposal --- This commit introduces a `turbo:before-morph-element` event that'll dispatch as part of the Idiomorph `beforeNodeMorphed` callback. It'll give interested parties access to the nodes before and after a morph. If that event is cancelled via `event.preventDefault()`, it'll skip the morph as if the element were marked with `[data-turbo-permanent]`. Along with `turbo:before-morph-element`, this commit also introduces a `turbo:before-morph-attribute` to correspond to the `beforeAttributeUpdated` callback that Idiomorph provides. When listeners (like an `HTMLDetailsElement`, an `HTMLDialogElement`, or a Stimulus controller) want to preserve the state of an attribute, they can cancel the `turbo:before-morph-attribute` event that corresponds with the attribute name (through `event.detail.attributeName`). Similarly, this commit adds a new `turbo:morph-element` event to be dispatched for every morphed node (via Idiomorph's `afterNodeMorphed` callback). The original implementation dispatched the event for the `` element as part of `MorphRenderer`'s lifecycle. That event will still be dispatched, since `` is the first element the callback will fire for. In addition to that event, each individual morphed node will dispatch one. This commit re-introduced test coverage for a Stimulus controller to demonstrate how an interested party might respond. It isn't immediately clear with that code should live, but once we iron out the details, it could be part of a `@hotwired/turbo/stimulus` package, or a `@hotwired/stimulus/turbo` package that users (or `@hotwired/turbo-rails`) could opt-into. [9944490]: https://github.com/hotwired/turbo/pull/1019/commits/9944490a3c8aec0c5060401125cc8932e93a32df [#1083]: https://github.com/hotwired/turbo/issues/1083 [@hotwired/turbo-rails#533]: https://github.com/hotwired/turbo-rails/issues/533 --- src/core/drive/morph_renderer.js | 37 ++++++++++++-- src/core/view.js | 2 +- src/tests/fixtures/page_refresh.html | 46 ++++++++++++++++- src/tests/fixtures/test.js | 4 ++ src/tests/functional/page_refresh_tests.js | 59 +++++++++++++++++++++- src/tests/functional/rendering_tests.js | 2 +- 6 files changed, 141 insertions(+), 9 deletions(-) diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js index beb94bfb1..87401ee23 100644 --- a/src/core/drive/morph_renderer.js +++ b/src/core/drive/morph_renderer.js @@ -33,7 +33,9 @@ export class MorphRenderer extends Renderer { callbacks: { beforeNodeAdded: this.#shouldAddElement, beforeNodeMorphed: this.#shouldMorphElement, - beforeNodeRemoved: this.#shouldRemoveElement + beforeAttributeUpdated: this.#shouldUpdateAttribute, + beforeNodeRemoved: this.#shouldRemoveElement, + afterNodeMorphed: this.#didMorphElement } }) } @@ -44,9 +46,36 @@ export class MorphRenderer extends Renderer { #shouldMorphElement = (oldNode, newNode) => { if (oldNode instanceof HTMLElement) { - return !oldNode.hasAttribute("data-turbo-permanent") && (this.isMorphingTurboFrame || !this.#isFrameReloadedWithMorph(oldNode)) - } else { - return true + 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 + } + }) } } diff --git a/src/core/view.js b/src/core/view.js index 8c5ae2e82..4a0c0d7fd 100644 --- a/src/core/view.js +++ b/src/core/view.js @@ -64,7 +64,7 @@ export class View { await this.prepareToRenderSnapshot(renderer) const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve)) - const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement } + const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod } const immediateRender = this.delegate.allowsImmediateRender(snapshot, options) if (!immediateRender) await renderInterception diff --git a/src/tests/fixtures/page_refresh.html b/src/tests/fixtures/page_refresh.html index d9523c6b1..f7f577d56 100644 --- a/src/tests/fixtures/page_refresh.html +++ b/src/tests/fixtures/page_refresh.html @@ -9,6 +9,45 @@ +