Skip to content
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

Start a section about writing Stimulus controllers for the back end #1519

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

m-vo
Copy link
Member

@m-vo m-vo commented Feb 15, 2025

No description provided.

@m-vo m-vo force-pushed the internals/stimulus branch from 5fc12e4 to 29baa7f Compare February 15, 2025 11:24
Comment on lines +2 to +3
title: "Turbo-compatible Stimulus controllers"
description: "Howto write Stimulus controllers for a Turbo application (that don't suck)"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
title: "Turbo-compatible Stimulus controllers"
description: "Howto write Stimulus controllers for a Turbo application (that don't suck)"
title: "Turbo-compatible Stimulus controllers"
description: "How to write Stimulus controllers for a Turbo application (that don't suck)"

Not sure we should use the wording "don't suck" in the official documentation ;)

Comment on lines +16 to +18
// Run once, when the current controller instance is created
initialize() {
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be helpful to elaborate on this a bit. i.e. describe the difference between initialize() and connect() a bit more. Because if you have data-controller="foobar" on multiple elements, initialize() will be called multiple times as well, right?

Copy link
Member Author

@m-vo m-vo Feb 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is basically:

foo = new FooController           --> foo.initialize()
<foo> element found in DOM        --> foo.connect()
<foo> element removed from DOM    --> foo.disconnect()
<foo> element readded to DOM      --> foo.connect() (on same fooInstance!)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but what happens during a turbo:render? Will initialize() be called for every new element again?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initialize() is run right before the first connect on each newly found element. When an element is removed from DOM (but a reference is being kept) and then it is re-added, the same controller instance will be used. So in this case, there would be a connect() call but no initialize() call.

But as an implementer I think you should not care about this: initialize() is basically your constructor, where you can set up things for the object instance. When Turbo serves a page from cache, the instances are probably still alive, but this is an implementation detail of the cache.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the source code: https://github.com/hotwired/stimulus/blob/main/src/core/context.ts#L33 where you can see, that initialize() is just invoked inside the constructor().

Copy link
Contributor

@fritzmg fritzmg Feb 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but that's the thing. With Turbo I cannot wrap my head around when initialize() will be called.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would you even bother? You should not alter the DOM or anything in there, it's just boring setup like in any constructor. 🙃

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still do not understand 😁
In the realm of Turbo and Stimulus, when is this constructor called?

I know in PHP for instance that a constructor of a service will only be called once per request. But if I understand this correctly, initialize() would not behave the same way, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A service is probably not the best analogy, because there you typically only have one instance of a kind/class, whereas in Stimulus each DOM element with a data-controller="…" property will be associated with its own instance.

Behind the scenes it kind of works like this:

  • the mutation observer detects a new controller DOM element e
  • look up, if e is known in the weak map for this controller type (DOM element -> controller instance)
    • if no: create a new instance (this will invoke initialize() on the newly created instance; see here) and use this
    • if yes: take the instance from the map
  • invoke connect()

So if your element was removed from the DOM and readded (for example by a script reorganizing things, and calling element.remove() and parent.appendChild()), you would not need to reinitialize things - event listeners would be still intact and so on. If however the reference is lost (e.g. due to Turbo creating a snapshot with clonedElement = element.clone(true) which is later appended to the DOM), then there is no possible connection between the DOM string <div … data-controller="…"> and the instance anymore. So if nobody is holding any reference anymore it will be garbage collected. And on the Stimulus side you would also see a initialize() call on the next connect (because new instance).

But in practice I think you should never ever rely on how often or if this is happening. It is merely an optimization: reusing things where possible saves resources. So as a guideline I would say:

  • Expect connect() to be called multiple times on this instance.
  • If you need to setup things during connect() that are then stored to the instance or the DOM element (= that are still valid the next time this DOM element reference is "connected"), put it in initialize().

Comment on lines +44 to +45

<div data-controller="add-foo"></div>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div data-controller="add-foo"></div>
<div data-controller="add-foo"></div>

Comment on lines +61 to +62

<div data-controller="add-foo">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div data-controller="add-foo">
<div data-controller="add-foo">

Comment on lines +70 to +71

<div data-controller="add-foo">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div data-controller="add-foo">
<div data-controller="add-foo">

Comment on lines +37 to +39
If you need to make changes to the DOM make sure these are idempotent. That means, applying the code multiple times does
not do any harm. Why is this important? Because cache entries are made before the DOM gets removed, so any cleanup in a
`disconnect()` method has no effect.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit confused here. You are saying that any cleanup in a disconnect() method has no effect, but further down in 2) you are also saying:

Cleanup CSS classes on parent elements, created sibling elements, etc.

So shouldn't it be enough to delete and created sibling elements in the disconnect() method rather than having to rely on the turbo:before-cache event?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order for a page transition with caching is beforeCache, then disconnect. So from the perspective of the snapshot the disconnect has no effect.

The disconnect is still crucial for when the DOM/parts of it get exchanged. Memory cleanup, removing parent classes etc. This will happen without beforeCache calls and even on the cached snapshot when it is displayed as a preview before the actual content has arrived.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically: beforeCache must shape the DOM, so that a connect can run on it, disconnect must restore the DOM to its original state.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free to improve the text, if we can make this more clear.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants