sveltesveltekitsvelte-componentsvelte-storesvelte-transition

onMount is being called twice


I have a layout page and each component inherits from it. My problem is onMount function is being called twice for each component. It makes many issues because my onMount function contains calls to API. It means that calls to Api are doubled. For a long time, I did not know why it happens. Recently I noticed that the layout component has a wrapper that triggers the transition. When I removed transition onMount was called one time as expected. From another hand, I don't want to lose transition because the page looks bad. How to solve this problem?

PageTransitions.svelte:

<script>
  import { fly } from "svelte/transition";
  export let refresh = "";
</script>

{#key refresh}
  <div in:fly={{ y: -50, duration: 250, delay: 300 }} out:fly={{ y: -50, duration: 250 }} class="flex flex-1">
    <slot>Zawartość</slot>
  </div>
{/key}

layout.svelte:

<script>
  import { googleMap } from "./../stores.js";
  import { page } from "$app/stores";
  import Footer from "$lib/Footer.svelte";
  import Header from "$lib/Header.svelte";
  import PageTransitions from "$lib/PageTransitions.svelte";
  import Notifications from "svelte-notifications";
  import "../app.css";
  import { GOOGLE_API_KEY } from "$lib/constants.js";
  import { setContext } from "svelte";
  import { writable } from "svelte/store";

  const url = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_API_KEY}&libraries=places,directions`;

  let isSideMenuOpen = setContext("isSideMenuOpen", writable(false));

  $: loaded = $googleMap.loaded;

  const onLoad = () => {
    $googleMap.loaded = true;
    console.log("Google Maps SDK loaded…", window.google);
  };
</script>

<svelte:head>
  {#if !loaded}
    <script
      src={url}
      type="application/javascript"
      defer
      async
      on:load={onLoad}></script>
  {/if}
</svelte:head>

<Notifications>
  <div
    class="h-screen flex flex-col bg-gray-50 dark:bg-gray-900"
    class:overflow-hidden={isSideMenuOpen}
  >
    <Header />

    <PageTransitions refresh={$page.path}>
      <slot>Strona</slot>
    </PageTransitions>

    <Footer />
  </div>
</Notifications>

modules.svelte (example component):

<script context="module">
  export const ssr = false;
  export const prerender = true;

  export async function load({ session }) {

    if (!session.authenticated) {
      return {
        status: 302,
        redirect: "/auth/login",
      };
    }
    return {
      props: {
        authenticated: session.authenticated,
        token: session.token,
        user: session.user,
      },
    };
  }
</script>

<script>
    import { slide } from 'svelte/transition';
  import GenericTable from "$lib/GenericTable.svelte";
  import { profile, modules } from "../../stores";
  import { getModuleDataURL, getModuleTypeDataURL, get, del } from "$lib/api";
  import GenericRow from "$lib/Rows/ModuleRow.svelte";
  import { onMount } from "svelte";
  import Loader from "$lib/Loader.svelte";
  import { getNotificationsContext } from "svelte-notifications";

  const { addNotification } = getNotificationsContext();

  let loading = false;

  export let authenticated;
  export let token;
  export let user;

  const siteHeader = "Moduły";
  const rootPath = "modules";

  let filters = {
    moduleType: null,
    imei: "",
    serialNumber: "",
    phoneNumber: "",
    isActive: null,
    desc: "",
    id: null,
  };

  const filtersChange = () => {
    let filteredItems = [...$modules.items];

    $modules.filters = false;
    if (
      filters.moduleType ||
      filters.id ||
      filters.desc.length > 2 ||
      filters.isActive !== null ||
      filters.imei.length > 2 ||
      filters.serialNumber.length > 2 ||
      filters.phoneNumber.length > 2
    ) {
      $modules.filters = true;
    }

    if (filters.moduleType) {
      filteredItems = filteredItems.filter(
        (item) => item.moduleType.id === filters.moduleType.value
      );
    }
    if (filters.isActive !== null) {
      filteredItems = filteredItems.filter(
        (item) => item.isActive === filters.isActive
      );
    }
    if (filters.imei.length > 2) {
      filteredItems = filteredItems.filter((item) =>
        item.imei.includes(filters.imei)
      );
    }
    if (filters.serialNumber.length > 2) {
      filteredItems = filteredItems.filter((item) =>
        item.serialNumber.includes(filters.serialNumber)
      );
    }
    if (filters.phoneNumber.length > 2) {
      filteredItems = filteredItems.filter((item) =>
        item.phoneNumber.includes(filters.phoneNumber)
      );
    }
    if (filters.desc.length > 2) {
      filteredItems = filteredItems.filter((item) =>
        item.desc ? item.desc.includes(filters.desc) : false
      );
    }
    if (filters.id === "DESC") {
      filteredItems.sort((a, b) => (a.id > b.id ? 1 : -1));
    } else if (filters.id === "ASC") {
      filteredItems.sort((a, b) => (a.id < b.id ? 1 : -1));
    }
    $modules.filteredModules = filteredItems;
  };

  const urlModuleType = getModuleTypeDataURL();
  const urlModuleData = getModuleDataURL();

  const deleteItem = async (id) => {
    loading = true;
    const url = getModuleDataURL() + "?id=" + id;
    try {
      const result = await del(url, token, false);
      if (result.status) {
        $modules.items = $modules.items.filter((item) => id !== item.id);
        $modules.filteredModules = $modules.filteredModules.filter(
          (item) => id !== item.id
        );
        addNotification({
          text: "Usunięto moduł o id - " + id,
          position: "bottom-right",
          type: "success",
          removeAfter: 4000,
        });
      } else throw result.message;
    } catch (err) {
      console.error(err);
      addNotification({
        text: err,
        position: "bottom-right",
        type: "danger",
        removeAfter: 4000,
      });
    }
    loading = false;
  };

  onMount(async () => {
    loading = true;
    $modules.filters = false;
    $modules.filteredGroups = [];
    try {
      const resultModules = await get(
        urlModuleData + "?$orderby=isActive desc,id",
        token
      );
      if (resultModules.data) $modules.items = resultModules.data.items;

      const resultModuleTypes = await get(urlModuleType, token);
      if (resultModuleTypes.data)
        $modules.moduleTypes = resultModuleTypes.data.items;
    } catch (err) {
      console.error(err);
      addNotification({
        text: "Podczas komunikacji z serwerem wystąpił błąd. Spróbuj raz jeszcze. Jeśli problem się powtórzy, skontaktuj się z administratorem aplikacji.",
        position: "bottom-right",
        type: "danger",
        removeAfter: 4000,
      });
    }
    loading = false;
  });

  let virtualListData = [];
  $: virtualListData = $modules.filters
    ? $modules.filteredModules
    : $modules.items;

  let moduleTypes = [];
  $: moduleTypes = $modules.moduleTypes.map((item) => ({
    value: item.id,
    label: item.type,
  }));

  const widths = ["5%", "15%", "10%", "10%", "15%", "15%", "20%", "10%"];

  let listTableHead = [];
  $: listTableHead = [
    {
      id: 1,
      name: "ID",
      filter: true,
      type: "id",
    },
    {
      id: 2,
      name: "IMEI",
      filter: true,
      label: "imei",
      type: "text",
      placeholder: "Wpisz minimum 3 znaki",
      onInput: filtersChange,
    },
    {
      id: 3,
      name: "Typ modułu",
      filter: true,
      label: "moduleTypes",
      type: "select",
      placeholder: "-",
      items: moduleTypes,
      select: (e) => {
        filters.moduleType = e.detail;
        filtersChange();
      },
      clear: () => {
        filters.moduleType = null;
        filtersChange();
      },
    },
    {
      id: 4,
      name: "Aktywność",
      filter: true,
      label: "isActive",
      type: "select",
      placeholder: "-",
      items: [
        { value: true, label: "Aktywny" },
        { value: false, label: "Nieaktywny" },
      ],
      select: (e) => {
        filters.isActive = e.detail.value;
        filtersChange();
      },
      clear: () => {
        filters.isActive = null;
        filtersChange();
      },
    },
    {
      id: 5,
      name: "Numer telefonu",
      filter: true,
      label: "phoneNumber",
      type: "text",
      placeholder: "Wpisz minimum 3 znaki",
      onInput: filtersChange,
    },
    {
      id: 6,
      name: "Numer seryjny",
      filter: true,
      label: "serialNumber",
      type: "text",
      placeholder: "Wpisz minimum 3 znaki",
      onInput: filtersChange,
    },
    {
      id: 7,
      name: "Opis",
      filter: true,
      label: "desc",
      type: "text",
      placeholder: "Wpisz minimum 3 znaki",
      onInput: filtersChange,
    },
    {
      id: 8,
      name: "Dodaj nowy moduł",
      type: "addNew",
    },
  ];
</script>

<main class="w-full flex flex-1 overflow-y-auto bg-red-100">
  <div class="p-2 overflow-y-auto flex flex-1">
    {#if loading}
      <Loader />
    {/if}
    <div
      transition:slide
      style="display:{loading ? 'none' : 'block'}"
      class="flex flex-col flex-1 items-center justify-center"
    >
      <GenericTable
        {siteHeader}
        {filtersChange}
        {deleteItem}
        {virtualListData}
        {widths}
        {listTableHead}
        {filters}
        {rootPath}
        {GenericRow}
      />
    </div>
  </div>
</main>

What I tried:

  1. I tried to remove PageTransitions from the layout.svelte component, it helps in terms of onMount function but the page looks bad
  2. I tried to move transitions to each component separately, partially works but not everywhere
  3. Tried to move onMount function content to Load function, but I cannot compile

Why does this happen?


Solution

  • There is a related issue documented here (without solution).

    The root cause is a timing issue. layout.svelte changes the <slot/> in PageTransitions.svelte. This causes the new slot/page to load and even fire the onMount.

    The new page would also be visible on screen for milliseconds, since no transition happend yet!

    After the slot was changed, Svelte triggers a $page store to update. In you code, this causes the transition to update ({#key refresh}) and therefore loading the <slot/> again.

    There is no way to change the timing between the <slot/> exchange and $page store set without modifing Svelte itself.

    This code fires the transition and only causes one component mount.

    layout.svelte:

    <script context="module">
        export const load = async ({ url }) => ({ props: { refresh: url } });
    </script>
    <script>
        import { googleMap } from "./../stores.js";
        import Footer from "$lib/Footer.svelte";
        import Header from "$lib/Header.svelte";
        import PageTransitions from "$lib/PageTransitions.svelte";
        import Notifications from "svelte-notifications";
        import "../app.css";
        import { GOOGLE_API_KEY } from "$lib/constants.js";
        import { setContext } from "svelte";
        import { writable } from "svelte/store";
    
        export let refresh;
        const url = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_API_KEY}&libraries=places,directions`;
    
        let isSideMenuOpen = setContext("isSideMenuOpen", writable(false));
    
        $: loaded = $googleMap.loaded;
    
        const onLoad = () => {
          $googleMap.loaded = true;
          console.log("Google Maps SDK loaded…", window.google);
        };
      </script>
    
      <svelte:head>
        {#if !loaded}
          <script
            src={url}
            type="application/javascript"
            defer
            async
            on:load={onLoad}></script>
        {/if}
      </svelte:head>
    
      <Notifications>
        <div
          class="h-screen flex flex-col bg-gray-50 dark:bg-gray-900"
          class:overflow-hidden={isSideMenuOpen}
        >
          <Header />
    
          <PageTransitions {refresh}>
            <slot>Strona</slot>
          </PageTransitions>
    
          <Footer />
        </div>
      </Notifications>