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

Infinite Loop Attaching Behavior #4

Open
jon49 opened this issue Jul 25, 2022 · 11 comments
Open

Infinite Loop Attaching Behavior #4

jon49 opened this issue Jul 25, 2022 · 11 comments

Comments

@jon49
Copy link

jon49 commented Jul 25, 2022

When connecting an input element I wrap the tag in a span and this causes the input element to be attached again in an infinite loop.

A temporary fix to this that I did was to change the code around this line:

if (!Behavior) return

with the following code:

if (!Behavior || (this.ownerElement._attached || []).includes(name))
        return;
const behavior = new Behavior(this.ownerElement);
if (!this.ownerElement._attached) {
  this.ownerElement._attached = []
}
this.ownerElement._attached.push(name)

Maybe there is a better solution? I don't know. But it works for now.

It is interesting to note that the version used in the code pen example in the docs doesn't have this behavior. It only occurs in the latest version.

@trusktr
Copy link
Member

trusktr commented Jul 25, 2022

Hey Jon, thanks for reporting, and interesting to see someone using this. I've actually made some refactors to this part of the code, about to release those today.

Do you have a snippet of code I can use to reproduce it? I updated the codepen to pull in the latest version:

https://codepen.io/trusktr/pen/ymPXNb

@trusktr
Copy link
Member

trusktr commented Jul 25, 2022

I gave it a shot like the following, but it works:

<html>
	<head>
		<script src="https://unpkg.com/[email protected]/dist/global.js"></script>

		<script>
			class ClickCounter {
				constructor(element) {
					this.element = element
					this.count = 0
				}

				connectedCallback() {
					this.render()

					this.element.addEventListener('click', () => {
						this.count++
						this.render()
					})
				}

				render() {
					this.element.textContent = `count: ${this.count}`
				}
			}

			elementBehaviors.define('click-counter', ClickCounter)

			class ClickLogger {
				constructor(element) {
					this.element = element
				}

				connectedCallback() {
					this.element.addEventListener('click', () => {
						console.log('clicked on element: ', this.element)
					})
				}
			}

			elementBehaviors.define('click-logger', ClickLogger)
		</script>
	</head>
	<body>
		<span>
			<input has="click-counter click-logger" />
		</span>
	</body>
</html>

@jon49
Copy link
Author

jon49 commented Jul 26, 2022

Yes, I like the idea of is and was disappointed that Safari will never implement it. But yours is even better as you can add multiple behaviors and you don't need to inherit HTMLElement. It is really nice as I don't need to add any superfluous html to the code base just to add behavior to an element. Thanks for writing and maintaining this library!

Here's an example of an infinite loop.

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://unpkg.com/[email protected]/dist/global.js"></script>
</head>
<body>
    <script src="./infinite-loop.js"></script>

    <input has="infinite-loop" value="Infitely looping!">
</body>
</html>

infinite-loop.js:

class InfiniteLoop {
    constructor(element) {
        this.element = element
    }

    connectedCallback() {
        let element = this.element
        let span = document.createElement('span')
        element.parentNode.insertBefore(span, element)
        span.appendChild(element)
    }
}

elementBehaviors.define('infinite-loop', InfiniteLoop)

This results in the constructor and connectedCallback being called over and over again until the browser ends the page.

A different solution to this problem could be adding a WeakMap that tracks what elements have already initialized for different behaviors. I suppose it is simpler to add it to the element itself though. But it would be a little complicated than my solution as disconnected a behavior would need to be tracked also. Or whatever novel solution you find :-) as you know the code base better than me.

@trusktr
Copy link
Member

trusktr commented Jul 27, 2022

Ah, thank you for the repro. This isn't something that can be solved in element-behaviors, or even in custom elements API. The code is just doing what you're telling it to: you disconnect and then re-connect the element (with span.appendChild(element)) which means it will re-run connectedCallback (this is expected).

Here is the same problem, but with custom elements:

https://codepen.io/trusktr/pen/jOzGJJg?editors=1010

The only solution is for user code to not do this. As an example, here's one way user code could solve it:

https://codepen.io/trusktr/pen/wvmrOZO?editors=1010

Fixing this in element-behaviors would mean that we have changed the contract of how connectedCallback works, to a functionality that would be unexpected (it should always fire when the element is connected).


As a side note, I would consider this sort of DOM patching to be an anti pattern: custom elements (or element behaviors) should not change the end user's perceived DOM. For example, if the user writes

<some-element></some-element>

that's what they should end with, otherwise if the DOM changes to something they didn't write, unexpectedly, as in

<span><some-element></some-element></span>

it means that some-element is adding the potential for the end user to have unexpected behavior (for example, the CSS they wrote may not work because they had no idea the element would wrap itself in a span, therefore their CSS does not match with the HTML that they wrote).

As a rule of thumb, always prevent custom elements (or behaviors) from modifying outside DOM or attributes, because those outside things are the user's API contract (the user's input). There can be exceptions to the rule, but it is something to avoid as much as possible.

@trusktr
Copy link
Member

trusktr commented Jul 27, 2022

Thanks for writing and maintaining this library!

No prob! I was pleasantly surprised someone used it. Thanks for the input!

@jon49
Copy link
Author

jon49 commented Jul 28, 2022

So, the way the built-in web components work is that the connectedCallback method is called multiple times. But your library it is only called once and then on subsequent times a new object is created and then the connectedCallback is called on that new object. So, your solution won't actually work since that isn't the problem. So, I guess, if an object is already associated with an element than what should be happening is that the connectedCallback is called on the existing object rather than creating a new object.

You can see how this is behaving with this:

;(function() {
    class InfiniteLoop {
        movingElement = false
        createdCount
        constructor(element) {
            this.element = element
            if (element._createdCount) {
                this.createdCount = element._createdCount++
            } else {
                element._createdCount = 1
                this.createdCount = 1
            }
            console.log(`Created count: ${this.createdCount}`)
        }

        connectedCallback() {
            console.log(`Connected callback called for '${this.createdCount}'`)
            let element = this.element
            if (this.movingElement) return
            let span = document.createElement('span')
            element.parentNode.insertBefore(span, element)
            this.movingElement = true
            span.appendChild(element)
            this.movingElement = false
        }
    }

    elementBehaviors.define('infinite-loop', InfiniteLoop)
})();

I think an elegant solution to this problem might be something like this (not tested):

const associatedElements = new WeakMap
...
if (!Behavior)
        return;
let behaviors
let existingBehavior
if ((behaviors = associatedElements.get(this.ownerElement)) && (existingBehavior = behaviors.find(x => x instanceof Behavior))) {
   typeof existingBehavior.connectedCallback === "function" && existingBehavior.connectedCallback()
  return
}
const behavior = new Behavior(this.ownerElement);
if (!behaviors) {
    behaviors = []
    associatedElements.set(this.ownerElement, behaviors)
}
behaviors.push(behavior)

Using the WeakMap makes so no clean up is needed when an element is deleted.

@jon49
Copy link
Author

jon49 commented Jul 28, 2022

Note, the way I'm debugging this is by commenting out the line elementBehaviors.define('infinite-loop', InfiniteLoop) and then opening the console and putting break points in the class. I'm also using the extension Live Server by Ritwick Dey in VS Code.

@trusktr
Copy link
Member

trusktr commented Jul 30, 2022

But your library it is only called once and then on subsequent times a new object is created and then the connectedCallback is called on that new object. So, your solution won't actually work since that isn't the problem.

Ah, good point! I was wondering if we should keep the behavior alive, so long as the element has the behavior listed in the has attribute. However the issue with this is that, we would have to patch any APIs that can modify the element's has attribute synchronously (for example, catch the value change in a monkey patched setAttribute call), instead of using MutationObserver.

MutationObserver only works while the element is connected into the DOM because the MO is on the document. So, currently, changes to the has attribute are only detected while the element is connected. Because of this, removing and re-creating the behaviors was the simplest way for it to always operate well under the single MutationObserver approach (the MO being from custom-attributes)

The upside of the monkey patching would be better behavior, more like we would see if this were a native API in the browser, but the downside is that polyfill code will get uglier and more hacky.


In the meantime, here's how we can apply the same workaround as the custom element example, but with element behaviors:

Infinite loop: https://codepen.io/trusktr/pen/WNzdGRB?editors=1000

Fixed: https://codepen.io/trusktr/pen/VwXyKMX?editors=1000

(the main trick is the setTimeout call ensures the = false logic happens after the MutationObserver microtask had a chance to run, same technique as used throughout the unit tests).

I would still recommend to avoid this pattern. People in the community consider the act of affecting the element user's DOM to be an anti-pattern.

Why do you need to patch the outside DOM?

@trusktr
Copy link
Member

trusktr commented Jul 30, 2022

I think an elegant solution to this problem might be something like this (not tested):

That's a nice attempt, but the only problem (that already exists regardless of your new code or the old code) is that the change mechanism does not work if the element is not connected. F.e., if we were to keep the behavior instances alive, then the following would happen:

const el = document.querySelector('.some-el')
el.remove() // change detection for the has attribute stops working at this point.
el.setAttribute('has', '') // try to remove behaviors, but nothing happens

await new Promise(r => setTimeout(r, 1000)) // sleep one second

!!el.behaviors.get('some-behavior') // still `true`, which is awkward considering `has` attribute is empty

// only appending the el back into the DOM will cause the behaviors to update:
document.body.append(el)

!!el.behaviors.get('some-behavior') // still `true`, MutationObserver didn't fire yet.

await new Promise(r => setTimeout(r)) // wait for MutationObserver

!!el.behaviors.get('some-behavior') // finally `false`

I think monkey patching would be the way to properly solve this (setAttribute, el.attributes, Attr, etc), and in fact will make the end result better in that behaviors could be modified (or their life cycle methods called) synchronously at the same time as custom elements would experience them, rather than always deferred in a microtask as with MutationObserver.

As a result of the monkey patching approach, that last solution example would not require a setTimeout call, because the life cycles would happen synchronously (assuming we patched insertBefore so that connectedCallback and disconnectedCallback happen synchronously, etc).

With monkey patching, that last code snippet would become this:

const el = document.querySelector('.some-el')
el.remove()
el.setAttribute('has', '') // remove behaviors, it works

!!el.behaviors.get('some-behavior') // `false`, nice!

document.body.append(el)

!!el.behaviors.get('some-behavior') // still `false`, nice!

and keeping the instances alive would then make great sense.

Currently, by removing the instances immediately on disconnect, we're not left with any chance for the behaviors to exist when they shouldn't. I think this is better than the has attribute being empty and instances still being alive.

@trusktr
Copy link
Member

trusktr commented Jul 31, 2022

Mmm, yeah, I'm imagining monkey patching will be the way to go! The following is what we'll need to patch:

To catch all construction/connected/disconnected cases:

  • el.innerHTML
  • el.outerHTML
  • el.appendChild
  • el.append
  • el.prepend
  • el.removeChild
  • el.remove
  • el.insertBefore
  • el.after
  • el.before
  • el.insertAdjacentElement
  • el.insertAdjacentHTML

To catch all attribute changes:

  • attr.value
  • el.setAttribute
  • el.removeAttribute
  • el.attributes.setNamedItem
  • el.attributes.removeNamedItem
  • el.style and sub properties not needed for either element-behaviors or custom-attributes specifically, but we can make style attribute observation for element-behaviors synchronous in case observedAttributes of a behavior includes style.

Did I miss any?

@jon49
Copy link
Author

jon49 commented Aug 1, 2022

Yeah, I'm not entirely sure about all the internals of what you are doing. So, whatever works! :-)

Why do you need to patch the outside DOM?

What I'm using this for is to change how the display works on a form input. I would use the pseudo selector before on the element itself, but HTML doesn't allow that - so I have to wrap it in a span element. So, I want it to be a currency display but when the user focuses on the input I want to remove all the dollar signs, commas, and periods. But at the same time I don't want the HTML to have to change just to make it so it looks better to the user. I want the HTML to be simple and the user to be happy. So, my solution was to use your library so the solution is decoupled from the HTML itself. If I decide I don't like my current solution I can change the solution without needing to change the HTML itself (in the hundreds of places it would be). So, without changing the HTML I would need to write:

<span has=input-currency><input type=number step="0.01"></span>

But if I keep the solution decoupled from the HTML I have:

<input type=number step="0.01" has=input-currency>

So, if I want to change anything all I need to do is go into the InputCurrency class and change it there. So, maybe I decide I don't like my solution. Then I could go into the class and update it so maybe it just hides the input and I have a cloned input that shows the currency display.

Also, using the element behaviors library allows me to not have to rely on a JavaScript framework like React which decouples my code from complex frameworks in favor of a simple small classes with specific state management. Which makes it easier to implement a micro front end.

So, that is my use case and rational :-).

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

No branches or pull requests

2 participants