stimulusreflex / stimulus_reflex

Build reactive applications with the Rails tooling you already know and love.
https://docs.stimulusreflex.com
MIT License
2.28k stars 172 forks source link

Stimulus' controllers are not reconnecting after reflex, why? #314

Closed paulbernhard closed 4 years ago

paulbernhard commented 4 years ago

Thanks again for working on StimulusReflex, i am starting to implement it in my Rails apps and it still is very (!) promising. In the following i encountered a problem using StimulusReflex which might exist on purpose, thus might not be a bug.

Problem

I have an element in the DOM hooked to a Stimulus controller hello which will be put in a specific state upon connection: its inner html will be set to "Hello, Stimulus!". After triggering a reflex on that same page, this element will again be morphed into the DOM but its connect() function is not called and its inner html is lost.

DOM before reflex:

<a href="#" data-reflex="click->CounterReflex#increment" data-step="1" data-count="0" data-controller="stimulus-reflex" data-action="click->stimulus-reflex#__perform">Increment 0</a>
<div data-controller="hello" data-target="hello.output">Hello, Stimulus!</div>

DOM after "CounterReflex#increment" performed and morphed:

<a href="#" data-reflex="click->CounterReflex#increment" data-step="1" data-count="1" data-controller="stimulus-reflex" data-action="click->stimulus-reflex#__perform">Increment 1</a>
<div data-controller="hello" data-target="hello.output"></div>

Though, if a new element using the hello controller is added to the DOM after a reflex, its controller is connected but the state of the previous ones will be lost.

DOM after "CounterReflex#increment" performed and morphed (once):

<a href="#" data-reflex="click->CounterReflex#increment" data-step="1" data-count="1" data-controller="stimulus-reflex" data-action="click->stimulus-reflex#__perform">Increment 1</a>
<div data-controller="hello" data-target="hello.output">Hello, Stimulus!</div>

DOM after "CounterReflex#increment" performed and morphed (twice):

<a href="#" data-reflex="click->CounterReflex#increment" data-step="1" data-count="1" data-controller="stimulus-reflex" data-action="click->stimulus-reflex#__perform">Increment 2</a>
<div data-controller="hello" data-target="hello.output"></div>
<div data-controller="hello" data-target="hello.output">Hello, Stimulus!</div>

To me it seems this behaviour is on purpose, but i wouldn't know why. It's quite common for me to bring elements into a certain state upon the connection of their controllers and this state always gets lost when they are "remorphed" into the DOM. We could make use of data-reflex-permanent in order to preserve them during morphs but this feels counter-intuitive to me.

Could you tell me, why this behaviour exists and how to deal best with such situations? Obviously, it could be that i am missing out on something.

Thanks!

To Reproduce

  1. Setup rails app with StimulusReflex according to Setup
  2. Controllers, reflexes, and views:
# config/routes.rb
Rails.application.routes.draw do
  root to: "application#home"
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base

  def home
    @count ||= 1
    render "/home"
  end
end

# app/reflexes/counter_reflex.rb
class CounterReflex < StimulusReflex::Reflex

  def increment
    @count = element.dataset[:count].to_i + element.dataset[:step].to_i 
  end
end
// app/javascript/controllers/hello_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "output" ]

  connect() {
    console.log("connect hello_controller on", this.element)
    this.outputTarget.textContent = 'Hello, Stimulus!'
  }

  disconnect() {
    console.log("disconnect hello_controller on", this.element)
  }
}
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
  <head>
    <title>StimulusReflexDummy</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <a href="#" data-reflex="click->CounterReflex#increment" data-step="1" data-count="<%= @count %>">Increment <%= @count.to_i %></a>
    <% @count.times do %>
      <div data-controller="hello" data-target="hello.output"></div>
    <% end %>
  </body>
</html>

Versions

StimulusReflex

External tools

Browser

leastbad commented 4 years ago

You cannot combine Gem 3.2.3 and NPM 3.3.0-pre6. Versions have to match.

paulbernhard commented 4 years ago

You cannot combine Gem 3.2.3 and NPM 3.3.0-pre6. Versions have to match.

This was the setup after following the Setup guide in the docs. I just changed the NPM version to match the Gem's and the behaviour is the same as above.

leastbad commented 4 years ago

Paul, you're an experienced developer. Come on, man. Don't blame the docs. You had to type those visibly different versions into the GH interface. I don't want to be mean but you have to admit this should have been your first flag.

Anyhow, I see that you dropped your npm package down to 3.2.3 instead of bringing your gem version up to help test the major release that is coming in two days. Could you please upgrade your packages to the latest and greatest so that we're not troubleshooting code that won't matter in 36 hours?

leastbad commented 4 years ago

In the meantime, let me offer some tips on what I can see. First, you do not need to use data-target on the same element the controller is on. You can access the element from inside the controller using this.element.

Next, if you morph an element out of existance, and it is replaced by a new element that looks identical, it will be a new instance of the Stimulus controller. This is just how JS and the DOM interact. It would be like creating two identical objects in Ruby that both have different object_id's.

The morphdom library which CableReady relies on attempts to do the least invasive DOM manipulations to get things changed to their new state. In theory, you should be able to change the innerHTML with a morph and it should just update the content, not replace the whole element. That said, there's unfortunately little we can do about it if morphdom wants to swap out.

https://github.com/patrick-steele-idem/morphdom/pull/206

paulbernhard commented 4 years ago

Don't blame the docs. You had to type those visibly different versions into the GH interface. I don't want to be mean but you have to admit this should have been your first flag.

It was not to blame the docs but executing the following steps…

$ rails new foo --webpack=stimulus --database=sqlite3 --skip-sprockets
$ cd foo
$ bundle add stimulus_reflex
$ bundle exec rails stimulus_reflex:install

… i get this setup without defining any version:

gem "stimulus_reflex", "~> 3.2"
"stimulus_reflex": "^3.3.0-pre6",

By the way, i think the docs are very well written and accessible.

Could you please upgrade your packages to the latest and greatest so that we're not troubleshooting code that won't matter in 36 hours?

Sure, my bad, i updated the Gem and NPM version to 3.3.0.pre6 and the behaviour stays the same as described in the first comment.

… First, you do not need to use data-target on the same element the controller is on. You can access the element from inside the controller using this.element.

The hello_controller.js with the data-target="output" is created by the Stimulus installer, but yes, i agree that it's superfluous.

Next, if you morph an element out of existance, and it is replaced by a new element that looks identical, it will be a new instance of the Stimulus controller. This is just how JS and the DOM interact. It would be like creating two identical objects in Ruby that both have different object_id's.

I see (i hope). In this case an element is replaced by one that looks identical <div data-controller="hello">Hello, Stimulus!</div> is replaced by <div data-controller="hello"></div>, but it seems there is no new instance of its Stimulus controller and connect() or diconnect() do not get called.

But i understand that this is more a "problem" of the interaction between morphdom and Stimulus. In a way it would be great if Stimulus controller elements could be brought to the state (eg. data-attributes) they have been before the morph but i don't know wether this is possible or desirable. In the end, using data-reflex-permanent works just fine, it just means to keep track of which elements and Stimulus controllers are possibly affected by morphs.

leastbad commented 4 years ago

That seems so frustrating! I'm really sorry that you have to deal with that.

It can be really hard to spot issues looking on a page, but if you end up putting your code on Github so that it's easy to clone and build locally, I'd be happy to take a look at whatever is bothering you.

Unfortunately, I'm going to be pretty slammed until v3.3.0 finally releases on Tuesday. I feel like I'm organizing a wedding.

paulbernhard commented 4 years ago

Oh, a wedding, how nice!

Thanks for looking into it, do not hurry, i can live with some data-reflex-permanent attributes for now. Here you'll find a repo of the described setup: https://gitlab.com/paulbernhard/stimulus-reflex-dummy

Have a good evening (day?)!

leastbad commented 4 years ago

Hi Paul! I finally had a chance to clone your repo and go over everything carefully. I am now confident that I fully understand the nature of your inquiry and have a pretty good idea of what the outcome should be.

My conjecture is that you've probably worked with React or other frameworks where components are expected to render themselves. Stimulus does not have a render concept because it's intended to imbue new behaviours into existing markup.

In the context of SR, the expectation is that you would specify the UI you want to exist in the browser on the server. There's no need for a render stage because the markup is already perfect before it is sent down the wire. There is no build stage, no rehydration. We're not taking over the task of redrawing the UI from the browser.

If you are working with a React component (or a Stimulus controller that renders markup during connect) that is outside of your direct control, the correct thing to do is move the equivalent of this.element.textContent = 'Hello, Stimulus!' out of your connect and create a new method called render for it. You can then call render() from connect, and install an event listener to pick up stimulus-reflex:after (soon, as of #317 it'll be even better to listen for stimulus-reflex:finalize).

connect () {
  this.element.addEventListener('stimulus-reflex:after', this.render)
}
disconnect () {
  this.element.removeEventListener('stimulus-reflex:after', this.render)
}

In summary: your best bet is to render the markup you desire. Failing that, you will need to tell your controllers to re-render after CableReady has its way with your DOM, because it sees the content your components rendered as differences from the DOM as the server thinks it should be.

Note that this all goes away if you use selector morphs to target individual elements for update.

paulbernhard commented 4 years ago

Hi @leastbad and thanks a lot for your comprehensive answer, this is really helpful. I am not or have not been working with React but i understand the difference of approach about no need for a render stage because the markup is already perfect before it is sent down the wire; and this is also why i appreciate StimulusReflex so much.

In case i need to keep or update states of elements using Stimulus, i'll use your solution using the stimulus-reflex:after or stimulus-reflex:finalize hooks.

Besides i hope the marriage went well and i am looking forward to give 3.3 a try.

Thanks for your work and best, Paul