fix: recover inert macos webview startup
This commit is contained in:
10
src/components/FrontendReadyMarker.tsx
Normal file
10
src/components/FrontendReadyMarker.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
import { markFrontendReady } from '@/utils/frontendReady'
|
||||
|
||||
export function FrontendReadyMarker() {
|
||||
useEffect(() => {
|
||||
markFrontendReady()
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react'
|
||||
|
||||
type ReactRootErrorInfo = { componentStack?: string }
|
||||
type ReactRootOptions = {
|
||||
@@ -80,11 +80,30 @@ function rootOptions(): ReactRootOptions {
|
||||
return options
|
||||
}
|
||||
|
||||
function renderedTree(): ReactNode {
|
||||
const tree = mocks.render.mock.calls[0]?.[0]
|
||||
if (!tree) throw new Error('React root was not rendered')
|
||||
return tree
|
||||
}
|
||||
|
||||
function hasElementTypeName(node: ReactNode, name: string): boolean {
|
||||
if (!isValidElement<{ children?: ReactNode }>(node)) return false
|
||||
|
||||
const typeName = typeof node.type === 'function' ? node.type.name : ''
|
||||
if (typeName === name) return true
|
||||
|
||||
return Children.toArray(node.props.children).some((child) =>
|
||||
hasElementTypeName(child, name),
|
||||
)
|
||||
}
|
||||
|
||||
describe('main entrypoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
window.__tolariaFrontendReady = false
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('captures React root errors through Sentry with component stack context', async () => {
|
||||
@@ -101,6 +120,7 @@ describe('main entrypoint', () => {
|
||||
)
|
||||
|
||||
const error = new Error('Maximum update depth exceeded')
|
||||
window.__tolariaFrontendReady = true
|
||||
rootOptions().onCaughtError?.(error, { componentStack: '\n in App' })
|
||||
|
||||
expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '\n in App' })
|
||||
@@ -110,11 +130,18 @@ describe('main entrypoint', () => {
|
||||
await importEntrypoint()
|
||||
|
||||
const error = new Error('recoverable render error')
|
||||
window.__tolariaFrontendReady = true
|
||||
rootOptions().onRecoverableError?.(error, {})
|
||||
|
||||
expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '' })
|
||||
})
|
||||
|
||||
it('mounts a frontend readiness marker after the app shell', async () => {
|
||||
await importEntrypoint()
|
||||
|
||||
expect(hasElementTypeName(renderedTree(), 'FrontendReadyMarker')).toBe(true)
|
||||
})
|
||||
|
||||
it('prevents browser navigation for file drags and still lets app drop handlers run', async () => {
|
||||
await importEntrypoint()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { FrontendReadyMarker } from './components/FrontendReadyMarker'
|
||||
import { LinuxTitlebar } from './components/LinuxTitlebar'
|
||||
import { applyStoredThemeMode } from './lib/themeMode'
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
type AppCommandShortcutEventOptions,
|
||||
} from './hooks/appCommandCatalog'
|
||||
import { shouldUseLinuxWindowChrome } from './utils/platform'
|
||||
import { reloadFrontendOnceIfStartupFailed } from './utils/frontendReady'
|
||||
|
||||
const EDITOR_DROP_SELECTOR = '.editor__blocknote-container'
|
||||
|
||||
@@ -113,6 +115,7 @@ function captureReactRootError(
|
||||
errorInfo: { componentStack?: string },
|
||||
): void {
|
||||
sentryReactErrorHandler(error, { componentStack: errorInfo.componentStack ?? '' })
|
||||
reloadFrontendOnceIfStartupFailed()
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!, {
|
||||
@@ -124,6 +127,7 @@ createRoot(document.getElementById('root')!, {
|
||||
<TooltipProvider>
|
||||
<LinuxTitlebar />
|
||||
<App />
|
||||
<FrontendReadyMarker />
|
||||
</TooltipProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
48
src/utils/frontendReady.test.ts
Normal file
48
src/utils/frontendReady.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
FRONTEND_READY_EVENT_NAME,
|
||||
STARTUP_RELOAD_ATTEMPT_KEY,
|
||||
markFrontendReady,
|
||||
reloadFrontendOnceIfStartupFailed,
|
||||
} from './frontendReady'
|
||||
|
||||
describe('frontend readiness recovery', () => {
|
||||
beforeEach(() => {
|
||||
window.__tolariaFrontendReady = false
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('marks the frontend ready and clears a pending startup reload', () => {
|
||||
const onReady = vi.fn()
|
||||
window.addEventListener(FRONTEND_READY_EVENT_NAME, onReady, { once: true })
|
||||
sessionStorage.setItem(STARTUP_RELOAD_ATTEMPT_KEY, '1')
|
||||
|
||||
markFrontendReady()
|
||||
|
||||
expect(window.__tolariaFrontendReady).toBe(true)
|
||||
expect(sessionStorage.getItem(STARTUP_RELOAD_ATTEMPT_KEY)).toBeNull()
|
||||
expect(onReady).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reloads once when React reports a startup error before readiness', () => {
|
||||
const reload = vi.fn()
|
||||
|
||||
const firstAttempt = reloadFrontendOnceIfStartupFailed({ reload })
|
||||
const secondAttempt = reloadFrontendOnceIfStartupFailed({ reload })
|
||||
|
||||
expect(firstAttempt).toBe(true)
|
||||
expect(secondAttempt).toBe(false)
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
expect(sessionStorage.getItem(STARTUP_RELOAD_ATTEMPT_KEY)).toBe('1')
|
||||
})
|
||||
|
||||
it('does not reload after the frontend has reported readiness', () => {
|
||||
const reload = vi.fn()
|
||||
markFrontendReady()
|
||||
|
||||
const didReload = reloadFrontendOnceIfStartupFailed({ reload })
|
||||
|
||||
expect(didReload).toBe(false)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
74
src/utils/frontendReady.ts
Normal file
74
src/utils/frontendReady.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export const FRONTEND_READY_EVENT_NAME = 'tolaria:frontend-ready'
|
||||
export const STARTUP_RELOAD_ATTEMPT_KEY = 'tolaria:startup-reload-attempted'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__tolariaFrontendReady?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type FrontendReadyOptions = {
|
||||
storage?: Storage
|
||||
win?: Window
|
||||
}
|
||||
|
||||
type StartupReloadOptions = FrontendReadyOptions & {
|
||||
reload?: () => void
|
||||
}
|
||||
|
||||
function getSessionStorage(win: Window): Storage | null {
|
||||
try {
|
||||
return win.sessionStorage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function removeSessionItem(storage: Storage | null, key: string): void {
|
||||
try {
|
||||
storage?.removeItem(key)
|
||||
} catch {
|
||||
// Storage can be unavailable in hardened WebView/privacy modes.
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionItem(storage: Storage | null, key: string): string | null {
|
||||
try {
|
||||
return storage?.getItem(key) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeSessionItem(storage: Storage | null, key: string, value: string): boolean {
|
||||
try {
|
||||
storage?.setItem(key, value)
|
||||
return storage !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function markFrontendReady(options: FrontendReadyOptions = {}): void {
|
||||
const win = options.win ?? window
|
||||
const storage = options.storage ?? getSessionStorage(win)
|
||||
|
||||
win.__tolariaFrontendReady = true
|
||||
removeSessionItem(storage, STARTUP_RELOAD_ATTEMPT_KEY)
|
||||
win.dispatchEvent(new Event(FRONTEND_READY_EVENT_NAME))
|
||||
}
|
||||
|
||||
export function reloadFrontendOnceIfStartupFailed(
|
||||
options: StartupReloadOptions = {},
|
||||
): boolean {
|
||||
const win = options.win ?? window
|
||||
const storage = options.storage ?? getSessionStorage(win)
|
||||
|
||||
if (win.__tolariaFrontendReady === true) return false
|
||||
if (readSessionItem(storage, STARTUP_RELOAD_ATTEMPT_KEY) === '1') return false
|
||||
if (!writeSessionItem(storage, STARTUP_RELOAD_ATTEMPT_KEY, '1')) return false
|
||||
|
||||
const reload = options.reload ?? (() => win.location.reload())
|
||||
reload()
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user