WebReflection / uhtml

A micro HTML/SVG render

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Disconnected custom elements do not unsubscribe from effects

fbedussi opened this issue · comments

Hi, thanks for this interesting library. I'm trying to use it to build a client side SPA keeping as close as possible to the web platform standards.

Maybe I 'm doing something wrong, but I stumbled in an issue shown here:
https://github.com/fbedussi/uhtm-test
https://fbedussi.github.io/uhtm-test/

I'm using the preactive variant.

When custom elements that share a common state signal are disconnected, they do not unsubscribe from the signal, so when the signal is updated, their render callback is invoked, even if they are not used in the page at that moment.

This does not happen if the second parameter of the render function is not a callback, but it needs to be a callback if the component must react to signal changes, as explained here: https://webreflection.github.io/uhtml/#reactivity.

Is this a bug or am I doing somthing wrong?

Thanks in advance

There are many things to consider in this demo:

  • not all reactive libraries work the same ... if you use https://cdn.jsdelivr.net/npm/uhtml/signal.js instead of the preactive.js for both components, you'll see different results and you also need to remove that setTimeout as it detaches signal subscription from the current render ... drop setTimeout, change the reactive library, see no issues whatsoever
  • the usage of the FinalizationRegistry here means you should never observe it, it should just work ... if you explicitly collect garbage (chrome performance tab) before and/or after every further click, you'll see a cleaner path
  • you are sharing same signals not per App but per inner components of the app and by rendering these explicitly you are also creating nested effects ... if the custom element is still referenced and not collected when you change a shared signal state it might render and then get collected as replaced by the outer render that would dispose and forget the previous target custom element

Accordingly, there are a few things to potentially discuss:

  • would you prefer to have an utility to explicitly dispose any signal attached to a node or its inner effect? this might be handy but it kinda defeats the whole idea behind the automatic dispose driven by the GC
  • why do you need a render on connected instead of right away in the constructor? you never want to re-render the element even if moved via DOM methods, you want to setup your view there and react to signal changes.

The latter point doesn't necessarily fix the issue but it might help reactive libraries understanding in which render pipe a signal has, or has not, already been subscribed and help optimize the rest.

Please also keep in mind that because this library promotes declarative Custom Elements as part of the html content, you can be sure that when that is parsed into a document fragment is the moment it gets (likely) connected but on top of all this ...

This does not happen if the second parameter of the render function is not a callback, but it needs to be a callback if the component must react to signal changes, as explained here: https://webreflection.github.io/uhtml/#reactivity.

That is true only for the outer top-most/initial render. In this specific use case, because you have shared signals across components, you just want to update the outer view where these components belong, not each single render independently.

If you had signals per components, passing a callback is a must for your render reactivity, but if signals are foreign to the component, you are better off, performance included, with not passing a callback ... as the signals are not confined to the component state, rather part of the outer App ... which is a perfect case/scenario for SPA applications.

I don't know if anything I've written makes sense, but in your case I would really consider components that, unless the signals they handle are private/internal, or part of the component module, would not render creating an effect, as that's overhead that brings more roubles than it solve and it practically gives you worse performance.

OK, enough philosophy here ... we can have both worlds to help preventing seppuku.

There's a new helper called detach(element) that allows you to manually drop subscriptions on disconnect if needed.

Your example would then import that helper from the library and on disconnectedCallback you can simply detach(this) and re-render when you want whatever you want.

All points around avoiding nested effects when not strictly necessary are still valid, but I think this helper is a welcome addition for more complex scenarios with components.

Please let me know if this new helper solves your issue, thank you.

Thanks for your suggestions.

First of all, the detach helper works fine without any other changes, so it is definitely a possible solution, but, as you said, a littel bit too much imperative.

Removing the shared signal also fixes the issue, but if we use signals to handle the centralized state, it seems natural that this signal is shared among components, and shared in a way that makes possible for the components to react to state changes.

If I keep the shared signal, but don't pass a callback to the render function of the page-home and page-inner components, they do not react to signal/state changes, if the change is async, and it seems to me that a centralized state should be able to store async values, such as values coming from APIs. But I need to furhter investigate this point to undestand if there are alternative approaches.

Rendering in the constructor instead that in the connectedCallback is a nice suggestion, thanks.

While using the https://cdn.jsdelivr.net/npm/uhtml/signal.js and triggering the garbage collector in the dev tools made no difference (without removing the setTimeout).

If your contract is to render on connected you can use a uce approach, you create a thin HTMLElement extend as base class that invokes this.render() on connected and detaches on disconnected. You then move your logic into a render function and the rest is automatic. You forget about these details but you’ll have a more robust and portable code.

A concrete example might be worth thousand words:

import { render, html, detach } from 'uhtml/preactive';

class ReactiveElement extends HTMLElement {
  connectedCallback() {
    // just pass a callback to react via
    // inner effects on signal changes
    render(this, this.render.bind(this));
  }
  disconnectedCallback() {
    // just detach
    detach(this);
  }
}

import { text } from './state.js';

@define('my-thing')
class MyThing extends ReactiveElement {
  render() {
    return html`<div>${text.value}</div>`;
  }
}

// with or without callback if no other
// signals are around in the template
render(document.body, html`<my-thing />`);

Can't remember the current state of decorators but as primitive that looks very promising to me, hope you agree.

Thanks, this is definitely an option. I'd rather go without the decorator until they are not standard yet.

My last question is: would it be possible to bake this behavior directly in the library? I studied the attach function
https://github.com/WebReflection/uhtml/blob/main/esm/render/reactive.js#L22
and, as far as I understand, when the element is garbage collected the dispose function is invoked, why not to call drop(dispose) as well, as the detach function does?

why not to call drop(dispose) as well, as the detach function does?

when the GC kicks in the dispose is not observed anymore so there's nothing to drop ... it will not be part of the WeakMap and it's invoked when the DOM element is collected.

Basically, drop is a way to stop observing the element in the FinalizationRegistry ... when it kicks though, it means that the element is not around anymore and the effect can be disposed.

It's all good, there are no leaks here, it's been tested already .... the nested effect was a weird case a part because the element might not be collected ASAP and if the signal change reaches that element it might also never be collected.

Here the behavior was also different in Chromium, WebKit, and Firefox. GC consistency is hard, but detach solves it all, imho. On the top-most outer effect, everything works as expected though.

in short ... if you see what drop does, it just unregister the reference (dispose) so that onGC for that reference won't ever trigger and you need to dispose() explicitly.

When the element is garbage collected the unregister happens behind the scene right before the onGC invokes that dispose so there's nothing to drop: it's already dropped.

I hope this clarifies.

Yes, thanks for the explanation. It's the first time I deal with the garbage collector explicitly, and I sitll have to fully understand all the ins and outs