diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 96a0f2243b..9cf9590f7c 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -13,6 +13,7 @@ import { trackCommitPhotoPage } from '@/lib/commit-photo'; import { initializeCommitState } from '@/lib/commit-state'; import { trackCurrentPage } from '@/lib/current-page'; import { trackFollowRequests } from '@/lib/follow-requests'; +import { trackOrganizationMembersHistory } from '@/lib/organization-members-history'; import { pageTitle } from '@/lib/page-title'; import { trackPrefetchedPages } from '@/lib/prefetched-pages'; import { registerServiceWorker } from '@/lib/service-worker'; @@ -22,6 +23,7 @@ import '../css/app.css'; trackCurrentPage(); trackCommitPhotoPage(); +trackOrganizationMembersHistory(); trackVisualViewport(); trackAnalyticsEvents(); trackPrefetchedPages(); diff --git a/resources/js/lib/organization-members-history.ts b/resources/js/lib/organization-members-history.ts new file mode 100644 index 0000000000..202292a42f --- /dev/null +++ b/resources/js/lib/organization-members-history.ts @@ -0,0 +1,56 @@ +import { router } from '@inertiajs/react'; +import { currentPageComponent } from '@/lib/current-page'; + +const authUserId = (page: { props: Record }) => { + const auth = page.props.auth as + | { user?: { id: string | number } | null } + | undefined; + + return auth?.user?.id ?? null; +}; + +/** Another admin may change memberships or roles after this page was cached. */ +export function trackOrganizationMembersHistory(): VoidFunction { + let currentUserId: string | number | null = null; + + const refresh = (): void => { + const previousUserId = currentUserId; + + router.reload({ + only: ['organization', 'role', 'members', 'auth'], + preserveScroll: true, + onSuccess: (page) => { + if (authUserId(page) !== previousUserId) { + window.location.reload(); + } + }, + }); + }; + + const stop = router.on('navigate', ({ detail: { type, page } }) => { + currentUserId = authUserId(page); + + if ( + type === 'history' && + page.component === 'organization-member/index' + ) { + refresh(); + } + }); + + const onPageShow = (event: PageTransitionEvent): void => { + if ( + event.persisted && + currentPageComponent() === 'organization-member/index' + ) { + refresh(); + } + }; + + window.addEventListener('pageshow', onPageShow); + + return () => { + stop(); + window.removeEventListener('pageshow', onPageShow); + }; +} diff --git a/tests/Unit/OrganizationMembersHistory.test.tsx b/tests/Unit/OrganizationMembersHistory.test.tsx new file mode 100644 index 0000000000..11075b5a0d --- /dev/null +++ b/tests/Unit/OrganizationMembersHistory.test.tsx @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vite-plus/test'; + +type NavigationType = 'initial' | 'visit' | 'history'; +type Navigation = { + detail: { + type: NavigationType; + page: { + component: string; + props: { auth: { user: { id: string } } }; + }; + }; +}; + +const reload = vi.fn(); +let navigate: ((event: Navigation) => void) | undefined; +let current = 'log/show'; + +vi.doMock('@inertiajs/react', () => ({ + router: { + on: (event: string, listener: (event: Navigation) => void) => { + assert.equal(event, 'navigate'); + navigate = listener; + + return () => { + navigate = undefined; + }; + }, + reload, + }, +})); +vi.doMock('@/lib/current-page', () => ({ + currentPageComponent: () => current, +})); + +const { trackOrganizationMembersHistory } = await import( + '../../resources/js/lib/organization-members-history' +); + +test('refreshes members and identity on history and bfcache restore', () => { + reload.mockClear(); + const events = new Map void>(); + const reloadPage = vi.fn(); + vi.stubGlobal('window', { + location: { reload: reloadPage }, + addEventListener: (name: string, listener: (event: { persisted: boolean }) => void) => + events.set(name, listener), + removeEventListener: (name: string) => events.delete(name), + }); + + try { + const stop = trackOrganizationMembersHistory(); + const go = (type: NavigationType, component: string) => + navigate?.({ + detail: { + type, + page: { component, props: { auth: { user: { id: '1' } } } }, + }, + }); + + go('initial', 'organization-member/index'); + go('visit', 'organization-member/index'); + go('history', 'log/show'); + assert.equal(reload.mock.calls.length, 0); + + go('history', 'organization-member/index'); + assert.equal(reload.mock.calls.length, 1); + const options = reload.mock.calls[0][0]; + assert.deepEqual(options.only, [ + 'organization', 'role', 'members', 'auth', + ]); + assert.equal(options.preserveScroll, true); + + options.onSuccess({ props: { auth: { user: { id: '1' } } } }); + assert.equal(reloadPage.mock.calls.length, 0); + options.onSuccess({ props: { auth: { user: { id: '2' } } } }); + assert.equal(reloadPage.mock.calls.length, 1); + + current = 'organization-member/index'; + events.get('pageshow')?.({ persisted: false }); + assert.equal(reload.mock.calls.length, 1); + events.get('pageshow')?.({ persisted: true }); + assert.equal(reload.mock.calls.length, 2); + + stop(); + assert.equal(events.has('pageshow'), false); + go('history', 'organization-member/index'); + assert.equal(reload.mock.calls.length, 2); + } finally { + vi.unstubAllGlobals(); + } +});