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

View File

@ -41,7 +41,10 @@ export async function write<K extends keyof Schema>(
} }
write satisfies PersistedApi['write'] 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 () => {} return () => {}
} }
onUpdate satisfies PersistedApi['onUpdate'] 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. // 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. // 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 = {
..._state, ..._state,
[key]: value, [key]: value,
} }
writeToStorage(_state) 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'] write satisfies PersistedApi['write']
export function onUpdate(cb: () => void): () => void { export function onUpdate<K extends keyof Schema>(
_emitter.addListener('update', cb) key: K,
return () => _emitter.removeListener('update', cb) 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'] onUpdate satisfies PersistedApi['onUpdate']
@ -72,12 +90,23 @@ export async function clearStorage() {
clearStorage satisfies PersistedApi['clearStorage'] clearStorage satisfies PersistedApi['clearStorage']
async function onBroadcastMessage({data}: MessageEvent) { 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 // read next state, possibly updated by another tab
const next = readFromStorage() const next = readFromStorage()
if (next === _state) {
return
}
if (next) { if (next) {
_state = 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 { } else {
logger.error( logger.error(
`persisted state: handled update update from broadcast channel, but found no data`, `persisted state: handled update update from broadcast channel, but found no data`,

View File

@ -4,6 +4,9 @@ export type PersistedApi = {
init(): Promise<void> init(): Promise<void>
get<K extends keyof Schema>(key: K): Schema[K] get<K extends keyof Schema>(key: K): Schema[K]
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void> 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> clearStorage: () => Promise<void>
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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