matthewp / haunted

React's Hooks API implemented for web components 👻
BSD 2-Clause "Simplified" License
2.58k stars 92 forks source link

Virtual Components #475

Open big-camel opened 7 months ago

big-camel commented 7 months ago

https://github.com/matthewp/haunted/blob/main/src/virtual.ts#L59 Why don't you use the disconnected method here?

 protected disconnected(): void {
        console.log('disconnected')
        this.cont?.teardown()
 }

Is there more performance consumption with MutationObserver?

big-camel commented 7 months ago
const RENDERER_TO_VIRTUAL = new WeakMap<Renderer, ReturnType<typeof directive>>();

function virtual(renderer: Renderer) {
    const virtualDirective = RENDERER_TO_VIRTUAL.get(renderer);
    if(virtualDirective) return virtualDirective;
    class VirtualDirective extends AsyncDirective {
       ...
       update(part: ChildPart, args: DirectiveParameters<this>) {
          ...
          // teardownOnRemove(this.cont, part);
          ...
       }
       protected disconnected(): void {
        if(this.cont) {
          this.cont.teardown();
          this.cont = undefined;
        }
      }
    }
    const v = directive(VirtualDirective);
    RENDERER_TO_VIRTUAL.set(renderer, v);
    return v;
}

The virtual.test test file also runs with this solution

megheaiulian commented 7 months ago

For the disconnected function to work in virtual it needs to be called from an ancestor somewhere in the tree. For component this does not happen and you can end up with cases where the cleanup of effects in descendant virtual does not run. First component needs to call disconnect on the template result in its disconnectedCallback. Watching an actual element is safer ...

big-camel commented 7 months ago

For the disconnected function to work in virtual it needs to be called from an ancestor somewhere in the tree. For component this does not happen and you can end up with cases where the cleanup of effects in descendant virtual does not run. First component needs to call disconnect on the template result in its disconnectedCallback. Watching an actual element is safer ...

My understanding is that the disconnected method is called by lit-html/async-directive.js, and the virtual component functions as a custom directive. In my test case, the disconnected callback is always executed correctly.

I am unable to reproduce the scenario you described. Could you please provide a test case for me? Thank you.

megheaiulian commented 7 months ago

I will provide a better example at a some later point but basically:

  1. create a custom element with component
  2. create a virtual with a effect (console.log in the cleanup phase)
  3. in the custom element render the virtual
  4. conditionally render and remove the custom element somewhere (or detach it)
  5. the virtual's effect cleanup does not run because disconnect is not called
big-camel commented 7 months ago

https://github.com/matthewp/haunted/issues/475#issuecomment-1827963683 On the basis of the above code, here is my component and the runtime result:

Additionally, I am using lit-html@3.1.0

export const VirtualButton = virtual(() => {
  useEffect(() => {
    console.log('effect')
    return () => {
      console.log('effect-clear')
    }
  }, [])
  return html`<button>button</button>`
})

const MyApp = () => {
  const [count, setCount] = useState(0)

  return html`<div>
    <div>${count % 2 === 0 ? VirtualButton() : 'None'}</div>
  </div>`
}

output

effect
effect-clear
megheaiulian commented 7 months ago

Yes! this works. But does it work if you unmount/detach the my-app element ? from web inspector.

big-camel commented 7 months ago

Understood. If we only consider the unmounting of the component, it is feasible to collect all child nodes' virtual components using a WeakMap. Then, we can handle them uniformly in the disconnectedCallback of the component. Does this approach seem feasible to you?

megheaiulian commented 7 months ago

If we handle this correctly by

  1. storing the result of render in this.renderResult.
  2. call setConnected on the renderResult on unmount(disconnectedCallback) and on remount (on connectedCallback)

in component then we might not need the MutationObserver and teardownOnRemove.

I don't know if we actually need to have a map of parts or schedulers . To me it looks like disconnected will be called by the AsyncDirective's parent's and in it we can just call cont.teardown(). Additionally we could also create the scheduler once in the constructor like its done in component. A good example somewhat similar to virtual is the example implementation of a ObservableDirective in lit docs. https://lit.dev/docs/templates/custom-directives/#async-directives

big-camel commented 7 months ago

I've written some code, primarily to collect AsyncDirectives under the component. Now, when removing the component, the virtual component can correctly trigger the disconnected method.

const virtualDirectives = new WeakMap<HTMLComponent<P>, Set<typeof Directive>>()
    const setVirtualDirectives = (values: unknown[]): void => {
      for (const value of values) {
          if (isDirectiveResult(value)) {
            const directive = getDirectiveClass(value)
            if (!directive) continue
            const directives = virtualDirectives.get(component) || new Set()
            directives.add(directive)
            virtualDirectives.set(component, directives)
            // @ts-ignore
            setVirtualDirectives(value.values ?? [])
          }
        }
    }

component scheduler render

scheduler.render = () => {
      const result = scheduler.state.run(() => renderer.call(component, component))
      if (isTemplateResult(result)) {
        setVirtualDirectives(result.values)
      } else if (isDirectiveResult(result)) {
        setVirtualDirectives([result])
      } else if (Array.isArray(result)) {
        setVirtualDirectives(result)
      }
      console.log(result,virtualDirectives)
      return result
    }

component scheduler teardown

const superTeardown = scheduler.teardown
    scheduler.teardown = (): void => {
      const directives = virtualDirectives.get(component)
      if (directives) {
        for (const directive of directives) {
          // @ts-ignore
          const instance = VIRTUAL_CLASS_TO_INSTANCE.get(directive)
          if (instance) {
            // @ts-ignore
            instance['_$notifyDirectiveConnectionChanged'](false)
          }
        }
      }
      superTeardown()
    }

Now it meets the unmounting requirement for releasing the virtual component at the root node.

However, in a structure like the one below, where the

is removed using the DOM, it still doesn't release properly:

useEffect(() => {
    // Removing <div> using the DOM
    divRef.remove();
}, [])

return html`<div ${ref(divRef)}>${VirtualComponent()}</div>`

I believe there should be a convention here, discouraging the direct use of the DOM to manipulate nodes rendered from HTML templates. What do you think?

megheaiulian commented 7 months ago

I am using this patch to detach on component disconnect:

diff --git a/node_modules/haunted/lib/component.js b/node_modules/haunted/lib/component.js
index fae37bb..702cd4b 100644
--- a/node_modules/haunted/lib/component.js
+++ b/node_modules/haunted/lib/component.js
@@ -3,12 +3,13 @@ const toCamelCase = (val = '') => val.replace(/-+([a-z])?/g, (_, char) => char ?
 function makeComponent(render) {
     class Scheduler extends BaseScheduler {
         frag;
+        renderResult;
         constructor(renderer, frag, host) {
             super(renderer, (host || frag));
             this.frag = frag;
         }
         commit(result) {
-            render(result, this.frag);
+            this.renderResult = render(result, this.frag);
         }
     }
     function component(renderer, baseElementOrOptions, options) {
@@ -31,9 +32,11 @@ function makeComponent(render) {
             }
             connectedCallback() {
                 this._scheduler.update();
+                this._scheduler.renderResult?.setConnected(true)
             }
             disconnectedCallback() {
                 this._scheduler.teardown();
+                this._scheduler.renderResult?.setConnected(false)
             }
             attributeChangedCallback(name, oldValue, newValue) {
                 if (oldValue === newValue) {
big-camel commented 7 months ago

Great! I'll take it with me.