From 631ae01343303f2d82433b73efb2c07c3a8052dd Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Sat, 17 Oct 2020 22:26:06 +0200 Subject: [PATCH] Add the morph feature With morphs we more easily modify specific parts of the DOM directly in the reflex. We also got access to a broadcaster which means that we can use that to surgically alter certain DOM elements to our liking. In this commit we also drop the commitment to maintain Channels 2.4. Channels 2.4 should still work, but the CI showed weird issues and this added to a maintainence headache which wasn't worth it (unless explicitly asked for and paid by someone). --- .github/workflows/build.yml | 2 +- cypress/integration/websocket_spec.js | 8 + docs/SUMMARY.md | 1 + docs/morph-mode.md | 256 +++++++++++++++++++++++ docs/troubleshooting.md | 108 ++++++++++ package.json | 2 +- sockpuppet/consumer.py | 20 +- sockpuppet/reflex.py | 87 +++++++- tests/example/reflexes/example_reflex.py | 5 + tests/example/templates/example.html | 4 + tox.ini | 26 +-- 11 files changed, 486 insertions(+), 33 deletions(-) create mode 100644 docs/morph-mode.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8c4befd..e5cae48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,7 +68,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - channels: ["2.4", "3.0"] + channels: ["3.0"] steps: - uses: actions/checkout@v2 diff --git a/cypress/integration/websocket_spec.js b/cypress/integration/websocket_spec.js index 341651b..e9c8052 100644 --- a/cypress/integration/websocket_spec.js +++ b/cypress/integration/websocket_spec.js @@ -79,4 +79,12 @@ describe("Integration tests", () => { cy.get('#user-reflex').should('have.text', 'test_user') }) + + it("can send a morph in a reflex", () => { + cy.visit('/test') + cy.wait(200) + cy.get('#morph-button').click() + + cy.get('#morph').should('have.text', 'I got morphed!') + }) }) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 4d01e23..ec0c703 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -8,6 +8,7 @@ * [Scoping](scoping.md) * [Persistence](persistence.md) * [Useful Patterns](patterns.md) +* [Morph mode](morph-mode.md) * [Working with Events](events.md) * [Working with Forms](working-with-form.md) * [Security & Authentication](security.md) diff --git a/docs/morph-mode.md b/docs/morph-mode.md new file mode 100644 index 0000000..24cde6b --- /dev/null +++ b/docs/morph-mode.md @@ -0,0 +1,256 @@ +--- +description: "How to update everything, nothing or something in-between \U0001F9D8" +--- + +# Morphs + +By default, django-sockpuppet updates your entire page. After re-processing your view, rendering your template, and sending the raw HTML to your browser, django-sockpuppet uses [morphdom](https://github.com/patrick-steele-idem/morphdom) library to do the smallest number of DOM modifications necessary to refresh your UI in just a few milliseconds. For many developers, this will be a perfect solution forever. They can stop reading here. + +Most real-world applications are more sophisticated, though. You think of your site in terms of sections, components and content areas. We reason about our functionality with abstractions like _"sidebar"_ but when it's time to work, we shift back to contemplating a giant tree of nested containers. Sometimes we need to surgically swap out one of those containers with something new. Sending the entire page to the client seems like massive overkill. We need to update just part of the DOM without disturbing the rest of the tree... _and we need it to happen in ~10ms_. + +Other times... we just need to hit a button which feeds a cat which may or may not still be alive in a steel box. 🐈 + +It's _almost_ as if complex, real-world scenarios don't always fit the one-size-fits-all default full page Reflex. + +## Introducing Morphs + +Behind the scenes, there are actually three different _modes_ in which StimulusReflex can process your requests. We refer to them by _what they will replace_ on your page: **Page**, **Selector** and **Nothing**. All three benefit from the same logging, callbacks, events and promises. + +Changing the Morph mode happens in your server-side Reflex class in one of your action methods. Both markup e.g. `data-reflex` and programmatic e.g. `stimulate()` mechanisms for initiating a Reflex on the client work without modification. + +`morph` is only available in Reflex classes. Once you change modes, you cannot change between them. Meaning that if you call morph in the reflex class it will only execute that morph and no other change to the page will be made. + +![Each Morph is useful in different scenarios.](.gitbook/assets/power-rangers%20%281%29.jpg) + +| What are you replacing? | Typical Round-Trip Speed | +| :--- | :--- | :--- | +| The full **page** \(default\) | ~50ms-100ms | +| All children of a CSS DOM **selector** | ~15ms | +| **Nothing** at all | No | ~6ms | + +## Page Morphs + +Full-page Reflexes are described in great detail on the Reflexes page. Page morphs are the default behavior of StimulusReflex and they are what will occur if you don't call `morph` in your Reflex. + +All Reflexes are, in fact, Page morphs - until they are not. 👴⚗️ + +If you've already been using Page morphs, nothing below changes what you know about them. + +{% page-ref page="reflexes.md" %} + +### Scoping Page Morphs + +Instead of updating your entire page, you can specify exactly which parts of the DOM will be updated using the `data-reflex-root` attribute. + +`data-reflex-root=".class, #id, [attribute]"` + +Simply pass a comma-delimited list of CSS selectors. Each selector will retrieve one DOM element; if there are no elements that match, the selector will be ignored. + +StimulusReflex will decide which element's children to replace by evaluating three criteria in order: + +1. Is there a `data-reflex-root` on the element with the `data-reflex`? +2. Is there a `data-reflex-root` on an ancestor element above the element in the DOM? It could be the element's immediate parent, but it doesn't have to be. +3. Just use the `body` element. + +Here is a simple example: the user is presented with a text box. Anything they type into the text box will be echoed back in two div elements, forwards and backwards. + +{% tabs %} +{% tab title="index.html" %} +```html +
+ +
{{ words }}
+
{{ words_reverse }}
+
+``` +{% endtab %} + +{% tab title="example\_reflex.py" %} +```python + def words(self): + self.words = self.element['value'] + self.words_reverse = self.element['value'][::-1] +``` +{% endtab %} +{% endtabs %} + +{% hint style="info" %} +One interesting detail of this example is that by assigning the root to `[forward],[backward]` we are implicitly telling StimulusReflex to **not** update the text input itself. This prevents resetting the input value while the user is typing. +{% endhint %} + +{% hint style="warning" %} +In StimulusReflex, morphdom is called with the **childrenOnly** flag set to _true_. + +This means that <body> or the custom parent selector\(s\) you specify are not updated. For this reason, it's necessary to wrap anything you need to be updated in a div, span or other bounding tag so that it can be swapped out without confusion. + +If you're stuck with an element that just won't update, make sure that you're not attempting to update the attributes on an <a>. +{% endhint %} + +{% hint style="info" %} +It's completely valid to for an element with a data-reflex-root attribute to reference itself via a CSS class or other mechanism. Just always remember that the parent itself will not be replaced! Only the children of the parent are modified. +{% endhint %} + +### Permanent Elements + +Perhaps you just don't want a section of your DOM to be updated by StimulusReflex. Perhaps you need to integrate 3rd-party elements such as ad tracking scripts, Google Analytics, and any other widget that renders itself such as a React component or legacy jQuery plugin. + +Just add `data-reflex-permanent` to any element in your DOM, and it will be left unchanged by full-page Reflex updates and `morph` calls that re-render partials. Note that `morph` calls which insert simple strings or empty values do not respect the `data-reflex-permanent` attribute. + +{% code title="index.html" %} +```html +
+ +
+``` +{% endcode %} + +{% hint style="warning" %} +We have encountered scenarios where the `data-reflex-permanent` attribute is ignored unless there is a unique `id` attribute on the element as well. Please let us know if you can identify this happening in the wild, as technically it shouldn't be necessary... and yet, it works. + +¯\\__\(ツ\)\__/¯ +{% endhint %} + +{% hint style="danger" %} +Beware of Python packages that implicitly inject HTML into the body as it might be removed from the DOM when a Reflex is invoked. Packages like this often provide instructions for explicitly including their markup. We recommend using the explicit option whenever possible, so that you can wrap the content with `data-reflex-permanent`. +{% endhint %} + +## Selector Morphs +A selector morph is the act of executing `morph` function in the reflex class. This is the perfect option if you want to re-render a template partial, update a counter or just set a container to empty. Since it accepts a string or a template, you can pass a value to it directly. + +### Tutorial + +Let's first establish a baseline HTML sample to modify. Our attention will focus primarily on the `div` known colloquially as **\#foo**. + +{% code title="show.html" %} +```html +
+ {% include "path/to/foo.html" message="Am I the medium or the massage?" %} +
+``` +{% endcode %} + +Behold! For this is the `foo` partial. It is an example of perfection: + +{% code title="\_foo.html" %} +```html +
+ {{ message }} +
+``` +{% endcode %} + +You create a Selector morph by calling the `morph` method. In its simplest form, it takes two parameters: **selector** and **html**. We pass any valid CSS DOM selector that returns a reference to the first matching element, as well as the value we're updating it with. + +You can also provide the keyword argument `template` and `context` to morph, in that case it will either treat the template argument as a string or look for the template partial and render the template with the provided context. + +{% code title="my_app/reflexes/example\_reflex.py" %} +```python +from sockpuppet import reflex + +class ExampleReflex(reflex.Reflex): + def change(self): + self.morph("#foo", html="Your muscles... they are so tight.") +``` +{% endcode %} + +If you consult your Elements Inspector, you'll now see that "\#foo" now contains a text node and your `header` has gained some attributes. + +```html +
+
Your muscles... they are so tight.
+
+``` + +**Morphs only replace the children of the element that you are targeting.** If you need to update the target element \(as you would with `outerHTML`\) consider targeting the parent of the element you need to change. You could, for example, call `morph "header", "No more #foo."` and start fresh. + +{% hint style="info" %} +Cool, but _where did the span go_? We're glad you asked! + +The truth is that a lot of complexity and nasty edge cases are being hidden away, while presenting you intelligent defaults and generally trying to follow the _principle of least surprise_. + +There's no sugar coating the fact that there's a happy path for all of the typical use cases, and lots of gotchas to be mindful of otherwise. We're going to tackle this by showing you best practices first. Start by \#winning now and later there will be a section with all of the logic behind the decisions so you can troubleshoot if things go awry / [Charlie Sheen](https://www.youtube.com/watch?v=pipTwjwrQYQ). +{% endhint %} + +### Intelligent defaults + +Morphs work differently depending on whether you are replacing existing content with a new version or something entirely new. This allows us to intelligently re-render partials and ViewComponents based on data that has been changed in the Reflex action. + +```python +yelling = "hello world".upper() +self.morph("#foo", template="path/to/foo.html", context={"message": yelling}) +``` + +The `foo` partial (listed in the [Tutorial](morph-mode.md#tutorial) section above) is an example of a best practice for several subtle but important reasons which you should use to model your own updates: + +* it has a **single** top-level container element with the same CSS selector as the target +* inside that container element is another [element node](https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType), **not a text node** + +If you can follow those two guidelines, you will see several important benefits regardless of how the HTML stream is generated: + +* DOM changes will be performed by the morphdom library, which is highly efficient +* morph will respect elements with the `data-reflex-permanent` attribute +* any event handlers set on contents should remain intact \(unless they no longer exist\) + +As you have already seen, it's **okay** to morph a container with a string, or a container element that has a different CSS selector. However, `morph` will treat these updates _slightly_ differently: + +* DOM elements are replaced by updating innerHTML +* elements with the `data-reflex-permanent` attribute will be over-written +* any event handlers on replaced elements are immediately de-referenced +* you could end up with a nested container that might be jarring if you're not expecting it + +Let's say that you update \#foo with the following morph: + +```python +self.morph("#foo", html="
Let's do something about those muscles.
") +``` + +This update will use morphdom to update the existing \#foo div. However, because \#foo contains a [text node](https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType), `data-reflex-permanent` is ignored. + +```python +self.morph("#foo", html="
Just breathe in... and out.
") +``` + +Now your content is contained in a `span` element node. All set... except that you changed \#foo to \#baz. + +```html +
+
+
+ Just breathe in... and out. +
+
+
+``` + +That's great - if that's what you want. 🤨 + +Ultimately, we've optimized for two primary use cases for morph functionality: + +1. Updating a partial to reflect a state change. +2. Updating a container element with a new simple value or HTML fragment. + +### Things go wrong... + +We've worked really hard to make morphs easy to work with, but there are some rules and edge cases that you have to follow. If something strange seems to be happening, please consult the [Morphing Sanity Checklist](https://sockpuppet.argpar.se/troubleshooting#morphing-sanity-checklist) to make sure you're on the right side of history. + +## Nothing Morphs + +Your user clicks a button. Something happens on the server. The browser is notified that this task was completed via the usual callbacks and events. + +Nothing morphs are [Remote Procedure Calls](https://en.wikipedia.org/wiki/Remote_procedure_call), implemented on top of ActionCable. + +Sometimes you want to take advantage of the chassis and infrastructure of sockpuppet, without any assumptions or expectations about changing your DOM afterwards. The bare metal nature of Nothing morphs means that the time between initiating a Reflex and receiving a confirmation can be low single-digit milliseconds, if you don't do anything to slow it down. + +Nothing morphs usually initiate a long-running process, such as making calls to APIs or supervising I/O operations like file uploads or video transcoding. However, they are equally useful for emitting signals; you could send messages into a queue, tell your media player to play, or tell your Arduino to launch the rocket. + +The key strategy when working with Nothing morphs is to **avoid blocking calls at all costs**. + +#### I can't take the suspense. How can I capture this raw power for myself? + +It's wickedly hard... but with practice, you'll be able to do it, too: + +```python +self.morph() +``` + +That's it. That's the entire API surface. 🙇 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f2e9f38..3b8e633 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -61,3 +61,111 @@ LOGGING = { } } ``` + +## Morphing Sanity Checklist + +We want to stress that if you follow the happy path explained on the [Morphs](../rtfm/morph-modes.md#intelligent-defaults) page, you shouldn't need to ever worry about the edge cases that follow. However, we have worked hard to think of and collect the possible ways someone could abuse the HTML spec and potentially experience unexpected outcomes. + +#### You cannot change the attributes of your morph target. + +Even if you maintain the same CSS selector, you cannot modify any attributes \(including data attributes\) of the container element with the `morph` method. + +```python +self.morph("#foo", html="
data-muscles will not be set.
") +``` + +You might consider digging into the CableReady/Channel implementation and use `outer_html` or `set_attribute`, have a look in the [source code](https://github.com/jonathan-s/django-sockpuppet/blob/master/sockpuppet/channel.py#L110-L118) + +#### Your top-level content needs to be an element. + +It's not enough for the container selector to match. Your content needs to be wrapped in an element, or else `data-reflex-permanent` will not work. + +```python +self.morph("#foo", html="

Strengthen your core.

") +``` + +#### No closing tag? No problem. + +Inexplicably, morphdom just doesn't seem to care if your top-level element node is closed. + +```python +self.morph("#foo", html="
Who needs muscl") +``` + +#### Different element type altogether? Who cares, so long as the CSS selector matches? + +Go ahead, turn your `div` into a `span`. morphdom just doesn't care. + +```python +self.morph("#foo", html="Are these muscles or rocks? lol") +``` + +#### A new CSS selector \(or no CSS selector\) will be processed with innerHTML + +Changing the CSS selector will result in some awkward nesting issues. + +```python +self.morph("#foo", html="
Let me know if this is too strong.
") +``` + +```html +
+
Let me know if this is too strong.
+
+``` + +#### If the element with the CSS selector is surrounded, external content will be discarded. + +```python +self.morph("#foo", html="I am excited to see your
muscles
next week.") +``` + +```html +
muscles
+``` + +#### If an element matches the target CSS selector, other elements will be ignored. + +```python +self.morph("#foo", html="
Foo!
Avant-Foo!
") +``` + +```html +
Foo!
+``` + +#### This is true even if the elements are reversed. + +```python +self.morph("#foo", html="
Avant-Foo!
Foo!
") +``` + +```html +
Foo!
+``` + +#### But it's all good in the hood if the selector is not present. + +```python +self.morph("#foo", html="
Mike
and
Ike
") +``` + +```html +
+
Mike
+ and +
Ike
+
+``` + +{% hint style="success" %} +Do you have any more weird edge cases? Please let us know! +{% endhint %} + +## Open Issues + +There are some things that we'd very much like to fix, but we simply haven't been able to or the responsibility falls to an upstream dependency we don't have direct access to. + +#### iFrame gets refreshed despite data-reflex-permanent + +Depending on how your DOM is structured, it's possible to have an iframe element which has been marked with `data-reflex-permanent` get morphed. [We're aware of it, and we've tried to fix it.](https://github.com/hopsoft/stimulus_reflex/issues/452) diff --git a/package.json b/package.json index e23260b..9e70748 100644 --- a/package.json +++ b/package.json @@ -54,4 +54,4 @@ "cypress:open": "cypress open", "cypress:run": "cypress run" } -} \ No newline at end of file +} diff --git a/sockpuppet/consumer.py b/sockpuppet/consumer.py index 0799549..479be12 100644 --- a/sockpuppet/consumer.py +++ b/sockpuppet/consumer.py @@ -173,6 +173,7 @@ def reflex_message(self, data, **kwargs): url = data["url"] selectors = data["selectors"] if data["selectors"] else ["body"] target = data["target"] + identifier = data["identifier"] reflex_class_name, method_name = target.split("#") arguments = data["args"] if data.get("args") else [] params = dict(parse_qsl(data["formData"])) @@ -180,10 +181,23 @@ def reflex_message(self, data, **kwargs): if not self.reflexes: self.load_reflexes() + # TODO can be removed once stimulus-reflex has increased a couple of versions + permanent_attribute_name = data.get("permanent_attribute_name") + if not permanent_attribute_name: + # Used in stimulus-reflex >= 3.4 + permanent_attribute_name = data["permanentAttributeName"] + try: ReflexClass = self.reflexes.get(reflex_class_name) reflex = ReflexClass( - self, url=url, element=element, selectors=selectors, params=params + self, + url=url, + element=element, + selectors=selectors, + identifier=identifier, + params=params, + reflex_id=data["reflexId"], + permanent_attribute_name=permanent_attribute_name, ) self.delegate_call_to_reflex(reflex, method_name, arguments) except TypeError as exc: @@ -218,6 +232,10 @@ def reflex_message(self, data, **kwargs): logger.debug("Reflex took %6.2fms", (time.perf_counter() - start) * 1000) def render_page_and_broadcast_morph(self, reflex, selectors, data): + if reflex.is_morph: + # The reflex has already sent a message so consumer doesn't need to. + return + html = self.render_page(reflex) if html: self.broadcast_morphs(selectors, data, html, reflex) diff --git a/sockpuppet/reflex.py b/sockpuppet/reflex.py index a6f1da0..662f57c 100644 --- a/sockpuppet/reflex.py +++ b/sockpuppet/reflex.py @@ -1,11 +1,21 @@ from django.urls import resolve from urllib.parse import urlparse +from django.template.loader import render_to_string +from django.template.backends.django import Template from django.test import RequestFactory +from .channel import Channel + PROTECTED_VARIABLES = [ + "broadcaster", "consumer", + "context", "element", + "identifier", + "params", + "permanent_attribute_name", + "reflex_id", "selectors", "session", "url", @@ -13,18 +23,49 @@ class Reflex: - def __init__(self, consumer, url, element, selectors, params): + def __init__( + self, + consumer, + url, + element, + selectors, + params, + identifier="", + permanent_attribute_name=None, + reflex_id=None, + ): self.consumer = consumer - self.url = url + self.context = {} self.element = element + self.identifier = identifier + self.is_morph = False + self.params = params + self.permanent_attribute_name = permanent_attribute_name + self.reflex_id = reflex_id + # TODO since this isn't used, we could remove this self.selectors = selectors self.session = consumer.scope["session"] - self.params = params - self.context = {} + self.url = url + + self._broadcaster = None + self._init_run = True def __repr__(self): return f"" + def __setattr__(self, name, value): + if name in PROTECTED_VARIABLES and getattr(self, "_init_run", None): + raise ValueError("This instance variable is used by the reflex.") + super().__setattr__(name, value) + + @property + def broadcaster(self): + if not self._broadcaster: + self._broadcaster = Channel( + self.get_channel_id(), identifier=self.identifier + ) + return self._broadcaster + def get_context_data(self, *args, **kwargs): if self.context: self.context.update(**kwargs) @@ -45,7 +86,7 @@ def get_context_data(self, *args, **kwargs): context = view.get_context_data(**{"stimulus_reflex": True}) - self.context = context + self.context.update(context) self.context.update(**kwargs) return self.context @@ -68,3 +109,39 @@ def request(self): def reload(self): """A default reflex to force a refresh""" pass + + def morph(self, selector="", html="", template="", context={}): + """ + If a morph is executed without any arguments, nothing is executed + and the reflex won't send over any data to the frontend. + """ + self.is_morph = True + no_arguments = [not selector, not html, (not template and not context)] + if all(no_arguments) and not selector: + # an empty morph, nothing is sent ever. + return + + if not selector: + raise ValueError("No selector defined for morph") + + if html: + html = html + elif isinstance(template, Template): + html = template.render(context) + else: + html = render_to_string(template, context) + + self.broadcaster.morph( + { + "selector": selector, + "html": html, + "children_only": True, + "permanent_attribute_name": self.permanent_attribute_name, + "stimulus_reflex": { + "morph": "selector", + "reflexId": self.reflex_id, + "url": self.url, + }, + } + ) + self.broadcaster.broadcast() diff --git a/tests/example/reflexes/example_reflex.py b/tests/example/reflexes/example_reflex.py index c7034f4..578212e 100644 --- a/tests/example/reflexes/example_reflex.py +++ b/tests/example/reflexes/example_reflex.py @@ -31,3 +31,8 @@ class UserReflex(Reflex): def get_user(self): context = self.get_context_data() self.user_reveal = context['object'] + + +class MorphReflex(Reflex): + def morph_me(self): + self.morph('#morph', 'I got morphed!') diff --git a/tests/example/templates/example.html b/tests/example/templates/example.html index 4665841..0a7a36c 100644 --- a/tests/example/templates/example.html +++ b/tests/example/templates/example.html @@ -37,4 +37,8 @@ {{ text_output }} {{ stimulus_reflex }} + + + + diff --git a/tox.ini b/tox.ini index b86d1fb..163238b 100644 --- a/tox.ini +++ b/tox.ini @@ -25,30 +25,6 @@ basepython = py3.7: python3.7 py3.6: python3.6 -[testenv:py3.9-django3.2-channels2.4-cypress] -setenv = - PYTHONPATH = {toxinidir}:{toxinidir}/sockpuppet -whitelist_externals = - sh - npm - inv -commands = - sh -c "pwd" - inv integration - sh -c "coverage run manage.py testserver cypress/fixtures/user.json --noinput &" - npm run cypress:run - sh -c "echo $(ps | grep coverage | grep -v grep | awk '\{print $1\}')" - sh -c "kill -s HUP $(ps | grep coverage | grep -v grep | awk '\{print $1\}')" - codecov -e TOXENV - -deps = - django3.1: Django>=3.2,<3.3 - channels2.4: channels<3.0 - -r{toxinidir}/requirements_test.txt - -basepython = - py3.9: python3.9 - [testenv:py3.9-django3.2-channels3.0-cypress] setenv = PYTHONPATH = {toxinidir}:{toxinidir}/sockpuppet @@ -66,7 +42,7 @@ commands = codecov -e TOXENV deps = - django3.1: Django>=3.2,<3.3 + django3.2: Django>=3.2,<3.3 channels3.0: channels>=3.0,<3.1 -r{toxinidir}/requirements_test.txt