diff --git a/frontend/apps/desktop/src/__tests__/app-sync-activity.test.ts b/frontend/apps/desktop/src/__tests__/app-sync-activity.test.ts index 00b24f5bd..423d77bcb 100644 --- a/frontend/apps/desktop/src/__tests__/app-sync-activity.test.ts +++ b/frontend/apps/desktop/src/__tests__/app-sync-activity.test.ts @@ -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', () => ({ @@ -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 () => { @@ -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 () => { @@ -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]) diff --git a/frontend/apps/desktop/src/__tests__/edit-profile-dialog.test.tsx b/frontend/apps/desktop/src/__tests__/edit-profile-dialog.test.tsx index e3c7fb502..b0b47ea07 100644 --- a/frontend/apps/desktop/src/__tests__/edit-profile-dialog.test.tsx +++ b/frontend/apps/desktop/src/__tests__/edit-profile-dialog.test.tsx @@ -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})) @@ -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 () => { @@ -112,6 +114,7 @@ describe('EditProfileDialog', () => { updateProfileMock.mockResolvedValue(undefined) fileUploadMock.mockReset() invalidateQueriesMock.mockReset() + invalidateAccountAndAliasesEverywhereMock.mockReset() useAccountMock.mockReset() toastSuccessMock.mockReset() capturedFormProps.current = null @@ -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) diff --git a/frontend/apps/desktop/src/app-api.ts b/frontend/apps/desktop/src/app-api.ts index 773799aa0..dce38e308 100644 --- a/frontend/apps/desktop/src/app-api.ts +++ b/frontend/apps/desktop/src/app-api.ts @@ -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' @@ -422,6 +422,7 @@ export const router = t.router({ }), queryInvalidation, + accountInvalidation, getDaemonInfo: t.procedure.query(async () => { const buildInfoUrl = `${DAEMON_HTTP_URL}/debug/buildinfo` diff --git a/frontend/apps/desktop/src/app-invalidation.ts b/frontend/apps/desktop/src/app-invalidation.ts index ac7346d60..c0e5b588c 100644 --- a/frontend/apps/desktop/src/app-invalidation.ts +++ b/frontend/apps/desktop/src/app-invalidation.ts @@ -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' @@ -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((emit) => { + function handler(uid: string) { + emit.next(uid) + } + accountInvalidationHandlers.add(handler) + return () => { + accountInvalidationHandlers.delete(handler) + } + }) +}) diff --git a/frontend/apps/desktop/src/app-sync.ts b/frontend/apps/desktop/src/app-sync.ts index ea6ecb76c..2cbd971aa 100644 --- a/frontend/apps/desktop/src/app-sync.ts +++ b/frontend/apps/desktop/src/app-sync.ts @@ -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 ============ @@ -66,16 +66,6 @@ async function timeAsync(label: string, fn: () => Promise): Promise { } } -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 @@ -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', () => { @@ -331,9 +320,11 @@ export function getUnconditionalInvalidations(event: Event): Array { 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]) } @@ -347,6 +338,25 @@ export function getUnconditionalInvalidations(event: Event): Array { 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() + 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)) @@ -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]) } diff --git a/frontend/apps/desktop/src/components/edit-profile-dialog.tsx b/frontend/apps/desktop/src/components/edit-profile-dialog.tsx index 5ac6ce1a5..65a10f887 100644 --- a/frontend/apps/desktop/src/components/edit-profile-dialog.tsx +++ b/frontend/apps/desktop/src/components/edit-profile-dialog.tsx @@ -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' @@ -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') diff --git a/frontend/apps/desktop/src/desktop-universal-client.tsx b/frontend/apps/desktop/src/desktop-universal-client.tsx index d497340f7..72c1ca3c6 100644 --- a/frontend/apps/desktop/src/desktop-universal-client.tsx +++ b/frontend/apps/desktop/src/desktop-universal-client.tsx @@ -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) }, diff --git a/frontend/apps/desktop/src/main.ts b/frontend/apps/desktop/src/main.ts index b11396f0a..4bbfdb6c1 100644 --- a/frontend/apps/desktop/src/main.ts +++ b/frontend/apps/desktop/src/main.ts @@ -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 { @@ -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) }) diff --git a/frontend/apps/desktop/src/models/entities.ts b/frontend/apps/desktop/src/models/entities.ts index 2cfd8b0c2..21af3fdf7 100644 --- a/frontend/apps/desktop/src/models/entities.ts +++ b/frontend/apps/desktop/src/models/entities.ts @@ -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' } @@ -257,9 +255,7 @@ function emitSubscriptionKeys() { const keys = new Set() 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() @@ -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) { diff --git a/frontend/apps/desktop/src/pages/desktop-feed.tsx b/frontend/apps/desktop/src/pages/desktop-feed.tsx index 5e2afd82c..e45056459 100644 --- a/frontend/apps/desktop/src/pages/desktop-feed.tsx +++ b/frontend/apps/desktop/src/pages/desktop-feed.tsx @@ -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' @@ -126,7 +125,6 @@ export default function DesktopFeedPage() { return (
{ ipc.send?.('invalidate_queries', queryKey) }) +// Bridge renderer-originated account-and-aliases invalidations to main, which +// re-broadcasts via the accountInvalidation tRPC subscription so every window +// runs its own cache scan. +onAccountInvalidation((uid: string) => { + ipc.send?.('invalidate_account_and_aliases', uid) +}) + // RQ will refuse to run mutations if !isOnline onlineManager.setOnline(true) @@ -279,9 +292,19 @@ function MainApp({}: {}) { console.log('[Rebase invalidation] subscription error', err) }, }) + const accountSub = client.accountInvalidation.subscribe(undefined, { + onData: (value: unknown) => { + if (typeof value !== 'string' || !value) return + invalidateAccountAndAliases(value) + }, + onError: (err) => { + console.log('[Account invalidation] subscription error', err) + }, + }) return () => { console.log('[Rebase invalidation] unsubscribing from queryInvalidation IPC bridge') sub.unsubscribe() + accountSub.unsubscribe() } }, [showOnboarding]) diff --git a/frontend/apps/desktop/src/use-hacky-authors-subscriptions.ts b/frontend/apps/desktop/src/use-hacky-authors-subscriptions.ts deleted file mode 100644 index b176212b3..000000000 --- a/frontend/apps/desktop/src/use-hacky-authors-subscriptions.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {hmId} from '@shm/shared' -import {useUniversalClient} from '@shm/shared/routing' -import {useEffect, useMemo, useRef} from 'react' - -/** - * Desktop-only hook to subscribe to author resources for syncing. - * Only creates subscriptions - does NOT use useResources/useDiscoveryStates - * to avoid triggering re-renders of the parent component when discovery - * states change (which would re-render hundreds of comments). - */ -export function useHackyAuthorsSubscriptions(authorIds: string[]) { - const client = useUniversalClient() - - // Create stable key from sorted IDs to detect actual content changes - const idsKey = useMemo(() => [...authorIds].sort().join(','), [authorIds]) - - // Track previous IDs to avoid recreating hmId objects unnecessarily - const prevIdsRef = useRef('') - const hmIdsRef = useRef[]>([]) - - // Only recalculate hmIds when the actual content changes - const hmIds = useMemo(() => { - if (prevIdsRef.current !== idsKey) { - prevIdsRef.current = idsKey - hmIdsRef.current = authorIds.map((id) => hmId(id)) - } - return hmIdsRef.current - }, [idsKey, authorIds]) - - // Subscribe directly without useResources to avoid re-render cascade. - // Uses profile scope to only discover name + icon, not full resource. - useEffect(() => { - if (!client.subscribeEntity) return - const cleanups = hmIds.filter((id) => !!id).map((id) => client.subscribeEntity!({id, scope: 'profile'})) - return () => cleanups.forEach((cleanup) => cleanup()) - }, [idsKey, client.subscribeEntity]) -} diff --git a/frontend/apps/web/app/auth.tsx b/frontend/apps/web/app/auth.tsx index f36f065cd..8dc71fe97 100644 --- a/frontend/apps/web/app/auth.tsx +++ b/frontend/apps/web/app/auth.tsx @@ -4,7 +4,7 @@ import {HMDocument, HMPrepareDocumentChangeInput, HMSigner} from '@seed-hypermed import {hmId, hostnameStripProtocol, postAccountCreateAction, queryKeys, useUniversalAppContext} from '@shm/shared' import {WEB_IDENTITY_ORIGIN} from '@shm/shared/constants' import {useAccount, useResource} from '@shm/shared/models/entity' -import {invalidateQueries} from '@shm/shared/models/query-client' +import {invalidateAccountAndAliasesEverywhere, invalidateQueries} from '@shm/shared/models/query-client' import {useTx, useTxString} from '@shm/shared/translation' import {Button} from '@shm/ui/button' import {DialogDescription, DialogFooter, DialogTitle} from '@shm/ui/components/dialog' @@ -683,7 +683,7 @@ export function EditProfileDialog({onClose, input}: {onClose: () => void; input: invalidateQueries([queryKeys.DOCUMENT_DISCUSSION]) invalidateQueries([queryKeys.ENTITY, id.id]) invalidateQueries([queryKeys.RESOLVED_ENTITY, id.id]) - invalidateQueries([queryKeys.ACCOUNT]) + invalidateAccountAndAliasesEverywhere(input.accountUid) }, }) return ( diff --git a/frontend/apps/web/app/routes/hm.device-link.$.tsx b/frontend/apps/web/app/routes/hm.device-link.$.tsx index 28b491cde..2bd5f90ef 100644 --- a/frontend/apps/web/app/routes/hm.device-link.$.tsx +++ b/frontend/apps/web/app/routes/hm.device-link.$.tsx @@ -21,8 +21,7 @@ import { } from '@seed-hypermedia/client/hm-types' import {useRouteLink, useUniversalAppContext} from '@shm/shared' import {useAccount} from '@shm/shared/models/entity' -import {invalidateQueries} from '@shm/shared/models/query-client' -import {queryKeys} from '@shm/shared/models/query-keys' +import {invalidateAccountAndAliasesEverywhere} from '@shm/shared/models/query-client' import {Button} from '@shm/ui/button' import {Input} from '@shm/ui/components/input' import {extractIpfsUrlCid} from '@shm/ui/get-file-url' @@ -670,8 +669,11 @@ function useLinkDevice(localIdentity: LocalWebIdentity) { error: (error as Error).message, }) }, - onSuccess: (data) => { - invalidateQueries([queryKeys.ACCOUNT]) + onSuccess: () => { + // The browser-local account just became an alias of the desktop + // account. Invalidate it (and anything currently aliased to it, if any) + // so the next fetch picks up the new alias relationship. + invalidateAccountAndAliasesEverywhere(localIdentity.id) }, }), } diff --git a/frontend/packages/client/src/hm-types.ts b/frontend/packages/client/src/hm-types.ts index dba5b2591..1d1895752 100644 --- a/frontend/packages/client/src/hm-types.ts +++ b/frontend/packages/client/src/hm-types.ts @@ -622,6 +622,14 @@ export const HMMetadataPayloadSchema = z id: unpackedHmIdSchema, metadata: HMDocumentMetadataSchema.or(z.null()), hasSite: z.boolean().optional(), + // Account-only field (set when this payload represents an account). + // For non-alias accounts, equals the account uid; for aliased accounts, + // the resolved alias-target uid. Omitted on document/resource payloads. + profileOwner: z.string().optional(), + // Account-only field (set when this payload represents an account). + // The home document version (CID) used by version-aware cache writes + // to skip no-op updates. Omitted on document/resource payloads. + version: z.string().nullable().optional(), }) .strict() export type HMMetadataPayload = z.infer diff --git a/frontend/packages/shared/src/__tests__/account-cache.test.ts b/frontend/packages/shared/src/__tests__/account-cache.test.ts new file mode 100644 index 000000000..45e3e6388 --- /dev/null +++ b/frontend/packages/shared/src/__tests__/account-cache.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for the account-cache primitives in `models/query-client.ts`: + * `populateAccountIfChanged` (version-aware writes) and the cache-scan + * `invalidateAccountAndAliases` that walks `[ACCOUNT, *]` entries and + * invalidates every alias of the target uid by inspecting `profileOwner`. + * + * Each test installs a fresh QueryClient via `registerQueryClient` to keep + * the module-level `registeredClient` reference scoped to the test. + */ +import {QueryClient} from '@tanstack/react-query' +import type {HMMetadataPayload} from '@seed-hypermedia/client/hm-types' +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {hmId} from '../utils/entity-id-url' +import {invalidateAccountAndAliases, populateAccountIfChanged, registerQueryClient} from '../models/query-client' +import {queryKeys} from '../models/query-keys' + +let qc: QueryClient + +function payload(uid: string, version: string | null, profileOwner = uid): HMMetadataPayload { + return { + id: hmId(uid, {version: version ?? undefined}), + metadata: {name: `name-${uid}`}, + profileOwner, + version, + } +} + +beforeEach(() => { + qc = new QueryClient() + registerQueryClient(qc) +}) + +afterEach(() => { + qc.clear() +}) + +describe('populateAccountIfChanged', () => { + it('writes when no entry exists', () => { + const wrote = populateAccountIfChanged(qc, 'A', payload('A', 'v1')) + expect(wrote).toBe(true) + expect(qc.getQueryData([queryKeys.ACCOUNT, 'A'])).toMatchObject({version: 'v1'}) + }) + + it('skips when the version matches', () => { + populateAccountIfChanged(qc, 'A', payload('A', 'v1')) + const wrote = populateAccountIfChanged(qc, 'A', payload('A', 'v1')) + expect(wrote).toBe(false) + }) + + it('writes when the version changes', () => { + populateAccountIfChanged(qc, 'A', payload('A', 'v1')) + const wrote = populateAccountIfChanged(qc, 'A', payload('A', 'v2')) + expect(wrote).toBe(true) + expect(qc.getQueryData([queryKeys.ACCOUNT, 'A'])?.version).toBe('v2') + }) + + it('skips writes that would degrade a versioned entry with sparser data', () => { + populateAccountIfChanged(qc, 'A', payload('A', 'v1')) + const wrote = populateAccountIfChanged(qc, 'A', payload('A', null)) + expect(wrote).toBe(false) + expect(qc.getQueryData([queryKeys.ACCOUNT, 'A'])?.version).toBe('v1') + }) + + it('writes when the existing payload has no version (any data is improvement)', () => { + populateAccountIfChanged(qc, 'A', payload('A', null)) + const wrote = populateAccountIfChanged(qc, 'A', payload('A', 'v1')) + expect(wrote).toBe(true) + }) +}) + +describe('invalidateAccountAndAliases', () => { + function isStale(uid: string): boolean { + return qc.getQueryState([queryKeys.ACCOUNT, uid])?.isInvalidated ?? false + } + + it('invalidates the target entry directly', () => { + populateAccountIfChanged(qc, 'A', payload('A', 'v1')) + invalidateAccountAndAliases('A') + expect(isStale('A')).toBe(true) + }) + + it('invalidates accounts whose profileOwner equals the target uid', () => { + // A → B alias: [ACCOUNT, A] holds B's data with profileOwner=B + populateAccountIfChanged(qc, 'A', payload('A', 'v-B', 'B')) + populateAccountIfChanged(qc, 'B', payload('B', 'v-B')) + invalidateAccountAndAliases('B') + expect(isStale('A')).toBe(true) + expect(isStale('B')).toBe(true) + }) + + it('does not invalidate unrelated accounts', () => { + populateAccountIfChanged(qc, 'A', payload('A', 'v-A')) + populateAccountIfChanged(qc, 'B', payload('B', 'v-B')) + populateAccountIfChanged(qc, 'C', payload('C', 'v-C')) + invalidateAccountAndAliases('A') + expect(isStale('A')).toBe(true) + expect(isStale('B')).toBe(false) + expect(isStale('C')).toBe(false) + }) + + it('invalidates several aliases sharing one target', () => { + populateAccountIfChanged(qc, 'A', payload('A', 'v-C', 'C')) + populateAccountIfChanged(qc, 'B', payload('B', 'v-C', 'C')) + populateAccountIfChanged(qc, 'C', payload('C', 'v-C')) + invalidateAccountAndAliases('C') + expect(isStale('A')).toBe(true) + expect(isStale('B')).toBe(true) + expect(isStale('C')).toBe(true) + }) + + it('still invalidates the requested uid even when nothing is in cache', () => { + invalidateAccountAndAliases('ghost') + // No assertion on isStale (no cache entry to check), but the call + // shouldn't throw and the queryClient remains usable. + expect(qc.getQueryState([queryKeys.ACCOUNT, 'ghost'])).toBeUndefined() + }) +}) diff --git a/frontend/packages/shared/src/__tests__/api-account.test.ts b/frontend/packages/shared/src/__tests__/api-account.test.ts new file mode 100644 index 000000000..2a10da36d --- /dev/null +++ b/frontend/packages/shared/src/__tests__/api-account.test.ts @@ -0,0 +1,158 @@ +/** + * Tests for `resolveAccountChain` (the pure alias-walker) and `loadAccount` + * (which delegates to it). + * + * Behavior under test: + * - Chain walking returns the right hops and leaf for non-alias, single-hop, + * and multi-hop inputs. + * - Cycles are bounded by `maxDepth` (legacy recursive `loadAccount` would + * stack-overflow on a cycle). + * - gRPC errors and missing accounts return `account-not-found` cleanly. + * - `loadAccount` preserves the legacy semantics that `id.uid` is the + * resolved target uid for an aliased input — notify and other consumers + * depend on this. + */ +import {Code, ConnectError} from '@connectrpc/connect' +import {describe, expect, it, vi} from 'vitest' +import {loadAccount, resolveAccountChain} from '../api-account' + +type GetAccountResponse = { + metadata?: unknown + homeDocumentInfo?: {metadata?: unknown; version?: string} | null + profile?: {name?: string; icon?: string; description?: string} | null + aliasAccount?: string +} + +/** + * Build a mock GRPCClient whose `documents.getAccount({id})` returns the + * response keyed by `id` in `responses`, or throws when the value at that + * key is an Error instance. Calls without a key produce a not-found error + * so accidental fetches surface clearly. + */ +function makeGrpcClient(responses: Record) { + const getAccount = vi.fn(async ({id}: {id: string}) => { + const value = responses[id] + if (!value) throw new ConnectError(`unmocked uid ${id}`, Code.NotFound) + if (value instanceof Error) throw value + return value + }) + return {documents: {getAccount}} as any +} + +describe('resolveAccountChain', () => { + it('returns the leaf with no hops for a non-alias account', async () => { + const grpcClient = makeGrpcClient({ + A: {homeDocumentInfo: {version: 'v-A'}}, + }) + const {hops, result} = await resolveAccountChain(grpcClient, 'A') + expect(hops).toEqual([]) + expect(result).toMatchObject({type: 'account', uid: 'A', version: 'v-A'}) + }) + + it('walks a single A→B alias and returns one hop', async () => { + const grpcClient = makeGrpcClient({ + A: {aliasAccount: 'B'}, + B: {homeDocumentInfo: {version: 'v-B'}}, + }) + const {hops, result} = await resolveAccountChain(grpcClient, 'A') + expect(hops).toEqual([{source: 'A', target: 'B'}]) + expect(result).toMatchObject({type: 'account', uid: 'B', version: 'v-B'}) + }) + + it('walks multi-hop chain A→B→C and returns each hop', async () => { + const grpcClient = makeGrpcClient({ + A: {aliasAccount: 'B'}, + B: {aliasAccount: 'C'}, + C: {homeDocumentInfo: {version: 'v-C'}}, + }) + const {hops, result} = await resolveAccountChain(grpcClient, 'A') + expect(hops).toEqual([ + {source: 'A', target: 'B'}, + {source: 'B', target: 'C'}, + ]) + expect(result).toMatchObject({type: 'account', uid: 'C', version: 'v-C'}) + }) + + it('returns account-not-found when the account is missing', async () => { + const grpcClient = makeGrpcClient({}) + const {hops, result} = await resolveAccountChain(grpcClient, 'missing') + expect(hops).toEqual([]) + expect(result).toEqual({type: 'account-not-found', uid: 'missing'}) + }) + + it('returns account-not-found when a generic gRPC error is thrown', async () => { + const grpcClient = makeGrpcClient({ + A: new ConnectError('boom', Code.Internal), + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const {hops, result} = await resolveAccountChain(grpcClient, 'A') + expect(hops).toEqual([]) + expect(result).toEqual({type: 'account-not-found', uid: 'A'}) + expect(errorSpy).toHaveBeenCalled() + errorSpy.mockRestore() + }) + + it('caps the chain at maxDepth so a cycle does not loop forever', async () => { + const grpcClient = makeGrpcClient({ + A: {aliasAccount: 'B'}, + B: {aliasAccount: 'A'}, + }) + const {hops, result} = await resolveAccountChain(grpcClient, 'A', 4) + expect(hops.length).toBe(4) + expect(result.type).toBe('account-not-found') + }) + + it('records hops even when the chain exhausts via not-found', async () => { + const grpcClient = makeGrpcClient({ + A: {aliasAccount: 'B'}, + // B is missing — chain stops at B with not-found + }) + const {hops, result} = await resolveAccountChain(grpcClient, 'A') + expect(hops).toEqual([{source: 'A', target: 'B'}]) + expect(result).toEqual({type: 'account-not-found', uid: 'B'}) + }) +}) + +describe('loadAccount', () => { + it('returns the resolved-target uid in id.uid for a non-alias account', async () => { + const grpcClient = makeGrpcClient({ + A: {homeDocumentInfo: {version: 'v-A'}}, + }) + const result = await loadAccount(grpcClient, 'A') + expect(result).toMatchObject({type: 'account', id: {uid: 'A', version: 'v-A'}}) + }) + + it('populates profileOwner and version with the resolved-leaf uid for a non-alias account', async () => { + const grpcClient = makeGrpcClient({ + A: {homeDocumentInfo: {version: 'v-A'}}, + }) + const result = await loadAccount(grpcClient, 'A') + expect(result).toMatchObject({type: 'account', profileOwner: 'A', version: 'v-A'}) + }) + + it('preserves legacy semantics: id.uid is the resolved target uid for an alias', async () => { + const grpcClient = makeGrpcClient({ + A: {aliasAccount: 'B'}, + B: {homeDocumentInfo: {version: 'v-B'}}, + }) + const result = await loadAccount(grpcClient, 'A') + expect(result).toMatchObject({type: 'account', id: {uid: 'B', version: 'v-B'}}) + }) + + it('populates profileOwner with the alias target so cache scans can find aliased entries', async () => { + const grpcClient = makeGrpcClient({ + A: {aliasAccount: 'B'}, + B: {homeDocumentInfo: {version: 'v-B'}}, + }) + const result = await loadAccount(grpcClient, 'A') + expect(result).toMatchObject({type: 'account', profileOwner: 'B', version: 'v-B'}) + }) + + it('returns account-not-found with the leaf uid when the chain ends in error', async () => { + const grpcClient = makeGrpcClient({ + A: {aliasAccount: 'B'}, + }) + const result = await loadAccount(grpcClient, 'A') + expect(result).toEqual({type: 'account-not-found', uid: 'B'}) + }) +}) diff --git a/frontend/packages/shared/src/api-account.ts b/frontend/packages/shared/src/api-account.ts index c66940ca7..40d2b683d 100644 --- a/frontend/packages/shared/src/api-account.ts +++ b/frontend/packages/shared/src/api-account.ts @@ -6,6 +6,7 @@ import { HMAccountPayload, HMAccountRequest, HMAccountResult, + HMMetadata, HMMetadataPayload, } from '@seed-hypermedia/client/hm-types' import {getErrorMessage, HMNotFoundError} from './models/entity' @@ -16,39 +17,94 @@ export const AccountParams: HMRequestParams = { paramsToInput: (params: Record) => params.id!, } +/** A single hop in an alias chain: source aliases to target. */ +export type AccountChainHop = {source: string; target: string} + +/** The terminal step of an alias chain — either a leaf account or not-found. */ +export type AccountChainResult = + | { + type: 'account' + uid: string + metadata: HMMetadata | null + version: string | null + } + | {type: 'account-not-found'; uid: string} + /** - * Load a single account with alias resolution. + * Walks the alias chain starting from `uid` until it finds a non-alias + * account, runs out of hops at `maxDepth`, or hits a not-found / gRPC error. + * + * Pure: does not register aliases or touch any cache. Returns the hops + * traversed so callers can decide whether to register them. + * + * Behavior change vs. legacy recursive `loadAccount`: this caps depth at 10 + * by default, so a malformed cyclic alias configuration returns `not-found` + * cleanly instead of overflowing the stack. */ -export async function loadAccount(client: GRPCClient, uid: string): Promise { - try { - const grpcAccount = await client.documents.getAccount({id: uid}) +export async function resolveAccountChain( + client: GRPCClient, + uid: string, + maxDepth = 10, +): Promise<{hops: AccountChainHop[]; result: AccountChainResult}> { + const hops: AccountChainHop[] = [] + let currentUid = uid + for (let i = 0; i < maxDepth; i++) { + let grpcAccount + try { + grpcAccount = await client.documents.getAccount({id: currentUid}) + } catch (e) { + const err = getErrorMessage(e) + if (!(err instanceof HMNotFoundError)) { + console.error(`Error loading account ${currentUid}:`, e) + } + return {hops, result: {type: 'account-not-found', uid: currentUid}} + } if (grpcAccount.aliasAccount) { - return await loadAccount(client, grpcAccount.aliasAccount) + hops.push({source: currentUid, target: grpcAccount.aliasAccount}) + currentUid = grpcAccount.aliasAccount + continue } return { - type: 'account', - id: hmId(uid, {version: grpcAccount.homeDocumentInfo?.version}), - metadata: accountMetadataFromAccount(grpcAccount), - } satisfies HMAccountPayload - } catch (e) { - const err = getErrorMessage(e) - if (err instanceof HMNotFoundError) { - return { - type: 'account-not-found', - uid, - } satisfies HMAccountNotFound + hops, + result: { + type: 'account', + uid: currentUid, + metadata: accountMetadataFromAccount(grpcAccount), + version: grpcAccount.homeDocumentInfo?.version ?? null, + }, } - console.error(`Error loading account ${uid}:`, e) - return { - type: 'account-not-found', - uid, - } satisfies HMAccountNotFound } + return {hops, result: {type: 'account-not-found', uid: currentUid}} } /** - * Load multiple accounts individually. + * Load an account, transparently following aliases. + * + * For an A→B alias, the returned `result.id.uid` is the resolved target (B), + * not the requested input (A) — this preserves the legacy recursive + * behavior that callers like notify depend on for signer-to-effective-account + * resolution. + * + * `profileOwner` carries the resolved-leaf uid so that consumers writing this + * payload into `[ACCOUNT, requestedUid]` can later spot alias entries by + * comparing `profileOwner` against the cache key. `version` carries the home + * document version of the leaf, enabling version-aware cache writes. */ +export async function loadAccount(client: GRPCClient, uid: string): Promise { + const {result} = await resolveAccountChain(client, uid) + if (result.type === 'account-not-found') { + return {type: 'account-not-found', uid: result.uid} satisfies HMAccountNotFound + } + return { + type: 'account', + id: hmId(result.uid, {version: result.version ?? undefined}), + metadata: result.metadata, + profileOwner: result.uid, + version: result.version, + } satisfies HMAccountPayload +} + +/** Load multiple accounts individually. */ export async function loadAccounts(client: GRPCClient, uids: string[]): Promise> { const results = await Promise.all(uids.map((uid) => loadAccount(client, uid))) const accounts: Record = {} diff --git a/frontend/packages/shared/src/comments-service-provider.tsx b/frontend/packages/shared/src/comments-service-provider.tsx index 32656aff2..c1c5cf2d6 100644 --- a/frontend/packages/shared/src/comments-service-provider.tsx +++ b/frontend/packages/shared/src/comments-service-provider.tsx @@ -2,7 +2,7 @@ import { deleteComment as createDeleteCommentBlob, updateComment as createUpdateCommentBlob, } from '@seed-hypermedia/client' -import {useMutation, useQuery} from '@tanstack/react-query' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {createContext, PropsWithChildren, ReactNode, useContext, useMemo} from 'react' import { HMBlockNode, @@ -15,7 +15,7 @@ import { HMListDiscussionsOutput, UnpackedHypermediaId, } from '@seed-hypermedia/client/hm-types' -import {invalidateQueries} from './models/query-client' +import {invalidateQueries, populateAccountIfChanged} from './models/query-client' import {queryKeys} from './models/query-keys' import {useUniversalClient} from './routing' import {hmId} from './utils/entity-id-url' @@ -34,11 +34,6 @@ type CommentsProviderValue = { onReplyCountClick?: (comment: HMComment) => void /** Render an inline editor for editing an existing comment. */ renderInlineEditor?: (props: InlineEditCommentProps) => ReactNode - /** - * Desktop-only hook to subscribe to author resources for syncing. - * No-op on web. This is a temporary workaround while syncing is improved. - */ - useHackyAuthorsSubscriptions?: (authorIds: string[]) => void /** * When true, deleted comment content is shown with a "deleted" banner * using the version history (pre-deletion versions). @@ -63,7 +58,6 @@ const defaultCommentsContext: CommentsProviderValue = { console.log('onReplyCountClick not implemented', comment) }, renderInlineEditor: undefined, - useHackyAuthorsSubscriptions: undefined, showDeletedContent: false, } @@ -74,7 +68,6 @@ export function CommentsProvider({ onReplyClick = defaultCommentsContext.onReplyClick, onReplyCountClick = defaultCommentsContext.onReplyCountClick, renderInlineEditor, - useHackyAuthorsSubscriptions, showDeletedContent = false, pushAfterCommentPublish, }: PropsWithChildren) { @@ -85,18 +78,10 @@ export function CommentsProvider({ onReplyClick, onReplyCountClick, renderInlineEditor, - useHackyAuthorsSubscriptions, showDeletedContent, pushAfterCommentPublish, }), - [ - onReplyClick, - onReplyCountClick, - renderInlineEditor, - useHackyAuthorsSubscriptions, - showDeletedContent, - pushAfterCommentPublish, - ], + [onReplyClick, onReplyCountClick, renderInlineEditor, showDeletedContent, pushAfterCommentPublish], )} > {children} @@ -114,11 +99,16 @@ export function useCommentsServiceContext() { export function useCommentsService(params: HMListCommentsInput) { const client = useUniversalClient() + const queryClient = useQueryClient() return useQuery({ queryKey: [queryKeys.DOCUMENT_COMMENTS, params.targetId], queryFn: async (): Promise => { try { - return await client.request('ListComments', params) + const result = await client.request('ListComments', params) + Object.entries(result.authors).forEach(([uid, payload]) => { + populateAccountIfChanged(queryClient, uid, payload) + }) + return result } catch (error) { console.error('Error fetching comments:', error) throw error @@ -131,12 +121,17 @@ export function useCommentsService(params: HMListCommentsInput) { export function useDiscussionsService(params: HMListDiscussionsInput) { const client = useUniversalClient() + const queryClient = useQueryClient() return useQuery({ queryKey: [queryKeys.DOCUMENT_DISCUSSION, params.targetId, params.commentId], queryFn: async (): Promise => { try { - return await client.request('ListDiscussions', params) + const result = await client.request('ListDiscussions', params) + Object.entries(result.authors).forEach(([uid, payload]) => { + populateAccountIfChanged(queryClient, uid, payload) + }) + return result } catch (error) { console.error('Error fetching discussions:', error) throw error @@ -149,12 +144,17 @@ export function useDiscussionsService(params: HMListDiscussionsInput) { export function useBlockDiscussionsService(params: HMListCommentsByReferenceInput) { const client = useUniversalClient() + const queryClient = useQueryClient() return useQuery({ queryKey: [queryKeys.BLOCK_DISCUSSIONS, params.targetId], queryFn: async (): Promise => { try { - return await client.request('ListCommentsByReference', params) + const result = await client.request('ListCommentsByReference', params) + Object.entries(result.authors).forEach(([uid, payload]) => { + populateAccountIfChanged(queryClient, uid, payload) + }) + return result } catch (error) { console.error('Error fetching block discussions:', error) throw error @@ -165,11 +165,6 @@ export function useBlockDiscussionsService(params: HMListCommentsByReferenceInpu }) } -export function useHackyAuthorsSubscriptions(authorIds: string[]) { - const context = useCommentsServiceContext() - context.useHackyAuthorsSubscriptions?.(authorIds) -} - export function isRouteEqualToCommentTarget({ id, comment, diff --git a/frontend/packages/shared/src/models/entity.ts b/frontend/packages/shared/src/models/entity.ts index ea02c50ed..096931baa 100644 --- a/frontend/packages/shared/src/models/entity.ts +++ b/frontend/packages/shared/src/models/entity.ts @@ -40,6 +40,7 @@ import { queryDirectory, queryResource, } from './queries' +import {populateAccountIfChanged} from './query-client' import {queryKeys} from './query-keys' export function documentMetadataParseAdjustments(metadata: any) { @@ -117,13 +118,11 @@ export function useResource( options?: UseQueryOptions & { subscribed?: boolean recursive?: boolean - /** `'high'` requests faster discovery polling for the active document. */ - priority?: 'normal' | 'high' onRedirectOrDeleted?: (opts: {isDeleted: boolean; redirectTarget: UnpackedHypermediaId | null}) => void }, ) { const client = useUniversalClient() - const {subscribed, recursive, priority, onRedirectOrDeleted, ...queryOptions} = options ?? {} + const {subscribed, recursive, onRedirectOrDeleted, ...queryOptions} = options ?? {} // Discovery subscription (desktop only) useEffect(() => { @@ -139,14 +138,13 @@ export function useResource( console.log('[Rebase sub] useResource calling subscribeEntity', { idStr: id.id, recursive: !!recursive, - priority: priority ?? 'normal', }) - const cleanup = client.subscribeEntity({id, recursive, priority}) + const cleanup = client.subscribeEntity({id, recursive}) return () => { console.log('[Rebase sub] useResource unsubscribing', {idStr: id.id}) cleanup() } - }, [subscribed, recursive, priority, id?.id, client.subscribeEntity]) + }, [subscribed, recursive, id?.id, client.subscribeEntity]) const result = useQuery({ ...queryResource(client, id), @@ -627,12 +625,17 @@ export function useComments(id: UnpackedHypermediaId | null | undefined) { export function useAuthoredComments(id: UnpackedHypermediaId | null | undefined) { const client = useUniversalClient() + const queryClient = useQueryClient() const isRootAccount = !id?.path?.filter((p) => !!p).length return useQuery({ queryKey: [queryKeys.AUTHORED_COMMENTS, id?.id], queryFn: async (): Promise => { if (!id) throw new Error('ID required') - return await client.request('ListCommentsByAuthor', {authorId: id}) + const result = await client.request('ListCommentsByAuthor', {authorId: id}) + Object.entries(result.authors).forEach(([uid, payload]) => { + populateAccountIfChanged(queryClient, uid, payload) + }) + return result }, enabled: !!id && isRootAccount, }) diff --git a/frontend/packages/shared/src/models/query-client.ts b/frontend/packages/shared/src/models/query-client.ts index bf1a3b809..f2a57333e 100644 --- a/frontend/packages/shared/src/models/query-client.ts +++ b/frontend/packages/shared/src/models/query-client.ts @@ -1,4 +1,6 @@ import {Query, QueryCache, QueryClient, type QueryKey} from '@tanstack/react-query' +import type {HMMetadataPayload} from '@seed-hypermedia/client/hm-types' +import {queryKeys} from './query-keys' // Re-export for consumers to avoid duplicate package instances export {QueryClientProvider, useQueryClient} from '@tanstack/react-query' @@ -41,6 +43,19 @@ export function onQueryInvalidation(handler: (queryKey: QueryKey) => void) { queryInvalidationSubscriptions.add(handler) } +/** + * Subscribers fire for every `invalidateAccountAndAliasesEverywhere(uid)` + * call. Platforms register a handler here to bridge the call to other windows + * (desktop sends IPC to main, which broadcasts a tRPC subscription back to + * every renderer). On single-window platforms (web), no subscriber is + * registered and the call only runs locally. + */ +const accountInvalidationSubscriptions = new Set<(uid: string) => void>() + +export function onAccountInvalidation(handler: (uid: string) => void) { + accountInvalidationSubscriptions.add(handler) +} + let registeredClient: QueryClient | null = null export function registerQueryClient(client: QueryClient) { @@ -65,3 +80,70 @@ export function setQueriesDataByKey(queryKey: QueryKey, data: unknown) { registeredClient.setQueriesData({queryKey}, data) } } + +/** + * Write `data` into `[ACCOUNT, uid]` only when it is a real improvement over + * what's cached. Skipping no-op writes avoids spurious re-renders when comment + * queries refetch and re-emit the same author metadata. Returns true when the + * cache was written, false when skipped. + * + * The skip rules, in order: + * 1. If the existing entry has a `version` and `data` carries no version, + * skip — the new payload is sparser (e.g. an optimistic write missing + * alias info) and writing would degrade the cache. + * 2. If both have a `version` and they match, skip — the data is unchanged. + * + * Otherwise (no existing entry, no existing version, or a different version) + * write through. + */ +export function populateAccountIfChanged(client: QueryClient, uid: string, data: HMMetadataPayload): boolean { + const queryKey = [queryKeys.ACCOUNT, uid] as const + const existing = client.getQueryData(queryKey) + if (existing?.version) { + if (!data.version) return false + if (existing.version === data.version) return false + } + client.setQueryData(queryKey, data) + return true +} + +/** + * Cross-window variant of `invalidateAccountAndAliases`. Runs the local + * cache scan immediately for instant feedback in the originating window, + * then fires `accountInvalidationSubscriptions` so other windows on the same + * platform can run their own scans against their own caches. + * + * On web (single window) only the local scan runs. On desktop, the renderer + * subscription bridges to main, main re-broadcasts to every renderer, and + * each renderer scans locally — including the originator (idempotent). + */ +export function invalidateAccountAndAliasesEverywhere(uid: string) { + invalidateAccountAndAliases(uid) + accountInvalidationSubscriptions.forEach((handler) => handler(uid)) +} + +/** + * Invalidate `[ACCOUNT, uid]` plus every cached account whose `profileOwner` + * matches `uid` — i.e. accounts that alias to `uid` and therefore display + * its profile metadata. The cache itself is the source of truth: each entry + * carries its resolved `profileOwner`, so a single scan finds the closure. + * + * Each match routes through `invalidateQueries` so platform subscriptions + * (desktop IPC broadcast) fire normally for every invalidated key. + */ +export function invalidateAccountAndAliases(uid: string) { + if (!registeredClient) { + invalidateQueries([queryKeys.ACCOUNT, uid]) + return + } + const queries = registeredClient.getQueryCache().findAll({queryKey: [queryKeys.ACCOUNT]}) + const toInvalidate = new Set([uid]) + queries.forEach((q) => { + const data = q.state.data as {profileOwner?: string} | null | undefined + const cacheKeyUid = q.queryKey[1] + if (data?.profileOwner === uid && typeof cacheKeyUid === 'string') { + toInvalidate.add(cacheKeyUid) + } + }) + toInvalidate.forEach((aliasUid) => invalidateQueries([queryKeys.ACCOUNT, aliasUid])) +} diff --git a/frontend/packages/shared/src/optimistic-comment.ts b/frontend/packages/shared/src/optimistic-comment.ts index d3d7e60a7..426cf5c90 100644 --- a/frontend/packages/shared/src/optimistic-comment.ts +++ b/frontend/packages/shared/src/optimistic-comment.ts @@ -10,6 +10,7 @@ import { UnpackedHypermediaId, } from '@seed-hypermedia/client/hm-types' import type {QueryClient} from '@tanstack/react-query' +import {populateAccountIfChanged} from './models/query-client' import {queryKeys} from './models/query-keys' import type {NavRoute} from './routes' @@ -122,6 +123,19 @@ export function applyOptimisticComment( }) rollbacks.push(() => qc.setQueryData(commentsKey, prevComments)) + // Mirror the author into [ACCOUNT, uid] so useAccount consumers see fresh + // metadata for the optimistic commenter without waiting for a refetch. + // Only register a rollback when populateAccountIfChanged actually wrote — + // otherwise we would rewrite identical (or sparser) data on rollback. + if (authorMetadata && comment.author) { + const accountKey = [queryKeys.ACCOUNT, comment.author] + const prevAccount = qc.getQueryData(accountKey) + const wrote = populateAccountIfChanged(qc, comment.author, authorMetadata) + if (wrote) { + rollbacks.push(() => qc.setQueryData(accountKey, prevAccount)) + } + } + // Also update BLOCK_DISCUSSIONS cache when quoting a specific block if (quotingBlockId) { const blockTargetId = {...targetId, blockRef: quotingBlockId} diff --git a/frontend/packages/shared/src/universal-client.ts b/frontend/packages/shared/src/universal-client.ts index 6c2afba2f..a374fabb7 100644 --- a/frontend/packages/shared/src/universal-client.ts +++ b/frontend/packages/shared/src/universal-client.ts @@ -54,8 +54,6 @@ export type UniversalClient = { subscribeEntity?: (opts: { id: UnpackedHypermediaId 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' }) => () => void diff --git a/frontend/packages/ui/src/comments.tsx b/frontend/packages/ui/src/comments.tsx index 7365e2c0e..2041800d3 100644 --- a/frontend/packages/ui/src/comments.tsx +++ b/frontend/packages/ui/src/comments.tsx @@ -6,7 +6,6 @@ import { HMCommentGroup, HMDocument, HMExternalCommentGroup, - HMMetadata, HMMetadataPayload, UnpackedHypermediaId, } from '@seed-hypermedia/client/hm-types' @@ -22,7 +21,6 @@ import { useUniversalAppContext, } from '@shm/shared' -import {HMListDiscussionsOutput} from '@seed-hypermedia/client/hm-types' import { useBlockDiscussionsService, useCommentReplyCount, @@ -31,10 +29,9 @@ import { useCommentVersions, useDeleteComment, useDiscussionsService, - useHackyAuthorsSubscriptions, useUpdateComment, } from '@shm/shared/comments-service-provider' -import {useIsCurrentUser, useResource} from '@shm/shared/models/entity' +import {useAccount, useIsCurrentUser, useResource} from '@shm/shared/models/entity' import {useReadOnlyViewer} from '@shm/shared/readonly-viewer-context' import {getRoutePanel} from '@shm/shared/routes' import {useTxString} from '@shm/shared/translation' @@ -87,26 +84,6 @@ export function CommentDiscussions({ const parentThread = useCommentParents(commentsService.data?.comments, commentId) const commentGroupReplies = useCommentGroups(commentsService.data?.comments, commentId) - // Subscribe to all authors in this discussion - const allAuthorIds = useMemo(() => { - const authors = new Set() - if (parentThread?.thread) { - parentThread.thread.forEach((c) => { - if (c.author) authors.add(c.author) - }) - } - if (commentGroupReplies.data) { - commentGroupReplies.data.forEach((cg) => { - cg.comments.forEach((c) => { - if (c.author) authors.add(c.author) - }) - }) - } - return Array.from(authors) - }, [parentThread?.thread, commentGroupReplies.data]) - - useHackyAuthorsSubscriptions(allAuthorIds) - const {showDeletedContent} = useCommentsServiceContext() const commentFound = commentsService.data?.comments?.some((c) => c.id === commentId) @@ -201,7 +178,6 @@ export function CommentDiscussions({ { return (
- +
) }) @@ -268,28 +238,6 @@ export const Discussions = memo(function Discussions({ }) { const discussionsService = useDiscussionsService({targetId, commentId}) - // Subscribe to all authors in discussions - const allAuthorIds = useMemo(() => { - const authors = new Set() - if (discussionsService.data?.discussions) { - discussionsService.data.discussions.forEach((cg) => { - cg.comments.forEach((c) => { - if (c.author) authors.add(c.author) - }) - }) - } - if (discussionsService.data?.citingDiscussions) { - discussionsService.data.citingDiscussions.forEach((cg) => { - cg.comments.forEach((c) => { - if (c.author) authors.add(c.author) - }) - }) - } - return Array.from(authors) - }, [discussionsService.data?.discussions, discussionsService.data?.citingDiscussions]) - - useHackyAuthorsSubscriptions(allAuthorIds) - let panelContent = null if (discussionsService.isLoading && !discussionsService.data) { panelContent = ( @@ -315,12 +263,7 @@ export const Discussions = memo(function Discussions({ return (
- +
) @@ -329,12 +272,7 @@ export const Discussions = memo(function Discussions({ return (
- +
) @@ -363,19 +301,6 @@ export function BlockDiscussions({ const commentsService = useBlockDiscussionsService({targetId}) const doc = useResource(targetId) - // Subscribe to all authors in block discussions - const allAuthorIds = useMemo(() => { - const authors = new Set() - if (commentsService.data?.comments) { - commentsService.data.comments.forEach((c) => { - if (c.author) authors.add(c.author) - }) - } - return Array.from(authors) - }, [commentsService.data?.comments]) - - useHackyAuthorsSubscriptions(allAuthorIds) - let quotedContent = null let panelContent = null @@ -419,7 +344,6 @@ export function BlockDiscussions({ key={comment.id} comment={comment} authorId={comment.author} - authorMetadata={commentsService.data.authors[comment.author]?.metadata} targetDomain={targetDomain} />
@@ -482,13 +406,11 @@ function LazyCommentGroup({children}: {children: ReactNode}) { export const CommentGroup = memo(function CommentGroup({ commentGroup, - authors, enableReplies = true, highlightLastComment = false, targetDomain, }: { commentGroup: HMCommentGroup | HMExternalCommentGroup - authors?: HMListDiscussionsOutput['authors'] enableReplies?: boolean highlightLastComment?: boolean targetDomain?: string @@ -521,7 +443,6 @@ export const CommentGroup = memo(function CommentGroup({ } key={comment.id} comment={comment} - authorMetadata={comment.author ? authors?.[comment.author]?.metadata : null} authorId={comment.author} enableReplies={enableReplies} highlight={highlightLastComment && isLastCommentInGroup} @@ -537,7 +458,6 @@ export const Comment = memo(function Comment({ comment, isFirst = true, isLast = false, - authorMetadata, authorId, enableReplies = true, defaultExpandReplies = false, @@ -550,7 +470,6 @@ export const Comment = memo(function Comment({ comment: HMComment isFirst?: boolean isLast?: boolean - authorMetadata?: HMMetadata | null authorId?: string | null enableReplies?: boolean defaultExpandReplies?: boolean @@ -575,6 +494,13 @@ export const Comment = memo(function Comment({ const deleteCommentDialog = useDeleteCommentDialog() const currentRoute = useNavRoute() + // Each Comment subscribes to its own author so individual updates only + // re-render this Comment, never the parent list. Reads cached data + // populated by the comment queryFn (Phase 3) so the first render is + // immediate; the subscribe option triggers desktop P2P discovery. + const authorAccount = useAccount(authorId ?? comment.author, {subscribe: true}) + const authorMetadata = authorAccount.data?.metadata ?? null + const authorHmId = comment.author || authorId ? hmId(authorId || comment.author) : null const docId = getCommentTargetId(comment) const authorLink = useRouteLink(getContextualProfileRoute(currentRoute, authorHmId, docId?.uid)) diff --git a/frontend/packages/ui/src/feed.tsx b/frontend/packages/ui/src/feed.tsx index 74b21bea9..25ac9d5f2 100644 --- a/frontend/packages/ui/src/feed.tsx +++ b/frontend/packages/ui/src/feed.tsx @@ -1,4 +1,4 @@ -import {useDeleteComment, useHackyAuthorsSubscriptions} from '@shm/shared/comments-service-provider' +import {useDeleteComment} from '@shm/shared/comments-service-provider' import {HMBlockNode, HMTimestamp, UnpackedHypermediaId} from '@seed-hypermedia/client/hm-types' import {HMListEventsParams, LoadedCommentEvent, LoadedEvent} from '@shm/shared/models/activity-service' import {useResource, useSelectedAccountId} from '@shm/shared/models/entity' @@ -11,7 +11,7 @@ import {commentIdToHmId, getCommentTargetId, getVersionHeads, hmId} from '@shm/s import {useNavRoute} from '@shm/shared/utils/navigation' import _ from 'lodash' import {CircleAlert, Link, Merge, Trash2} from 'lucide-react' -import {memo, useEffect, useMemo, useRef} from 'react' +import {memo, useEffect, useRef} from 'react' import {SelectionContent} from './accessories' import {useReadOnlyViewer} from '@shm/shared/readonly-viewer-context' import {Button} from './button' @@ -88,38 +88,11 @@ export function Feed({ } }, [isLoading, hasNextPage, isFetchingNextPage, fetchNextPage]) - // Flatten all pages into a single array of events + // Flatten all pages into a single array of events. Author/subject/delegate + // discovery is handled in the leaf renderers via `useAccount({subscribe: true})`, + // so each rendered author triggers its own profile-scoped subscription. const allEvents = data?.pages.flatMap((page) => page.events) || [] - // Extract unique account IDs from events and subscribe for discovery. - // Includes authors, reply parents, contact subjects, and capability delegates - // so their profiles are discovered before we render them. - const authorIds = useMemo(() => { - const ids = new Set() - allEvents.forEach((event) => { - if (event.author?.id?.uid) { - ids.add(event.author.id.uid) - } - if (event.type === 'comment' && event.replyParentAuthor?.id?.uid) { - ids.add(event.replyParentAuthor.id.uid) - } - if (event.type === 'contact' && event.contact.subject?.id?.uid) { - ids.add(event.contact.subject.id.uid) - } - if (event.type === 'capability') { - event.delegates.forEach((delegate) => { - if (delegate?.id?.uid) { - ids.add(delegate.id.uid) - } - }) - } - }) - return Array.from(ids) - }, [allEvents]) - - // Subscribe to author accounts for discovery (desktop only, no-op on web) - useHackyAuthorsSubscriptions(authorIds) - const isSingleResource = filterResource && !filterResource.endsWith('*') ? true : false if (error) { diff --git a/frontend/packages/ui/src/inline-descriptor.tsx b/frontend/packages/ui/src/inline-descriptor.tsx index 1739f82c6..3f9554d9f 100644 --- a/frontend/packages/ui/src/inline-descriptor.tsx +++ b/frontend/packages/ui/src/inline-descriptor.tsx @@ -82,8 +82,8 @@ export function getContextualProfileRoute( export function AuthorNameLink({author, siteUid}: {author: HMContactItem | null; siteUid?: string}) { const currentRoute = useNavRoute() // Use the account query to get fresh cache data and distinguish loading from settled. - // When useHackyAuthorsSubscriptions discovers the account, this query gets invalidated - // and re-renders with the resolved name. + // `subscribe: true` triggers a profile-scoped P2P discovery on desktop so the + // resolved name renders as soon as the account is found locally. const account = useAccount(author?.id?.uid, {subscribe: true}) const resolvedName = account.data?.metadata?.name || author?.metadata?.name const authorName = resolvedName || abbreviateUid(author?.id?.uid) diff --git a/frontend/packages/ui/src/resource-page-common.tsx b/frontend/packages/ui/src/resource-page-common.tsx index 694a3f42c..22e301d93 100644 --- a/frontend/packages/ui/src/resource-page-common.tsx +++ b/frontend/packages/ui/src/resource-page-common.tsx @@ -17,7 +17,6 @@ import { unpackHmId, useUniversalAppContext, } from '@shm/shared' -import {useHackyAuthorsSubscriptions} from '@shm/shared/comments-service-provider' import {IS_DESKTOP, NOTIFY_SERVICE_HOST} from '@shm/shared/constants' import type {BlockRangeSelectOptions, DocumentContentProps} from '@shm/shared/document-content-props' import { @@ -323,15 +322,9 @@ export function ResourcePage({ const route = useNavRoute() const isSiteProfile = route.key === 'site-profile' - // Load document data via React Query (hydrated from SSR prefetch). - // The active resource page subscribes with priority: 'high' so the daemon - // polls discovery faster (3s vs 20s) while the window is focused — this - // shrinks the time-to-detect for incoming remote updates while the user - // is editing or actively reading the document. const resource = useResource(docId, { subscribed: true, recursive: true, - priority: 'high', }) // docId.uid determines the site header — for site-profile, docId IS the site context @@ -1091,9 +1084,10 @@ function DocumentBody({ showSidebars: !isHomeDoc && document.metadata?.showOutline !== false && activeView === 'content', }) - // Fetch author metadata for document header and subscribe for discovery + // Fetch author metadata for document header. The DocumentHeader's per-author + // AuthorLink subscribes via `useAccount({subscribe: true})`, so each author's + // P2P discovery is triggered at the leaf level. const accountsMetadata = useAccountsMetadata(document.authors || []) - useHackyAuthorsSubscriptions(document.authors || []) const authorPayloads: AuthorPayload[] = useMemo(() => { return (document.authors || []).map((uid) => { const data = accountsMetadata.data[uid]