Skip to content
61 changes: 46 additions & 15 deletions frontend/apps/desktop/src/__tests__/app-sync-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import {queryKeys} from '@shm/shared/models/query-keys'
// ---- Mocks for heavy Electron/gRPC deps ----

const appInvalidateQueriesMock = vi.fn()
const appInvalidateAccountAndAliasesMock = vi.fn()

vi.mock('../app-invalidation', () => ({
appInvalidateQueries: appInvalidateQueriesMock,
appInvalidateAccountAndAliases: appInvalidateAccountAndAliasesMock,
getInvalidationHandlerCount: () => 0,
}))

vi.mock('../app-focus', () => ({
Expand Down Expand Up @@ -82,10 +85,12 @@ describe('activity event processing', () => {
})

describe('getUnconditionalInvalidations', () => {
it('returns blanket ACCOUNT and LIST_ACCOUNTS invalidation for Profile events', async () => {
it('returns LIST_ACCOUNTS invalidation for Profile events (per-uid handled separately)', async () => {
const {getUnconditionalInvalidations} = await loadModule()
const event = makeBlobEvent('Profile', 'hm://z6MkUser123')
expect(getUnconditionalInvalidations(event)).toEqual([[queryKeys.ACCOUNT], [queryKeys.LIST_ACCOUNTS]])
// Targeted per-uid invalidation goes through appInvalidateAccountAndAliases,
// not the queryKey path. LIST_ACCOUNTS is the only queryKey-based fallout.
expect(getUnconditionalInvalidations(event)).toEqual([[queryKeys.LIST_ACCOUNTS]])
})

it('returns empty for non-Profile non-Capability events', async () => {
Expand All @@ -94,30 +99,56 @@ describe('activity event processing', () => {
expect(getUnconditionalInvalidations(makeBlobEvent('Comment', 'hm://z6MkOwner/doc'))).toEqual([])
expect(getUnconditionalInvalidations(makeBlobEvent('Contact', 'hm://z6MkOwner'))).toEqual([])
})
})

it('returns blanket ACCOUNT invalidation regardless of resource', async () => {
const {getUnconditionalInvalidations} = await loadModule()
// Aliases make per-uid targeting unreliable, so Profile events blanket-invalidate
describe('getProfileTargetUids', () => {
it('extracts the uid from a profile event resource IRI', async () => {
const {getProfileTargetUids} = await loadModule()
const event = makeBlobEvent('Profile', 'hm://z6MkUser123')
expect(getProfileTargetUids([event])).toEqual(['z6MkUser123'])
})

it('strips the version query string before unpacking', async () => {
const {getProfileTargetUids} = await loadModule()
const event = makeBlobEvent('Profile', 'hm://z6MkUser?v=abc')
expect(getUnconditionalInvalidations(event)).toEqual([[queryKeys.ACCOUNT], [queryKeys.LIST_ACCOUNTS]])
expect(getProfileTargetUids([event])).toEqual(['z6MkUser'])
})

it('dedupes uids across multiple profile events', async () => {
const {getProfileTargetUids} = await loadModule()
const events = [makeBlobEvent('Profile', 'hm://z6MkUser'), makeBlobEvent('Profile', 'hm://z6MkUser?v=newver')]
expect(getProfileTargetUids(events)).toEqual(['z6MkUser'])
})

it('skips non-profile events', async () => {
const {getProfileTargetUids} = await loadModule()
const events = [makeBlobEvent('Ref', 'hm://z6MkUser/doc'), makeBlobEvent('Comment', 'hm://z6MkUser/doc')]
expect(getProfileTargetUids(events)).toEqual([])
})

it('skips profile events with no resource', async () => {
const {getProfileTargetUids} = await loadModule()
expect(getProfileTargetUids([makeBlobEvent('Profile', '')])).toEqual([])
})
})

describe('processEvents integration', () => {
it('blanket-invalidates all ACCOUNT queries for Profile events (covers aliases)', async () => {
it('targets the resource uid for Profile events via the alias-aware bridge', async () => {
const {processEvents} = await loadModule()
processEvents([makeBlobEvent('Profile', 'hm://z6MkProfileUser')])
// Blanket [ACCOUNT] prefix catches aliases (A→B means [ACCOUNT, A] stores B's data)
expect(appInvalidateQueriesMock).toHaveBeenCalledWith([queryKeys.ACCOUNT])
// No more blanket [ACCOUNT] — per-uid invalidation routes through
// appInvalidateAccountAndAliases so renderers can scan their caches.
expect(appInvalidateAccountAndAliasesMock).toHaveBeenCalledWith('z6MkProfileUser')
expect(appInvalidateQueriesMock).not.toHaveBeenCalledWith([queryKeys.ACCOUNT])
expect(appInvalidateQueriesMock).toHaveBeenCalledWith([queryKeys.LIST_ACCOUNTS])
})

it('blanket-invalidates ACCOUNT once even for multiple Profile events', async () => {
it('fires one targeted invalidation per unique profile uid', async () => {
const {processEvents} = await loadModule()
processEvents([makeBlobEvent('Profile', 'hm://z6MkUserA'), makeBlobEvent('Profile', 'hm://z6MkUserB')])
// Single blanket invalidation covers both profiles + any aliases
expect(appInvalidateQueriesMock).toHaveBeenCalledWith([queryKeys.ACCOUNT])
expect(appInvalidateQueriesMock).toHaveBeenCalledWith([queryKeys.LIST_ACCOUNTS])
expect(appInvalidateAccountAndAliasesMock).toHaveBeenCalledWith('z6MkUserA')
expect(appInvalidateAccountAndAliasesMock).toHaveBeenCalledWith('z6MkUserB')
expect(appInvalidateAccountAndAliasesMock).toHaveBeenCalledTimes(2)
})

it('invalidates listing and feed caches for Ref events', async () => {
Expand Down Expand Up @@ -151,8 +182,8 @@ describe('activity event processing', () => {
makeBlobEvent('Profile', 'hm://z6MkUserC'),
makeBlobEvent('Comment', 'hm://z6MkOwner/doc'),
])
// Profile → blanket ACCOUNT + LIST_ACCOUNTS
expect(appInvalidateQueriesMock).toHaveBeenCalledWith([queryKeys.ACCOUNT])
// Profile → targeted per-uid alias-aware invalidation + LIST_ACCOUNTS
expect(appInvalidateAccountAndAliasesMock).toHaveBeenCalledWith('z6MkUserC')
expect(appInvalidateQueriesMock).toHaveBeenCalledWith([queryKeys.LIST_ACCOUNTS])
// Ref → listing caches
expect(appInvalidateQueriesMock).toHaveBeenCalledWith([queryKeys.LIBRARY])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
const updateProfileMock = vi.hoisted(() => vi.fn())
const fileUploadMock = vi.hoisted(() => vi.fn())
const invalidateQueriesMock = vi.hoisted(() => vi.fn())
const invalidateAccountAndAliasesEverywhereMock = vi.hoisted(() => vi.fn())
const useAccountMock = vi.hoisted(() => vi.fn())
const toastSuccessMock = vi.hoisted(() => vi.fn())
const capturedFormProps = vi.hoisted(() => ({current: null as any}))
Expand Down Expand Up @@ -37,6 +38,7 @@ vi.mock('@shm/shared/models/entity', () => ({

vi.mock('@shm/shared/models/query-client', () => ({
invalidateQueries: invalidateQueriesMock,
invalidateAccountAndAliasesEverywhere: invalidateAccountAndAliasesEverywhereMock,
}))

vi.mock('@shm/ui/components/dialog', async () => {
Expand Down Expand Up @@ -112,6 +114,7 @@ describe('EditProfileDialog', () => {
updateProfileMock.mockResolvedValue(undefined)
fileUploadMock.mockReset()
invalidateQueriesMock.mockReset()
invalidateAccountAndAliasesEverywhereMock.mockReset()
useAccountMock.mockReset()
toastSuccessMock.mockReset()
capturedFormProps.current = null
Expand Down Expand Up @@ -157,7 +160,7 @@ describe('EditProfileDialog', () => {
},
signingKeyName: ACCOUNT_UID,
})
expect(invalidateQueriesMock).toHaveBeenCalledWith(['ACCOUNT', ACCOUNT_UID])
expect(invalidateAccountAndAliasesEverywhereMock).toHaveBeenCalledWith(ACCOUNT_UID)
expect(invalidateQueriesMock).toHaveBeenCalledWith(['LIST_ACCOUNTS'])
expect(toastSuccessMock).toHaveBeenCalledWith('Profile updated')
expect(onClose).toHaveBeenCalledTimes(1)
Expand Down
3 changes: 2 additions & 1 deletion frontend/apps/desktop/src/app-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {notificationReadApi, startNotificationReadBackgroundSync} from './app-no
import {notificationInboxApi, startNotificationInboxBackgroundIngestor} from './app-notification-inbox'
import {gatewaySettingsApi} from './app-gateway-settings'
import {hostApi} from './app-host'
import {appInvalidateQueries, queryInvalidation} from './app-invalidation'
import {accountInvalidation, appInvalidateQueries, queryInvalidation} from './app-invalidation'
import {userDataPath} from './app-paths'
import {promptingApi} from './app-prompting'
import {recentSignersApi} from './app-recent-signers'
Expand Down Expand Up @@ -422,6 +422,7 @@ export const router = t.router({
}),

queryInvalidation,
accountInvalidation,

getDaemonInfo: t.procedure.query(async () => {
const buildInfoUrl = `${DAEMON_HTTP_URL}/debug/buildinfo`
Expand Down
23 changes: 23 additions & 0 deletions frontend/apps/desktop/src/app-invalidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {observable} from '@trpc/server/observable'
import {t} from './app-trpc'

const invalidationHandlers = new Set<(queryKey: any) => void>()
const accountInvalidationHandlers = new Set<(uid: string) => void>()

const PROFILE_ENABLED = process.env.SEED_SYNC_PROFILE === '1'

Expand Down Expand Up @@ -45,3 +46,25 @@ export const queryInvalidation = t.procedure.subscription(() => {
}
})
})

/**
* Trigger a targeted account-and-aliases invalidation across every renderer.
* Each renderer's `accountInvalidation` subscriber receives the uid and runs
* the local cache scan, so accounts aliased to `uid` get refreshed even if
* different windows have different cached aliases.
*/
export function appInvalidateAccountAndAliases(uid: string) {
accountInvalidationHandlers.forEach((handler) => handler(uid))
}

export const accountInvalidation = t.procedure.subscription(() => {
return observable<string>((emit) => {
function handler(uid: string) {
emit.next(uid)
}
accountInvalidationHandlers.add(handler)
return () => {
accountInvalidationHandlers.delete(handler)
}
})
})
54 changes: 34 additions & 20 deletions frontend/apps/desktop/src/app-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {StateStream, writeableStateStream} from '@shm/shared/utils/stream'
import {observable} from '@trpc/server/observable'
import z from 'zod'
import {isAnyWindowFocused, onAppFocusChange} from './app-focus'
import {appInvalidateQueries, getInvalidationHandlerCount} from './app-invalidation'
import {appInvalidateAccountAndAliases, appInvalidateQueries, getInvalidationHandlerCount} from './app-invalidation'
import {t} from './app-trpc'

// ============ Profile instrumentation ============
Expand Down Expand Up @@ -66,16 +66,6 @@ async function timeAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
}
}

function getPriorityBreakdown(): {high: number; normal: number} {
let high = 0
let normal = 0
state.subscriptionCounts.forEach((_count, key) => {
if (key.includes('!high')) high++
else normal++
})
return {high, normal}
}

// Polling intervals (base values, multiplied by getPollingMultiplier())
const DISCOVERY_POLL_INTERVAL_MS = 14_000
const ACTIVITY_POLL_INTERVAL_MS = 1_000
Expand Down Expand Up @@ -249,11 +239,10 @@ function flushInvalidations() {
state.debounceTimer = null
const resources = Array.from(state.pendingInvalidations)
if (PROFILE_ENABLED) {
const {high, normal} = getPriorityBreakdown()
profileLog(
`flushInvalidations: pending=${resources.length} subs=${
state.subscriptions.size
} (high=${high} normal=${normal}) handlers=${getInvalidationHandlerCount()}`,
} handlers=${getInvalidationHandlerCount()}`,
)
}
timeSync('flushInvalidations', () => {
Expand Down Expand Up @@ -331,9 +320,11 @@ export function getUnconditionalInvalidations(event: Event): Array<string[]> {
const blobType = event.data.value.blobType?.toLowerCase()
const resource = event.data.value.resource

// Profile events blanket-invalidate all ACCOUNT queries (aliases make per-uid targeting unreliable)
// Profile events: refresh the global account list. Per-uid account
// invalidation is handled out-of-band via appInvalidateAccountAndAliases
// because it requires a renderer-side cache scan (the main process has no
// React Query cache to inspect).
if (blobType === 'profile') {
invalidations.push([queryKeys.ACCOUNT])
invalidations.push([queryKeys.LIST_ACCOUNTS])
}

Expand All @@ -347,6 +338,25 @@ export function getUnconditionalInvalidations(event: Event): Array<string[]> {
return invalidations
}

/**
* Pulls the resource-uid (the account whose profile changed) out of profile
* blob events. Use the `resource` IRI rather than `author` because for
* delegated profiles the signer differs from the profile owner. Exported
* for testing.
*/
export function getProfileTargetUids(events: Event[]): string[] {
const targets = new Set<string>()
for (const event of events) {
if (event.data.case !== 'newBlob') continue
if (event.data.value.blobType?.toLowerCase() !== 'profile') continue
const resource = event.data.value.resource
if (!resource) continue
const id = unpackHmId(resource.split('?')[0] || '')
if (id) targets.add(id.uid)
}
return Array.from(targets)
}

/** Processes activity events and fires invalidations. Exported for testing. */
export function processEvents(events: Event[]) {
return timeSync(`processEvents(${events.length})`, () => processEventsInner(events))
Expand Down Expand Up @@ -398,12 +408,16 @@ function processEventsInner(events: Event[]) {

// ── Second pass: batched invalidations by event type ──

// Profile changes: blanket-invalidate all ACCOUNT queries + account list.
// We can't target specific UIDs because accounts may be aliases (A→B):
// when B updates, [ACCOUNT, A] must also be invalidated since it resolves to B's data.
// A blanket [ACCOUNT] prefix invalidation catches all aliases.
// Profile changes: targeted per-uid invalidation. We extract the profile
// owner from `event.data.value.resource` (the IRI), not `author`, because
// for delegated profiles the signer ≠ owner. `appInvalidateAccountAndAliases`
// broadcasts each uid to every renderer, where a local cache scan finds
// [ACCOUNT, *] entries whose `profileOwner` matches and invalidates them
// alongside the canonical [ACCOUNT, uid] key.
if (seenBlobTypes.has('profile')) {
appInvalidateQueries([queryKeys.ACCOUNT])
for (const uid of getProfileTargetUids(events)) {
appInvalidateAccountAndAliases(uid)
}
appInvalidateQueries([queryKeys.LIST_ACCOUNTS])
}

Expand Down
4 changes: 2 additions & 2 deletions frontend/apps/desktop/src/components/edit-profile-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {grpcClient} from '@/grpc-client'
import {fileUpload} from '@/utils/file-upload'
import {queryKeys} from '@shm/shared'
import {useAccount} from '@shm/shared/models/entity'
import {invalidateQueries} from '@shm/shared/models/query-client'
import {invalidateAccountAndAliasesEverywhere, invalidateQueries} from '@shm/shared/models/query-client'
import {DialogTitle} from '@shm/ui/components/dialog'
import {EditProfileForm, SiteMetaFields} from '@shm/ui/edit-profile-form'
import {Spinner} from '@shm/ui/spinner'
Expand Down Expand Up @@ -37,7 +37,7 @@ export function EditProfileDialog({onClose, input}: {onClose: () => void; input:
signingKeyName: accountUid,
})

invalidateQueries([queryKeys.ACCOUNT, accountUid])
invalidateAccountAndAliasesEverywhere(accountUid)
invalidateQueries([queryKeys.LIST_ACCOUNTS])

toast.success('Profile updated')
Expand Down
4 changes: 2 additions & 2 deletions frontend/apps/desktop/src/desktop-universal-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export const desktopUniversalClient: UniversalClient = {
request: seedClient.request as UniversalClient['request'],
publish: seedClient.publish,

subscribeEntity: ({id, recursive, priority, scope}) => {
const sub = {id, recursive, priority, scope}
subscribeEntity: ({id, recursive, scope}) => {
const sub = {id, recursive, scope}
addSubscribedEntity(sub)
return () => removeSubscribedEntity(sub)
},
Expand Down
8 changes: 7 additions & 1 deletion frontend/apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import path from 'node:path'

import {dispatchFocusedWindowAppEvent, handleSecondInstance, handleUrlOpen, openInitialWindows, trpc} from './app-api'
import {grpcClient} from './app-grpc'
import {appInvalidateQueries} from './app-invalidation'
import {appInvalidateAccountAndAliases, appInvalidateQueries} from './app-invalidation'
import {createAppMenu} from './app-menu'
import {initPaths} from './app-paths'
import {
Expand Down Expand Up @@ -475,6 +475,12 @@ function initializeIpcHandlers() {
appInvalidateQueries(info)
})

ipcMain.on('invalidate_account_and_aliases', (_event, uid: unknown) => {
if (typeof uid === 'string' && uid) {
appInvalidateAccountAndAliases(uid)
}
})

ipcMain.on('focusedWindowAppEvent', (_event, info) => {
dispatchFocusedWindowAppEvent(info)
})
Expand Down
11 changes: 2 additions & 9 deletions frontend/apps/desktop/src/models/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,6 @@ export function cleanupAllEntitySubscriptions() {
export type EntitySubscription = {
id?: UnpackedHypermediaId | null
recursive?: boolean
/** `'high'` polls faster (3s while focused) for the active document. */
priority?: 'normal' | 'high'
/** Discovery scope. `'profile'` only fetches profile blobs (name + icon). */
scope?: 'all' | 'profile'
}
Expand All @@ -257,9 +255,7 @@ function emitSubscriptionKeys() {
const keys = new Set<string>()
for (const key of Object.keys(entitySubscriptionCounts)) {
if (entitySubscriptionCounts[key] > 0) {
// Strip !high priority suffix — it's an internal detail, not relevant for display
const displayKey = key.replace('!high', '')
keys.add(displayKey)
keys.add(key)
}
}
const sorted = Array.from(keys).sort()
Expand All @@ -269,11 +265,8 @@ function emitSubscriptionKeys() {
function getEntitySubscriptionKey(sub: EntitySubscription) {
const {id, recursive} = sub
if (!id) return null
// Priority is part of the key so a normal-priority sub doesn't shadow a
// high-priority one (or vice versa) when both are added concurrently.
const priorityKey = sub.priority === 'high' ? '!high' : ''
const scopeKey = sub.scope === 'profile' ? ':profile' : ''
return id.id + (recursive ? '/*' : '') + priorityKey + scopeKey
return id.id + (recursive ? '/*' : '') + scopeKey
}

export function addSubscribedEntity(sub: EntitySubscription) {
Expand Down
2 changes: 0 additions & 2 deletions frontend/apps/desktop/src/pages/desktop-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {renderDesktopInlineEditor, triggerCommentDraftFocus} from '@/components/
import {useCopyReferenceUrl} from '@/components/copy-reference-url'
import {DesktopDocumentActionsProvider} from '@/components/document-actions-provider'
import {useGatewayUrl} from '@/models/gateway-settings'
import {useHackyAuthorsSubscriptions} from '@/use-hacky-authors-subscriptions'
import {useNavigate} from '@/utils/useNavigate'
import {hmId} from '@shm/shared'
import {CommentsProvider, isRouteEqualToCommentTarget} from '@shm/shared/comments-service-provider'
Expand Down Expand Up @@ -126,7 +125,6 @@ export default function DesktopFeedPage() {
return (
<div className="h-full max-h-full overflow-hidden rounded-lg border bg-white">
<CommentsProvider
useHackyAuthorsSubscriptions={useHackyAuthorsSubscriptions}
onReplyClick={onReplyClick}
onReplyCountClick={onReplyCountClick}
renderInlineEditor={renderDesktopInlineEditor}
Expand Down
Loading
Loading