From 758aca2b493aa8bfdbf2f43929fb85081a7a1d82 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 25 Nov 2025 10:53:45 -0600 Subject: [PATCH 1/3] feat: move stats collection out of initialContext Previously, stats would block the application until it was resolved. This led to a serious issue in which the stats server was not responding and the request timed out: the application would be completely inacessible while this was occuring. Because it's not essential, stats are now sent in the background. (cherry picked from commit f1904fec7b6b6d048fffcec5afe993dcab1b3adf) --- .../lib/components/Core/ContextLoader.tsx | 27 +++++-- .../lib/components/InitialContext/index.ts | 28 +++++++ .../lib/components/InitialContext/stats.ts | 81 +++++++++++++++++++ .../components/InitialContext/systemInfo.ts | 80 ------------------ 4 files changed, 130 insertions(+), 86 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts diff --git a/specifyweb/frontend/js_src/lib/components/Core/ContextLoader.tsx b/specifyweb/frontend/js_src/lib/components/Core/ContextLoader.tsx index 1742c55324e..620c55f2295 100644 --- a/specifyweb/frontend/js_src/lib/components/Core/ContextLoader.tsx +++ b/specifyweb/frontend/js_src/lib/components/Core/ContextLoader.tsx @@ -5,25 +5,40 @@ import { useDelay } from '../../hooks/useDelay'; import { commonText } from '../../localization/common'; import { f } from '../../utils/functools'; import { SECOND } from '../Atoms/timeUnits'; -import { crash } from '../Errors/Crash'; +import { crash, softFail } from '../Errors/Crash'; import { useMenuItems } from '../Header/menuItemProcessing'; -import { initialContext } from '../InitialContext'; +import { initialContext, secondaryContext } from '../InitialContext'; import { Main } from './Main'; import { SplashScreen } from './SplashScreen'; // Show loading splash screen if didn't finish load within 2 seconds const LOADING_TIMEOUT = 2 * SECOND; -const fetchContext = async (): Promise => - initialContext.then(f.true).catch(crash); +const fetchContext = + ( + context: Promise, + errorMode: 'crash' | 'console' + ): (() => Promise) => + async () => + context.then(f.true).catch(errorMode === 'crash' ? crash : softFail); /** - * - Load initial context + * - Load initial and secondary context * - Display loading screen while loading * - Display the main component afterward */ export function ContextLoader(): JSX.Element | null { - const [isContextLoaded = false] = useAsyncState(fetchContext, false); + const [isContextLoaded = false] = useAsyncState( + React.useCallback(fetchContext(initialContext, 'crash'), []), + false + ); + const [_secondaryContextLoaded = false] = useAsyncState( + React.useCallback( + isContextLoaded ? fetchContext(secondaryContext, 'console') : f.undefined, + [isContextLoaded] + ), + false + ); const menuItems = useMenuItems(); const isLoaded = isContextLoaded && typeof menuItems === 'object'; diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts index 5b2f5a36efb..d3f94c9283f 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts @@ -65,6 +65,34 @@ export const load = async (path: string, mimeType: MimeType): Promise => return data; }); +/** + * These endpoints should still be called as a part of loading the initial + * context, but are not strictly required for normal operation of the + * application. + * Because of this, these endpoints are called after the initialContext and do + * not block or prevent access to Specify + */ +export const secondaryContext = Promise.all([ + /** REFACTOR: Move non-essential endpoints here from initialContext to speed + * up initial loading times. + * Icon Definitions, Legacy UI Localization, Uniqueness Rules, and possibly + * even Field Formatters and Remote Prefs can all theoretically be moved here. + * + * Some more work would need to be done to handle the case where a component + * attempts to access the resources as they're being fetched. + */ + // Send basic stats + import('./stats'), +]).then(async (modules) => + Promise.all(modules.map(async ({ fetchContext }) => fetchContext)) +); + +/** + * These endpoints are essential for nearly all operations in Specify and have + * to be fetched before the application can even be accessed. + * That is, the application will necessarily be blocked until + * all of these requests are resolved. + */ export const initialContext = Promise.all([ // Fetch general context information (NOT CACHED) import('../DataModel/schema'), diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts new file mode 100644 index 00000000000..47a182932a1 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts @@ -0,0 +1,81 @@ +import { fetchContext as fetchSystemInfo } from './systemInfo'; +import { ping } from '../../utils/ajax/ping'; +import { softFail } from '../Errors/Crash'; +import { formatUrl } from '../Router/queryString'; +import { load } from './index'; + +type StatsCounts = { + readonly Collectionobject: number; + readonly Collection: number; + readonly Specifyuser: number; +}; + +function buildStatsLambdaUrl(base: string | null | undefined): string | null { + if (!base) return null; + let u = base.trim(); + + if (!/^https?:\/\//i.test(u)) u = `https://${u}`; + + const hasRoute = /\/(prod|default)\/[^\s/]+/.test(u); + if (!hasRoute) { + const stage = 'prod'; + const route = 'AggrgatedSp7Stats'; + u = `${u.replace(/\/$/, '')}/${stage}/${route}`; + } + return u; +} + +export const fetchContext = fetchSystemInfo.then(async (systemInfo) => { + if (systemInfo === undefined) { + return; + } + if (systemInfo.stats_url === null && systemInfo.stats_2_url === null) { + return; + } + let counts: StatsCounts | null = null; + try { + counts = await load( + '/context/stats_counts.json', + 'application/json' + ); + } catch { + // If counts fetch fails, proceed without them. + counts = null; + } + + const parameters = { + version: systemInfo.version, + dbVersion: systemInfo.database_version, + institution: systemInfo.institution, + institutionGUID: systemInfo.institution_guid, + discipline: systemInfo.discipline, + collection: systemInfo.collection, + collectionGUID: systemInfo.collection_guid, + isaNumber: systemInfo.isa_number, + disciplineType: systemInfo.discipline_type, + collectionObjectCount: counts?.Collectionobject ?? 0, + collectionCount: counts?.Collection ?? 0, + userCount: counts?.Specifyuser ?? 0, + }; + if (systemInfo.stats_url) + await ping( + formatUrl( + systemInfo.stats_url, + parameters, + /* + * I don't know if the receiving server handles GET parameters in a + * case-sensitive way. Thus, don't convert keys to lower case, but leave + * them as they were sent in previous versions of Specify 7 + */ + false + ), + { errorMode: 'silent' } + ).catch(softFail); + + const lambdaUrl = buildStatsLambdaUrl(systemInfo.stats_2_url); + if (lambdaUrl) { + await ping(formatUrl(lambdaUrl, parameters, false), { + errorMode: 'silent', + }).catch(softFail); + } +}); diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts index 21518f41335..c2893510082 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts @@ -4,9 +4,6 @@ import type { LocalizedString } from 'typesafe-i18n'; -import { ping } from '../../utils/ajax/ping'; -import { softFail } from '../Errors/Crash'; -import { formatUrl } from '../Router/queryString'; import { load } from './index'; type SystemInfo = { @@ -26,91 +23,14 @@ type SystemInfo = { readonly discipline_type: string; }; -type StatsCounts = { - readonly Collectionobject: number; - readonly Collection: number; - readonly Specifyuser: number; -}; - let systemInfo: SystemInfo; -function buildStatsLambdaUrl(base: string | null | undefined): string | null { - if (!base) return null; - let u = base.trim(); - - if (!/^https?:\/\//i.test(u)) u = `https://${u}`; - - const hasRoute = /\/(prod|default)\/[^\s/]+/.test(u); - if (!hasRoute) { - const stage = 'prod'; - const route = 'AggrgatedSp7Stats'; - u = `${u.replace(/\/$/, '')}/${stage}/${route}`; - } - return u; -} - export const fetchContext = load( '/context/system_info.json', 'application/json' ).then(async (data) => { systemInfo = data; - if (systemInfo.stats_url !== null) { - let counts: StatsCounts | null = null; - try { - counts = await load( - '/context/stats_counts.json', - 'application/json' - ); - } catch { - // If counts fetch fails, proceed without them. - counts = null; - } - - const parameters = { - version: systemInfo.version, - dbVersion: systemInfo.database_version, - institution: systemInfo.institution, - institutionGUID: systemInfo.institution_guid, - discipline: systemInfo.discipline, - collection: systemInfo.collection, - collectionGUID: systemInfo.collection_guid, - isaNumber: systemInfo.isa_number, - disciplineType: systemInfo.discipline_type, - collectionObjectCount: counts?.Collectionobject ?? 0, - collectionCount: counts?.Collection ?? 0, - userCount: counts?.Specifyuser ?? 0, - }; - - await ping( - formatUrl( - systemInfo.stats_url, - parameters, - /* - * I don't know if the receiving server handles GET parameters in a - * case-sensitive way. Thus, don't convert keys to lower case, but leave - * them as they were sent in previous versions of Specify 7 - */ - false - ), - { errorMode: 'silent' } - ).catch(softFail); - - /* - * Await ping( - * formatUrl(systemInfo.stats_2_url, parameters, false), - * { errorMode: 'silent' } - * ).catch(softFail); - */ - - const lambdaUrl = buildStatsLambdaUrl(systemInfo.stats_2_url); - if (lambdaUrl) { - await ping(formatUrl(lambdaUrl, parameters, false), { - errorMode: 'silent', - }).catch(softFail); - } - } - return systemInfo; }); From 28450dc126748c4e2ca795c97d0fc34fc1df5275 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 25 Nov 2025 17:02:04 +0000 Subject: [PATCH 2/3] Lint code with ESLint and Prettier Triggered by f1904fec7b6b6d048fffcec5afe993dcab1b3adf on branch refs/heads/secondary-context (cherry picked from commit 3148768714ed5f05149b768e68ee6c252c90e6d6) --- .../frontend/js_src/lib/components/Core/ContextLoader.tsx | 2 +- .../frontend/js_src/lib/components/InitialContext/index.ts | 3 ++- .../frontend/js_src/lib/components/InitialContext/stats.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Core/ContextLoader.tsx b/specifyweb/frontend/js_src/lib/components/Core/ContextLoader.tsx index 620c55f2295..f4538602eb6 100644 --- a/specifyweb/frontend/js_src/lib/components/Core/ContextLoader.tsx +++ b/specifyweb/frontend/js_src/lib/components/Core/ContextLoader.tsx @@ -17,7 +17,7 @@ const LOADING_TIMEOUT = 2 * SECOND; const fetchContext = ( context: Promise, - errorMode: 'crash' | 'console' + errorMode: 'console' | 'crash' ): (() => Promise) => async () => context.then(f.true).catch(errorMode === 'crash' ? crash : softFail); diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts index d3f94c9283f..a459b73f363 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts @@ -73,7 +73,8 @@ export const load = async (path: string, mimeType: MimeType): Promise => * not block or prevent access to Specify */ export const secondaryContext = Promise.all([ - /** REFACTOR: Move non-essential endpoints here from initialContext to speed + /** + * REFACTOR: Move non-essential endpoints here from initialContext to speed * up initial loading times. * Icon Definitions, Legacy UI Localization, Uniqueness Rules, and possibly * even Field Formatters and Remote Prefs can all theoretically be moved here. diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts index 47a182932a1..4bf4d164617 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/stats.ts @@ -1,8 +1,8 @@ -import { fetchContext as fetchSystemInfo } from './systemInfo'; import { ping } from '../../utils/ajax/ping'; import { softFail } from '../Errors/Crash'; import { formatUrl } from '../Router/queryString'; import { load } from './index'; +import { fetchContext as fetchSystemInfo } from './systemInfo'; type StatsCounts = { readonly Collectionobject: number; From 9dcfaeec39e11fba183e60cbbafa374e9acf0e54 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Mon, 1 Dec 2025 09:36:55 -0600 Subject: [PATCH 3/3] refactor: Remove unnecessary async in systemInfo endpoint (cherry picked from commit 1cb0fac5d8b13cc89e5c8ebd2b175d665705712b) --- .../frontend/js_src/lib/components/InitialContext/systemInfo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts index c2893510082..e7729565eda 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/systemInfo.ts @@ -28,7 +28,7 @@ let systemInfo: SystemInfo; export const fetchContext = load( '/context/system_info.json', 'application/json' -).then(async (data) => { +).then((data) => { systemInfo = data; return systemInfo;