[Persisted] Make broadcast subscriptions granular by key (#4874)

* Add fast path for guaranteed noop updates

* Change persisted.onUpdate() API to take a key

* Implement granular broadcast listeners
zio/stable
dan 2024-08-06 01:30:52 +01:00 committed by GitHub
parent 966f6c511f
commit 686d5ebb53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 95 additions and 42 deletions

View File

@ -1,4 +1,5 @@
import React from 'react'
import * as persisted from '#/state/persisted'
type StateContext = persisted.Schema['invites']
@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('invites'))
return persisted.onUpdate('invites', nextInvites => {
setState(nextInvites)
})
}, [setState])

View File

@ -41,7 +41,10 @@ export async function write<K extends keyof Schema>(
}
write satisfies PersistedApi['write']
export function onUpdate(_cb: () => void): () => void {
export function onUpdate<K extends keyof Schema>(
_key: K,
_cb: (v: Schema[K]) => void,
): () => void {
return () => {}
}
onUpdate satisfies PersistedApi['onUpdate']

View File

@ -47,18 +47,36 @@ export async function write<K extends keyof Schema>(
// Don't fire the update listeners yet to avoid a loop.
// If there was a change, we'll receive the broadcast event soon enough which will do that.
}
try {
if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
// Fast path for updates that are guaranteed to be noops.
// This is good mostly because it avoids useless broadcasts to other tabs.
return
}
} catch (e) {
// Ignore and go through the normal path.
}
_state = {
..._state,
[key]: value,
}
writeToStorage(_state)
broadcast.postMessage({event: UPDATE_EVENT})
broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
}
write satisfies PersistedApi['write']
export function onUpdate(cb: () => void): () => void {
_emitter.addListener('update', cb)
return () => _emitter.removeListener('update', cb)
export function onUpdate<K extends keyof Schema>(
key: K,
cb: (v: Schema[K]) => void,
): () => void {
const listener = () => cb(get(key))
_emitter.addListener('update', listener) // Backcompat while upgrading
_emitter.addListener('update:' + key, listener)
return () => {
_emitter.removeListener('update', listener) // Backcompat while upgrading
_emitter.removeListener('update:' + key, listener)
}
}
onUpdate satisfies PersistedApi['onUpdate']
@ -72,12 +90,23 @@ export async function clearStorage() {
clearStorage satisfies PersistedApi['clearStorage']
async function onBroadcastMessage({data}: MessageEvent) {
if (typeof data === 'object' && data.event === UPDATE_EVENT) {
if (
typeof data === 'object' &&
(data.event === UPDATE_EVENT || // Backcompat while upgrading
data.event?.type === UPDATE_EVENT)
) {
// read next state, possibly updated by another tab
const next = readFromStorage()
if (next === _state) {
return
}
if (next) {
_state = next
_emitter.emit('update')
if (typeof data.event.key === 'string') {
_emitter.emit('update:' + data.event.key)
} else {
_emitter.emit('update') // Backcompat while upgrading
}
} else {
logger.error(
`persisted state: handled update update from broadcast channel, but found no data`,

View File

@ -4,6 +4,9 @@ export type PersistedApi = {
init(): Promise<void>
get<K extends keyof Schema>(key: K): Schema[K]
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
onUpdate(_cb: () => void): () => void
onUpdate<K extends keyof Schema>(
key: K,
cb: (v: Schema[K]) => void,
): () => void
clearStorage: () => Promise<void>
}

View File

@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('requireAltTextEnabled'))
})
return persisted.onUpdate(
'requireAltTextEnabled',
nextRequireAltTextEnabled => {
setState(nextRequireAltTextEnabled)
},
)
}, [setStateWrapped])
return (

View File

@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(Boolean(persisted.get('disableAutoplay')))
return persisted.onUpdate('disableAutoplay', nextDisableAutoplay => {
setState(Boolean(nextDisableAutoplay))
})
}, [setStateWrapped])

View File

@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(Boolean(persisted.get('disableHaptics')))
return persisted.onUpdate('disableHaptics', nextDisableHaptics => {
setState(Boolean(nextDisableHaptics))
})
}, [setStateWrapped])

View File

@ -35,8 +35,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('externalEmbeds'))
return persisted.onUpdate('externalEmbeds', nextExternalEmbeds => {
setState(nextExternalEmbeds)
})
}, [setStateWrapped])

View File

@ -44,8 +44,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('hiddenPosts'))
return persisted.onUpdate('hiddenPosts', nextHiddenPosts => {
setState(nextHiddenPosts)
})
}, [setStateWrapped])

View File

@ -34,8 +34,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('useInAppBrowser'))
return persisted.onUpdate('useInAppBrowser', nextUseInAppBrowser => {
setState(nextUseInAppBrowser)
})
}, [setStateWrapped])

View File

@ -21,8 +21,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('kawaii'))
return persisted.onUpdate('kawaii', nextKawaii => {
setState(nextKawaii)
})
}, [setStateWrapped])

View File

@ -43,8 +43,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('languagePrefs'))
return persisted.onUpdate('languagePrefs', nextLanguagePrefs => {
setState(nextLanguagePrefs)
})
}, [setStateWrapped])

View File

@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('largeAltBadgeEnabled'))
})
return persisted.onUpdate(
'largeAltBadgeEnabled',
nextLargeAltBadgeEnabled => {
setState(nextLargeAltBadgeEnabled)
},
)
}, [setStateWrapped])
return (

View File

@ -19,9 +19,12 @@ export function Provider({children}: {children: React.ReactNode}) {
}
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('hasCheckedForStarterPack'))
})
return persisted.onUpdate(
'hasCheckedForStarterPack',
nextHasCheckedForStarterPack => {
setState(nextHasCheckedForStarterPack)
},
)
}, [])
return (

View File

@ -185,8 +185,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, [state])
React.useEffect(() => {
return persisted.onUpdate(() => {
const synced = persisted.get('session')
return persisted.onUpdate('session', nextSession => {
const synced = nextSession
addSessionDebugLog({type: 'persisted:receive', data: synced})
dispatch({
type: 'synced-accounts',

View File

@ -1,4 +1,5 @@
import React from 'react'
import * as persisted from '#/state/persisted'
type StateContext = {
@ -43,10 +44,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setColorMode(persisted.get('colorMode'))
setDarkTheme(persisted.get('darkTheme'))
const unsub1 = persisted.onUpdate('darkTheme', nextDarkTheme => {
setDarkTheme(nextDarkTheme)
})
const unsub2 = persisted.onUpdate('colorMode', nextColorMode => {
setColorMode(nextColorMode)
})
return () => {
unsub1()
unsub2()
}
}, [])
return (

View File

@ -1,6 +1,7 @@
import React from 'react'
import * as persisted from '#/state/persisted'
import {track} from '#/lib/analytics/analytics'
import * as persisted from '#/state/persisted'
export const OnboardingScreenSteps = {
Welcome: 'Welcome',
@ -81,13 +82,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
return persisted.onUpdate(() => {
const next = persisted.get('onboarding').step
return persisted.onUpdate('onboarding', nextOnboarding => {
const next = nextOnboarding.step
// TODO we've introduced a footgun
if (state.step !== next) {
dispatch({
type: 'set',
step: persisted.get('onboarding').step as OnboardingStep,
step: nextOnboarding.step as OnboardingStep,
})
}
})