[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 listenerszio/stable
parent
966f6c511f
commit
686d5ebb53
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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']
|
||||||
|
|
|
@ -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`,
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue