diff --git a/resources/js/components/product-hunt-banner.tsx b/resources/js/components/product-hunt-banner.tsx index 6594ce8a2..c14b57bec 100644 --- a/resources/js/components/product-hunt-banner.tsx +++ b/resources/js/components/product-hunt-banner.tsx @@ -19,10 +19,19 @@ export function ProductHuntBanner() { const [now, setNow] = useState(() => Date.now()); useEffect(() => { - const interval = setInterval(() => setNow(Date.now()), 1000); + if (now >= LAUNCH_ENDS_AT) { + return; + } - return () => clearInterval(interval); - }, []); + const nextChange = + now < LAUNCH_STARTS_AT ? LAUNCH_STARTS_AT : LAUNCH_ENDS_AT; + const timeout = setTimeout( + () => setNow(Date.now()), + Math.min(2_147_483_647, Math.max(0, nextChange - Date.now())), + ); + + return () => clearTimeout(timeout); + }, [now]); if (now < LAUNCH_STARTS_AT || now >= LAUNCH_ENDS_AT) { return null; diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx index 3e87fb4c4..2c286e9d6 100644 --- a/resources/js/layouts/app-layout.tsx +++ b/resources/js/layouts/app-layout.tsx @@ -52,6 +52,18 @@ export default function AppLayout({ children }: AppLayoutProps) { const showShortcuts = useCallback(() => setIsShowingShortcuts(true), []); const openSearch = useCallback(() => setIsSearching(true), []); const isGuest = auth.user === null; + // Public feeds and documents have no unread counts, and these routes cannot be deleted. + const skipCountPolling = + isGuest && + [ + 'log/show', + 'about/show', + 'brand/show', + 'changelog/show', + 'privacy/show', + 'search/index', + 'terms/show', + ].includes(component); const isMessages = component.startsWith('conversation/'); const hasComposer = component === 'log/show' && !isGuest; const isComposerInView = useIsComposerInView(); @@ -106,7 +118,7 @@ export default function AppLayout({ children }: AppLayoutProps) { data-nav={isLayoutOpen ? 'expanded' : 'collapsed'} className="mx-auto flex min-h-svh w-full max-w-[1264px] justify-center" > - {deletedPage === null && ( + {deletedPage === null && !skipCountPolling && ( +import assert from 'node:assert/strict'; +import * as React from 'react'; +import { afterAll, afterEach, test, vi } from 'vite-plus/test'; + +const updates = vi.fn(); +const cleanups: (() => void)[] = []; + +vi.doMock('react', () => ({ + ...React, + useState: (getInitial: () => number) => [getInitial(), updates], + useEffect: (effect: () => (() => void) | undefined) => { + const cleanup = effect(); + if (cleanup) cleanups.push(cleanup); + }, +})); + +const { ProductHuntBanner } = await import( + '../../resources/js/components/product-hunt-banner' +); + +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()); + vi.useRealTimers(); + updates.mockClear(); +}); +afterAll(() => vi.doUnmock('react')); + +test('expired launch does not leave a one-second timer running', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-25T12:00:00Z')); + + assert.equal(ProductHuntBanner(), null); + assert.equal(vi.getTimerCount(), 0); +}); + +test('before launch, schedules only the launch start', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-19T12:00:00Z')); + + assert.equal(ProductHuntBanner(), null); + assert.equal(vi.getTimerCount(), 1); + + vi.advanceTimersByTime(1_000); + assert.equal(updates.mock.calls.length, 0); + + vi.advanceTimersToNextTimer(); + assert.equal(updates.mock.calls.length, 1); +}); + +test('during launch, schedules only the launch end', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-20T12:00:00Z')); + + assert.notEqual(ProductHuntBanner(), null); + assert.equal(vi.getTimerCount(), 1); + + vi.advanceTimersByTime(1_000); + assert.equal(updates.mock.calls.length, 0); + + vi.advanceTimersToNextTimer(); + assert.equal(updates.mock.calls.length, 1); +}); + +test('a clock far before launch does not overflow the timeout delay', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-01T12:00:00Z')); + const start = Date.now(); + + assert.equal(ProductHuntBanner(), null); + assert.equal(vi.getTimerCount(), 1); + + vi.advanceTimersByTime(1_000); + assert.equal(updates.mock.calls.length, 0); + + vi.advanceTimersToNextTimer(); + assert.equal(updates.mock.calls.length, 1); + assert.equal(updates.mock.calls[0]?.[0], start + 2_147_483_647); +});