Skip to content

Commit

Permalink
[Persisted] Make broadcast subscriptions granular by key (bluesky-soc…
Browse files Browse the repository at this point in the history
…ial#4874)

* Add fast path for guaranteed noop updates

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

* Implement granular broadcast listeners
  • Loading branch information
gaearon authored Aug 6, 2024
1 parent 966f6c5 commit 686d5eb
Show file tree
Hide file tree
Showing 17 changed files with 95 additions and 42 deletions.
5 changes: 3 additions & 2 deletions src/state/invites.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react'

import * as persisted from '#/state/persisted'

type StateContext = persisted.Schema['invites']
Expand Down Expand Up @@ -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])

Expand Down
5 changes: 4 additions & 1 deletion src/state/persisted/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
41 changes: 35 additions & 6 deletions src/state/persisted/index.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand All @@ -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`,
Expand Down
5 changes: 4 additions & 1 deletion src/state/persisted/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
}
9 changes: 6 additions & 3 deletions src/state/preferences/alt-text-required.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions src/state/preferences/autoplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
4 changes: 2 additions & 2 deletions src/state/preferences/disable-haptics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
4 changes: 2 additions & 2 deletions src/state/preferences/external-embeds-prefs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
4 changes: 2 additions & 2 deletions src/state/preferences/hidden-posts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
4 changes: 2 additions & 2 deletions src/state/preferences/in-app-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
4 changes: 2 additions & 2 deletions src/state/preferences/kawaii.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
4 changes: 2 additions & 2 deletions src/state/preferences/languages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
9 changes: 6 additions & 3 deletions src/state/preferences/large-alt-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
9 changes: 6 additions & 3 deletions src/state/preferences/used-starter-packs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions src/state/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
13 changes: 10 additions & 3 deletions src/state/shell/color-mode.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react'

import * as persisted from '#/state/persisted'

type StateContext = {
Expand Down Expand Up @@ -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 (
Expand Down
9 changes: 5 additions & 4 deletions src/state/shell/onboarding.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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,
})
}
})
Expand Down

0 comments on commit 686d5eb

Please sign in to comment.