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

feat(store-sdk): Add Session to store-sdk #896

Merged
merged 14 commits into from
Aug 18, 2021
9 changes: 9 additions & 0 deletions packages/store-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
"size": "size-limit",
"analyze": "size-limit --why"
},
"jest": {
"setupFiles": [
"./test/setup.js"
]
},
"size-limit": [
{
"path": "dist/store-sdk.cjs.production.min.js",
Expand All @@ -42,10 +47,14 @@
},
"devDependencies": {
"@size-limit/preset-small-lib": "^4.10.2",
"fake-indexeddb": "^3.1.3",
"react": "^17.0.2",
"size-limit": "^4.10.2",
"tsdx": "^0.14.1",
"tslib": "^2.2.0",
"typescript": "^4.2.4"
},
"dependencies": {
"idb-keyval": "^5.1.3"
}
}
15 changes: 15 additions & 0 deletions packages/store-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,18 @@ export type {
InitialState as UIInitialState,
} from './ui/Provider'
export { useGlobalUIState } from './ui/useGlobalUIState'

// Session
export {
Provider as SessionProvider,
Context as SessionContext,
} from './session/Provider'
export type {
InitialState as SessionInitialState,
Currency as SessionCurrency,
User as SessionUser,
} from './session/Provider'
export { useSession } from './session/useSession'

// Storage
export { useStorage } from './storage/useStorage'
72 changes: 72 additions & 0 deletions packages/store-sdk/src/session/Provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React, { createContext, useMemo } from 'react'
import type { FC } from 'react'

import { useStorage } from '../storage/useStorage'

export interface Currency {
code: string // Ex: USD
symbol: string // Ex: $
}

export interface User {
id: string // user id
}

interface Session {
locale: string // en-US
currency: Currency
country: string // BRA
channel: string | null
postalCode: string | null
user: User | null
}

export interface ContextValue extends Session {
setSession: (session: Session) => void
}

export const Context = createContext<ContextValue | undefined>(undefined)
igorbrasileiro marked this conversation as resolved.
Show resolved Hide resolved
Context.displayName = 'StoreSessionContext'

const baseInitialState: Session = {
currency: {
code: 'USD',
symbol: '$',
},
country: 'USA',
locale: 'en',
postalCode: null,
channel: null,
user: null,
}

export type InitialState = Record<string, any>

interface Props {
initialState?: Partial<Session>
namespace?: string
}

export const Provider: FC<Props> = ({
children,
initialState,
namespace = 'main',
}) => {
const [session, setSession] = useStorage<Session>(
`${namespace}::store::session`,
() => ({
...baseInitialState,
...initialState,
})
)

const value = useMemo(
() => ({
...session,
setSession: (data: Session) => setSession({ ...session, ...data }),
}),
[session, setSession]
)

return <Context.Provider value={value}>{children}</Context.Provider>
}
4 changes: 4 additions & 0 deletions packages/store-sdk/src/session/useSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Context } from './Provider'
import { useContext } from '../utils/useContext'

export const useSession = () => useContext(Context)
71 changes: 71 additions & 0 deletions packages/store-sdk/src/storage/useStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Safe IDB storage interface. These try..catch are usefull because
* some browsers may block accesss to these APIs due to security policies
*
* Also, the stored value is lazy-loaded to avoid hydration mismatch
* between server/browser. When state is 'hydrated', the value in the heap
* is the same as the value in IDB
*/
import { useState, useEffect, useMemo } from 'react'
import { get, set } from 'idb-keyval'

const getItem = async <T>(key: string) => {
try {
const value = await get<T>(key)

return value ?? null
} catch (err) {
return null
}
}

const setItem = async <T>(key: string, value: T | null) => {
try {
await set(key, value)
icazevedo marked this conversation as resolved.
Show resolved Hide resolved
} catch (err) {
// noop
}
}

const isFunction = <T>(x: T | (() => T)): x is () => T =>
typeof x === 'function'

export const useStorage = <T>(key: string, initialValue: T | (() => T)) => {
const [data, setData] = useState(() => ({
payload: isFunction(initialValue) ? initialValue() : initialValue,
state: 'initial',
}))

useEffect(() => {
let cancel = false

const effect = async () => {
if (data.state === 'initial') {
const item = (await getItem<T>(key)) ?? data.payload

if (!cancel) {
setData({ payload: item, state: 'hydrated' })
}
} else if (!cancel) {
setItem(key, data.payload)
}
}

effect()

return () => {
cancel = true
}
}, [data.payload, data.state, key])

const memoized = useMemo(
() =>
[
data.payload,
(value: T) => setData({ state: 'hydrated', payload: value }),
] as const,
[data.payload]
)

return memoized
}
29 changes: 29 additions & 0 deletions packages/store-sdk/test/session/Provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { renderHook } from '@testing-library/react-hooks'
import { set } from 'idb-keyval'

import { SessionProvider, useSession } from '../../src'

test('Session Provider: Set initial session values', async () => {
const { result } = renderHook(useSession, {
wrapper: SessionProvider,
initialProps: { initialState: { channel: 'test-channel' } },
})

expect(result.current.channel).toBe('test-channel')
})

test('Session Provider: Hydrate values from storage', async () => {
// Renders once with a custom initial state
const storedState = { channel: 'test-channel' }

await set('main::store::session', storedState)

// We should have stored the past session on storage and we should be able to hydrate from it
const run = renderHook(useSession, {
wrapper: SessionProvider,
})

await run.waitForValueToChange(() => run.result.current.channel)

expect(run.result.current.channel).toBe(storedState.channel)
})
3 changes: 3 additions & 0 deletions packages/store-sdk/test/setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/* eslint-disable no-undef */
// Fake indexedDB
globalThis.indexedDB = require('fake-indexeddb')
24 changes: 24 additions & 0 deletions packages/store-sdk/test/storage/useStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { renderHook } from '@testing-library/react-hooks'
import { set } from 'idb-keyval'

import { useStorage } from '../../src'

test('useStorage: Hydrate with initial value', async () => {
const hook = renderHook(() => useStorage('k', { a: 1 }))

expect(hook.result.current[0].a).toBe(1)
})

test('useStorage: Read value from localStorage after hydration', async () => {
const key = 'k'
const storedValue = { a: 1 }
const initialValue = { a: 2 }

set(key, storedValue)

const run = renderHook(() => useStorage(key, initialValue))

await run.waitForValueToChange(() => run.result.current[0].a)

expect(run.result.current[0]).toEqual(storedValue)
})
Loading