lit-apps / lit-app

mono-repo for lit-app and dependencies
30 stars 5 forks source link

@storage an Object results in storing [object Object] #14

Open csandeep opened 7 months ago

csandeep commented 7 months ago

Persisting an object to localSorage results in storing the object's toString() value, which is a [object Object]. Here's my code

data-types.ts:

export interface Session {
    owner: Owner;
    status: string;
    id: string;
}

export interface Owner {
    email: string;
    user_agent: string;
    active: boolean;
    name: string;
    id: string;
}

app-state.ts:

import {State, property, storage} from '@lit-app/state';
import {Session} from './data-types';

class AppState extends State {
    @storage({key: 'session'})
    @property()
    session?: Session;
}

export const appState = new AppState();

I have followed the @hook documentation but couldn't figure out how to, any hints ?

christophe-g commented 7 months ago

Thanks a lot for reporting this @csandeep - Can you tell me which version of lit you are running this code with? I have not yet tested it with latest TS (https://lit.dev/docs/components/decorators/#decorators-typescript) and would suspect that TS compile option have some kind of influence here.

Planning to to some cleanup (there are a couple of PR I'd like to accept) and further tests against latest version of lit and decorators this week. I will also look into this issue.

as per @hook, this is the one I use to sync a state with a firebase database:

/**
 * A hook for @lit-app/state synchronizing with firebase
 */

import { set, child, onValue, DatabaseReference, DataSnapshot, Unsubscribe, Query } from 'firebase/database'
import { Hook, State } from '@lit-app/state'

// TODO: Ref should be DatabaseReference | Map<string, DatabaseReference>

type Ref = DatabaseReference | { [key: string]: DatabaseReference } | undefined
type hookDef = {
    path?: string,
    forceSet?: boolean
}

/** DatabaseReference is an interface. We cannot use value instanceof DatabaseReference */
const isRef = (value: Ref) => {
    return value && value.key && value.root && value.parent;
};
const hookName = 'firebase'

/** 
 * Firebase hook to synchronize state values with a firebase path
 * 
 * The path resolves relatively to a Database reference set to the hook. 
 * 
 * Synchronisation works like this: 
 * 
 * When the ref is set: 
 * If the value of the database does not exits (onValue returning null), or the hook property has `forceSet` set to `true` set with current state value
 * Otherwise (database value does exist, no `forceSet`), read the value from the database first
 * 
 * Then database write updates on state value and vice-versa.
 * 
 * The Database reference is either :
 * 1. one ref (applying to all hooked properties), or
 * 2. a <key, ref> object, with individual ref applying to hooked properties
 * 
 * 
 * Examples: 
 * 
 * Same ref for the all state properties
 * ```js
 * @hook(firebase, {path: /mypath})
 * @property() a // property `a` will sync with child(state.ref, 'mypath')
 * @hook(firebase )
 * @property() name // property `name` will sync with child(state.ref, 'name')
 * ```
 * 
 * setting a <key,ref> object 
 * 
 * ```js
 * const ref = {
 *  a: ref(getDatabase(), '/a/path/for/a')
 *  name: ref(getDatabase(), '/a/path/for/name')
 * }
 * @hook(firebase, {path: /mypath})
 * @property() a // property `a` will sync with with path '/a/path/for/a' (mypath is not taken into account)
 * @hook(firebase )
 * @property() name // property `name` will sync path '/a/path/for/name'
 * ```
 * 
 */
export class HookFirebase extends Hook {
    static override hookName: string = hookName;
    private _ref!: Ref
    private _hasSynched: Map<string, boolean> = new Map()
    private _unsubscribe: Unsubscribe[] = []

    set ref(ref: Ref) {
        this._unsubscribe.forEach(unsubscribe => unsubscribe())
        this._unsubscribe = []
        this._hasSynched = new Map()
        if (ref) {
            this._ref = ref;

            const callback = (key: string) => (snap: DataSnapshot) => {
                const value = snap.val()
                const definition = this.getDefinition(key)
                const hookDef = definition?.hook?.firebase as hookDef
                const stateValue = this.state[key as keyof State] 
                // consider the value has been synched if it is the same value as the one in the state
                if (value !== null && (JSON.stringify(value) === JSON.stringify(stateValue))) {
                    this._hasSynched.set(key, true)
                }
                else if (!this._hasSynched.get(key) &&
                    // skipAsync is set to true when the value is set by a query parameter
                    (value === null || hookDef.forceSet || definition?.skipAsync) && stateValue !== undefined) {
                    set(snap.ref, stateValue)
                        .then(() => this._hasSynched.set(key, true))
                } else {
                    this.toState({ [key]: snap.val() })
                    this._hasSynched.set(key, true)
                }
            }

            this.hookedProps.forEach(([key, definition]) => {
                const hookDef = definition?.hook?.firebase as hookDef
                // @ts-ignore
                const r = isRef(this.ref[key]) ? this._ref[key] :
                    isRef(this.ref) ? child(this.ref as DatabaseReference, hookDef?.path || key) :
                        null
                if (r) {
                    this._unsubscribe.push(onValue(r as Query, callback(key), (error) => {
                        console.error(error)
                    }))

                }
            })
        }
    }

    get ref(): Ref {
        return this._ref
    }

    store() {
        if (isRef(this._ref)) {
            const stateValue = this.state.stateValue
            const value = {}
            this.hookedProps.forEach(([key, definition]) => {
                const hookDef = definition?.hook?.firebase as hookDef
                const v = stateValue[key]
                // @ts-ignore
                if(v !== undefined) {value[hookDef?.path || key] = v}
            })
            set(this._ref as DatabaseReference, value)
        }
    }

    constructor(public override state: State, ref?: Ref) {
        super(state)
        if (ref) {
            this.ref = ref
        }
    }

    override fromState(key: string, value: unknown): void {
        // console.info('fromState', key, value)
        if (value !== undefined && this._hasSynched.get(key) && this.ref) {
            const definition = this.getDefinition(key)
            const isKeyedRef = isRef((this.ref as { [key: string]: DatabaseReference })[key]) 
            set(
                //@ts-ignore
                isKeyedRef ? (this.ref[key] as DatabaseReference) : 
                child(this.ref as DatabaseReference, 
                    (definition?.hook?.firebase as hookDef)?.path ||  key), value
            )
        }
    }

    override reset():void {
        this.ref = undefined
    }
}
csandeep commented 7 months ago

Thank you @christophe-g , I'm using lit v3.1.0

christophe-g commented 6 months ago

@csandeep - released a new version which now supports lit ^3 and @lit/reactive-element@^2.

That should fix your issue.