Skip to content

Add useQueryModels hook#1983

Draft
labkey-alan wants to merge 20 commits intodevelopfrom
fb_useQueryModels
Draft

Add useQueryModels hook#1983
labkey-alan wants to merge 20 commits intodevelopfrom
fb_useQueryModels

Conversation

@labkey-alan
Copy link
Copy Markdown
Contributor

@labkey-alan labkey-alan commented Apr 18, 2026

Rationale

This PR adds a useQueryModels hook. Intended to be a replacement for the withQueryModels HOC. Note: the majority of the code was written directly by me, with some assistance from Claude (mostly by reviewing my code as I went to ensure it was at parity with the HOC, but several actions were implemented by Claude as well).

Backwards incompatible changes I am considering making in this PR

  1. Adding a loadSelections attribute to QueryModel, dropping the flag from all actions
    • This would address the issue outlined in the componentDidMount of withQueryModels (Issue 48758), and allow us to stop automatically loading selections when autoLoad is true.
    • This may be the best time to make this change, since we can opt into that flag as needed while we transition usages of withQueryModels to useQueryModels, which would net us the benefit of less API requests on each page.
    • This could be done in a backwards compatible way, by keeping the loadSelections arg on all Actions methods, but ignoring the arg (and emitting a warning when it is anything other than undefined).
  2. Removing Actions.setSchemaQuery (done ✅ )
    • While technically this is backwards incompatible we have zero usages of this method, and as far as I know, we have never had any usages of this method. I am not sure why it ever existed.

New feature(s) I am considering adding in this PR

  1. I am considering adding an option to the hook called reloadOnQueryConfigIdChanges which would cancel all pending API requests in the existing manager, then create a new manager with the most recent queryConfigs passed to the hook, if the IDs of any of the QueryConfig objects have changed.
    • This would potentially solve for the many cases we have that use a key prop to force a remount of a component wrapped with withQueryModels
    • We'd basically reset if Object.keys(queryConfigs).sort().join() !== Object.keys(queryModels).sort().join()
    • We could also consider making this the default behavior, and not a flag

Related Pull Requests

  • TBO

Changes

  • Move utilities from withQueryModels.tsx to utils.ts
  • Move interfaces and types from withQueryModels.tsx to QueryModel.ts
  • Move RequestManager to utils.ts
  • Add QueryModelManager
  • Add useQueryModels hook
  • Add unit tests (tests written by Claude)
  • QueryModelLoader: add loadTotalCount
  • withQueryModels
    • use QueryModelLoader.loadTotalCount
    • move initModels to utils.ts
    • move bindURL to utils.ts
  • Components updated to use useQueryModels
    • APIKeysPanel
    • TODO: something with more selenium test coverage
  • Actions: remove setSchemaQuery
    • This is a backwards incompatible change, though it should be reasonably safe, because there are no known usages of this action

@labkey-alan labkey-alan self-assigned this Apr 18, 2026
Copy link
Copy Markdown
Contributor

@labkey-nicka labkey-nicka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great!

manager.current = new QueryModelManager(queryConfigs, searchParams, setSearchParams, modelLoader);
}

const state = useSyncExternalStore(manager.current.subscribe, manager.current.getSnapshot);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool. Perfect usage of this.

const state = useSyncExternalStore(manager.current.subscribe, manager.current.getSnapshot);

useEffect(() => {
// Note: It's not ideal because it will try to load selections for models that aren't active or aren't used in
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a loadSelections attribute to QueryModel, dropping the flag from all actions
- This would address the issue outlined in the componentDidMount of withQueryModels (Issue 48758), and allow us to stop automatically loading selections when autoLoad is true.
- This may be the best time to make this change, since we can opt into that flag as needed while we transition usages of withQueryModels to useQueryModels, which would net us the benefit of less API requests on each page.
- This could be done in a backwards compatible way, by keeping the loadSelections arg on all Actions methods, but ignoring the arg (and emitting a warning when it is anything other than undefined).

I agree with making this change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should take a backwards incompatible approach, and create a separate Actions interface for the hook that does not include the loadSelections flag? Or do you think we should keep using the existing Actions interface and emit warnings when the flag is used, but ignore it in favor of the QueryModel attribute.

setFilters: (id: string, filters: Filter.IFilter[], loadSelections?: boolean) => void;
setMaxRows: (id: string, maxRows: number) => void;
setOffset: (id: string, offset: number, reloadModel?: boolean) => void;
setSchemaQuery: (id: string, schemaQuery: SchemaQuery, loadSelections?: boolean) => void;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing Actions.setSchemaQuery
- While technically this is backwards incompatible we have zero usages of this method, and as far as I know, we have never had any usages of this method. I am not sure why it ever existed.

I agree we should remove this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

);
return new Set(result?.selected ?? []);
},
async loadTotalCount(model: QueryModel, requestHandler: RequestHandler): Promise<number> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would introduction of useQueryModel() be a good time to also switch over to using selectRows() instead of selectRowsDeprecated? I'm thinking the hook could use selectRows and withQueryModels can continue using selectRowsDeprecated. Just a thought.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it may be a good time for that change, but I haven't thought too hard about the implications. My only potential concern would be that we need to ensure that the underlying data in QueryModel is not shaped differently, and I don't think it would be. We could use this as an opportunity to improve the underlying type used on QueryModel (e.g. change get gridData's return type to Row[] instead of Record<string, any>[]. While techincally that would be a backwards incompatible change (because the type is so much more strict it could break builds) I'm already planning on bumping the major version in this PR so that would probably be fine.

const manager = useRef<QueryModelManager>(null);

/* eslint-disable react-hooks/refs */
if (!manager.current) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another approach that would be stable and have "built in" initialization is to use useState. Example:

export function useQueryModels(queryConfigs: QueryConfigMap, options: UseQueryModelsOptions = {}): InjectedQueryModels {
    const { autoLoad = false, modelLoader } = options;
    const [searchParams, setSearchParams] = useOptionalSearchParams();
    const [manager] = useState(() => new QueryModelManager(queryConfigs, searchParams, setSearchParams, modelLoader));

    const state = useSyncExternalStore(manager.subscribe, manager.getSnapshot);

    useEffect(() => {
        // Note: It's not ideal because it will try to load selections for models that aren't active or aren't used in
        // grids (e.g., details models). We should add a loadSelections attribute to QueryModel so we can opt in to
        // loading selections for our grid models only. See Issue 48758 for additional context.
        if (autoLoad) manager.loadAllModels(true);
        else manager.loadAllQueryInfos();

        return () => {
            manager.destroy();
        };
    }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only run on mount

    useEffect(() => {
        manager.updateRouter(searchParams, setSearchParams);
    }, [manager, searchParams, setSearchParams]);

    return state;
}

…s, add syncURL, as saveSettings, fix minor issues
Move network code from withQueryModels to DefaultQueryModelLoader
…adQueryInfo, loadAllQueryInfos

useQueryModels: add options, wire up QueryModelLoader
…w, setFilters, setSchemaQuery, setSelections, setView
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants