diff --git a/AGENTS.md b/AGENTS.md
index f9f5c62071..3bfa8ef9fe 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -314,6 +314,7 @@ Always run `yarn typecheck` before committing.
**React Patterns:**
+- The project uses **React Compiler** (`babel-plugin-react-compiler`) in the desktop-client. The compiler auto-memoizes component bodies, so you can omit manual `useCallback`, `useMemo`, and `React.memo` when adding or refactoring code; prefer inline callbacks and values unless a stable identity is required by a non-compiled dependency.
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
- Don't use `React.*` patterns - use named imports instead
- Use `` instead of `` tags
diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx
index ea5904cb1f..c9a312f7c3 100644
--- a/packages/desktop-client/src/components/App.tsx
+++ b/packages/desktop-client/src/components/App.tsx
@@ -34,6 +34,7 @@ import {
import { handleGlobalEvents } from '@desktop-client/global-events';
import { useIsTestEnv } from '@desktop-client/hooks/useIsTestEnv';
import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref';
+import { useOnVisible } from '@desktop-client/hooks/useOnVisible';
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
import { setI18NextLanguage } from '@desktop-client/i18n';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
@@ -179,6 +180,11 @@ export function App() {
);
const dispatch = useDispatch();
+ useOnVisible(async () => {
+ console.debug('triggering sync because of visibility change');
+ await dispatch(sync());
+ });
+
useEffect(() => {
function checkScrollbars() {
if (hiddenScrollbars !== hasHiddenScrollbars()) {
@@ -186,25 +192,9 @@ export function App() {
}
}
- let isSyncing = false;
-
- async function onVisibilityChange() {
- if (!isSyncing) {
- console.debug('triggering sync because of visibility change');
- isSyncing = true;
- await dispatch(sync());
- isSyncing = false;
- }
- }
-
window.addEventListener('focus', checkScrollbars);
- window.addEventListener('visibilitychange', onVisibilityChange);
-
- return () => {
- window.removeEventListener('focus', checkScrollbars);
- window.removeEventListener('visibilitychange', onVisibilityChange);
- };
- }, [dispatch, hiddenScrollbars]);
+ return () => window.removeEventListener('focus', checkScrollbars);
+ }, [hiddenScrollbars]);
const [theme] = useTheme();
diff --git a/packages/desktop-client/src/components/ServerContext.tsx b/packages/desktop-client/src/components/ServerContext.tsx
index bd404aff9b..057917331c 100644
--- a/packages/desktop-client/src/components/ServerContext.tsx
+++ b/packages/desktop-client/src/components/ServerContext.tsx
@@ -12,6 +12,7 @@ import { t } from 'i18next';
import { send } from 'loot-core/platform/client/connection';
import type { Handlers } from 'loot-core/types/handlers';
+import { useOnVisible } from '@desktop-client/hooks/useOnVisible';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -110,6 +111,16 @@ export function ServerProvider({ children }: { children: ReactNode }) {
void run();
}, []);
+ useOnVisible(
+ async () => {
+ const version = await getServerVersion();
+ setVersion(version);
+ },
+ {
+ isEnabled: !!serverURL,
+ },
+ );
+
const refreshLoginMethods = useCallback(async () => {
if (serverURL) {
const data: Awaited> =
diff --git a/packages/desktop-client/src/components/manager/ConfigServer.tsx b/packages/desktop-client/src/components/manager/ConfigServer.tsx
index fa8f11f8dd..f4cc0cf675 100644
--- a/packages/desktop-client/src/components/manager/ConfigServer.tsx
+++ b/packages/desktop-client/src/components/manager/ConfigServer.tsx
@@ -313,7 +313,7 @@ export function ConfigServer() {
switch (error) {
case 'network-failure':
return t(
- 'Server is not running at this URL. Make sure you have HTTPS set up properly.',
+ 'Connection failed. If you use a self-signed certificate or were recently offline, try refreshing the page. Otherwise ensure you have HTTPS set up properly.',
);
default:
return t(
diff --git a/packages/desktop-client/src/hooks/useOnVisible.test.ts b/packages/desktop-client/src/hooks/useOnVisible.test.ts
new file mode 100644
index 0000000000..c28e810937
--- /dev/null
+++ b/packages/desktop-client/src/hooks/useOnVisible.test.ts
@@ -0,0 +1,108 @@
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useOnVisible } from './useOnVisible';
+
+function setVisibilityState(value: DocumentVisibilityState) {
+ Object.defineProperty(document, 'visibilityState', {
+ value,
+ configurable: true,
+ writable: true,
+ });
+}
+
+function dispatchVisibilityChange() {
+ document.dispatchEvent(new Event('visibilitychange'));
+}
+
+describe('useOnVisible', () => {
+ const originalVisibilityState = document.visibilityState;
+
+ beforeEach(() => {
+ setVisibilityState('visible');
+ });
+
+ afterEach(() => {
+ setVisibilityState(originalVisibilityState);
+ vi.clearAllMocks();
+ });
+
+ it('invokes callback when document becomes visible', () => {
+ const callback = vi.fn();
+ renderHook(() => useOnVisible(callback));
+
+ dispatchVisibilityChange();
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not invoke callback when visibilityState is hidden', () => {
+ const callback = vi.fn();
+ renderHook(() => useOnVisible(callback));
+
+ setVisibilityState('hidden');
+ dispatchVisibilityChange();
+
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('does not attach listener when isEnabled is false', () => {
+ const callback = vi.fn();
+ renderHook(() => useOnVisible(callback, { isEnabled: false }));
+
+ dispatchVisibilityChange();
+
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('stops invoking callback after unmount', () => {
+ const callback = vi.fn();
+ const { unmount } = renderHook(() => useOnVisible(callback));
+
+ unmount();
+ dispatchVisibilityChange();
+
+ expect(callback).not.toHaveBeenCalled();
+ });
+
+ it('invokes callback on every visibilitychange when visibilityState is visible', async () => {
+ const callback = vi.fn();
+ renderHook(() => useOnVisible(callback));
+
+ dispatchVisibilityChange();
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ await Promise.resolve();
+ dispatchVisibilityChange();
+ expect(callback).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not invoke callback again until previous async callback completes', async () => {
+ let resolve: () => void;
+ const callback = vi.fn().mockImplementation(
+ () =>
+ new Promise(r => {
+ resolve = r;
+ }),
+ );
+ renderHook(() => useOnVisible(callback));
+
+ dispatchVisibilityChange();
+ dispatchVisibilityChange();
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ resolve();
+ await Promise.resolve();
+ dispatchVisibilityChange();
+ expect(callback).toHaveBeenCalledTimes(2);
+ });
+
+ it('invokes callback when isEnabled is true by default', () => {
+ const callback = vi.fn();
+ renderHook(() => useOnVisible(callback));
+
+ dispatchVisibilityChange();
+
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/desktop-client/src/hooks/useOnVisible.ts b/packages/desktop-client/src/hooks/useOnVisible.ts
new file mode 100644
index 0000000000..3fd8c3fb20
--- /dev/null
+++ b/packages/desktop-client/src/hooks/useOnVisible.ts
@@ -0,0 +1,47 @@
+import { useEffect, useEffectEvent, useRef } from 'react';
+
+type UseOnVisibleOptions = {
+ /** When false, the visibility listener is not attached. Default true. */
+ isEnabled?: boolean;
+};
+
+/**
+ * Runs the given callback when the document becomes visible (e.g. user
+ * switches back to the tab). Uses a guard so the callback is not invoked
+ * again until the previous invocation has finished (handles async callbacks).
+ */
+export function useOnVisible(
+ callback: () => void | Promise,
+ options: UseOnVisibleOptions = {},
+) {
+ const { isEnabled = true } = options;
+ const inProgress = useRef(false);
+
+ const runCallback = useEffectEvent(async () => {
+ if (inProgress.current) {
+ return;
+ }
+ inProgress.current = true;
+ try {
+ await callback();
+ } finally {
+ inProgress.current = false;
+ }
+ });
+
+ useEffect(() => {
+ if (!isEnabled) {
+ return;
+ }
+ function onVisibilityChange() {
+ if (document.visibilityState !== 'visible') {
+ return;
+ }
+ void runCallback();
+ }
+
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ return () =>
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ }, [isEnabled]);
+}
diff --git a/upcoming-release-notes/7111.md b/upcoming-release-notes/7111.md
new file mode 100644
index 0000000000..7e62aa1429
--- /dev/null
+++ b/upcoming-release-notes/7111.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [MatissJanis]
+---
+
+Reload server version when visibility to the page changes