Skip to content

Commit

Permalink
Add persistent state provider (#1830)
Browse files Browse the repository at this point in the history
* Add persistent state provider

* Catch write error

* Handle read errors, update error msgs

* Fix lint

* Don't provide initial state to loader

* Remove colorMode from shell state

* Idea: hook into persisted context from other files

* Migrate settings to new hook

* Rework persisted state to split individual contexts

* Tweak persisted schema and validation

---------

Co-authored-by: Paul Frazee <[email protected]>
  • Loading branch information
estrattonbailey and pfrazee authored Nov 7, 2023
1 parent bfe196b commit 96d8faf
Show file tree
Hide file tree
Showing 13 changed files with 465 additions and 74 deletions.
51 changes: 35 additions & 16 deletions src/App.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {QueryClientProvider} from '@tanstack/react-query'

import 'view/icons'

import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import {ThemeProvider} from 'lib/ThemeContext'
import {s} from 'lib/styles'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
Expand All @@ -23,7 +25,8 @@ import {Provider as ShellStateProvider} from 'state/shell'

SplashScreen.preventAutoHideAsync()

const App = observer(function AppImpl() {
const InnerApp = observer(function AppImpl() {
const colorMode = useColorMode()
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
Expand All @@ -44,24 +47,40 @@ const App = observer(function AppImpl() {
if (!rootStore) {
return null
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
)
})

function App() {
const [isReady, setReady] = useState(false)

React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])

if (!isReady) {
return null
}

return (
<ShellStateProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
<InnerApp />
</ShellStateProvider>
)
})
}

export default App
51 changes: 35 additions & 16 deletions src/App.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {RootSiblingParent} from 'react-native-root-siblings'

import 'view/icons'

import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import * as analytics from 'lib/analytics/analytics'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from 'view/shell/index'
Expand All @@ -16,7 +18,8 @@ import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query'
import {Provider as ShellStateProvider} from 'state/shell'

const App = observer(function AppImpl() {
const InnerApp = observer(function AppImpl() {
const colorMode = useColorMode()
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
Expand All @@ -34,24 +37,40 @@ const App = observer(function AppImpl() {
return null
}

return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<ToastContainer />
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
)
})

function App() {
const [isReady, setReady] = useState(false)

React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])

if (!isReady) {
return null
}

return (
<ShellStateProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<ToastContainer />
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</QueryClientProvider>
<InnerApp />
</ShellStateProvider>
)
})
}

export default App
4 changes: 0 additions & 4 deletions src/state/models/root-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ export class RootStoreModel {
session: this.session.serialize(),
me: this.me.serialize(),
onboarding: this.onboarding.serialize(),
shell: this.shell.serialize(),
preferences: this.preferences.serialize(),
invitedUsers: this.invitedUsers.serialize(),
mutedThreads: this.mutedThreads.serialize(),
Expand All @@ -99,9 +98,6 @@ export class RootStoreModel {
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}
if (hasProp(v, 'shell')) {
this.shell.hydrate(v.shell)
}
if (hasProp(v, 'preferences')) {
this.preferences.hydrate(v.preferences)
}
Expand Down
30 changes: 0 additions & 30 deletions src/state/models/ui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {makeAutoObservable, runInAction} from 'mobx'
import {ProfileModel} from '../content/profile'
import {isObj, hasProp} from 'lib/type-guards'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '../media/image'
import {ListModel} from '../content/list'
import {GalleryModel} from '../media/gallery'
import {StyleProp, ViewStyle} from 'react-native'
import {isWeb} from 'platform/detection'

export type ColorMode = 'system' | 'light' | 'dark'

Expand Down Expand Up @@ -265,7 +263,6 @@ export interface ComposerOpts {
}

export class ShellUiModel {
colorMode: ColorMode = 'system'
isModalActive = false
activeModals: Modal[] = []
isLightboxActive = false
Expand All @@ -276,40 +273,13 @@ export class ShellUiModel {

constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
serialize: false,
rootStore: false,
hydrate: false,
})

this.setupClock()
this.setupLoginModals()
}

serialize(): unknown {
return {
colorMode: this.colorMode,
}
}

hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'colorMode') && isColorMode(v.colorMode)) {
this.setColorMode(v.colorMode)
}
}
}

setColorMode(mode: ColorMode) {
this.colorMode = mode

if (isWeb && typeof window !== 'undefined') {
const html = window.document.documentElement
// remove any other color mode classes
html.className = html.className.replace(/colorMode--\w+/g, '')
html.classList.add(`colorMode--${mode}`)
}
}

/**
* returns true if something was closed
* (used by the android hardware back btn)
Expand Down
6 changes: 6 additions & 0 deletions src/state/persisted/broadcast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default class BroadcastChannel {
constructor(public name: string) {}
postMessage(_data: any) {}
close() {}
onmessage: (event: MessageEvent) => void = () => {}
}
1 change: 1 addition & 0 deletions src/state/persisted/broadcast/index.web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default BroadcastChannel
91 changes: 91 additions & 0 deletions src/state/persisted/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import EventEmitter from 'eventemitter3'
import {logger} from '#/logger'
import {defaults, Schema} from '#/state/persisted/schema'
import {migrate} from '#/state/persisted/legacy'
import * as store from '#/state/persisted/store'
import BroadcastChannel from '#/state/persisted/broadcast'

export type {Schema} from '#/state/persisted/schema'
export {defaults as schema} from '#/state/persisted/schema'

const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
const UPDATE_EVENT = 'BSKY_UPDATE'

let _state: Schema = defaults
const _emitter = new EventEmitter()

/**
* Initializes and returns persisted data state, so that it can be passed to
* the Provider.
*/
export async function init() {
logger.debug('persisted state: initializing')

broadcast.onmessage = onBroadcastMessage

try {
await migrate() // migrate old store
const stored = await store.read() // check for new store
if (!stored) await store.write(defaults) // opt: init new store
_state = stored || defaults // return new store
} catch (e) {
logger.error('persisted state: failed to load root state from storage', {
error: e,
})
// AsyncStorage failured, but we can still continue in memory
return defaults
}
}

export function get<K extends keyof Schema>(key: K): Schema[K] {
return _state[key]
}

export async function write<K extends keyof Schema>(
key: K,
value: Schema[K],
): Promise<void> {
try {
_state[key] = value
await store.write(_state)
// must happen on next tick, otherwise the tab will read stale storage data
setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
logger.debug(`persisted state: wrote root state to storage`)
} catch (e) {
logger.error(`persisted state: failed writing root state to storage`, {
error: e,
})
}
}

export function onUpdate(cb: () => void): () => void {
_emitter.addListener('update', cb)
return () => _emitter.removeListener('update', cb)
}

async function onBroadcastMessage({data}: MessageEvent) {
// validate event
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
try {
// read next state, possibly updated by another tab
const next = await store.read()

if (next) {
logger.debug(`persisted state: handling update from broadcast channel`)
_state = next
_emitter.emit('update')
} else {
logger.error(
`persisted state: handled update update from broadcast channel, but found no data`,
)
}
} catch (e) {
logger.error(
`persisted state: failed handling update from broadcast channel`,
{
error: e,
},
)
}
}
}
Loading

0 comments on commit 96d8faf

Please sign in to comment.