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

Fire callbacks when targets are added or removed #367

Closed
wants to merge 9 commits into from
Closed
25 changes: 25 additions & 0 deletions docs/reference/lifecycle_callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ Method | Invoked by Stimulus…
------------ | --------------------
initialize() | Once, when the controller is first instantiated
connect() | Anytime the controller is connected to the DOM
[name]TargetConnected(target: Element) | Anytime a target is connected to the DOM
disconnect() | Anytime the controller is disconnected from the DOM
[name]TargetDisconnected(target: Element) | Anytime a target is disconnected from the DOM

## Connection

Expand All @@ -38,6 +40,15 @@ A controller is _connected_ to the document when both of the following condition

When a controller becomes connected, Stimulus calls its `connect()` method.

### Targets

A target is _connected_ to the document when both of the following conditions are true:

* its element is present in the document as a descendant of its corresponding controller's element
* its identifier is present in the element's `data-{identifier}-target` attribute

When a target becomes connected, Stimulus calls its controller's `[name]TargetConnected()` method, passing the target element as a parameter.

## Disconnection

A connected controller will later become _disconnected_ when either of the preceding conditions becomes false, such as under any of the following scenarios:
Expand All @@ -50,12 +61,26 @@ A connected controller will later become _disconnected_ when either of the prece

When a controller becomes disconnected, Stimulus calls its `disconnect()` method.

### Targets

A connected target will later become _disconnected_ when either of the preceding conditions becomes false, such as under any of the following scenarios:

* the element is explicitly removed from the document with `Node#removeChild()` or `ChildNode#remove()`
* one of the element's parent elements is removed from the document
* one of the element's parent elements has its contents replaced by `Element#innerHTML=`
* the element's `data-{identifier}-target` attribute is removed or modified
* the document installs a new `<body>` element, such as during a Turbo page change

When a target becomes disconnected, Stimulus calls its controller's `[name]TargetDisconnected()` method, passing the target element as a parameter.

## Reconnection

A disconnected controller may become connected again at a later time.

When this happens, such as after removing the controller's element from the document and then re-attaching it, Stimulus will reuse the element's previous controller instance, calling its `connect()` method multiple times.

Similarly, a disconnected target may be connected again at a later time. Stimulus will invoke its controller's `[name]TargetConnected()` method multiple times.

## Order and Timing

Stimulus watches the page for changes asynchronously using the [DOM `MutationObserver` API](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver).
Expand Down
23 changes: 23 additions & 0 deletions docs/reference/targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,29 @@ if (this.hasResultsTarget) {
}
```

## Addition and Removal Callbacks

Target _element callbacks_ let you respond whenever a target element is added or
removed within the controller's element.

Define a method `[name]TargetConnected` or `[name]TargetDisconnected` in the controller, where `[name]` is the name of the target you want to observe for additions or removals. The method receives the element as the first argument.

Stimulus invokes each element callback any time its target elements are added or removed after `connect()` and before `disconnect()` lifecycle hooks.

```js
export default class extends Controller {
static targets = [ "input" ]

inputTargetConnected(element) {
element.classList.add("added-animation")
}

inputTargetDisconnected(element) {
element.classList.add("removed-animation")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@sstephenson I now realize that the element will already be out of the DOM, so this contrived example doesn't really illustrate the point.

One use case I had in mind was sorting, would that be a better example? Can you think of a use case where having access to the Element instance that was removed would be helpful?

}
}
```

## Naming Conventions

Always use camelCase to specify target names, since they map directly to properties on your controller.
26 changes: 25 additions & 1 deletion packages/@stimulus/core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,23 @@ import { Module } from "./module"
import { Schema } from "./schema"
import { Scope } from "./scope"
import { ValueObserver } from "./value_observer"
import { TargetObserver, TargetObserverDelegate } from "./target_observer"

export class Context implements ErrorHandler {
export class Context implements ErrorHandler, TargetObserverDelegate {
readonly module: Module
readonly scope: Scope
readonly controller: Controller
private bindingObserver: BindingObserver
private valueObserver: ValueObserver
private targetObserver: TargetObserver

constructor(module: Module, scope: Scope) {
this.module = module
this.scope = scope
this.controller = new module.controllerConstructor(this)
this.bindingObserver = new BindingObserver(this, this.dispatcher)
this.valueObserver = new ValueObserver(this, this.controller)
this.targetObserver = new TargetObserver(this, this)

try {
this.controller.initialize()
Expand All @@ -32,6 +35,7 @@ export class Context implements ErrorHandler {
connect() {
this.bindingObserver.start()
this.valueObserver.start()
this.targetObserver.start()

try {
this.controller.connect()
Expand All @@ -47,6 +51,7 @@ export class Context implements ErrorHandler {
this.handleError(error, "disconnecting controller")
}

this.targetObserver.stop()
this.valueObserver.stop()
this.bindingObserver.stop()
}
Expand Down Expand Up @@ -82,4 +87,23 @@ export class Context implements ErrorHandler {
detail = Object.assign({ identifier, controller, element }, detail)
this.application.handleError(error, `Error ${message}`, detail)
}

// Target observer delegate

targetConnected(element: Element, name: string) {
this.invokeControllerMethod(`${name}TargetConnected`, element)
}

targetDisconnected(element: Element, name: string) {
this.invokeControllerMethod(`${name}TargetDisconnected`, element)
}

// Private

invokeControllerMethod(methodName: string, ...args: any[]) {
const controller: any = this.controller
if (typeof controller[methodName] == "function") {
controller[methodName](...args)
}
}
}
86 changes: 86 additions & 0 deletions packages/@stimulus/core/src/target_observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Multimap } from "@stimulus/multimap"
import { Token, TokenListObserver, TokenListObserverDelegate } from "@stimulus/mutation-observers"
import { Context } from "./context"

export interface TargetObserverDelegate {
targetConnected(element: Element, name: string): void
targetDisconnected(element: Element, name: string): void
}

export class TargetObserver implements TokenListObserverDelegate {
readonly context: Context
readonly delegate: TargetObserverDelegate
readonly targetsByName: Multimap<string, Element>
private tokenListObserver?: TokenListObserver

constructor(context: Context, delegate: TargetObserverDelegate) {
this.context = context
this.delegate = delegate
this.targetsByName = new Multimap
}

start() {
if (!this.tokenListObserver) {
this.tokenListObserver = new TokenListObserver(this.element, this.attributeName, this)
this.tokenListObserver.start()
}
}

stop() {
if (this.tokenListObserver) {
this.disconnectAllTargets()
this.tokenListObserver.stop()
delete this.tokenListObserver
}
}

// Token list observer delegate

tokenMatched({ element, content: name }: Token) {
if (this.scope.containsElement(element)) {
this.connectTarget(element, name)
}
}

tokenUnmatched({ element, content: name }: Token) {
this.disconnectTarget(element, name)
}

// Target management

connectTarget(element: Element, name: string) {
if (!this.targetsByName.has(name, element)) {
this.targetsByName.add(name, element)
this.delegate.targetConnected(element, name)
}
}

disconnectTarget(element: Element, name: string) {
if (this.targetsByName.has(name, element)) {
this.targetsByName.delete(name, element)
this.delegate.targetDisconnected(element, name)
}
}

disconnectAllTargets() {
for (const name of this.targetsByName.keys) {
for (const element of this.targetsByName.getValuesForKey(name)) {
this.disconnectTarget(element, name)
}
}
}

// Private

private get attributeName() {
return `data-${this.context.identifier}-target`
}

private get element() {
return this.context.element
}

private get scope() {
return this.context.scope
}
}
2 changes: 1 addition & 1 deletion packages/@stimulus/core/src/target_properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ function propertiesForTargetDefinition(name: string) {
get(this: Controller) {
return this.targets.has(name)
}
}
},
}
}
20 changes: 20 additions & 0 deletions packages/@stimulus/core/src/tests/controllers/target_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ class BaseTargetController extends Controller {
}

export class TargetController extends BaseTargetController {
static classes = [ "connected", "disconnected" ]
static targets = [ "beta", "input" ]
static values = { inputTargetConnectedCallCount: Number, inputTargetDisconnectedCallCount: Number }

betaTarget!: Element | null
betaTargets!: Element[]
Expand All @@ -18,4 +20,22 @@ export class TargetController extends BaseTargetController {
inputTarget!: Element | null
inputTargets!: Element[]
hasInputTarget!: boolean

hasConnectedClass!: boolean
hasDisconnectedClass!: boolean
connectedClass!: string
disconnectedClass!: string

inputTargetConnectedCallCountValue = 0
inputTargetDisconnectedCallCountValue = 0

inputTargetConnected(element: Element) {
if (this.hasConnectedClass) element.classList.add(this.connectedClass)
this.inputTargetConnectedCallCountValue++
}

inputTargetDisconnected(element: Element) {
if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass)
this.inputTargetDisconnectedCallCountValue++
}
}
Loading