jbrowse-plugin-msaview 2.10.0 → 2.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/useCachedBlastResults.d.ts +1 -1
  2. package/dist/LaunchMsaView/components/BlastQuery/useCachedBlastResults.js +14 -17
  3. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +1 -1
  4. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.d.ts +2 -1
  5. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.js +48 -50
  6. package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +4 -5
  7. package/dist/LaunchMsaView/components/useFeatureSequence.d.ts +6 -4
  8. package/dist/LaunchMsaView/components/useFeatureSequence.js +20 -11
  9. package/dist/LaunchMsaView/components/useTranscriptSelection.d.ts +1 -1
  10. package/dist/LaunchMsaView/extendStateModel.test.d.ts +1 -0
  11. package/dist/LaunchMsaView/extendStateModel.test.js +69 -0
  12. package/dist/LaunchMsaView/index.d.ts +2 -0
  13. package/dist/LaunchMsaView/index.js +44 -54
  14. package/dist/LaunchMsaView/launchTarget.d.ts +25 -0
  15. package/dist/LaunchMsaView/launchTarget.js +42 -0
  16. package/dist/LaunchMsaView/launchTarget.test.d.ts +1 -0
  17. package/dist/LaunchMsaView/launchTarget.test.js +54 -0
  18. package/dist/MsaViewPanel/doLaunchOrthologs.js +4 -2
  19. package/dist/MsaViewPanel/model.d.ts +4 -2
  20. package/dist/jbrowse-plugin-msaview.umd.production.min.js +29 -40
  21. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  22. package/dist/utils/ebiJobDispatcher.test.js +10 -1
  23. package/dist/utils/ncbiOrthologs.d.ts +1 -1
  24. package/dist/utils/ncbiOrthologs.js +1 -1
  25. package/dist/utils/ncbiTaxonomy.d.ts +19 -0
  26. package/dist/utils/ncbiTaxonomy.js +42 -9
  27. package/dist/utils/ncbiTaxonomy.test.d.ts +1 -0
  28. package/dist/utils/ncbiTaxonomy.test.js +69 -0
  29. package/dist/utils/useFetch.d.ts +22 -0
  30. package/dist/utils/useFetch.js +79 -0
  31. package/dist/utils/useFetch.test.d.ts +1 -0
  32. package/dist/utils/useFetch.test.js +23 -0
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/package.json +7 -6
  36. package/src/LaunchMsaView/components/BlastQuery/useCachedBlastResults.ts +16 -29
  37. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +1 -0
  38. package/src/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.tsx +63 -49
  39. package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +3 -6
  40. package/src/LaunchMsaView/components/useFeatureSequence.ts +35 -15
  41. package/src/LaunchMsaView/extendStateModel.test.ts +76 -0
  42. package/src/LaunchMsaView/index.ts +47 -80
  43. package/src/LaunchMsaView/launchTarget.test.ts +77 -0
  44. package/src/LaunchMsaView/launchTarget.ts +65 -0
  45. package/src/MsaViewPanel/doLaunchOrthologs.ts +4 -2
  46. package/src/MsaViewPanel/model.ts +4 -2
  47. package/src/utils/ebiJobDispatcher.test.ts +10 -1
  48. package/src/utils/ncbiOrthologs.ts +1 -1
  49. package/src/utils/ncbiTaxonomy.test.ts +84 -0
  50. package/src/utils/ncbiTaxonomy.ts +52 -9
  51. package/src/utils/useFetch.test.ts +30 -0
  52. package/src/utils/useFetch.ts +116 -0
  53. package/src/version.ts +1 -1
  54. package/dist/LaunchMsaView/components/useSWRFeatureSequence.d.ts +0 -12
  55. package/dist/LaunchMsaView/components/useSWRFeatureSequence.js +0 -25
  56. package/dist/utils/swrConfig.d.ts +0 -8
  57. package/dist/utils/swrConfig.js +0 -8
  58. package/src/LaunchMsaView/components/useSWRFeatureSequence.ts +0 -54
  59. package/src/utils/swrConfig.ts +0 -8
@@ -32,7 +32,16 @@ describe('waitForEbiJob', () => {
32
32
  });
33
33
  test('failures that do not persist never accumulate to the limit', async () => {
34
34
  const blip = new TypeError('Failed to fetch');
35
- statuses([blip, 'RUNNING', blip, 'RUNNING', blip, 'RUNNING', blip, 'FINISHED']);
35
+ statuses([
36
+ blip,
37
+ 'RUNNING',
38
+ blip,
39
+ 'RUNNING',
40
+ blip,
41
+ 'RUNNING',
42
+ blip,
43
+ 'FINISHED',
44
+ ]);
36
45
  await expect(wait()).resolves.toBeUndefined();
37
46
  });
38
47
  test('an endpoint that has genuinely gone away is not polled forever', async () => {
@@ -26,7 +26,7 @@ export declare function resolveGeneId(candidates: string[], taxId: number): Prom
26
26
  *
27
27
  * `taxa` narrows the set when a caller wants specific species; omitted, every
28
28
  * species NCBI has an ortholog for is a candidate. `exclude` drops the query
29
- * taxon, which the QUERY row already represents.
29
+ * taxon, which already has its own `<species>_query` row.
30
30
  */
31
31
  export declare function fetchOrthologGenes(geneId: string, { taxa, exclude, limit, }?: {
32
32
  taxa?: Set<number>;
@@ -85,7 +85,7 @@ export async function resolveGeneId(candidates, taxId) {
85
85
  *
86
86
  * `taxa` narrows the set when a caller wants specific species; omitted, every
87
87
  * species NCBI has an ortholog for is a candidate. `exclude` drops the query
88
- * taxon, which the QUERY row already represents.
88
+ * taxon, which already has its own `<species>_query` row.
89
89
  */
90
90
  export async function fetchOrthologGenes(geneId, { taxa, exclude, limit = defaultMaxSpecies, } = {}) {
91
91
  const json = await jsonfetch(ncbiUrl(`${DATASETS}/gene/id/${geneId}/orthologs?returned_content=COMPLETE`));
@@ -9,3 +9,22 @@
9
9
  * species silently resolves the wrong organism's gene for anyone outside it.
10
10
  */
11
11
  export declare function resolveTaxId(query: string): Promise<number | undefined>;
12
+ /**
13
+ * A JBrowse assembly name -> the species it is an assembly of.
14
+ *
15
+ * The dialog's query species used to open on `human` whoever was browsing, and
16
+ * that is the same silent wrong answer the 23-species list gave: `resolveGeneId`
17
+ * searches `SYMBOL[Gene Name] AND <taxid>[taxid]`, so a user right-clicking a
18
+ * mouse gene got the HUMAN gene of that symbol, its orthologs, and mouse twice
19
+ * — once as the query row and once as an ortholog, because the excluded taxon
20
+ * was the wrong one. A symbol with no human counterpart failed outright.
21
+ *
22
+ * db=assembly indexes the names JBrowse configs actually carry: hg38, GRCh38,
23
+ * mm39, danRer11, dm6 and sacCer3 all resolve, and so do RefSeq/GenBank
24
+ * accessions. An assembly NCBI has never heard of resolves to nothing and the
25
+ * caller keeps its own default, so this can only improve on guessing.
26
+ */
27
+ export declare function resolveAssemblySpecies(assemblyName: string): Promise<{
28
+ taxId: number;
29
+ speciesName: string;
30
+ } | undefined>;
@@ -1,6 +1,15 @@
1
1
  import { NCBI_EMAIL, NCBI_TOOL } from './eutils';
2
2
  import { jsonfetch } from './fetch';
3
3
  const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
4
+ function eutilsUrl(endpoint, params) {
5
+ const search = new URLSearchParams({
6
+ ...params,
7
+ retmode: 'json',
8
+ tool: NCBI_TOOL,
9
+ email: NCBI_EMAIL,
10
+ });
11
+ return `${EUTILS}/${endpoint}?${search.toString()}`;
12
+ }
4
13
  /**
5
14
  * Free text -> NCBI taxon id. A bare number is taken as the id itself; anything
6
15
  * else is searched against db=taxonomy, which resolves a scientific name
@@ -19,15 +28,39 @@ export async function resolveTaxId(query) {
19
28
  if (/^\d+$/.test(term)) {
20
29
  return Number(term);
21
30
  }
22
- const search = new URLSearchParams({
23
- db: 'taxonomy',
24
- term,
25
- retmode: 'json',
26
- retmax: '1',
27
- tool: NCBI_TOOL,
28
- email: NCBI_EMAIL,
29
- });
30
- const json = await jsonfetch(`${EUTILS}/esearch.fcgi?${search.toString()}`);
31
+ const json = await jsonfetch(eutilsUrl('esearch.fcgi', { db: 'taxonomy', term, retmax: '1' }));
31
32
  const id = json.esearchresult?.idlist?.[0];
32
33
  return id ? Number(id) : undefined;
33
34
  }
35
+ /**
36
+ * A JBrowse assembly name -> the species it is an assembly of.
37
+ *
38
+ * The dialog's query species used to open on `human` whoever was browsing, and
39
+ * that is the same silent wrong answer the 23-species list gave: `resolveGeneId`
40
+ * searches `SYMBOL[Gene Name] AND <taxid>[taxid]`, so a user right-clicking a
41
+ * mouse gene got the HUMAN gene of that symbol, its orthologs, and mouse twice
42
+ * — once as the query row and once as an ortholog, because the excluded taxon
43
+ * was the wrong one. A symbol with no human counterpart failed outright.
44
+ *
45
+ * db=assembly indexes the names JBrowse configs actually carry: hg38, GRCh38,
46
+ * mm39, danRer11, dm6 and sacCer3 all resolve, and so do RefSeq/GenBank
47
+ * accessions. An assembly NCBI has never heard of resolves to nothing and the
48
+ * caller keeps its own default, so this can only improve on guessing.
49
+ */
50
+ export async function resolveAssemblySpecies(assemblyName) {
51
+ const term = assemblyName.trim();
52
+ if (!term) {
53
+ return undefined;
54
+ }
55
+ const search = await jsonfetch(eutilsUrl('esearch.fcgi', { db: 'assembly', term, retmax: '1' }));
56
+ const uid = search.esearchresult?.idlist?.[0];
57
+ if (!uid) {
58
+ return undefined;
59
+ }
60
+ const summary = await jsonfetch(eutilsUrl('esummary.fcgi', { db: 'assembly', id: uid }));
61
+ const record = summary.result?.[uid];
62
+ const taxId = Number(record?.speciestaxid);
63
+ return record?.speciesname && taxId
64
+ ? { taxId, speciesName: record.speciesname }
65
+ : undefined;
66
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import { afterEach, describe, expect, test, vi } from 'vitest';
2
+ import { resolveAssemblySpecies, resolveTaxId } from './ncbiTaxonomy';
3
+ const stubFetch = (handler) => {
4
+ const seen = [];
5
+ vi.stubGlobal('fetch', (url) => {
6
+ seen.push(url);
7
+ return Promise.resolve({
8
+ ok: true,
9
+ status: 200,
10
+ json: () => Promise.resolve(handler(url)),
11
+ });
12
+ });
13
+ return seen;
14
+ };
15
+ afterEach(() => {
16
+ vi.unstubAllGlobals();
17
+ });
18
+ describe('resolveTaxId', () => {
19
+ test('takes a bare number as the id and spends no request on it', async () => {
20
+ const seen = stubFetch(() => ({}));
21
+ expect(await resolveTaxId(' 9606 ')).toBe(9606);
22
+ expect(seen).toEqual([]);
23
+ });
24
+ test('searches db=taxonomy for anything else', async () => {
25
+ const seen = stubFetch(() => ({ esearchresult: { idlist: ['7955'] } }));
26
+ expect(await resolveTaxId('zebrafish')).toBe(7955);
27
+ expect(seen[0]).toContain('db=taxonomy');
28
+ });
29
+ test('an unmatched name resolves to nothing', async () => {
30
+ stubFetch(() => ({ esearchresult: { idlist: [] } }));
31
+ expect(await resolveTaxId('not an organism')).toBeUndefined();
32
+ });
33
+ });
34
+ // The dialog opened on `human` whoever was browsing, which on a mouse assembly
35
+ // resolves the gene symbol to the HUMAN gene and excludes the wrong taxon, so
36
+ // mouse lands twice. db=assembly is where the assembly names JBrowse configs
37
+ // carry are indexed.
38
+ describe('resolveAssemblySpecies', () => {
39
+ const summaryFor = (speciestaxid, speciesname) => ({
40
+ result: { uids: ['1'], '1': { speciestaxid, speciesname } },
41
+ });
42
+ test('resolves an assembly name to the species it is an assembly of', async () => {
43
+ const seen = stubFetch(url => url.includes('esearch')
44
+ ? { esearchresult: { idlist: ['1'] } }
45
+ : summaryFor('10090', 'Mus musculus'));
46
+ expect(await resolveAssemblySpecies('mm39')).toEqual({
47
+ taxId: 10090,
48
+ speciesName: 'Mus musculus',
49
+ });
50
+ expect(seen[0]).toContain('db=assembly');
51
+ expect(seen[1]).toContain('esummary');
52
+ });
53
+ test('an assembly NCBI does not index resolves to nothing, without a summary call', async () => {
54
+ const seen = stubFetch(() => ({ esearchresult: { idlist: [] } }));
55
+ expect(await resolveAssemblySpecies('my_custom_asm')).toBeUndefined();
56
+ expect(seen).toHaveLength(1);
57
+ });
58
+ test('a summary carrying no species resolves to nothing rather than taxon NaN', async () => {
59
+ stubFetch(url => url.includes('esearch')
60
+ ? { esearchresult: { idlist: ['1'] } }
61
+ : { result: { uids: ['1'], '1': {} } });
62
+ expect(await resolveAssemblySpecies('hg38')).toBeUndefined();
63
+ });
64
+ test('an empty name asks nothing', async () => {
65
+ const seen = stubFetch(() => ({}));
66
+ expect(await resolveAssemblySpecies(' ')).toBeUndefined();
67
+ expect(seen).toEqual([]);
68
+ });
69
+ });
@@ -0,0 +1,22 @@
1
+ export type FetchKey = string | readonly unknown[] | null | undefined | false;
2
+ export declare function serializeKey(key: FetchKey): string | null;
3
+ /**
4
+ * Fetch once per key, keeping loading and error state with the data instead of
5
+ * spread across three `useState`s and a cancellation flag.
6
+ *
7
+ * Everything the caller reads is derived from the one settled result, which is
8
+ * what keeps the states consistent: while a key change is in flight there is no
9
+ * frame showing the previous key's data as though it were the new key's. A
10
+ * `mutate()` refetch under the same key does leave its data up, because it is
11
+ * the same question asked again and blanking it flashes an empty list.
12
+ */
13
+ export declare function useFetch<Data>(key: FetchKey, fetcher: () => Promise<Data>, { onSuccess }?: {
14
+ onSuccess?: (data: Data) => void;
15
+ }): {
16
+ data: Data | undefined;
17
+ error: unknown;
18
+ isLoading: boolean;
19
+ mutate: () => void;
20
+ };
21
+ /** `value`, but only after it has stopped changing for `ms`. */
22
+ export declare function useDebounced<T>(value: T, ms: number): T;
@@ -0,0 +1,79 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ const isNil = (k) => k === null || k === undefined || k === false;
3
+ // A null result means "don't fetch": no key, or an array key with a missing
4
+ // piece. Note what that makes of a boolean IN a key -- `false` reads as a
5
+ // missing piece and silently disables the fetch, so put a string there.
6
+ export function serializeKey(key) {
7
+ return isNil(key) || (Array.isArray(key) && key.some(isNil))
8
+ ? null
9
+ : JSON.stringify(key);
10
+ }
11
+ /**
12
+ * Fetch once per key, keeping loading and error state with the data instead of
13
+ * spread across three `useState`s and a cancellation flag.
14
+ *
15
+ * Everything the caller reads is derived from the one settled result, which is
16
+ * what keeps the states consistent: while a key change is in flight there is no
17
+ * frame showing the previous key's data as though it were the new key's. A
18
+ * `mutate()` refetch under the same key does leave its data up, because it is
19
+ * the same question asked again and blanking it flashes an empty list.
20
+ */
21
+ export function useFetch(key, fetcher, { onSuccess } = {}) {
22
+ const serialized = serializeKey(key);
23
+ const [nonce, setNonce] = useState(0);
24
+ const [settled, setSettled] = useState();
25
+ const attempt = serialized === null ? null : `${nonce}\u0000${serialized}`;
26
+ const current = settled?.attempt === attempt;
27
+ const answersThisKey = settled?.question === serialized;
28
+ // The fetcher and the success callback are inline closures at every call site,
29
+ // so depending on their identity would refetch every render. This effect is
30
+ // declared before the fetch effect and has no dependency list, so it has
31
+ // already run with the current render's values by the time that one does.
32
+ const latest = useRef({ fetcher, onSuccess });
33
+ useEffect(() => {
34
+ latest.current = { fetcher, onSuccess };
35
+ });
36
+ useEffect(() => {
37
+ if (attempt === null || serialized === null) {
38
+ return undefined;
39
+ }
40
+ let alive = true;
41
+ latest.current
42
+ .fetcher()
43
+ .then(data => {
44
+ if (alive) {
45
+ setSettled({ attempt, question: serialized, data });
46
+ latest.current.onSuccess?.(data);
47
+ }
48
+ })
49
+ .catch((error) => {
50
+ if (alive) {
51
+ setSettled({ attempt, question: serialized, error });
52
+ }
53
+ });
54
+ return () => {
55
+ alive = false;
56
+ };
57
+ }, [attempt, serialized]);
58
+ return {
59
+ data: answersThisKey ? settled.data : undefined,
60
+ error: current ? settled.error : undefined,
61
+ isLoading: attempt !== null && !current,
62
+ mutate: useCallback(() => {
63
+ setNonce(n => n + 1);
64
+ }, []),
65
+ };
66
+ }
67
+ /** `value`, but only after it has stopped changing for `ms`. */
68
+ export function useDebounced(value, ms) {
69
+ const [debounced, setDebounced] = useState(value);
70
+ useEffect(() => {
71
+ const timer = setTimeout(() => {
72
+ setDebounced(value);
73
+ }, ms);
74
+ return () => {
75
+ clearTimeout(timer);
76
+ };
77
+ }, [value, ms]);
78
+ return debounced;
79
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { serializeKey } from './useFetch';
3
+ describe('serializeKey', () => {
4
+ test('a key identifies the fetch', () => {
5
+ expect(serializeKey('msa-list')).toBe(serializeKey('msa-list'));
6
+ expect(serializeKey(['NM_1', 'hg38'])).not.toBe(serializeKey(['NM_1', 'mm39']));
7
+ });
8
+ test('no key means do not fetch', () => {
9
+ expect(serializeKey(null)).toBeNull();
10
+ expect(serializeKey(undefined)).toBeNull();
11
+ expect(serializeKey(false)).toBeNull();
12
+ expect(serializeKey('')).not.toBeNull();
13
+ });
14
+ test('an array key with a piece still missing means do not fetch', () => {
15
+ expect(serializeKey(['NM_1', undefined, 'feature-sequence'])).toBeNull();
16
+ });
17
+ // the trap this rule sets: a boolean IN a key reads as a missing piece, so a
18
+ // `!!selected` where a string belonged disables the fetch and raises nothing
19
+ test('a boolean in a key disables the fetch', () => {
20
+ expect(serializeKey(['gene', false])).toBeNull();
21
+ expect(serializeKey(['gene', true])).not.toBeNull();
22
+ });
23
+ });
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "2.10.0";
1
+ export declare const version = "2.10.2";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.10.0';
1
+ export const version = '2.10.2';
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.10.0",
2
+ "version": "2.10.2",
3
3
  "license": "MIT",
4
4
  "name": "jbrowse-plugin-msaview",
5
5
  "repository": {
@@ -19,8 +19,7 @@
19
19
  "@emotion/styled": "^11.14.1",
20
20
  "@gmod/bgzf-filehandle": "^6.2.0",
21
21
  "g2p_mapper": "^2.1.5",
22
- "idb": "^8.0.3",
23
- "swr": "^2.4.2"
22
+ "idb": "^8.0.3"
24
23
  },
25
24
  "devDependencies": {
26
25
  "@emotion/react": "^11.14.0",
@@ -41,9 +40,10 @@
41
40
  "eslint-plugin-react": "^7.37.5",
42
41
  "eslint-plugin-react-hooks": "^7.1.1",
43
42
  "eslint-plugin-unicorn": "^72.0.0",
43
+ "git-cliff": "^2.13.1",
44
44
  "mobx": "^6.16.1",
45
45
  "mobx-react": "^9.2.2",
46
- "msa-parsers": "^5.9.0",
46
+ "msa-parsers": "^5.10.0",
47
47
  "pixelmatch": "^7.2.0",
48
48
  "pngjs": "^7.0.0",
49
49
  "prettier": "^3.9.6",
@@ -51,7 +51,7 @@
51
51
  "puppeteer": "^25.3.0",
52
52
  "react": "^19.2.8",
53
53
  "react-dom": "^19.2.8",
54
- "react-msaview": "^5.9.0",
54
+ "react-msaview": "^5.10.0",
55
55
  "rimraf": "^6.1.3",
56
56
  "rxjs": "^7.8.2",
57
57
  "serve": "^14.2.6",
@@ -67,6 +67,7 @@
67
67
  "build": "tsc && NODE_ENV=production node esbuild.mjs && cp distconfig.json dist/config.json",
68
68
  "prebuild": "pnpm clean",
69
69
  "lint": "eslint src --report-unused-disable-directives --max-warnings 0",
70
+ "check-text-source": "node scripts/check-text-source.mjs",
70
71
  "pretest": "rm -rf .test-jbrowse && npx @jbrowse/cli create .test-jbrowse --nightly",
71
72
  "test": "vitest run",
72
73
  "test:watch": "vitest",
@@ -77,7 +78,7 @@
77
78
  "host-compat": "node scripts/host-compat-probe.mjs --bundle dist/jbrowse-plugin-msaview.umd.production.min.js",
78
79
  "check-ci": "node scripts/require-green-ci.mjs",
79
80
  "preversion": "pnpm check-ci && pnpm lint && pnpm build && pnpm host-compat",
80
- "version": "node -e \"console.log('export const version = \\'' + require('./package.json').version + '\\'')\" > src/version.ts && git add src/version.ts",
81
+ "version": "node -e \"console.log('export const version = \\'' + require('./package.json').version + '\\'')\" > src/version.ts && git-cliff --tag v$npm_package_version --unreleased --prepend CHANGELOG.md && git add src/version.ts CHANGELOG.md",
81
82
  "postversion": "git push --follow-tags"
82
83
  }
83
84
  }
@@ -1,10 +1,8 @@
1
- import useSWR from 'swr'
2
-
3
1
  import {
4
2
  deleteCachedResult,
5
3
  getAllCachedResults,
6
4
  } from '../../../utils/blastCache'
7
- import { staticSwrConfig } from '../../../utils/swrConfig'
5
+ import { useFetch } from '../../../utils/useFetch'
8
6
 
9
7
  export function useCachedBlastResults(geneIds: string[]) {
10
8
  const {
@@ -12,36 +10,25 @@ export function useCachedBlastResults(geneIds: string[]) {
12
10
  error,
13
11
  isLoading,
14
12
  mutate,
15
- } = useSWR(
16
- `cached-blast-${geneIds.join(',')}`,
17
- async () => {
18
- const cached = await getAllCachedResults()
19
- return cached.filter(r => r.geneId && geneIds.includes(r.geneId))
20
- },
21
- staticSwrConfig,
22
- )
23
-
24
- const handleDelete = async (id: string) => {
25
- await deleteCachedResult(id)
26
- await mutate(
27
- results => results?.filter(result => result.id !== id) ?? [],
28
- false,
29
- )
30
- }
31
-
32
- // deletes only what this hook listed, i.e. the results for these gene ids.
33
- // The list the user is looking at is gene-scoped, so a store-wide clear here
34
- // would silently throw away every other gene's cached alignments too
35
- const handleClearAll = async () => {
36
- await Promise.all((results ?? []).map(r => deleteCachedResult(r.id)))
37
- await mutate([], false)
38
- }
13
+ } = useFetch(`cached-blast-${geneIds.join(',')}`, async () => {
14
+ const cached = await getAllCachedResults()
15
+ return cached.filter(r => r.geneId && geneIds.includes(r.geneId))
16
+ })
39
17
 
40
18
  return {
41
19
  results: results ?? [],
42
20
  error,
43
21
  isLoading,
44
- handleDelete,
45
- handleClearAll,
22
+ handleDelete: async (id: string) => {
23
+ await deleteCachedResult(id)
24
+ mutate()
25
+ },
26
+ // deletes only what this hook listed, i.e. the results for these gene ids.
27
+ // The list the user is looking at is gene-scoped, so a store-wide clear here
28
+ // would silently throw away every other gene's cached alignments too
29
+ handleClearAll: async () => {
30
+ await Promise.all((results ?? []).map(r => deleteCachedResult(r.id)))
31
+ mutate()
32
+ },
46
33
  }
47
34
  }
@@ -73,6 +73,7 @@ const OrthologPanel = observer(function ({
73
73
  <QuerySpeciesSelect
74
74
  className={classes.selectField}
75
75
  value={taxId}
76
+ assemblyName={view.assemblyNames[0]}
76
77
  onChange={setTaxId}
77
78
  />
78
79
 
@@ -1,8 +1,24 @@
1
- import React, { useEffect, useState } from 'react'
1
+ import React, { useState } from 'react'
2
2
 
3
3
  import TextField2 from '../../../components/TextField2'
4
- import { resolveTaxId } from '../../../utils/ncbiTaxonomy'
4
+ import {
5
+ resolveAssemblySpecies,
6
+ resolveTaxId,
7
+ } from '../../../utils/ncbiTaxonomy'
5
8
  import { fetchTaxonomyInfo } from '../../../utils/taxonomyNames'
9
+ import { useDebounced, useFetch } from '../../../utils/useFetch'
10
+
11
+ async function describeTaxon(query: string) {
12
+ const taxId = await resolveTaxId(query)
13
+ if (!taxId) {
14
+ throw new Error(`No NCBI taxon matches "${query}"`)
15
+ }
16
+ const info = (await fetchTaxonomyInfo([taxId])).get(taxId)
17
+ const label = [info?.sciname, info?.commonName && `(${info.commonName})`]
18
+ .filter(Boolean)
19
+ .join(' ')
20
+ return { taxId, label: label || `taxon ${taxId}` }
21
+ }
6
22
 
7
23
  /**
8
24
  * The species the query gene came from, as free text resolved against NCBI's
@@ -15,62 +31,60 @@ import { fetchTaxonomyInfo } from '../../../utils/taxonomyNames'
15
31
  */
16
32
  export default function QuerySpeciesSelect({
17
33
  value,
34
+ assemblyName,
18
35
  onChange,
19
36
  className,
20
37
  }: {
21
38
  value: number
39
+ assemblyName?: string
22
40
  onChange: (taxId: number) => void
23
41
  className?: string
24
42
  }) {
25
- const [text, setText] = useState('human')
26
- const [resolved, setResolved] = useState<string>()
27
- const [error, setError] = useState<unknown>()
43
+ // undefined until the user types, which is what makes the two lookups
44
+ // exclusive rather than both firing on open
45
+ const [typed, setTyped] = useState<string>()
46
+ const debounced = useDebounced(typed, 400)
28
47
 
29
- useEffect(() => {
30
- // read through a call rather than as a property: the cleanup writes it from
31
- // another turn of the loop, and a bare `run.live` narrows to true after the
32
- // first check, which reads to the compiler as a redundant second one
33
- const run = { live: true }
34
- const cancelled = () => !run.live
35
- async function lookup() {
36
- try {
37
- setError(undefined)
38
- const taxId = await resolveTaxId(text)
39
- if (cancelled()) {
40
- return
41
- }
42
- if (!taxId) {
43
- setResolved(undefined)
44
- setError(new Error(`No NCBI taxon matches "${text}"`))
45
- return
48
+ // Opening on `human` for everyone is the same silent wrong answer the fixed
49
+ // species list gave: on a mouse assembly the gene symbol resolves to the HUMAN
50
+ // gene, and the excluded taxon is human too, so mouse appears twice. The
51
+ // assembly being browsed is the one thing here that already knows the answer.
52
+ //
53
+ // db=assembly already returns the taxon id, so this is the whole lookup — the
54
+ // taxonomy chain below would be two more requests for an answer we hold. That
55
+ // is not just waste: eutils allows 3 requests a second and throttles by
56
+ // answering without CORS headers, so all four fired on open and the browser
57
+ // reported the throttle as "blocked by CORS policy" in the helper text.
58
+ const { data: fromAssembly } = useFetch(
59
+ assemblyName && typed === undefined
60
+ ? [assemblyName, 'assembly-species']
61
+ : null,
62
+ () => resolveAssemblySpecies(assemblyName!),
63
+ {
64
+ onSuccess: found => {
65
+ if (found) {
66
+ onChange(found.taxId)
46
67
  }
47
- const info = (await fetchTaxonomyInfo([taxId])).get(taxId)
48
- if (cancelled()) {
49
- return
50
- }
51
- setResolved(
52
- [info?.sciname, info?.commonName && `(${info.commonName})`]
53
- .filter(Boolean)
54
- .join(' ') || `taxon ${taxId}`,
55
- )
68
+ },
69
+ },
70
+ )
71
+
72
+ const { data: fromText, error } = useFetch(
73
+ debounced?.trim() ? [debounced.trim(), 'taxon'] : null,
74
+ () => describeTaxon(debounced!),
75
+ {
76
+ onSuccess: ({ taxId }) => {
56
77
  onChange(taxId)
57
- } catch (e) {
58
- if (!cancelled()) {
59
- setError(e)
60
- }
61
- }
62
- }
63
- const timer = setTimeout(() => {
64
- void lookup()
65
- }, 400)
66
- return () => {
67
- run.live = false
68
- clearTimeout(timer)
69
- }
70
- // onChange is a setState updater from the parent and stable in practice;
71
- // including it would re-run the lookup on every parent render
72
- // eslint-disable-next-line react-hooks/exhaustive-deps
73
- }, [text])
78
+ },
79
+ },
80
+ )
81
+
82
+ // derived, not seeded through an effect: whatever the user typed wins, and
83
+ // until they type anything the assembly's species does, so a lookup that
84
+ // lands while they are mid-word cannot overwrite the field
85
+ const text = typed ?? fromAssembly?.speciesName ?? 'human'
86
+ const resolved =
87
+ typed === undefined ? fromAssembly?.speciesName : fromText?.label
74
88
 
75
89
  return (
76
90
  <TextField2
@@ -79,7 +93,7 @@ export default function QuerySpeciesSelect({
79
93
  className={className}
80
94
  value={text}
81
95
  onChange={event => {
82
- setText(event.target.value)
96
+ setTyped(event.target.value)
83
97
  }}
84
98
  error={!!error}
85
99
  helperText={
@@ -4,11 +4,10 @@ import { LoadingEllipses, SanitizedHTML } from '@jbrowse/core/ui'
4
4
  import { getEnv, getSession } from '@jbrowse/core/util'
5
5
  import { MenuItem } from '@mui/material'
6
6
  import { observer } from 'mobx-react'
7
- import useSWR from 'swr'
8
7
  import { makeStyles } from 'tss-react/mui'
9
8
 
10
9
  import TextField2 from '../../../components/TextField2'
11
- import { staticSwrConfig } from '../../../utils/swrConfig'
10
+ import { useFetch } from '../../../utils/useFetch'
12
11
  import { getGeneDisplayName, getLinearGenomeView } from '../../util'
13
12
  import LaunchPanelContent from '../LaunchPanelContent'
14
13
  import SubmitCancelActions from '../SubmitCancelActions'
@@ -51,10 +50,9 @@ const PreLoadedMSA = observer(function ({
51
50
  data: msaList,
52
51
  isLoading: msaListLoading,
53
52
  error: msaListFetchError,
54
- } = useSWR(
53
+ } = useFetch(
55
54
  selectedDataset ? `${selectedDataset.datasetId}-msa-list` : null,
56
55
  () => fetchMSAList({ config: selectedDataset!.adapter, pluginManager }),
57
- staticSwrConfig,
58
56
  )
59
57
 
60
58
  const transcriptSelection = useTranscriptSelection({
@@ -68,7 +66,7 @@ const PreLoadedMSA = observer(function ({
68
66
  data: msaData,
69
67
  isLoading: msaDataLoading,
70
68
  error: msaDataFetchError,
71
- } = useSWR(
69
+ } = useFetch(
72
70
  selectedId && selectedDataset && msaList
73
71
  ? `${selectedDataset.datasetId}-${selectedId}-msa`
74
72
  : null,
@@ -78,7 +76,6 @@ const PreLoadedMSA = observer(function ({
78
76
  config: selectedDataset!.adapter,
79
77
  pluginManager,
80
78
  }),
81
- staticSwrConfig,
82
79
  )
83
80
 
84
81
  const e =