Skip to content

Commit

Permalink
refactor(base): refactor/clean up user-store
Browse files Browse the repository at this point in the history
- Improves dataloader caching
- Deprecates the `userStore.currentUser` observable of events in favor of the `userStore.me` Observable of `CurrentUser | null`
- Improves typings and adds strict mode for better type safety
- Adds a `useCurrentUser()` hook that returns the current user (or `null` if logged out)
  • Loading branch information
bjoerge committed Jun 8, 2021
1 parent f8910bf commit 8f78f21
Show file tree
Hide file tree
Showing 6 changed files with 125 additions and 114 deletions.
1 change: 1 addition & 0 deletions packages/@sanity/base/package.json
Expand Up @@ -83,6 +83,7 @@
"devDependencies": {
"@types/chance": "^1.1.0",
"@types/element-resize-detector": "1.1.2",
"@types/raf": "^3.4.0",
"@types/refractor": "^3.0.0",
"@types/resize-observer-browser": "^0.1.4",
"jest": "^26.6.3",
Expand Down
201 changes: 91 additions & 110 deletions packages/@sanity/base/src/datastores/user/createUserStore.ts
@@ -1,122 +1,77 @@
import {Observable, of, from} from 'rxjs'
import {Observable, of, from, merge, defer} from 'rxjs'
import {catchError, map, mergeMap, mapTo, switchMap, shareReplay, tap, take} from 'rxjs/operators'
import raf from 'raf'
import DataLoader from 'dataloader'
import pubsub from 'nano-pubsub'
import authenticationFetcher from 'part:@sanity/base/authentication-fetcher'
import {versionedClient} from '../../client/versionedClient'
import {User, CurrentUser, CurrentUserEvent} from './types'
import {observableCallback} from 'observable-callback'
import generateHelpUrl from '@sanity/generate-help-url'
import {versionedClient as sanityClient} from '../../client/versionedClient'
import {User, CurrentUser, UserStore, CurrentUserSnapshot} from './types'

const userCache: Record<string, User | null> = {}
const [logout$, logout] = observableCallback()
const [refresh$, refresh] = observableCallback()

const userChannel = pubsub<CurrentUser | null>()
const errorChannel = pubsub<Error | null>()

let _initialFetched = false
let _currentUser: CurrentUser | null = null
let _currentError: Error | null = null

userChannel.subscribe((val: CurrentUser | null) => {
_currentUser = val

if (val) {
const normalized = normalizeOwnUser(val)
userCache.me = normalized
userCache[val.id] = normalized
const userLoader = new DataLoader(
(userIds: readonly string[]) =>
fetchApiEndpoint<(User | null)[]>(`/users/${userIds.join(',')}`, {tag: 'users.get'})
.then(arrayify)
.then((response) => userIds.map((id) => response.find((user) => user?.id === id) || null)),
{
batchScheduleFn: (cb) => raf(cb),
}
})

errorChannel.subscribe((val) => {
_currentError = val
})

function fetchInitial(): Promise<CurrentUser> {
return authenticationFetcher.getCurrentUser().then(
(user) => userChannel.publish(user),
(err) => errorChannel.publish(err)
)

function fetchCurrentUser(): Observable<CurrentUser | null> {
return defer(() => {
const currentUserPromise = authenticationFetcher.getCurrentUser() as Promise<CurrentUser>
userLoader.prime(
'me',
// @ts-expect-error although not reflected in typings, priming with a promise is indeed supported, see https://github.com/graphql/dataloader/issues/235#issuecomment-692495153 and this PR for fixing it https://github.com/graphql/dataloader/pull/252
currentUserPromise.then((u) => normalizeOwnUser(u))
)
return currentUserPromise
}).pipe(
tap((user) => {
if (user) {
// prime the data loader cache with the id of current user
userLoader.prime(user.id, normalizeOwnUser(user))
}
})
)
}

function logout(): Promise<null> {
return authenticationFetcher.logout().then(
() => userChannel.publish(null),
(err) => errorChannel.publish(err)
const currentUser: Observable<CurrentUser | null> = merge(
fetchCurrentUser(), // initial fetch
refresh$.pipe(switchMap(() => fetchCurrentUser())), // re-fetch as response to request to refresh current user
logout$.pipe(
mergeMap(() => authenticationFetcher.logout()),
mapTo(null)
)
}

const currentUser = new Observable<CurrentUserEvent>((observer) => {
if (_initialFetched) {
const emitter = _currentError ? emitError : emitSnapshot
emitter(_currentError || _currentUser)
} else {
_initialFetched = true
fetchInitial()
}

const unsubUser = userChannel.subscribe((nextUser) => emitSnapshot(nextUser))
const unsubError = errorChannel.subscribe((err) => emitError(err))
const unsubscribe = () => {
unsubUser()
unsubError()
}

return unsubscribe

function emitError(error) {
observer.next({type: 'error', error})
}
).pipe(shareReplay({refCount: true, bufferSize: 1}))

function emitSnapshot(user) {
observer.next({type: 'snapshot', user})
}
})

const userLoader = new DataLoader(loadUsers, {
batchScheduleFn: (cb) => raf(cb),
})

async function loadUsers(userIds: readonly string[]): Promise<(User | null)[]> {
const missingIds = userIds.filter((userId) => !(userId in userCache))
let users: User[] = []
if (missingIds.length > 0) {
users = await versionedClient
.request({
uri: `/users/${missingIds.join(',')}`,
withCredentials: true,
tag: 'users.get',
})
.then(arrayify)

users.forEach((user) => {
userCache[user.id] = user
})
}

return userIds.map((userId) => {
// Try cache first
if (userCache[userId]) {
return userCache[userId]
}
const normalizedCurrentUser = currentUser.pipe(
map((user) => (user ? normalizeOwnUser(user) : user))
)

// Look up from returned users
return users.find((user) => user.id === userId) || null
function fetchApiEndpoint<T>(endpoint: string, {tag}: {tag: string}): Promise<T> {
return sanityClient.request({
uri: endpoint,
withCredentials: true,
tag,
})
}

function getUser(userId: string): Promise<User | null> {
return userLoader.load(userId)
function getUser(userId: string): Observable<User | null> {
return userId === 'me' ? normalizedCurrentUser : from(userLoader.load(userId))
}

async function getUsers(ids: string[]): Promise<User[]> {
const users = await userLoader.loadMany(ids)
return users.filter(isUser)
}

function arrayify(users: User | User[]): User[] {
return Array.isArray(users) ? users : [users]
}

function isUser(thing: unknown): thing is User {
return Boolean(thing && thing !== null && typeof (thing as User).id === 'string')
function arrayify<T>(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value]
}

function normalizeOwnUser(user: CurrentUser): User {
Expand All @@ -127,26 +82,52 @@ function normalizeOwnUser(user: CurrentUser): User {
}
}

const observableApi = {
currentUser,
function isUser(thing: any): thing is User {
return Boolean(typeof thing?.id === 'string')
}

getUser: (userId: string): Observable<User | null> =>
typeof userCache[userId] === 'undefined' ? from(getUser(userId)) : of(userCache[userId]),
const currentUserEvents = currentUser.pipe(
map((user): CurrentUserSnapshot => ({type: 'snapshot', user})),
catchError((error: Error) => of({type: 'error', error} as const))
)

let warned = false
function getDeprecatedCurrentUserEvents() {
if (!warned) {
console.warn(
`userStore.currentUser is deprecated. Instead use \`userStore.me\`, which is an observable of the current user (or null if not logged in). ${generateHelpUrl(
'studio-user-store-currentuser-deprecated'
)}`
)
warned = true
}
return currentUserEvents
}

getUsers: (userIds: string[]): Observable<User[]> => {
const missingIds = userIds.filter((userId) => !(userId in userCache))
return missingIds.length === 0
? of(userIds.map((userId) => userCache[userId]).filter(isUser))
: from(getUsers(userIds))
const observableApi = {
me: currentUser,
getCurrentUser: () => currentUser.pipe(take(1)),
getUser: getUser,
getUsers: (userIds: string[]) => from(getUsers(userIds)),
get currentUser() {
return getDeprecatedCurrentUserEvents()
},
}

export default function createUserStore() {
export default function createUserStore(): UserStore {
return {
actions: {logout, retry: fetchInitial},
currentUser,
getUser,
actions: {logout, retry: refresh},
me: currentUser,
getCurrentUser() {
return currentUser.pipe(take(1)).toPromise()
},
getUser(id: string) {
return getUser(id).pipe(take(1)).toPromise()
},
getUsers,
get currentUser() {
return getDeprecatedCurrentUserEvents()
},
observable: observableApi,
}
}
7 changes: 6 additions & 1 deletion packages/@sanity/base/src/datastores/user/hooks.ts
@@ -1,7 +1,12 @@
import userStore from 'part:@sanity/base/user'

import {LoadableState, useLoadable} from '../../util/useLoadable'
import {User} from './types'
import {CurrentUser, User} from './types'

export function useUser(userId: string): LoadableState<User> {
return useLoadable(userStore.observable.getUser(userId))
}

export function useCurrentUser(): LoadableState<CurrentUser> {
return useLoadable(userStore.me)
}
3 changes: 3 additions & 0 deletions packages/@sanity/base/src/datastores/user/tsconfig.json
@@ -0,0 +1,3 @@
{
"extends": "../../../tsconfig.strict",
}
25 changes: 23 additions & 2 deletions packages/@sanity/base/src/datastores/user/types.ts
@@ -1,4 +1,4 @@
import createUserStore from './createUserStore'
import type {Observable} from 'rxjs'

export interface CurrentUser {
id: string
Expand All @@ -24,6 +24,27 @@ export interface User {
id: string
displayName?: string
imageUrl?: string
email?: string
}

export type UserStore = ReturnType<typeof createUserStore>
export interface UserStore {
actions: {logout: () => void; retry: () => void}
me: Observable<CurrentUser | null>
getCurrentUser(): Promise<CurrentUser | null>
getUser(userId: string): Promise<User | null>
getUsers: (ids: string[]) => Promise<User[]>

/** @deprecated use userStore.me instead */
currentUser: Observable<CurrentUserEvent>

observable: {
me: Observable<CurrentUser | null>
getUser(userId: string): Observable<User | null>
getCurrentUser(): Observable<CurrentUser | null>
getUsers(userIds: string[]): Observable<User[]>
getUsers(userIds: ('me' | string)[]): Observable<(User | CurrentUser)[]>

/** @deprecated use userStore.me instead */
currentUser: Observable<CurrentUserEvent>
}
}
2 changes: 1 addition & 1 deletion packages/@sanity/base/src/hooks.ts
@@ -1,5 +1,5 @@
export {useDocumentPresence, useGlobalPresence} from './datastores/presence/hooks'
export {useUser} from './datastores/user/hooks'
export {useUser, useCurrentUser} from './datastores/user/hooks'
export {useUserColor} from './user-color/hooks'
export {useTimeAgo} from './time/useTimeAgo'
export {useDocumentValues} from './datastores/document/useDocumentValues'

0 comments on commit 8f78f21

Please sign in to comment.