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

Virtual Components #475

Open
big-camel opened this issue Nov 27, 2023 · 11 comments
Open

Virtual Components #475

big-camel opened this issue Nov 27, 2023 · 11 comments

Comments

@big-camel
Copy link

big-camel commented Nov 27, 2023

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
Copy link
Author

big-camel commented Nov 27, 2023

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
Copy link

megheaiulian commented Nov 30, 2023

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
Copy link
Author

big-camel commented Nov 30, 2023

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
Copy link

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
Copy link
Author

big-camel commented Nov 30, 2023

#475 (comment)
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
Copy link

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

@big-camel
Copy link
Author

big-camel commented Nov 30, 2023

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
Copy link

megheaiulian commented Nov 30, 2023

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
Copy link
Author

big-camel commented Nov 30, 2023

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
Copy link

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
Copy link
Author

Great! I'll take it with me.

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