From 7e2db111c8b51e36aec5720bc59b6dde72410d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 14 Jul 2026 11:34:27 +0200 Subject: [PATCH 1/7] feat: add explicit terminal state to search snapshot --- src/CONST/index.ts | 7 + src/libs/actions/Search.ts | 32 ++++- src/types/onyx/SearchResults.ts | 5 + tests/unit/Search/searchSnapshotStateTest.ts | 130 +++++++++++++++++++ 4 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 tests/unit/Search/searchSnapshotStateTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 746a7040104f..25fe7371ca48 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6517,6 +6517,13 @@ const CONST = { TRIP: 'trip', CHAT: 'chat', }, + // Terminal lifecycle state of a search snapshot's most recent request. Written by the search action lifecycle + // (loading on request start, loaded/error on resolve) so the snapshot always has an explicit, mutually exclusive state. + SNAPSHOT_STATE: { + LOADING: 'loading', + LOADED: 'loaded', + ERROR: 'error', + }, ACTION_FILTERS: { SUBMIT: 'submit', APPROVE: 'approve', diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index ec18332cf4ae..621dce0835e6 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -663,6 +663,13 @@ function getOnyxLoadingData( shouldCalculateTotals?: boolean, ): OnyxData { const shouldClearTotals = isSearchAPI && shouldCalculateTotals === false && offset === 0; + + // `search.state` tracks the lifecycle of a real search request (identified by its queryJSON): it starts as + // `loading` optimistically and is resolved to `loaded`/`error` by successData/failureData. handlePreventSearchAPI + // reuses this helper as a UI-only loading toggle with no query and no success/failure step, so it must stay out of + // the state machine — otherwise it would strand `state: loading` with no terminal write to clear it. + const isSearchRequest = isSearchAPI && !!queryJSON; + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -670,6 +677,7 @@ function getOnyxLoadingData( value: { search: { ...(isSearchAPI && {isLoading: true}), + ...(isSearchRequest && {state: CONST.SEARCH.SNAPSHOT_STATE.LOADING}), ...(offset !== undefined ? {offset} : {}), ...(shouldClearTotals ? {count: null, total: null, currency: null} : {}), }, @@ -684,6 +692,23 @@ function getOnyxLoadingData( }, ]; + // successData writes the terminal `loaded` state on any jsonCode 200 resolve. It also stamps type/status so a + // resolve that returns no snapshot data (the "dataless resolve" bug) still leaves the snapshot in a consistent, + // terminal state that the anti-stale isSearchDataLoaded check can resolve. + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + value: { + search: { + ...(isSearchRequest && {isLoading: false, state: CONST.SEARCH.SNAPSHOT_STATE.LOADED, status: queryJSON?.status, type: queryJSON?.type}), + }, + }, + }, + ]; + + // finallyData runs after successData/failureData regardless of jsonCode, so it deliberately does NOT write `state`: + // doing so would clobber the `error` terminal set by failureData. The terminal state is owned by success/failure. const finallyData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -706,13 +731,14 @@ function getOnyxLoadingData( status: queryJSON?.status, type: queryJSON?.type, ...(isSearchAPI && {isLoading: false}), + ...(isSearchRequest && {state: CONST.SEARCH.SNAPSHOT_STATE.ERROR}), }, errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, }, ]; - return {optimisticData, finallyData, failureData}; + return {optimisticData, successData, finallyData, failureData}; } function saveSearch({queryJSON, newName}: {queryJSON: Readonly; newName?: string}) { @@ -909,7 +935,7 @@ function search({ } inFlightSearchRequests.add(dedupeKey); - const {optimisticData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals); + const {optimisticData, successData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals); const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; const backendQueryJSON = shouldUseBackendDateSortFallback(queryJSON.sortBy) ? { @@ -941,7 +967,7 @@ function search({ } const startRequest = () => - makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}) + makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, successData, finallyData, failureData}) .then((result) => { if (shouldUpdateLastSearchParams) { const response = result?.onyxData?.[0]?.value as OnyxSearchResponse; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index caa95fa809b5..0aea0459bc41 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -51,6 +51,11 @@ type SearchResultsInfo = { /** Whether the search results are currently loading */ isLoading: boolean; + /** Explicit terminal lifecycle state of the most recent search request for this snapshot. + * Optional because snapshots persisted before this field existed (and snapshots written by + * non-search actions) may not carry it. */ + state?: ValueOf; + /** The number of results */ count?: number; diff --git a/tests/unit/Search/searchSnapshotStateTest.ts b/tests/unit/Search/searchSnapshotStateTest.ts new file mode 100644 index 000000000000..3540d09644d4 --- /dev/null +++ b/tests/unit/Search/searchSnapshotStateTest.ts @@ -0,0 +1,130 @@ +import {search} from '@libs/actions/Search'; +import {makeRequestWithSideEffects, waitForWrites} from '@libs/API'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {OnyxUpdate} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../../utils/getOnyxValue'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +jest.mock('@libs/API', () => ({ + makeRequestWithSideEffects: jest.fn(), + waitForWrites: jest.fn(), + write: jest.fn(), + read: jest.fn(), +})); + +// A jsonCode that is not 200, so the request resolves down the failure branch of applyHTTPSOnyxUpdates. +const FAILURE_JSON_CODE = 500; + +function getQueryJSON() { + const queryJSON = buildSearchQueryJSON(''); + if (!queryJSON) { + throw new Error('Query JSON should be defined for test setup'); + } + + return queryJSON; +} + +/** Read the {optimisticData, successData, failureData, finallyData} that search() handed to the API layer. */ +function getCapturedSearchOnyxData(): NonNullable[2]> { + const lastCall = jest.mocked(makeRequestWithSideEffects).mock.calls.at(-1); + if (!lastCall) { + throw new Error('makeRequestWithSideEffects was not called'); + } + return lastCall[2] ?? {}; +} + +/** + * Replays a captured search request the way applyHTTPSOnyxUpdates does: optimisticData first, then any server + * onyxData, then successData (jsonCode 200) or failureData (non-200), and finallyData last regardless of jsonCode. + * This lets us assert the terminal snapshot state that the real API application order would produce. + */ +async function simulateResolvedRequest({jsonCode, serverOnyxData}: {jsonCode: number; serverOnyxData?: Array>}) { + const {optimisticData, successData, failureData, finallyData} = getCapturedSearchOnyxData(); + + await Onyx.update(optimisticData ?? []); + if (serverOnyxData) { + await Onyx.update(serverOnyxData); + } + if (jsonCode === CONST.JSON_CODE.SUCCESS) { + await Onyx.update(successData ?? []); + } else { + await Onyx.update(failureData ?? []); + } + await Onyx.update(finallyData ?? []); + await waitForBatchedUpdates(); +} + +describe('search snapshot terminal state', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mocked(waitForWrites).mockResolvedValue(undefined); + jest.mocked(makeRequestWithSideEffects).mockResolvedValue(undefined); + await Onyx.clear(); + }); + + it('marks the snapshot loading optimistically while a request is in flight', async () => { + const queryJSON = getQueryJSON(); + + await search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, isLoading: false}); + const {optimisticData} = getCapturedSearchOnyxData(); + await Onyx.update(optimisticData ?? []); + await waitForBatchedUpdates(); + + const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const); + expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.LOADING); + }); + + it('resolves the snapshot to loaded on a successful response', async () => { + const queryJSON = getQueryJSON(); + + await search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, isLoading: false}); + await simulateResolvedRequest({ + jsonCode: CONST.JSON_CODE.SUCCESS, + serverOnyxData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}`, + value: {search: {hasResults: true}}, + }, + ], + }); + + const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const); + expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.LOADED); + expect(snapshot?.search?.hasResults).toBe(true); + }); + + it('resolves the snapshot to error on a failed response', async () => { + const queryJSON = getQueryJSON(); + + await search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, isLoading: false}); + await simulateResolvedRequest({jsonCode: FAILURE_JSON_CODE}); + + const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const); + // finallyData runs after failureData; the error state must survive it. + expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.ERROR); + }); + + it('reaches a terminal state when a successful response resolves without any snapshot data', async () => { + const queryJSON = getQueryJSON(); + + await search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, isLoading: false}); + // The bug scenario: a 200 response that writes no server onyxData at all. + await simulateResolvedRequest({jsonCode: CONST.JSON_CODE.SUCCESS}); + + const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const); + expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.LOADED); + expect(snapshot?.search?.state).not.toBe(CONST.SEARCH.SNAPSHOT_STATE.LOADING); + }); +}); From f19f3965c6f222b4802164ece957c18a7dea01a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 14 Jul 2026 12:15:47 +0200 Subject: [PATCH 2/7] fix: remove nonexistent status field from search successData --- src/libs/actions/Search.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 26af1b43c993..b528abfa75dc 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -692,7 +692,7 @@ function getOnyxLoadingData( }, ]; - // successData writes the terminal `loaded` state on any jsonCode 200 resolve. It also stamps type/status so a + // successData writes the terminal `loaded` state on any jsonCode 200 resolve. It also stamps `type` so a // resolve that returns no snapshot data (the "dataless resolve" bug) still leaves the snapshot in a consistent, // terminal state that the anti-stale isSearchDataLoaded check can resolve. const successData: Array> = [ @@ -701,7 +701,7 @@ function getOnyxLoadingData( key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, value: { search: { - ...(isSearchRequest && {isLoading: false, state: CONST.SEARCH.SNAPSHOT_STATE.LOADED, status: queryJSON?.status, type: queryJSON?.type}), + ...(isSearchRequest && {isLoading: false, state: CONST.SEARCH.SNAPSHOT_STATE.LOADED, type: queryJSON?.type}), }, }, }, From c47a2b03224b5e582657f829a694725a14b4aa10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 14 Jul 2026 12:23:02 +0200 Subject: [PATCH 3/7] refactor: dedupe queryJSON type read in search onyx data --- src/libs/actions/Search.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index b528abfa75dc..21dde7d8f19c 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -669,6 +669,7 @@ function getOnyxLoadingData( // reuses this helper as a UI-only loading toggle with no query and no success/failure step, so it must stay out of // the state machine — otherwise it would strand `state: loading` with no terminal write to clear it. const isSearchRequest = isSearchAPI && !!queryJSON; + const type = queryJSON?.type; const optimisticData: Array> = [ { @@ -701,7 +702,7 @@ function getOnyxLoadingData( key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, value: { search: { - ...(isSearchRequest && {isLoading: false, state: CONST.SEARCH.SNAPSHOT_STATE.LOADED, type: queryJSON?.type}), + ...(isSearchRequest && {isLoading: false, state: CONST.SEARCH.SNAPSHOT_STATE.LOADED, type}), }, }, }, @@ -728,7 +729,7 @@ function getOnyxLoadingData( value: { ...(isOffline ? {} : {data: null}), search: { - type: queryJSON?.type, + type, ...(isSearchAPI && {isLoading: false}), ...(isSearchRequest && {state: CONST.SEARCH.SNAPSHOT_STATE.ERROR}), }, From 404049e8f17ece280301c21abc798f926d2cd40f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 14 Jul 2026 12:41:14 +0200 Subject: [PATCH 4/7] docs: correct successData comment about isSearchDataLoaded effect --- src/libs/actions/Search.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 21dde7d8f19c..2ac836efdcf3 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -693,9 +693,10 @@ function getOnyxLoadingData( }, ]; - // successData writes the terminal `loaded` state on any jsonCode 200 resolve. It also stamps `type` so a - // resolve that returns no snapshot data (the "dataless resolve" bug) still leaves the snapshot in a consistent, - // terminal state that the anti-stale isSearchDataLoaded check can resolve. + // successData writes the terminal `loaded` state on any jsonCode 200 resolve. It also stamps `type` so + // responses that do carry data stay consistent with the anti-stale isSearchDataLoaded check (which compares + // type/hash). On a dataless success, isSearchDataLoaded still resolves to false via its own data/errors gate; + // `state` is what marks that case as done once a future PR wires the read side to it. const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, From 902df482e59d62b72aa4fb7c41d93e592cf046f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 14 Jul 2026 12:59:01 +0200 Subject: [PATCH 5/7] docs: reword successData comment to avoid cspell unknown word --- src/libs/actions/Search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 2ac836efdcf3..693e04e38461 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -695,7 +695,7 @@ function getOnyxLoadingData( // successData writes the terminal `loaded` state on any jsonCode 200 resolve. It also stamps `type` so // responses that do carry data stay consistent with the anti-stale isSearchDataLoaded check (which compares - // type/hash). On a dataless success, isSearchDataLoaded still resolves to false via its own data/errors gate; + // type/hash). On a success response without data, isSearchDataLoaded still resolves to false via its own data/errors gate; // `state` is what marks that case as done once a future PR wires the read side to it. const successData: Array> = [ { From f1cb8d641cdaf2bec47974acad7c315a5959fd19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 14 Jul 2026 13:20:48 +0200 Subject: [PATCH 6/7] fix: apply failureData when a search request rejects instead of resolving --- src/libs/actions/Search.ts | 34 +++++++++++++------- src/types/onyx/SearchResults.ts | 9 +++++- tests/unit/Search/searchSnapshotStateTest.ts | 14 +++++++- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 693e04e38461..06095029582b 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -696,18 +696,20 @@ function getOnyxLoadingData( // successData writes the terminal `loaded` state on any jsonCode 200 resolve. It also stamps `type` so // responses that do carry data stay consistent with the anti-stale isSearchDataLoaded check (which compares // type/hash). On a success response without data, isSearchDataLoaded still resolves to false via its own data/errors gate; - // `state` is what marks that case as done once a future PR wires the read side to it. - const successData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, - value: { - search: { - ...(isSearchRequest && {isLoading: false, state: CONST.SEARCH.SNAPSHOT_STATE.LOADED, type}), - }, - }, - }, - ]; + // `state` is what marks that case as done once a future PR wires the read side to it. `isLoading` isn't set here + // because finallyData always runs right after and already clears it for isSearchAPI. Empty for the non-search + // callers of this helper so they don't pay for a meaningless `{search: {}}` merge on the SNAPSHOT key. + const successData: Array> = isSearchRequest + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + value: { + search: {state: CONST.SEARCH.SNAPSHOT_STATE.LOADED, type}, + }, + }, + ] + : []; // finallyData runs after successData/failureData regardless of jsonCode, so it deliberately does NOT write `state`: // doing so would clobber the `error` terminal set by failureData. The terminal state is owned by success/failure. @@ -1001,6 +1003,14 @@ function search({ return result?.jsonCode; }) + .catch((error) => { + // A network-level rejection (no HTTP response at all, e.g. offline/timeout) never reaches + // SaveResponseInOnyx, so nothing else applies failureData for it. Apply it here so the snapshot + // still reaches a terminal `error` state instead of being stranded in `loading`, then re-throw so + // this still rejects for any caller relying on that. + Onyx.update(failureData ?? []); + throw error; + }) .finally(() => { inFlightSearchRequests.delete(dedupeKey); }); diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index f90f33b90e48..226ade0f6a40 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -61,7 +61,14 @@ type SearchResultsInfo = { /** Explicit terminal lifecycle state of the most recent search request for this snapshot. * Optional because snapshots persisted before this field existed (and snapshots written by - * non-search actions) may not carry it. */ + * non-search actions) may not carry it. + * + * TODO: nothing reads this field yet. The existing isLoading/type/status-based loading and error gates + * migrate to read it in a follow-up PR. + * + * Residual limitation: if the app is killed or reloaded mid-request, no cleanup runs, so `loading` can + * still be stranded on disk. The future read side must treat a `loading` state with no in-flight request + * as stale. */ state?: ValueOf; /** The number of results */ diff --git a/tests/unit/Search/searchSnapshotStateTest.ts b/tests/unit/Search/searchSnapshotStateTest.ts index 3540d09644d4..05bf7d9f8711 100644 --- a/tests/unit/Search/searchSnapshotStateTest.ts +++ b/tests/unit/Search/searchSnapshotStateTest.ts @@ -125,6 +125,18 @@ describe('search snapshot terminal state', () => { const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const); expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.LOADED); - expect(snapshot?.search?.state).not.toBe(CONST.SEARCH.SNAPSHOT_STATE.LOADING); + }); + + it('resolves the snapshot to error when the request promise rejects, instead of staying loading', async () => { + const queryJSON = getQueryJSON(); + jest.mocked(makeRequestWithSideEffects).mockRejectedValueOnce(new Error('Network request failed')); + + // The failure class this field exists to eliminate: no HTTP response at all (offline/timeout), so + // nothing in the API layer ever applies failureData for it unless search() catches the rejection itself. + await expect(search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, offset: 0, isLoading: false})).rejects.toThrow(); + await waitForBatchedUpdates(); + + const snapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const); + expect(snapshot?.search?.state).toBe(CONST.SEARCH.SNAPSHOT_STATE.ERROR); }); }); From 12852ed60317738566cb12d9459806b64f50d00a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Obudzi=C5=84ski?= Date: Tue, 14 Jul 2026 15:30:05 +0200 Subject: [PATCH 7/7] docs: drop TODO marker from search snapshot state comment --- src/types/onyx/SearchResults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 226ade0f6a40..e4d5cc7a9159 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -63,7 +63,7 @@ type SearchResultsInfo = { * Optional because snapshots persisted before this field existed (and snapshots written by * non-search actions) may not carry it. * - * TODO: nothing reads this field yet. The existing isLoading/type/status-based loading and error gates + * Nothing reads this field yet. The existing isLoading/type/status-based loading and error gates * migrate to read it in a follow-up PR. * * Residual limitation: if the app is killed or reloaded mid-request, no cleanup runs, so `loading` can