mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(pwa): make web updates deterministic (#1093)
* fix(pwa): make web updates deterministic * fix(pwa): address review feedback
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react';
|
||||
import packageJson from '../../../package.json';
|
||||
import { fetchLatestStableVersion, isElectron, refreshServiceWorkerRegistration } from '../../lib/app-update';
|
||||
import useAppUpdateStore from '../../stores/use-app-update-store';
|
||||
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
const AppUpdateRegistration = () => {
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
|
||||
const syncUpdateAvailability = async () => {
|
||||
await refreshServiceWorkerRegistration().catch((error) => {
|
||||
console.error('Failed to refresh service worker registration', error);
|
||||
});
|
||||
|
||||
try {
|
||||
const latestStableVersion = await fetchLatestStableVersion();
|
||||
|
||||
if (!isDisposed) {
|
||||
useAppUpdateStore.getState().setNeedRefresh(packageJson.version !== latestStableVersion);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check app update availability', error);
|
||||
}
|
||||
};
|
||||
|
||||
void syncUpdateAvailability();
|
||||
const intervalId = window.setInterval(() => {
|
||||
void syncUpdateAvailability();
|
||||
}, UPDATE_CHECK_INTERVAL_MS);
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
window.clearInterval(intervalId);
|
||||
useAppUpdateStore.getState().setNeedRefresh(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AppUpdateRegistration;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './app-update-registration';
|
||||
+36
-4
@@ -7,12 +7,14 @@ import packageJson from '../../../../../package.json';
|
||||
import InterfaceSettings from '../interface-settings';
|
||||
import useFeedViewSettingsStore from '../../../../stores/use-feed-view-settings-store';
|
||||
import { INTERFACE_LANGUAGE_STORAGE_KEY } from '../../../../lib/constants';
|
||||
import useAppUpdateStore from '../../../../stores/use-app-update-store';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
alertMock: vi.fn(),
|
||||
applyAppUpdateMock: vi.fn(),
|
||||
changeLanguageMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
fitExpandedImagesToScreen: false,
|
||||
@@ -79,11 +81,16 @@ describe('InterfaceSettings', () => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
testState.alertMock.mockReset();
|
||||
testState.applyAppUpdateMock.mockReset();
|
||||
testState.changeLanguageMock.mockReset();
|
||||
testState.fetchMock.mockReset();
|
||||
testState.fitExpandedImagesToScreen = false;
|
||||
testState.setFitExpandedImagesToScreenMock.mockReset();
|
||||
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false);
|
||||
useAppUpdateStore.setState({
|
||||
needRefresh: false,
|
||||
applyAppUpdate: testState.applyAppUpdateMock,
|
||||
});
|
||||
vi.stubGlobal('alert', testState.alertMock);
|
||||
vi.stubGlobal('fetch', testState.fetchMock);
|
||||
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
@@ -188,7 +195,7 @@ describe('InterfaceSettings', () => {
|
||||
expect(testState.alertMock).toHaveBeenCalledWith(expect.stringContaining('latest_stable_version'));
|
||||
});
|
||||
|
||||
it('alerts when a newer stable version is available', async () => {
|
||||
it('applies the app update when a newer stable version is available', async () => {
|
||||
testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: '9.9.9' }));
|
||||
|
||||
render(createElement(InterfaceSettings));
|
||||
@@ -201,9 +208,8 @@ describe('InterfaceSettings', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const message = String(testState.alertMock.mock.calls.at(-1)?.[0] ?? '');
|
||||
expect(message).toContain('new_stable_version');
|
||||
expect(message).toContain('refresh_to_update');
|
||||
expect(testState.applyAppUpdateMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.alertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('alerts when already on the latest stable version', async () => {
|
||||
@@ -222,6 +228,32 @@ describe('InterfaceSettings', () => {
|
||||
expect(testState.alertMock).toHaveBeenCalledWith(expect.stringContaining('latest_stable_version'));
|
||||
});
|
||||
|
||||
it('renders an update button when a service worker refresh is ready', () => {
|
||||
useAppUpdateStore.setState({ needRefresh: true });
|
||||
|
||||
render(createElement(InterfaceSettings));
|
||||
|
||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'update');
|
||||
expect(button).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies the waiting service worker when the update button is pressed', async () => {
|
||||
useAppUpdateStore.setState({ needRefresh: true });
|
||||
|
||||
render(createElement(InterfaceSettings));
|
||||
|
||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'update');
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(testState.applyAppUpdateMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('alerts when fetching the latest version info fails', async () => {
|
||||
testState.fetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
|
||||
@@ -8,22 +8,26 @@ import useFeedViewSettingsStore from '../../../stores/use-feed-view-settings-sto
|
||||
import Version from '../../version';
|
||||
import StyleSelector from '../../style-selector/style-selector';
|
||||
import { INTERFACE_LANGUAGE_STORAGE_KEY, SUPPORTED_INTERFACE_LANGUAGES } from '../../../lib/constants';
|
||||
import { fetchLatestStableVersion, isElectron } from '../../../lib/app-update';
|
||||
import useAppUpdateStore from '../../../stores/use-app-update-store';
|
||||
|
||||
const commitRef = process.env.VITE_COMMIT_REF;
|
||||
const isElectron = window.electronApi?.isElectron === true;
|
||||
|
||||
const fetchLatestVersionInfo = async (t: (key: string, opts?: Record<string, unknown>) => string): Promise<void> => {
|
||||
const fetchLatestVersionInfo = async (t: (key: string, opts?: Record<string, unknown>) => string, applyAppUpdate: () => Promise<void>): Promise<void> => {
|
||||
try {
|
||||
const packageRes = await fetch('https://raw.githubusercontent.com/bitsocialnet/5chan/master/package.json', { cache: 'no-cache' });
|
||||
const packageData = await packageRes.json();
|
||||
const latestStableVersion = await fetchLatestStableVersion();
|
||||
let updateAvailable = false;
|
||||
|
||||
if (packageJson.version !== packageData.version) {
|
||||
const newVersionText = t('new_stable_version', { newVersion: packageData.version, oldVersion: packageJson.version });
|
||||
const updateActionText = isElectron
|
||||
? t('download_latest_desktop', { link: 'https://github.com/bitsocialnet/5chan/releases/latest', interpolation: { escapeValue: false } })
|
||||
: t('refresh_to_update');
|
||||
alert(newVersionText + ' ' + updateActionText);
|
||||
if (packageJson.version !== latestStableVersion) {
|
||||
if (isElectron) {
|
||||
const newVersionText = t('new_stable_version', { newVersion: latestStableVersion, oldVersion: packageJson.version });
|
||||
const updateActionText = t('download_latest_desktop', { link: 'https://github.com/bitsocialnet/5chan/releases/latest', interpolation: { escapeValue: false } });
|
||||
alert(newVersionText + ' ' + updateActionText);
|
||||
} else {
|
||||
await applyAppUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
updateAvailable = true;
|
||||
}
|
||||
|
||||
@@ -55,16 +59,26 @@ const fetchLatestVersionInfo = async (t: (key: string, opts?: Record<string, unk
|
||||
const CheckForUpdates = () => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const needRefresh = useAppUpdateStore((state) => state.needRefresh);
|
||||
const applyAppUpdate = useAppUpdateStore((state) => state.applyAppUpdate);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setLoading(true);
|
||||
await fetchLatestVersionInfo(t);
|
||||
setLoading(false);
|
||||
try {
|
||||
if (needRefresh) {
|
||||
await applyAppUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchLatestVersionInfo(t, applyAppUpdate);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={styles.checkForUpdatesButton} onClick={checkForUpdates} disabled={loading}>
|
||||
{t('check')}
|
||||
{needRefresh ? t('update') : t('check')}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
declare interface ImportMetaEnv {
|
||||
readonly VITE_COMMIT_REF: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HashRouter as Router } from 'react-router-dom';
|
||||
import './lib/init-translations';
|
||||
import './index.css';
|
||||
import './themes.css';
|
||||
import AppUpdateRegistration from './components/app-update-registration';
|
||||
import { App as CapacitorApp } from '@capacitor/app';
|
||||
import { Analytics } from '@vercel/analytics/react';
|
||||
|
||||
@@ -19,6 +20,7 @@ const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router>
|
||||
<AppUpdateRegistration />
|
||||
<App />
|
||||
{isVercelDeployment && <Analytics />}
|
||||
</Router>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const isElectron = window.electronApi?.isElectron === true;
|
||||
|
||||
const fetchLatestStableVersion = async (): Promise<string> => {
|
||||
const versionUrl = isElectron
|
||||
? 'https://raw.githubusercontent.com/bitsocialnet/5chan/master/package.json'
|
||||
: new URL(`/version.json?t=${Date.now()}`, window.location.origin).toString();
|
||||
const packageRes = await fetch(versionUrl, { cache: 'no-store' });
|
||||
const packageData = await packageRes.json();
|
||||
|
||||
if (typeof packageData?.version !== 'string') {
|
||||
throw new Error('invalid version payload');
|
||||
}
|
||||
|
||||
return packageData.version;
|
||||
};
|
||||
|
||||
const refreshServiceWorkerRegistration = async (): Promise<void> => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
await registration?.update();
|
||||
};
|
||||
|
||||
export { fetchLatestStableVersion, isElectron, refreshServiceWorkerRegistration };
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
import { refreshServiceWorkerRegistration } from '../lib/app-update';
|
||||
|
||||
interface AppUpdateState {
|
||||
needRefresh: boolean;
|
||||
setNeedRefresh: (needRefresh: boolean) => void;
|
||||
applyAppUpdate: () => Promise<void>;
|
||||
}
|
||||
|
||||
const reloadCurrentPage = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const useAppUpdateStore = create<AppUpdateState>((set) => ({
|
||||
needRefresh: false,
|
||||
setNeedRefresh: (needRefresh) => set({ needRefresh }),
|
||||
applyAppUpdate: async () => {
|
||||
await refreshServiceWorkerRegistration().catch((error) => {
|
||||
console.error('Failed to refresh service worker registration', error);
|
||||
});
|
||||
reloadCurrentPage();
|
||||
},
|
||||
}));
|
||||
|
||||
export default useAppUpdateStore;
|
||||
@@ -3,12 +3,24 @@
|
||||
|
||||
import { clientsClaim } from 'workbox-core';
|
||||
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { NetworkFirst } from 'workbox-strategies';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
// Precache all assets specified in the manifest
|
||||
const precacheEntries = self.__WB_MANIFEST.filter((entry) => (typeof entry === 'string' ? entry !== 'index.html' : entry.url !== 'index.html'));
|
||||
|
||||
// Precache revisioned assets, but let navigations fetch fresh HTML first.
|
||||
cleanupOutdatedCaches();
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
precacheAndRoute(precacheEntries);
|
||||
|
||||
registerRoute(
|
||||
({ request, url }) => request.mode === 'navigate' && !url.pathname.startsWith('/api') && !/^\/_\(.*\)/.test(url.pathname),
|
||||
new NetworkFirst({
|
||||
cacheName: 'html-cache',
|
||||
networkTimeoutSeconds: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
// Standard SW lifecycle methods
|
||||
self.skipWaiting();
|
||||
|
||||
Reference in New Issue
Block a user