Keb
Keb

Reputation: 113

Initializing a custom Svelte store asynchronously

Background
I am attempting to develop a cross-platform desktop app using Svelte and Tauri
When the app starts i need to load a settings.json-file from the filesystem into a custom Svelte store.
It needs to be a custom store because I must validate the data using a custom set-function before writing to it
The store will hold an object.

I am using regular Svelte and not Svelte-kit as SSR is not necessary.

Problems

  1. Tauri does not have any synchronous methods for reading files in their fs-api
  2. Svelte does not seem to have any intuitive way I can find for doing this

Tests

Example
It would be a lot of code if I were to post all the failed attempts, so I will provide a example of what I am attempting to achieve.
Everything in the code works when createStore is not async, except reading the settings-file.

import { writable, get as getStore } from 'svelte/store'; // Svelte store
import _set from 'lodash.set';                            // Creating objects with any key/path
import _merge from 'lodash.merge';                        // Merging objects
import { fs } from '@tauri-apps/api';                     // Accessing local filesystem


async function createStore() {
  // Read settings from the file system
  let settings = {}
  try { settings = JSON.parse(await fs.readTextFile('./settings.json')); }
  catch {}

  // Create the store
  const store = writable(settings);

  // Custom set function
  function set (key, value) {
    if(!key) return;

    // Use lodash to create an object
    const change = _set({}, key, value);

    // Retreive the current store and merge it with the object above
    const currentStore = getStore(store)
    const updated = _merge({}, currentStore, change)

    // Update the store
    store.update(() => updated)
    
    // Save the updated settings back to the filesystem
    fs.writeFile({
      contents: JSON.stringify(updated, null, 2),
      path: './settings.json'}
    )
  }

  // Bundle the custom store
  const customStore = {
    subscribe: store.subscribe,
    set
  }

  return customStore;
}

export default createStore();

Upvotes: 9

Views: 5179

Answers (4)

dalcam
dalcam

Reputation: 1077

Here is a simple reusable generic typescript version. This allows a readable store to be set after initialization. (You don't need to do this of course if your store is writable as you can call set/update after initialization):

import { type Readable, writable } from 'svelte/store';

export interface ReadOnlyAsyncStore<T> extends Readable<T> {
    init: (promise: Promise<T>) => Promise<T>;
}

/// <summary>
/// Creates a readable store that can be initialized after its created, asynchronously
/// </summary>
/// <usage>
/// export const myStore = createStore<YourInterface>(); // Can do this in a shared ts file
/// store.init(fetch('https://api.com/data')); //Can do this from your onmount etc
/// </usage>
/// <typeparam name="T">The type of the store</typeparam>
export function createReadableAsyncStore<T>(): ReadOnlyAsyncStore<T> {
    const { subscribe, update } = writable<T>();

    return {
        subscribe,
        init: async (promise) => {
            const data = await promise;
            update(() => data);
            return data;
        }
    };
}

Upvotes: 0

Duke
Duke

Reputation: 7444

I got inspired from @Corrl's excellent answer. Here are 2 store creation functions; asyncDerived resolves promises passed into it and passes the results to its callback fn. lateInitLoadable only does its initialization when the init() function is called, so you can do late init like when it needs to be done from onMount().

So here's an example of instantiating actual stores:


export const websocketClient = lateInitLoadable(async () => {
    const client: MySpecialWebsocket = await setupWSOnlyAfterPageHasLoaded();
    return client;
});


export const highLevelClient = asyncDerived(
    [websocketClient.load],
    async ([$websocketClient]) => {
        const client = new HighLevelClient($websocketClient);
        await client.asyncSetup();
        return client;
    }
);

And then get reactive in svelte! Make sure you call init() from onMount.

<script>
    import {onMount} from 'svelte'

    onMount(async () => {
        await websocketClient.init();
        await highLevelClient.load
        const val = await $highLevelClient.websocketRequest();
    })
</script>

{#await highLevelClient.load then $highLevelClient}
    'showing App'
{/await}

Remember that you can always do an if (browser) to localize the init to the store:

import { browser } from '$app/environment';

if (browser) {
    websocketClient.init();
}

Here's the source for asyncDerived and lateInitLoadable:

import { writable, type Readable } from 'svelte/store';

export type Loadable<T> = Readable<T> & { load: Promise<T>; init?: () => Promise<T> };

export function asyncDerived<S extends readonly unknown[], T>(
    deps: S,
    cb: (values: { [K in keyof S]: Awaited<S[K]> }) => Promise<T>
): Loadable<T> {
    const { subscribe, set } = writable<T>();
    const load = new Promise<T>((resolve) => {
        Promise.all(deps).then((resolvedDeps) => {
            cb(resolvedDeps).then((value) => {
                resolve(value);
                set(value);
            });
        });
    });

    return {
        subscribe,
        load
    };
}

export function lateInitLoadable<T>(lateInitFn: () => Promise<T>): Loadable<T> {
    const { subscribe, set } = writable<T>();
    // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
    let loadResolver: (value: T) => void = (_: T) => {};
    const load = new Promise<T>((resolve) => {
        loadResolver = resolve;
    });

    return {
        subscribe,
        async init() {
            const value = await lateInitFn();
            set(value as T);
            loadResolver(value as T);
            return value as T;
        },
        load
    };
}

Upvotes: 1

Corrl
Corrl

Reputation: 7689

When having a custom store which needs to be initialized asynchronously, I do this via an async method on the store which I'd call from the App component, if the store is directly needed
(note that fs.writeFile() also returns a Promise. If there was an error, this wouldn't be handled yet...)

App.svelte
<script>
    import settings from './settings'
    import {onMount} from 'svelte'
    
    let appInitialized

    onMount(async () => {
        try {
            await settings.init()           
            appInitialized = true
        }catch(error) {
            console.error(error)
        }
    })

</script>

{#if appInitialized}
    'showing App'
{:else}
    'initializing App'
{/if}

alternative component logic when there's just the one store to initialize using an {#await} block

<script>
    import settings from './settings'
</script>

{#await settings.init()}
    'initializing store'
{:then}
    'show App'
{:catch error}
    'Couldn't initialize - '{error.message}
{/await}

or one if there were more stores to initialize

<script>
    import settings from './settings'
    import store2 from './store2'
    import store3 from './store3'

    const initStores = [
        settings.init(),
        store2.init(),
        store3.init()
    ]
</script>

{#await Promise.all(initStores)}
    'initializing stores'
{:then}
    'showing App'
{:catch error}
    'Couldn't initialize - '{error.message}
{/await}
settings.js
import { writable, get } from 'svelte/store';
import { fs } from '@tauri-apps/api';  

function createStore() {

    let initialValue = {}
    // destructure the store on creation to have 'direct access' to methods
    const {subscribe, update, set} = writable(initialValue);

    return {
        subscribe,

        async init() {
            const savedSettings = JSON.parse(await fs.readTextFile('./settings.json'))
            set(savedSettings);
        },

        changeSetting(key, value) {
            if(!key) return;

            const storeValue = get(this)

            storeValue[key] = value

            update(_ => storeValue)
            
            fs.writeFile({
                contents: JSON.stringify(storeValue, null, 2),
                path: './settings.json'
            })
        }
    }
}

export default createStore();

Upvotes: 11

Bob Fanger
Bob Fanger

Reputation: 29897

Update: I'd recommend the wrapper component approach in Corrl revised answer,
but with an #await block instead of an #if.


As loading the settings are part of the app startup, you can delay the mounting your Svelte App until after the settings are loaded.

This allows components to use the store without worrying about the loading state:

// main.js
initSettings().then(()=> {
    new App({ target: document.body })
})

// settings.js
import { writable, get } from 'svelte/store';
import { fs } from '@tauri-apps/api';  

let store;
const settings = { 
  subscribe() {
    if (!store) {
      throw new Error('Not initialized')
    }
    return store.subscribe()
  },
  async changeSetting(key, value) {
    if (!store) {
      throw new Error('Not initialized')
    }
    // ... save to fs
  }
}

export default settings;

export async function initSettings() {
  const data = JSON.parse(await fs.readTextFile('./settings.json'))
  if (store) {
    store.set(data)
  } else {
    store = writable(data);
  }
}

Downside it that it delays the startup of the app and if you don't implement a .catch in main.js the app would stay blank when the promise is rejected.

Upvotes: 3

Related Questions