From 3afbf75ddd242cb60c9322cb82f821bf69cae627 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Tue, 27 Oct 2026 23:28:22 +0200 Subject: [PATCH 1/5] fix: forget cached profile previews when logging out or deleting the account --- KNOWLEDGE_BASE.md | 4 +-- resources/js/components/delete-user.tsx | 9 ++++- resources/js/components/user-menu-content.tsx | 2 ++ resources/js/components/user-preview.tsx | 4 +++ tests/Unit/AuthorHover.test.tsx | 34 ++++++++++++++++++- 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 97b28096d..1a300aeae 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -390,11 +390,11 @@ Sources: [group creation](app/Http/Requests/CreateOrgRequest.php), [invitation e ### Muting and blocking -**Mute** someone to remove their commits and replies from your logs, threads, search, and suggestions and stop their notifications. Muting is silent, leaves follows intact, and still lets you visit their profile, which shows a **Muted** badge. Commits you starred stay in Starred. +**Mute** someone to remove their commits and replies from your logs, threads, search, and suggestions and stop their notifications. Muting is silent, leaves follows intact, and still lets you visit their profile, which shows a **Muted** badge. Commits you starred stay in Starred. Hover cards show the same badge, and logging out or deleting your account forgets them, so whoever uses that tab next doesn't see who you muted or blocked. **Block** someone to stop messaging and interactions such as following, replies, stars, pastéis, and Tom's replies between the two accounts, and hide each other's commits while signed in. Their profile and follower lists become unavailable to you. Blocking removes follows in both directions. Unblocking does not restore those follows. Manage and reverse either choice in Settings → Safety. Blocking is an account-level interaction control, not a way to make public posts inaccessible to signed-out visitors. -Sources: [safety settings](resources/js/pages/safety/edit.tsx), [mute action](app/Actions/CreateUserMute.php), [block action](app/Actions/CreateUserBlock.php), [public routes](routes/web.php). +Sources: [safety settings](resources/js/pages/safety/edit.tsx), [mute action](app/Actions/CreateUserMute.php), [block action](app/Actions/CreateUserBlock.php), [public routes](routes/web.php), [hover cards](resources/js/components/user-preview.tsx). ### Reporting commits diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index fbac726a6..0841afeca 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -11,6 +11,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; +import { clearUserPreviews } from '@/components/user-preview'; import { WordmarkText } from '@/components/wordmark-text'; import { clearRecentSearches } from '@/hooks/use-recent-searches'; @@ -55,7 +56,13 @@ export default function DeleteUser() {
user && clearRecentSearches(user.id)} + onSuccess={() => { + clearUserPreviews(); + + if (user) { + clearRecentSearches(user.id); + } + }} > {({ processing }) => ( diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx index 0af87e97e..f387d69b7 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/user-menu-content.tsx @@ -24,6 +24,7 @@ import { DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { UserInfo } from '@/components/user-info'; +import { clearUserPreviews } from '@/components/user-preview'; import { useMobileNavigation } from '@/hooks/use-mobile-navigation'; import { clearRecentSearches } from '@/hooks/use-recent-searches'; import { clearStashes } from '@/hooks/use-stash'; @@ -44,6 +45,7 @@ export function UserMenuContent({ user }: Props) { cleanup(); clearStashes(user.id); clearRecentSearches(user.id); + clearUserPreviews(); router.flushAll(); }; diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index dfc245cbd..1a45832fa 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -73,6 +73,10 @@ async function fetchPreview(url: string): Promise { return data; } +export function clearUserPreviews(): void { + cache.clear(); +} + function positionFor(trigger: HTMLElement): Position { const rect = trigger.getBoundingClientRect(); const left = Math.min( diff --git a/tests/Unit/AuthorHover.test.tsx b/tests/Unit/AuthorHover.test.tsx index 029bd2b90..5ef150d60 100644 --- a/tests/Unit/AuthorHover.test.tsx +++ b/tests/Unit/AuthorHover.test.tsx @@ -38,7 +38,7 @@ const { UserAvatar } = await import('../../resources/js/components/user-avatar'); const { ActorAvatar } = await import('../../resources/js/pages/notification/index'); -const { UserPreview } = +const { UserPreview, clearUserPreviews } = await import('../../resources/js/components/user-preview'); const { SuggestedUserRow } = await import('../../resources/js/components/who-to-follow'); @@ -177,6 +177,38 @@ test('profile preview stays open when its menu is dismissed under the mouse', () assert.ok(!stateChanges.includes(false)); }); +test('profile preview opens without the cached relationship once previews are cleared', () => { + const user = { id: 'muted', handle: 'muted' } as User; + const content = () => { + showPreview = true; + const preview = UserPreview({ user, children: 'Muted' }); + showPreview = false; + + return ( + React.Children.toArray(preview.props.children)[1] as ReactElement<{ + children: ReactElement<{ + data: { followsYou: boolean; isMuting: boolean } | null; + onChange: (data: object) => void; + }>; + }> + ).props.children.props; + }; + + content().onChange({ + isFollowing: false, + followsYou: true, + isMuting: true, + isBlocking: false, + isUnavailable: false, + user, + }); + assert.equal(content().data?.isMuting, true); + + clearUserPreviews(); + + assert.equal(content().data, null); +}); + test('suggested user row wraps the author in a user preview anchored to their avatar', () => { const user = { id: 'author', From 46b89f14368235fdf2a3aea88bdc8cf6ed428585 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Wed, 28 Oct 2026 00:02:50 +0200 Subject: [PATCH 2/5] fix: drop profile previews that finish loading after the cache was cleared --- resources/js/components/user-preview.tsx | 8 +++ tests/Unit/AuthorHover.test.tsx | 89 ++++++++++++++++++------ 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index 1a45832fa..796671888 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -52,8 +52,10 @@ const viewportMargin = 8; // Where the centre of the preview's avatar sits: border, padding and half of the size-14 avatar. const avatarCentre = 45; const cache = new Map(); +let cacheGeneration = 0; async function fetchPreview(url: string): Promise { + const generation = cacheGeneration; const response = await fetch(url, { headers: { Accept: 'application/json', @@ -68,12 +70,18 @@ async function fetchPreview(url: string): Promise { } const data = (await response.json()) as PreviewData; + + if (generation !== cacheGeneration) { + throw new Error(`Dropped ${url} from before the previews were cleared`); + } + cache.set(url, data); return data; } export function clearUserPreviews(): void { + cacheGeneration++; cache.clear(); } diff --git a/tests/Unit/AuthorHover.test.tsx b/tests/Unit/AuthorHover.test.tsx index 5ef150d60..d07580796 100644 --- a/tests/Unit/AuthorHover.test.tsx +++ b/tests/Unit/AuthorHover.test.tsx @@ -177,36 +177,79 @@ test('profile preview stays open when its menu is dismissed under the mouse', () assert.ok(!stateChanges.includes(false)); }); +function previewContent(user: User) { + showPreview = true; + const preview = UserPreview({ user, children: user.handle }); + showPreview = false; + + return ( + React.Children.toArray(preview.props.children)[1] as ReactElement<{ + children: ReactElement<{ + data: { followsYou: boolean; isMuting: boolean } | null; + onChange: (data: object) => void; + }>; + }> + ).props.children.props; +} + +const mutedPreview = (user: User) => ({ + isFollowing: false, + followsYou: true, + isMuting: true, + isBlocking: false, + isUnavailable: false, + user, +}); + test('profile preview opens without the cached relationship once previews are cleared', () => { const user = { id: 'muted', handle: 'muted' } as User; - const content = () => { - showPreview = true; - const preview = UserPreview({ user, children: 'Muted' }); - showPreview = false; - return ( - React.Children.toArray(preview.props.children)[1] as ReactElement<{ - children: ReactElement<{ - data: { followsYou: boolean; isMuting: boolean } | null; - onChange: (data: object) => void; - }>; - }> - ).props.children.props; - }; + previewContent(user).onChange(mutedPreview(user)); + assert.equal(previewContent(user).data?.isMuting, true); - content().onChange({ - isFollowing: false, - followsYou: true, - isMuting: true, - isBlocking: false, - isUnavailable: false, - user, + clearUserPreviews(); + + assert.equal(previewContent(user).data, null); +}); + +test('profile preview drops a response that arrives after previews are cleared', async () => { + const user = { id: 'raced', handle: 'raced' } as User; + let respond = (_response: Response) => {}; + vi.useFakeTimers(); + vi.stubGlobal('window', { + matchMedia: () => ({ matches: true }), + innerWidth: 1440, + innerHeight: 900, }); - assert.equal(content().data?.isMuting, true); + vi.stubGlobal( + 'fetch', + () => new Promise((resolve) => (respond = resolve)), + ); + const trigger = React.Children.toArray( + UserPreview({ user, children: 'Raced' }).props.children, + )[0] as ReactElement< + HTMLAttributes & { ref: { current: unknown } } + >; + trigger.props.ref.current = { + getBoundingClientRect: () => ({ left: 0, top: 0, bottom: 20 }), + }; + trigger.props.onPointerEnter?.({ + pointerType: 'mouse', + } as PointerEvent); + vi.runAllTimers(); clearUserPreviews(); - - assert.equal(content().data, null); + respond(Response.json(mutedPreview(user))); + vi.useRealTimers(); + await new Promise((resolve) => setTimeout(resolve)); + vi.stubGlobal('window', { matchMedia: () => ({ matches: false }) }); + + assert.equal(previewContent(user).data, null); + assert.ok( + !stateChanges.some( + (change) => (change as { isMuting?: boolean } | null)?.isMuting, + ), + ); }); test('suggested user row wraps the author in a user preview anchored to their avatar', () => { From 7fd648b5270f730987b08d54edf8e57d30084992 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Wed, 28 Oct 2026 12:06:19 +0200 Subject: [PATCH 3/5] fix: remount the page after logging out so an open profile preview closes --- resources/js/components/user-menu-content.tsx | 1 + tests/Unit/UserMenuLogout.test.tsx | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/Unit/UserMenuLogout.test.tsx diff --git a/resources/js/components/user-menu-content.tsx b/resources/js/components/user-menu-content.tsx index f387d69b7..e5c456903 100644 --- a/resources/js/components/user-menu-content.tsx +++ b/resources/js/components/user-menu-content.tsx @@ -165,6 +165,7 @@ export function UserMenuContent({ user }: Props) { className="block w-full cursor-pointer" href={logout()} as="button" + preserveState={false} onClick={handleLogout} data-test="logout-button" > diff --git a/tests/Unit/UserMenuLogout.test.tsx b/tests/Unit/UserMenuLogout.test.tsx new file mode 100644 index 000000000..9606db1cb --- /dev/null +++ b/tests/Unit/UserMenuLogout.test.tsx @@ -0,0 +1,36 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement } from 'react'; +import { test, vi } from 'vite-plus/test'; +import type { User } from '../../resources/js/types'; + +vi.doMock('react', () => ({ + ...React, + useCallback: (callback: unknown) => callback, +})); +vi.doMock('@inertiajs/react', async () => ({ + ...(await vi.importActual('@inertiajs/react')), + usePage: () => ({ props: { auth: { isAdmin: false }, repository: '' } }), +})); + +const { UserMenuContent } = + await import('../../resources/js/components/user-menu-content'); + +test('logging out remounts the page instead of keeping what the signed in account had open', () => { + const items = React.Children.toArray( + UserMenuContent({ user: { id: 'author', handle: 'author' } as User }) + .props.children, + ); + const logout = ( + items.at(-1) as ReactElement<{ + children: ReactElement<{ + preserveState?: boolean; + 'data-test': string; + }>; + }> + ).props.children; + + assert.equal(logout.props['data-test'], 'logout-button'); + assert.equal(logout.props.preserveState, false); +}); From df18e3da087b124d201cdd712b0d571b6fbb4b62 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Wed, 28 Oct 2026 15:52:33 +0200 Subject: [PATCH 4/5] fix: stop a preview follow that lands after logging out from refilling the cleared cache --- resources/js/components/user-preview.tsx | 18 +++++++++++++++++- tests/Unit/FollowState.test.tsx | 16 +++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index 796671888..e9726eb8b 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -85,6 +85,21 @@ export function clearUserPreviews(): void { cache.clear(); } +/** + * Drops a change that lands after the previews were cleared. It reads the generation outside the component because the React Compiler would read it again at the comparison. + */ +function unlessCleared( + onChange: (data: PreviewData) => void, +): (data: PreviewData) => void { + const generation = cacheGeneration; + + return (data) => { + if (generation === cacheGeneration) { + onChange(data); + } + }; +} + function positionFor(trigger: HTMLElement): Position { const rect = trigger.getBoundingClientRect(); const left = Math.min( @@ -373,6 +388,7 @@ function UserPreviewContent({ } const follows = !isFollowing; + const change = unlessCleared(onChange); setPendingFollows(follows); router.visit( @@ -387,7 +403,7 @@ function UserPreviewContent({ onSuccess: () => { setIsFollowing(follows); router.flushAll(); - onChange({ + change({ ...data, isFollowing: follows, user: { diff --git a/tests/Unit/FollowState.test.tsx b/tests/Unit/FollowState.test.tsx index 68026c200..a8a1ab6d5 100644 --- a/tests/Unit/FollowState.test.tsx +++ b/tests/Unit/FollowState.test.tsx @@ -98,7 +98,7 @@ vi.doMock('../../resources/js/lib/chat', async () => ({ const { FollowButton } = await import('../../resources/js/components/follow-button'); -const { UserPreview } = +const { UserPreview, clearUserPreviews } = await import('../../resources/js/components/user-preview'); const { SuggestedUserRow, WhoToFollow, WhoToFollowInLog } = await import('../../resources/js/components/who-to-follow'); @@ -382,6 +382,20 @@ test('unfollowing through a cached preview keeps its follower count', () => { assert.equal(changes.at(-1)?.user.followers_count, 0); }); +test('a follow from their preview that lands after logging out stays out of the cleared previews', () => { + const user = suggestion('cleared'); + const changes: { user: Profile }[] = []; + + ( + previewFollowButton(user, false, (data) => changes.push(data)).props + .onClick as () => void + )(); + clearUserPreviews(); + visits.at(-1)?.onSuccess?.(); + + assert.deepEqual(changes, []); +}); + test('a preview unfollow that fails after a block keeps who to follow unfollowed', () => { const user = suggestion('raced'); followFromProfile(user); From 4a188653d324fad65a132a69d25a9bc9be950970 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Wed, 28 Oct 2026 18:05:30 +0200 Subject: [PATCH 5/5] fix: stop a preview from showing a guest the follow of the account that just logged out --- resources/js/components/user-preview.tsx | 3 ++- tests/Unit/FollowState.test.tsx | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index e9726eb8b..533eb8465 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -371,7 +371,8 @@ function UserPreviewContent({ ); const [pendingFollows, setPendingFollows] = useState(null); - const shownIsFollowing = pendingFollows ?? isFollowing; + const shownIsFollowing = + auth.user !== null && (pendingFollows ?? isFollowing); /** * The button flips right away, but like every other follow control the shared state only changes once the server confirms, so there is nothing to roll back. diff --git a/tests/Unit/FollowState.test.tsx b/tests/Unit/FollowState.test.tsx index a8a1ab6d5..9aea14556 100644 --- a/tests/Unit/FollowState.test.tsx +++ b/tests/Unit/FollowState.test.tsx @@ -12,6 +12,7 @@ let component = 'user/show'; let log: string | undefined; let loadedCommits: { data: { id: string }[] } | undefined; let suggestedUsers: SuggestedUser[] | undefined; +let viewer: { id: string } | null = { id: 'viewer' }; type VisitOptions = { only?: string[]; headers?: Record; @@ -57,7 +58,7 @@ vi.doMock('@inertiajs/react', async () => ({ usePage: () => ({ component, props: { - auth: { user: { id: 'viewer' } }, + auth: { user: viewer }, log, commits: loadedCommits, showsWhoToFollow: true, @@ -120,6 +121,7 @@ afterEach(() => { log = undefined; loadedCommits = undefined; suggestedUsers = undefined; + viewer = { id: 'viewer' }; refreshSharedState( new Map([ ['who-to-follow-dismissing', 0], @@ -396,6 +398,17 @@ test('a follow from their preview that lands after logging out stays out of the assert.deepEqual(changes, []); }); +test('after logging out a preview offers to follow someone the last account followed', () => { + const user = suggestion('previous'); + followFromProfile(user); + viewer = null; + + assert.equal( + previewFollowButton(user, false).props['aria-label'], + 'Follow @previous', + ); +}); + test('a preview unfollow that fails after a block keeps who to follow unfollowed', () => { const user = suggestion('raced'); followFromProfile(user);