[web] Add compilation indicator favicon (#25990)

* Import changes from Hackathon

https://github.com/overleaf/internal/pull/24501

* Update compile status: allow errors

* Update favicons. Use the ones from Figma

* Optimize and reuse path from favicon.svg

* Clear status favicon after 5s on active tab

* Rename hook from useCompileNotification to useStatusFavicon

* Add tests

* Revert changes to favicon.svg

* Query favicon on document.head

GitOrigin-RevId: 3972b1981abaf6c80273e0ed5b1bc05eb51bd689
This commit is contained in:
Antoine Clausse
2025-06-23 11:09:26 +02:00
committed by Copybot
parent 46555d27b0
commit 2d5a3efc12
6 changed files with 235 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import {
} from '@/features/ide-redesign/utils/new-editor-utils'
import EditorSurvey from '../editor-survey'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useStatusFavicon } from '@/features/ide-react/hooks/use-status-favicon'
const MainLayoutNew = lazy(
() => import('@/features/ide-redesign/components/main-layout')
@@ -30,6 +31,7 @@ export default function IdePage() {
useEditingSessionHeartbeat() // send a batched event when user is active
useRegisterUserActivity() // record activity and ensure connection when user is active
useHasLintingError() // pass editor:lint hasLintingError to the compiler
useStatusFavicon() // update the favicon based on the compile status
const newEditor = useIsNewEditorEnabled()
const canAccessNewEditor = canUseNewEditor()

View File

@@ -0,0 +1,75 @@
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
import { useEffect, useState } from 'react'
import usePreviousValue from '@/shared/hooks/use-previous-value'
const RESET_AFTER_MS = 5_000
const COMPILE_ICONS = {
ERROR: '/favicon-error.svg',
COMPILING: '/favicon-compiling.svg',
COMPILED: '/favicon-compiled.svg',
UNCOMPILED: '/favicon.svg',
}
type CompileStatus = keyof typeof COMPILE_ICONS
const useCompileStatus = (): CompileStatus => {
const compileContext = useCompileContext()
if (compileContext.uncompiled) return 'UNCOMPILED'
if (compileContext.compiling) return 'COMPILING'
if (compileContext.error) return 'ERROR'
return 'COMPILED'
}
const removeFavicon = () => {
const existingFavicons = document.head.querySelectorAll(
"link[rel='icon']"
) as NodeListOf<HTMLLinkElement>
existingFavicons.forEach(favicon => {
if (favicon.href.endsWith('.svg')) favicon.parentNode?.removeChild(favicon)
})
}
const updateFavicon = (status: CompileStatus = 'UNCOMPILED') => {
removeFavicon()
const linkElement = document.createElement('link')
linkElement.rel = 'icon'
linkElement.href = COMPILE_ICONS[status]
linkElement.type = 'image/svg+xml'
linkElement.setAttribute('data-compile-status', 'true')
document.head.appendChild(linkElement)
}
const isActive = () => !document.hidden
const useIsWindowActive = () => {
const [isWindowActive, setIsWindowActive] = useState(isActive())
useEffect(() => {
const handleVisibilityChange = () => setIsWindowActive(isActive())
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
return isWindowActive
}
export const useStatusFavicon = () => {
const compileStatus = useCompileStatus()
const previousCompileStatus = usePreviousValue(compileStatus)
const isWindowActive = useIsWindowActive()
useEffect(() => {
if (previousCompileStatus !== compileStatus) {
return updateFavicon(compileStatus)
}
if (
isWindowActive &&
(compileStatus === 'COMPILED' || compileStatus === 'ERROR')
) {
const timeout = setTimeout(updateFavicon, RESET_AFTER_MS)
return () => clearTimeout(timeout)
}
}, [compileStatus, isWindowActive, previousCompileStatus])
}

View File

@@ -0,0 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#fff"/>
<path
d="M13.0748 3.19694C11.6883 2.6541 6.67193 2.45789 6.66539 5.45331C5.20038 6.38857 4.23242 7.91244 4.23242 9.5475C4.23242 11.5226 5.83478 13.125 7.80992 13.125C9.78507 13.125 11.3874 11.5226 11.3874 9.5475C11.3874 8.02362 10.4326 6.71558 9.08527 6.20544C8.82366 6.10734 8.2612 5.93075 7.81646 5.96999C7.17552 6.37549 6.3907 7.21263 6.02444 8.04978C6.57382 7.38922 7.43059 7.10145 8.1958 7.22571C9.31418 7.40884 10.1709 8.37679 10.1709 9.55404C10.1709 10.8555 9.11797 11.9085 7.81646 11.9085C7.09704 11.9085 6.4561 11.588 6.02444 11.0844C5.37696 10.3323 5.21346 9.52133 5.34426 8.72997C5.79554 5.95691 9.08527 4.38072 11.5313 3.77248C10.7334 4.19759 9.29456 4.89085 8.28736 5.64298C11.2239 6.78098 11.7014 4.30223 13.0748 3.19694Z"
fill="#046530"/>
<circle cx="10.5" cy="11.5" r="3.5" fill="#fff"/>
<path fill="#000"
d="m9.615 12.032 2.75-2.921a.31.31 0 0 1 .26-.111q.146 0 .26.122a.395.395 0 0 1 0 .555l-3 3.2a.349.349 0 0 1-.531 0l-1.24-1.322a.395.395 0 0 1 0-.556.35.35 0 0 1 .261-.122q.146 0 .26.122z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#fff"/>
<path
d="M13.0748 3.19694C11.6883 2.6541 6.67193 2.45789 6.66539 5.45331C5.20038 6.38857 4.23242 7.91244 4.23242 9.5475C4.23242 11.5226 5.83478 13.125 7.80992 13.125C9.78507 13.125 11.3874 11.5226 11.3874 9.5475C11.3874 8.02362 10.4326 6.71558 9.08527 6.20544C8.82366 6.10734 8.2612 5.93075 7.81646 5.96999C7.17552 6.37549 6.3907 7.21263 6.02444 8.04978C6.57382 7.38922 7.43059 7.10145 8.1958 7.22571C9.31418 7.40884 10.1709 8.37679 10.1709 9.55404C10.1709 10.8555 9.11797 11.9085 7.81646 11.9085C7.09704 11.9085 6.4561 11.588 6.02444 11.0844C5.37696 10.3323 5.21346 9.52133 5.34426 8.72997C5.79554 5.95691 9.08527 4.38072 11.5313 3.77248C10.7334 4.19759 9.29456 4.89085 8.28736 5.64298C11.2239 6.78098 11.7014 4.30223 13.0748 3.19694Z"
fill="#046530"/>
<circle cx="10.5" cy="11.5" r="3.5" fill="#fff"/>
<path fill="#000"
d="M9.1 11.4q0 .4.158.742.167.341.442.583V12.5q0-.125.083-.208A.3.3 0 0 1 10 12.2a.27.27 0 0 1 .208.092.27.27 0 0 1 .092.208v1q0 .125-.092.217A.28.28 0 0 1 10 13.8H9a.3.3 0 0 1-.217-.083.3.3 0 0 1-.083-.217q0-.125.083-.208A.3.3 0 0 1 9 13.2h.317a2.6 2.6 0 0 1-.6-.792A2.4 2.4 0 0 1 8.5 11.4q0-.708.367-1.275.366-.575.966-.867a.22.22 0 0 1 .217 0 .28.28 0 0 1 .15.167.3.3 0 0 1-.008.233.3.3 0 0 1-.15.167 1.8 1.8 0 0 0-.684.65q-.258.408-.258.925m3.6 0a1.7 1.7 0 0 0-.167-.742 1.7 1.7 0 0 0-.433-.583v.225q0 .125-.092.217a.28.28 0 0 1-.208.083.3.3 0 0 1-.217-.083.3.3 0 0 1-.083-.217v-1q0-.125.083-.208A.3.3 0 0 1 11.8 9h1a.27.27 0 0 1 .208.092.27.27 0 0 1 .092.208q0 .125-.092.217a.28.28 0 0 1-.208.083h-.317q.376.333.592.8.225.459.225 1 0 .708-.367 1.283a2.36 2.36 0 0 1-.966.859.23.23 0 0 1-.217.008.3.3 0 0 1-.15-.175.35.35 0 0 1-.008-.225.3.3 0 0 1 .15-.167q.425-.225.691-.641a1.7 1.7 0 0 0 .267-.942"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="#fff"/>
<path
d="M13.0748 3.19694C11.6883 2.6541 6.67193 2.45789 6.66539 5.45331C5.20038 6.38857 4.23242 7.91244 4.23242 9.5475C4.23242 11.5226 5.83478 13.125 7.80992 13.125C9.78507 13.125 11.3874 11.5226 11.3874 9.5475C11.3874 8.02362 10.4326 6.71558 9.08527 6.20544C8.82366 6.10734 8.2612 5.93075 7.81646 5.96999C7.17552 6.37549 6.3907 7.21263 6.02444 8.04978C6.57382 7.38922 7.43059 7.10145 8.1958 7.22571C9.31418 7.40884 10.1709 8.37679 10.1709 9.55404C10.1709 10.8555 9.11797 11.9085 7.81646 11.9085C7.09704 11.9085 6.4561 11.588 6.02444 11.0844C5.37696 10.3323 5.21346 9.52133 5.34426 8.72997C5.79554 5.95691 9.08527 4.38072 11.5313 3.77248C10.7334 4.19759 9.29456 4.89085 8.28736 5.64298C11.2239 6.78098 11.7014 4.30223 13.0748 3.19694Z"
fill="#046530"/>
<circle cx="10.5" cy="11.5" r="3.5" fill="#fff"/>
<path fill="#000"
d="m10.5 12.064-1.814 1.814a.38.38 0 0 1-.277.122.43.43 0 0 1-.276-.133.38.38 0 0 1-.122-.276q0-.166.122-.288L9.936 11.5 8.122 9.686A.38.38 0 0 1 8 9.409a.44.44 0 0 1 .133-.287A.38.38 0 0 1 8.409 9q.166 0 .288.122l1.803 1.814 1.814-1.814A.38.38 0 0 1 12.591 9q.165 0 .287.122a.4.4 0 0 1 .122.287q0 .156-.122.277L11.064 11.5l1.814 1.814a.38.38 0 0 1 .122.277q0 .155-.122.276a.4.4 0 0 1-.287.122.38.38 0 0 1-.277-.122z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,131 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { renderHook } from '@testing-library/react'
import * as CompileContext from '@/shared/context/detach-compile-context'
import { useStatusFavicon } from '@/features/ide-react/hooks/use-status-favicon'
type Compilation = { uncompiled: boolean; compiling: boolean; error: boolean }
describe('useStatusFavicon', function () {
let mockUseDetachCompileContext: sinon.SinonStub
let clock: sinon.SinonFakeTimers
let originalHidden: PropertyDescriptor | undefined
const setCompilation = (compileContext: Compilation) => {
mockUseDetachCompileContext.returns(compileContext)
}
const setHidden = (hidden: boolean) => {
Object.defineProperty(document, 'hidden', {
writable: true,
configurable: true,
value: hidden,
})
document.dispatchEvent(new Event('visibilitychange'))
}
const getFaviconElements = () =>
document.querySelectorAll('link[data-compile-status="true"]')
const getCurrentFaviconHref = () => {
const favicon = document.querySelector(
'link[data-compile-status="true"]'
) as HTMLLinkElement
return favicon?.href || null
}
beforeEach(function () {
mockUseDetachCompileContext = sinon.stub(
CompileContext,
'useDetachCompileContext'
)
// Mock timers for timeout testing
clock = sinon.useFakeTimers()
// Store original document.hidden descriptor
originalHidden = Object.getOwnPropertyDescriptor(
Document.prototype,
'hidden'
)
// Clean up any existing favicon elements
document
.querySelectorAll('link[data-compile-status="true"]')
.forEach(el => el.remove())
})
afterEach(function () {
sinon.restore()
clock.restore()
// Restore original document.hidden
if (originalHidden) {
Object.defineProperty(document, 'hidden', originalHidden)
}
// Clean up favicon elements
document
.querySelectorAll('link[data-compile-status="true"]')
.forEach(el => el.remove())
})
it('updates favicon to reflect status: UNCOMPILED', function () {
setCompilation({ uncompiled: true, compiling: false, error: false })
renderHook(() => useStatusFavicon())
expect(getCurrentFaviconHref()).to.include('/favicon.svg')
})
it('updates favicon to reflect status: COMPILING', function () {
setCompilation({ uncompiled: false, compiling: true, error: false })
renderHook(() => useStatusFavicon())
expect(getCurrentFaviconHref()).to.include('/favicon-compiling.svg')
})
it('updates favicon to reflect status: COMPILED', function () {
setCompilation({ uncompiled: false, compiling: false, error: false })
renderHook(() => useStatusFavicon())
expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg')
})
it('updates favicon to reflect status: ERROR', function () {
setCompilation({ uncompiled: false, compiling: false, error: true })
renderHook(() => useStatusFavicon())
expect(getCurrentFaviconHref()).to.include('/favicon-error.svg')
})
it('keeps the COMPILED favicon for 5 seconds when the window is active', function () {
setCompilation({ uncompiled: false, compiling: false, error: false })
const { rerender } = renderHook(() => useStatusFavicon())
setHidden(false)
rerender()
expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg')
clock.tick(4500)
expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg')
clock.tick(1000)
expect(getCurrentFaviconHref()).to.include('/favicon.svg')
})
it('keeps the COMPILED favicon forever when the window is hidden', function () {
setCompilation({ uncompiled: false, compiling: false, error: false })
const { rerender } = renderHook(() => useStatusFavicon())
setHidden(true)
rerender()
expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg')
clock.tick(90000)
expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg')
})
it('should only have one favicon element at a time', function () {
setCompilation({ uncompiled: true, compiling: false, error: false })
const { rerender } = renderHook(() => useStatusFavicon())
expect(getFaviconElements()).to.have.length(1)
expect(getCurrentFaviconHref()).to.include('/favicon.svg')
setCompilation({ uncompiled: false, compiling: true, error: false })
rerender()
expect(getFaviconElements()).to.have.length(1)
expect(getCurrentFaviconHref()).to.include('/favicon-compiling.svg')
})
})