How to make Svelte re-use a component instance somewhere else in the DOM instead of destroying/recreating it?
Asked Answered
A

1

6

I'm working on an app that renders a tree structure graphically. There are various node types in the tree, each with a corresponding Svelte component which renders it.

One of the features of the app is that you can click and drag a node in the tree, and move it to a different parent node. When dropping the node in a new location, the underlying data structure is transformed accordingly, and Svelte updates the view.

The problem is, the component instance for the moved node gets destroyed (along with all of its descendants, which might be hundreds of nodes), and then the whole thing is re-created from scratch wherever the node was moved to. Which is a lot of unnecessary work, with very noticeable lag, when it should have just been able to move the existing component instance and associated DOM nodes in no time at all.

Is there any way to hint to Svelte that it should re-use the instance?

(There is a unique ID on each node, if that helps.)

Adin answered 29/8, 2021 at 5:14 Comment(1)
You could imperatively manage the lifecycle & attaching/detching of instances of the components svelte.dev/docs#Client-side_component_APIGenera
F
2

You can't hint to Svelte that it will reuse the instance, but you can make a borrowing system that will safely move a component to other containers. I've packaged this into a library, svelte-reparent.

By making an original container, Limbo, and renderers, Portals, with the ability to teleport between those components, we can also:

  • Attach the lifetime to Limbo (safely allowing a space to go back to)
  • Add destruction hooks to Portal that safely move it back to Limbo

Since Svelte doesn't have a Virtual DOM, it's significantly easier to move elements:

Limbo.svelte - our container

<!-- 
    Limbo is a place to initialize the element,
    and serves as a hidden space to keep track of nodes.

    Since Limbo owns the lifecycle of the current element,
    the moment that Limbo gets destroyed, it will destroy its child element
    that it *thinks* it owns. Because of this, it is quite safe to destroy and reinitialize
    a Limbo component without causing unintended side effects to the DOM.
-->

<script lang="ts">
    import { _components } from '$lib/Portal.svelte';
    import { onMount } from 'svelte';

    export let component: HTMLElement;
    let container: HTMLDivElement;

    // Register the component and its limbo
    onMount(() => _components.set(component, { ..._components.get(component), limbo: container }));
</script>

<!-- 
    We don't want to render this component, 
    but we use it as the initial holder before teleporting it.
    This allows us to have a safe fallback
    for when a Portal gets destroyed.

    We also wrap it to guarantee that `component` is a DOM component,
    since we can't guarantee that all svelte components only have 1 root node.
-->
<div style="display: none;" bind:this={container}>
    <div style="display: contents;" bind:this={component}><slot /></div>
</div>

Portal.svelte

<script context="module" lang="ts">
    import { writable } from 'svelte/store';

    type Container = HTMLElement;
    type Key = string | number | symbol;

    /**
     * Universal map to keep track of what portal a component wants to be in,
     * as well as its original limbo owner.
     *
     * DON'T MODIFY EXTERNALLY!
     * Doing so is **undefined behavior**.
     */
    export let _components = new Map<
        Container,
        {
            limbo?: HTMLElement;
            key?: Key;
        }
    >();

    // dirty tracker - a Map isn't reactive, so we need to coerce Svelte to re-render
    let dirty = writable(Symbol());

    export async function teleport(component: Container, key: Key) {
        _components.set(component, { ..._components.get(component), key });

        // trigger a re-render
        dirty.set(Symbol());
    }
</script>

<script lang="ts">
    import { onDestroy } from 'svelte';

    export let key: Key;
    export let component: Container;

    /*
        - component may be nil before mount
        - listen to dirty to force a re-render
    */
    $: if (component && $dirty && _components.get(component)?.key == key) {
        // appendChild forces a move, not a copy - we can safely use this as the DOM
        // handles ownership of the node for us
        container.appendChild(component);
    }

    let container: HTMLDivElement;

    onDestroy(() => {
        // check if we own the component
        const { limbo, key: localKey } = _components.get(component) || {};

        if (localKey !== key) return;
        _components.delete(component);

        // move the component back to the limbo till it gets re-mounted
        limbo?.appendChild(component);

        // trigger a re-render
        dirty.set(Symbol());
    });
</script>

<div style="display: contents;" bind:this={container} />

+page.svelte (example):

<script lang="ts">
    import { onMount } from 'svelte';
    import { Portal, Limbo, teleport } from '$lib';

    let component: HTMLElement;

    function send(label: string) {
        return () => {
            teleport(component, label);
        };
    }

    onMount((): void => send('a')());
</script>

<main>
    <Limbo bind:component>
        <input placeholder="Enter unkept state" />
    </Limbo>
    <div class="container">
        <h1>Container A</h1>
        <Portal key="a" {component} />
        <button on:click={send('a')}>Move Component Here</button>
    </div>
    <div class="container">
        <h1>Container B</h1>
        <Portal key="b" {component} />
        <button on:click={send('b')}>Move Component Here</button>
    </div>
</main>

<style>
    .container {
        border: 1px solid black;
        margin: 1rem;
        padding: 1rem;
    }
</style>

Uploaded Example

Forthcoming answered 8/9, 2023 at 13:16 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.