From fab1466eeb779f1d1dc5300a18112bd715f59fbe Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 10 Oct 2025 09:20:50 -0700 Subject: [PATCH 01/16] Issue 54041: musings from investigation --- packages/components/src/internal/query/api.ts | 19 ++++--- .../src/internal/query/selectRows.ts | 10 +++- .../src/public/QueryModel/QueryModelLoader.ts | 6 ++- .../src/public/QueryModel/withQueryModels.tsx | 51 ++++++++++++++++++- 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/packages/components/src/internal/query/api.ts b/packages/components/src/internal/query/api.ts index 65aaaabf39..1bfe44871a 100644 --- a/packages/components/src/internal/query/api.ts +++ b/packages/components/src/internal/query/api.ts @@ -465,28 +465,28 @@ export interface ISelectRowsResult { rowCount: number; } -export type SelectRowsDeprecatedOptions = Omit< - Query.SelectRowsOptions, - 'failure' | 'method' | 'requiredVersion' | 'scope' | 'success' ->; +export interface SelectRowsDeprecatedOptions + extends Omit { + requestHandler?: RequestHandler; +} /** * @deprecated use selectRows() or executeSql() instead. * Fetches an API response and normalizes the result JSON according to schema. * This makes every API response have the same shape, regardless of how nested it was. */ -export async function selectRowsDeprecated(options: SelectRowsDeprecatedOptions): Promise { +export async function selectRowsDeprecated(options_: SelectRowsDeprecatedOptions): Promise { + const { requestHandler, ...options } = options_; const schemaQuery = new SchemaQuery(options.schemaName, options.queryName, options.viewName); const columns = options.columns ? options.columns : '*'; const [queryInfo, response] = await Promise.all([ getQueryDetails(options), new Promise((resolve, reject) => { - Query.selectRows({ + const request_ = Query.selectRows({ ...options, requiredVersion: 17.1, method: 'POST', - // put on this another parameter! columns, containerFilter: options.containerFilter ?? getContainerFilter(options.containerPath), includeMetadata: isSelectRowMetadataRequired(options.includeMetadata, columns), @@ -495,7 +495,9 @@ export async function selectRowsDeprecated(options: SelectRowsDeprecatedOptions) resolve(response_); }, failure: (data, request) => { - console.error('There was a problem retrieving the data', data); + if (request.status !== 0) { + console.error('There was a problem retrieving the data', data); + } reject({ exceptionClass: data.exceptionClass, message: data.exception, @@ -504,6 +506,7 @@ export async function selectRowsDeprecated(options: SelectRowsDeprecatedOptions) }); }, }); + requestHandler?.(request_); }), ]); diff --git a/packages/components/src/internal/query/selectRows.ts b/packages/components/src/internal/query/selectRows.ts index cce5b3e001..705cf8f418 100644 --- a/packages/components/src/internal/query/selectRows.ts +++ b/packages/components/src/internal/query/selectRows.ts @@ -5,12 +5,14 @@ import { QueryInfo } from '../../public/QueryInfo'; import { URLResolver } from '../url/URLResolver'; import { getContainerFilter, getQueryDetails, isSelectRowMetadataRequired } from './api'; +import { RequestHandler } from '../request'; export interface SelectRowsOptions extends Omit< Query.SelectRowsOptions, 'failure' | 'queryName' | 'requiredVersion' | 'schemaName' | 'scope' | 'success' > { + requestHandler?: RequestHandler; schemaQuery: SchemaQuery; } @@ -37,6 +39,7 @@ export async function selectRows(options: SelectRowsOptions): Promise((resolve, reject) => { - Query.selectRows({ + const request_ = Query.selectRows({ ...selectRowsOptions, columns, containerFilter, @@ -61,7 +64,9 @@ export async function selectRows(options: SelectRowsOptions): Promise { - console.error('There was a problem retrieving the data', data); + if (request.status !== 0) { + console.error('There was a problem retrieving the data', data); + } reject({ exceptionClass: data.exceptionClass, message: data.exception, @@ -70,6 +75,7 @@ export async function selectRows(options: SelectRowsOptions): Promise Promise; + loadRows: (model: QueryModel, requestHandler?: RequestHandler) => Promise; /** * Loads the selected RowIds (or PK values) for the specified model. @@ -118,13 +119,14 @@ export const DefaultQueryModelLoader: QueryModelLoader = { }); return queryInfo.mutate({ columns: bindColumnRenderers(queryInfo.columns) }); }, - async loadRows(model) { + async loadRows(model, requestHandler) { const result = await selectRowsDeprecated({ ...model.loadRowsConfig, schemaName: model.schemaName, queryName: model.queryName, includeTotalCount: false, // if requesting to includeTotalCount, it will be loaded separately via loadTotalCount includeStyle: true, // Issue 49100 + requestHandler, }); const { key, models, orderedModels, rowCount, messages } = result; diff --git a/packages/components/src/public/QueryModel/withQueryModels.tsx b/packages/components/src/public/QueryModel/withQueryModels.tsx index 8799b32682..10462102d0 100644 --- a/packages/components/src/public/QueryModel/withQueryModels.tsx +++ b/packages/components/src/public/QueryModel/withQueryModels.tsx @@ -19,6 +19,7 @@ import { incrementClientSideMetricCount } from '../../internal/actions'; import { filterArraysEqual, getSelectRowCountColumnsStr, sortArraysEqual } from './utils'; import { DefaultQueryModelLoader, QueryModelLoader } from './QueryModelLoader'; +import { RequestHandler } from '../../internal/request'; import { getSettingsFromLocalStorage, GridMessage, @@ -309,6 +310,39 @@ export function withQueryModels( }; class ComponentWithQueryModels extends PureComponent { + // N.B. This is very similar to useRequestHandler() but we cannot use a hook here so we have to use class + // variables instead. Additionally, we cannot make use of React.createRef() since that returns an immutable + // reference unlike React.useRef() which is mutable. + + // TODO: Not married to this structure, just first thoughts on coalescing requests by model. + private _requests: Record> = {}; + + private cancelAllRequests = (): void => { + Object.values(this._requests).forEach(allReq => { + Object.values(allReq).forEach(req => { + req?.abort(); + }); + }); + this._requests = {}; + }; + + private getRequestHandler = + (id: string, requestType: string): RequestHandler => + request => { + if (!this._requests.hasOwnProperty(id)) { + this._requests[id] = {}; + } + + this._requests[id][requestType]?.abort(); + this._requests[id][requestType] = request; + }; + + private resetRequestHandler = (id: string, requestType: string): void => { + if (this._requests[id] !== undefined) { + this._requests[id][requestType] = undefined; + } + }; + static defaultProps; constructor(props: WrappedProps) { @@ -414,6 +448,10 @@ export function withQueryModels( } } + componentWillUnmount(): void { + this.cancelAllRequests(); + } + actions: Actions; bindURL = (id: string): void => { @@ -736,7 +774,9 @@ export function withQueryModels( ); try { - const result = await loadRows(this.state.queryModels[id]); + const requestType = 'loadRows'; + const result = await loadRows(this.state.queryModels[id], this.getRequestHandler(id, requestType)); + this.resetRequestHandler(id, requestType); const { messages, rows, orderedRows, rowCount } = result; this.setState( @@ -753,6 +793,7 @@ export function withQueryModels( () => this.maybeLoad(id, false, false, loadSelections, false, selectionsForReplace) ); } catch (error) { + if (error?.status === 0) return; let viewDoesNotExist = false; this.setState( produce((draft: WritableDraft) => { @@ -845,6 +886,8 @@ export function withQueryModels( loadRowsConfig.filterArray, queryInfo?.getPkCols() ); + + const requestType = 'loadTotalCount'; const { rowCount } = await selectRows({ ...loadRowsConfig, columns, @@ -855,7 +898,9 @@ export function withQueryModels( maxRows: 1, offset: 0, sort: undefined, + requestHandler: this.getRequestHandler(id, requestType), }); + this.resetRequestHandler(id, requestType); this.setState( produce((draft: WritableDraft) => { @@ -866,6 +911,7 @@ export function withQueryModels( }) ); } catch (error) { + if (error?.status === 0) return; this.setState( produce((draft: WritableDraft) => { const model = draft.queryModels[id]; @@ -946,6 +992,9 @@ export function withQueryModels( reloadTotalCount = false, selectionsForReplace?: string[] ): void => { + // TODO: Consider how to incorporate stale request handling. + // Feels like we should never be cancelling query info loading -- just to keep it simple. Already cached, etc. + // Definitely want to cancel loading selections. Need to make sure caller is requeuing the load. if (loadQueryInfo) { // Postpone loading any rows or selections if we're loading the QueryInfo. this.loadQueryInfo(id, loadRows, loadSelections); From 922fb67f294cd6faccfe8d3268c17eb611b8fe41 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Mon, 26 Jan 2026 17:16:56 -0800 Subject: [PATCH 02/16] Cancel loadSelections --- packages/components/src/internal/actions.ts | 252 +++++++----------- packages/components/src/internal/request.ts | 2 +- .../src/public/QueryModel/QueryModelLoader.ts | 7 +- .../src/public/QueryModel/withQueryModels.tsx | 9 +- 4 files changed, 107 insertions(+), 163 deletions(-) diff --git a/packages/components/src/internal/actions.ts b/packages/components/src/internal/actions.ts index 675d31656a..e8442cde17 100644 --- a/packages/components/src/internal/actions.ts +++ b/packages/components/src/internal/actions.ts @@ -38,9 +38,8 @@ import { } from './constants'; import { DataViewInfo } from './DataViewInfo'; -import { handleRequestFailure } from './request'; +import { request, RequestHandler } from './request'; import { resolveErrorMessage } from './util/messaging'; -import { buildURL } from './url/AppURL'; import { ViewInfo } from './ViewInfo'; import { createGridModelId } from './models'; @@ -56,21 +55,11 @@ export function selectAll( queryParameters?: Record, containerFilter?: Query.ContainerFilter ): Promise { - return new Promise((resolve, reject) => { - return Ajax.request({ - url: buildURL('query', 'selectAll.api', undefined, { - container: containerPath, - }), - method: 'POST', - params: buildQueryParams(key, schemaQuery, filterArray, queryParameters, containerPath, containerFilter), - success: Utils.getCallbackWrapper(response => { - resolve(response); - }), - failure: handleRequestFailure( - reject, - `Problem in selecting all items in the grid ${key} ${schemaQuery.schemaName} ${schemaQuery.queryName}` - ), - }); + return request({ + url: ActionURL.buildURL('query', 'selectAll.api', containerPath), + method: 'POST', + params: buildQueryParams(key, schemaQuery, filterArray, queryParameters, containerPath, containerFilter), + errorLogMsg: `Problem in selecting all items in the grid ${key} ${schemaQuery.schemaName} ${schemaQuery.queryName}`, }); } @@ -274,7 +263,8 @@ export function exportRows(type: EXPORT_TYPES, exportParams: Record } }); - let controller, action; + let action: string; + let controller: string; if (type === EXPORT_TYPES.CSV || type === EXPORT_TYPES.TSV || type === EXPORT_TYPES.LABEL_TEMPLATE) { controller = 'query'; action = 'exportRowsTsv.post'; @@ -298,7 +288,7 @@ export function exportRows(type: EXPORT_TYPES, exportParams: Record form.append('formDataEncoded', 'true'); Ajax.request({ - url: buildURL(controller, action, undefined, { container: containerPath, returnUrl: false }), + url: ActionURL.buildURL(controller, action, containerPath), method: 'POST', form, downloadFile: true, @@ -380,29 +370,24 @@ export function getSelected( filterArray?: Filter.IFilter[], containerPath?: string, queryParameters?: Record, - containerFilter?: Query.ContainerFilter + containerFilter?: Query.ContainerFilter, + requestHandler?: RequestHandler ): Promise { - if (useSnapshotSelection) return getSnapshotSelections(key, containerPath); + if (useSnapshotSelection) return getSnapshotSelections(key, containerPath, requestHandler); - return new Promise((resolve, reject) => { - return Ajax.request({ - url: buildURL('query', 'getSelected.api', undefined, { - container: containerPath, - }), - method: 'POST', - jsonData: getFilteredQueryParams( - key, - schemaQuery, - filterArray, - queryParameters, - containerPath, - containerFilter - ), - success: Utils.getCallbackWrapper(response => { - resolve(response); - }), - failure: handleRequestFailure(reject, 'Failed to get selected.'), - }); + return request({ + url: ActionURL.buildURL('query', 'getSelected.api', containerPath), + method: 'POST', + jsonData: getFilteredQueryParams( + key, + schemaQuery, + filterArray, + queryParameters, + containerPath, + containerFilter + ), + errorLogMsg: 'Failed to get selected.', + requestHandler, }); } @@ -452,26 +437,18 @@ export type ClearSelectedOptions = { }; export function clearSelected(options: ClearSelectedOptions): Promise { - return new Promise((resolve, reject) => { - return Ajax.request({ - url: ActionURL.buildURL('query', 'clearSelected.api', options.containerPath), - method: 'POST', - jsonData: getFilteredQueryParams( - options.selectionKey, - options.schemaQuery, - options.filters, - options.queryParameters, - options.containerPath, - options.containerFilter - ), - success: Utils.getCallbackWrapper(response => { - resolve(response); - }), - failure: handleRequestFailure( - reject, - `Problem clearing the selection ${options.selectionKey} ${options.schemaQuery?.schemaName} ${options.schemaQuery?.queryName}` - ), - }); + return request({ + url: ActionURL.buildURL('query', 'clearSelected.api', options.containerPath), + method: 'POST', + jsonData: getFilteredQueryParams( + options.selectionKey, + options.schemaQuery, + options.filters, + options.queryParameters, + options.containerPath, + options.containerFilter + ), + errorLogMsg: `Problem clearing the selection ${options.selectionKey} ${options.schemaQuery?.schemaName} ${options.schemaQuery?.queryName}`, }); } @@ -490,7 +467,7 @@ export function clearSelected(options: ClearSelectedOptions): Promise ): Promise { - return new Promise((resolve, reject) => { - return Ajax.request({ - url: buildURL('query', 'setSelected.api', undefined, { - container: containerPath, - }), - method: 'POST', - jsonData: { - id: ids, - key, - checked, - validateIds, - schemaName, - queryName, - filterList: filters, - queryParameters, - }, - success: Utils.getCallbackWrapper(response => { - resolve(response); - }), - failure: handleRequestFailure(reject, 'Failed to set selection.'), - }); + return request({ + url: ActionURL.buildURL('query', 'setSelected.api', containerPath), + method: 'POST', + jsonData: { + id: ids, + key, + checked, + validateIds, + schemaName, + queryName, + filterList: filters, + queryParameters, + }, + errorLogMsg: 'Failed to set selection.', }); } export type ReplaceSelectedOptions = { containerPath?: string; - id: string[] | string; + id: string | string[]; selectionKey: string; }; export function replaceSelected(options: ReplaceSelectedOptions): Promise { - return new Promise((resolve, reject) => { - return Ajax.request({ - url: ActionURL.buildURL('query', 'replaceSelected.api', options.containerPath), - method: 'POST', - jsonData: { key: options.selectionKey, id: options.id }, - success: Utils.getCallbackWrapper(response => { - resolve(response); - }), - failure: handleRequestFailure(reject, 'Failed to replace selection.'), - }); + return request({ + url: ActionURL.buildURL('query', 'replaceSelected.api', options.containerPath), + method: 'POST', + jsonData: { key: options.selectionKey, id: options.id }, + errorLogMsg: 'Failed to replace selection.', }); } @@ -551,24 +516,14 @@ export function replaceSelected(options: ReplaceSelectedOptions): Promise