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
@@ -1,6 +1,6 @@
1
1
  export declare function useCachedBlastResults(geneIds: string[]): {
2
2
  results: import("../../../utils/blastCache").CachedBlastResult[];
3
- error: any;
3
+ error: unknown;
4
4
  isLoading: boolean;
5
5
  handleDelete: (id: string) => Promise<void>;
6
6
  handleClearAll: () => Promise<void>;
@@ -1,27 +1,24 @@
1
- import useSWR from 'swr';
2
1
  import { deleteCachedResult, getAllCachedResults, } from '../../../utils/blastCache';
3
- import { staticSwrConfig } from '../../../utils/swrConfig';
2
+ import { useFetch } from '../../../utils/useFetch';
4
3
  export function useCachedBlastResults(geneIds) {
5
- const { data: results, error, isLoading, mutate, } = useSWR(`cached-blast-${geneIds.join(',')}`, async () => {
4
+ const { data: results, error, isLoading, mutate, } = useFetch(`cached-blast-${geneIds.join(',')}`, async () => {
6
5
  const cached = await getAllCachedResults();
7
6
  return cached.filter(r => r.geneId && geneIds.includes(r.geneId));
8
- }, staticSwrConfig);
9
- const handleDelete = async (id) => {
10
- await deleteCachedResult(id);
11
- await mutate(results => results?.filter(result => result.id !== id) ?? [], false);
12
- };
13
- // deletes only what this hook listed, i.e. the results for these gene ids.
14
- // The list the user is looking at is gene-scoped, so a store-wide clear here
15
- // would silently throw away every other gene's cached alignments too
16
- const handleClearAll = async () => {
17
- await Promise.all((results ?? []).map(r => deleteCachedResult(r.id)));
18
- await mutate([], false);
19
- };
7
+ });
20
8
  return {
21
9
  results: results ?? [],
22
10
  error,
23
11
  isLoading,
24
- handleDelete,
25
- handleClearAll,
12
+ handleDelete: async (id) => {
13
+ await deleteCachedResult(id);
14
+ mutate();
15
+ },
16
+ // deletes only what this hook listed, i.e. the results for these gene ids.
17
+ // The list the user is looking at is gene-scoped, so a store-wide clear here
18
+ // would silently throw away every other gene's cached alignments too
19
+ handleClearAll: async () => {
20
+ await Promise.all((results ?? []).map(r => deleteCachedResult(r.id)));
21
+ mutate();
22
+ },
26
23
  };
27
24
  }
@@ -34,7 +34,7 @@ const OrthologPanel = observer(function ({ handleClose, feature, model, }) {
34
34
  React.createElement(LaunchPanelContent, { error: e },
35
35
  React.createElement(Typography, { variant: "body2" }, "NCBI's precomputed orthologs, one gene per species, looked up rather than searched for. No BLAST job to queue."),
36
36
  React.createElement("div", null,
37
- React.createElement(QuerySpeciesSelect, { className: classes.selectField, value: taxId, onChange: setTaxId }),
37
+ React.createElement(QuerySpeciesSelect, { className: classes.selectField, value: taxId, assemblyName: view.assemblyNames[0], onChange: setTaxId }),
38
38
  React.createElement(MsaAlgorithmSelect, { className: classes.selectField, value: msaAlgorithm, onChange: setMsaAlgorithm }),
39
39
  React.createElement(TextField2, { variant: "outlined", label: "Rows to align", className: classes.selectField, type: "number", value: maxSpecies, onChange: event => {
40
40
  setMaxSpecies(event.target.value);
@@ -8,8 +8,9 @@ import React from 'react';
8
8
  * organism rather than to nothing, and the only place that surfaces is the gene
9
9
  * lookup, as "could not resolve NLRP1 in taxon 9986".
10
10
  */
11
- export default function QuerySpeciesSelect({ value, onChange, className, }: {
11
+ export default function QuerySpeciesSelect({ value, assemblyName, onChange, className, }: {
12
12
  value: number;
13
+ assemblyName?: string;
13
14
  onChange: (taxId: number) => void;
14
15
  className?: string;
15
16
  }): React.JSX.Element;
@@ -1,7 +1,19 @@
1
- import React, { useEffect, useState } from 'react';
1
+ import React, { useState } from 'react';
2
2
  import TextField2 from '../../../components/TextField2';
3
- import { resolveTaxId } from '../../../utils/ncbiTaxonomy';
3
+ import { resolveAssemblySpecies, resolveTaxId, } from '../../../utils/ncbiTaxonomy';
4
4
  import { fetchTaxonomyInfo } from '../../../utils/taxonomyNames';
5
+ import { useDebounced, useFetch } from '../../../utils/useFetch';
6
+ async function describeTaxon(query) {
7
+ const taxId = await resolveTaxId(query);
8
+ if (!taxId) {
9
+ throw new Error(`No NCBI taxon matches "${query}"`);
10
+ }
11
+ const info = (await fetchTaxonomyInfo([taxId])).get(taxId);
12
+ const label = [info?.sciname, info?.commonName && `(${info.commonName})`]
13
+ .filter(Boolean)
14
+ .join(' ');
15
+ return { taxId, label: label || `taxon ${taxId}` };
16
+ }
5
17
  /**
6
18
  * The species the query gene came from, as free text resolved against NCBI's
7
19
  * taxonomy rather than picked from a fixed list.
@@ -11,56 +23,42 @@ import { fetchTaxonomyInfo } from '../../../utils/taxonomyNames';
11
23
  * organism rather than to nothing, and the only place that surfaces is the gene
12
24
  * lookup, as "could not resolve NLRP1 in taxon 9986".
13
25
  */
14
- export default function QuerySpeciesSelect({ value, onChange, className, }) {
15
- const [text, setText] = useState('human');
16
- const [resolved, setResolved] = useState();
17
- const [error, setError] = useState();
18
- useEffect(() => {
19
- // read through a call rather than as a property: the cleanup writes it from
20
- // another turn of the loop, and a bare `run.live` narrows to true after the
21
- // first check, which reads to the compiler as a redundant second one
22
- const run = { live: true };
23
- const cancelled = () => !run.live;
24
- async function lookup() {
25
- try {
26
- setError(undefined);
27
- const taxId = await resolveTaxId(text);
28
- if (cancelled()) {
29
- return;
30
- }
31
- if (!taxId) {
32
- setResolved(undefined);
33
- setError(new Error(`No NCBI taxon matches "${text}"`));
34
- return;
35
- }
36
- const info = (await fetchTaxonomyInfo([taxId])).get(taxId);
37
- if (cancelled()) {
38
- return;
39
- }
40
- setResolved([info?.sciname, info?.commonName && `(${info.commonName})`]
41
- .filter(Boolean)
42
- .join(' ') || `taxon ${taxId}`);
43
- onChange(taxId);
44
- }
45
- catch (e) {
46
- if (!cancelled()) {
47
- setError(e);
48
- }
26
+ export default function QuerySpeciesSelect({ value, assemblyName, onChange, className, }) {
27
+ // undefined until the user types, which is what makes the two lookups
28
+ // exclusive rather than both firing on open
29
+ const [typed, setTyped] = useState();
30
+ const debounced = useDebounced(typed, 400);
31
+ // Opening on `human` for everyone is the same silent wrong answer the fixed
32
+ // species list gave: on a mouse assembly the gene symbol resolves to the HUMAN
33
+ // gene, and the excluded taxon is human too, so mouse appears twice. The
34
+ // assembly being browsed is the one thing here that already knows the answer.
35
+ //
36
+ // db=assembly already returns the taxon id, so this is the whole lookup — the
37
+ // taxonomy chain below would be two more requests for an answer we hold. That
38
+ // is not just waste: eutils allows 3 requests a second and throttles by
39
+ // answering without CORS headers, so all four fired on open and the browser
40
+ // reported the throttle as "blocked by CORS policy" in the helper text.
41
+ const { data: fromAssembly } = useFetch(assemblyName && typed === undefined
42
+ ? [assemblyName, 'assembly-species']
43
+ : null, () => resolveAssemblySpecies(assemblyName), {
44
+ onSuccess: found => {
45
+ if (found) {
46
+ onChange(found.taxId);
49
47
  }
50
- }
51
- const timer = setTimeout(() => {
52
- void lookup();
53
- }, 400);
54
- return () => {
55
- run.live = false;
56
- clearTimeout(timer);
57
- };
58
- // onChange is a setState updater from the parent and stable in practice;
59
- // including it would re-run the lookup on every parent render
60
- // eslint-disable-next-line react-hooks/exhaustive-deps
61
- }, [text]);
48
+ },
49
+ });
50
+ const { data: fromText, error } = useFetch(debounced?.trim() ? [debounced.trim(), 'taxon'] : null, () => describeTaxon(debounced), {
51
+ onSuccess: ({ taxId }) => {
52
+ onChange(taxId);
53
+ },
54
+ });
55
+ // derived, not seeded through an effect: whatever the user typed wins, and
56
+ // until they type anything the assembly's species does, so a lookup that
57
+ // lands while they are mid-word cannot overwrite the field
58
+ const text = typed ?? fromAssembly?.speciesName ?? 'human';
59
+ const resolved = typed === undefined ? fromAssembly?.speciesName : fromText?.label;
62
60
  return (React.createElement(TextField2, { variant: "outlined", label: "Query species", className: className, value: text, onChange: event => {
63
- setText(event.target.value);
61
+ setTyped(event.target.value);
64
62
  }, error: !!error, helperText: error
65
63
  ? `${error}`
66
64
  : (resolved ?? `the species this gene is from (taxon ${value})`) }));
@@ -3,10 +3,9 @@ import { LoadingEllipses, SanitizedHTML } from '@jbrowse/core/ui';
3
3
  import { getEnv, getSession } from '@jbrowse/core/util';
4
4
  import { MenuItem } from '@mui/material';
5
5
  import { observer } from 'mobx-react';
6
- import useSWR from 'swr';
7
6
  import { makeStyles } from 'tss-react/mui';
8
7
  import TextField2 from '../../../components/TextField2';
9
- import { staticSwrConfig } from '../../../utils/swrConfig';
8
+ import { useFetch } from '../../../utils/useFetch';
10
9
  import { getGeneDisplayName, getLinearGenomeView } from '../../util';
11
10
  import LaunchPanelContent from '../LaunchPanelContent';
12
11
  import SubmitCancelActions from '../SubmitCancelActions';
@@ -30,20 +29,20 @@ const PreLoadedMSA = observer(function ({ model, feature, handleClose, }) {
30
29
  const datasets = readMsaDatasets(session.jbrowse);
31
30
  const [selectedDatasetId, setSelectedDatasetId] = useState(datasets?.[0]?.datasetId);
32
31
  const selectedDataset = datasets?.find(d => d.datasetId === selectedDatasetId);
33
- const { data: msaList, isLoading: msaListLoading, error: msaListFetchError, } = useSWR(selectedDataset ? `${selectedDataset.datasetId}-msa-list` : null, () => fetchMSAList({ config: selectedDataset.adapter, pluginManager }), staticSwrConfig);
32
+ const { data: msaList, isLoading: msaListLoading, error: msaListFetchError, } = useFetch(selectedDataset ? `${selectedDataset.datasetId}-msa-list` : null, () => fetchMSAList({ config: selectedDataset.adapter, pluginManager }));
34
33
  const transcriptSelection = useTranscriptSelection({
35
34
  feature,
36
35
  view,
37
36
  validIds: msaList,
38
37
  });
39
38
  const { selectedId, selectedTranscript } = transcriptSelection;
40
- const { data: msaData, isLoading: msaDataLoading, error: msaDataFetchError, } = useSWR(selectedId && selectedDataset && msaList
39
+ const { data: msaData, isLoading: msaDataLoading, error: msaDataFetchError, } = useFetch(selectedId && selectedDataset && msaList
41
40
  ? `${selectedDataset.datasetId}-${selectedId}-msa`
42
41
  : null, () => fetchMSA({
43
42
  msaId: selectedId,
44
43
  config: selectedDataset.adapter,
45
44
  pluginManager,
46
- }), staticSwrConfig);
45
+ }));
47
46
  const e = msaListFetchError ??
48
47
  msaDataFetchError ??
49
48
  transcriptSelection.error ??
@@ -1,13 +1,15 @@
1
1
  import type { Feature } from '@jbrowse/core/util';
2
+ interface ViewLike {
3
+ assemblyNames?: string[];
4
+ }
2
5
  export declare function useFeatureSequence({ view, feature, }: {
3
- view: {
4
- assemblyNames?: string[];
5
- } | undefined;
6
+ view: ViewLike | undefined;
6
7
  feature?: Feature;
7
8
  }): {
8
9
  proteinSequence: string;
9
10
  sequence: {
10
11
  seq: string;
11
12
  } | undefined;
12
- error: any;
13
+ error: unknown;
13
14
  };
15
+ export {};
@@ -1,18 +1,27 @@
1
+ import { getSession } from '@jbrowse/core/util';
1
2
  import { getProteinSequenceFromFeature } from './calculateProteinSequence';
2
- import { useSWRFeatureSequence } from './useSWRFeatureSequence';
3
+ import { fetchSeq } from './fetchSeq';
4
+ import { useFetch } from '../../utils/useFetch';
3
5
  export function useFeatureSequence({ view, feature, }) {
4
- const { sequence, error } = useSWRFeatureSequence({
5
- view,
6
- feature,
6
+ const assemblyName = view?.assemblyNames?.[0];
7
+ const { data: sequence, error } = useFetch(feature && assemblyName
8
+ ? [feature.id(), assemblyName, 'feature-sequence']
9
+ : null, async () => {
10
+ const { start, end, refName } = feature.toJSON();
11
+ return {
12
+ seq: await fetchSeq({
13
+ start,
14
+ end,
15
+ refName,
16
+ assemblyName: assemblyName,
17
+ session: getSession(view),
18
+ }),
19
+ };
7
20
  });
8
- const proteinSequence = sequence && feature
9
- ? getProteinSequenceFromFeature({
10
- seq: sequence.seq,
11
- feature,
12
- })
13
- : '';
14
21
  return {
15
- proteinSequence,
22
+ proteinSequence: sequence && feature
23
+ ? getProteinSequenceFromFeature({ seq: sequence.seq, feature })
24
+ : '',
16
25
  sequence,
17
26
  error,
18
27
  };
@@ -11,6 +11,6 @@ export declare function useTranscriptSelection({ feature, view, validIds, }: {
11
11
  setSelectedId: import("react").Dispatch<import("react").SetStateAction<string>>;
12
12
  selectedTranscript: Feature | undefined;
13
13
  proteinSequence: string;
14
- error: any;
14
+ error: unknown;
15
15
  validIds: string[] | undefined;
16
16
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import { types } from '@jbrowse/mobx-state-tree';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { extendStateModel } from './index';
4
+ // A host display whose own contextMenuItems reaches the rest of itself through
5
+ // `this` -- which is what jbrowse-components shipped between b439251a21 and
6
+ // 104bbfc581, and what any host is free to do again. This plugin captures the
7
+ // base method and calls it detached, so a bare call leaves `this` undefined, the
8
+ // read throws inside the ErrorBoundary the menu builds in, and a right-click
9
+ // produces no menu at all: the host's own rows go with it.
10
+ function hostReadingThis(clickedType) {
11
+ return types
12
+ .model('MockDisplay', { id: types.optional(types.string, 'display1') })
13
+ .views(self => ({
14
+ get isGeneLike() {
15
+ return self.id === 'display1';
16
+ },
17
+ }))
18
+ .views(() => ({
19
+ contextMenuItems() {
20
+ return [{ label: `host item ${this.isGeneLike}` }];
21
+ },
22
+ get contextMenuInfo() {
23
+ return {
24
+ item: { featureId: 'f1', type: clickedType },
25
+ displayedRegionIndex: 0,
26
+ };
27
+ },
28
+ fetchFullFeature() {
29
+ return Promise.resolve(undefined);
30
+ },
31
+ }));
32
+ }
33
+ const labels = (stateModel) => stateModel
34
+ .create()
35
+ .contextMenuItems()
36
+ .map((i) => i.label);
37
+ describe('extendStateModel', () => {
38
+ test('calls the host contextMenuItems with a receiver', () => {
39
+ expect(labels(extendStateModel(hostReadingThis('mRNA')))).toEqual([
40
+ 'host item true',
41
+ 'Launch MSA view',
42
+ ]);
43
+ });
44
+ test('leaves the host menu alone when the click is not on a gene', () => {
45
+ expect(labels(extendStateModel(hostReadingThis('CDS')))).toEqual([
46
+ 'host item true',
47
+ ]);
48
+ });
49
+ // two plugins extending the same display each capture the previous
50
+ // contextMenuItems, so the receiver has to survive the whole chain
51
+ test('survives another plugin extending the display underneath it', () => {
52
+ const withOther = extendStateModel(hostReadingThis('mRNA')).views(self => {
53
+ const superContextMenuItems = self.contextMenuItems;
54
+ return {
55
+ contextMenuItems() {
56
+ return [
57
+ ...superContextMenuItems.call(self),
58
+ { label: 'other plugin' },
59
+ ];
60
+ },
61
+ };
62
+ });
63
+ expect(labels(withOther)).toEqual([
64
+ 'host item true',
65
+ 'Launch MSA view',
66
+ 'other plugin',
67
+ ]);
68
+ });
69
+ });
@@ -1,2 +1,4 @@
1
1
  import type PluginManager from '@jbrowse/core/PluginManager';
2
+ import type { IAnyModelType } from '@jbrowse/mobx-state-tree';
3
+ export declare function extendStateModel(stateModel: IAnyModelType): import("@jbrowse/mobx-state-tree").IModelType<any, any, any, any>;
2
4
  export default function LaunchMsaViewF(pluginManager: PluginManager): void;
@@ -1,69 +1,59 @@
1
1
  import { getContainingTrack, getSession } from '@jbrowse/core/util';
2
2
  import AddIcon from '@mui/icons-material/Add';
3
3
  import LaunchMsaViewDialog from './components/LaunchMsaViewDialog';
4
+ import { launchTarget } from './launchTarget';
4
5
  function isDisplay(elt) {
5
6
  return elt.name === 'LinearBasicDisplay';
6
7
  }
7
- // Read off the clicked item rather than off the display.
8
- //
9
- // LinearBasicDisplay used to publish an `isGeneLike` getter and this gated on
10
- // it. jbrowse-components 684142b3 (2026-08-16) inlined that getter into its own
11
- // `contextMenuItems`, and every host built after it returns `undefined` here --
12
- // so the gate was never satisfied, `onClick` stayed undefined, and the item
13
- // silently left the right-click menu on every gene track. Nothing failed loudly:
14
- // the display still had contextMenuInfo and fetchFullFeature, and the menu still
15
- // opened with its own items in it.
16
- //
17
- // A predicate over the type we were already given cannot go the same way, and it
18
- // costs one comparison. Deliberately the same loose case-insensitive test the
19
- // host applies (`isGeneLikeType` in collapseIntronsMenu.ts): real GFFs carry
20
- // 'mRNA', 'lnc_RNA', 'protein_coding_gene', 'transcript'.
21
- function isGeneLikeType(type) {
22
- const t = (type ?? '').toLowerCase();
23
- return t.includes('gene') || t.includes('rna') || t.includes('transcript');
8
+ // Walking to the track and the session at click time, not while the menu is
9
+ // built: contextMenuItems runs on every right-click and, on a host whose base
10
+ // method reads `this`, is the one place a plugin can take the whole menu down.
11
+ // Keeping it to a pure read of the display is also what lets a test call it.
12
+ function openDialog(self, feature) {
13
+ const track = getContainingTrack(self);
14
+ const session = getSession(track);
15
+ feature()
16
+ .then(f => {
17
+ if (f) {
18
+ session.queueDialog(handleClose => [
19
+ LaunchMsaViewDialog,
20
+ { model: track, handleClose, feature: f },
21
+ ]);
22
+ }
23
+ else {
24
+ session.notify('Could not load feature for MSA view', 'warning');
25
+ }
26
+ })
27
+ .catch((e) => {
28
+ session.notifyError(`${e}`, e);
29
+ });
24
30
  }
25
- const GENE_LIKE_TYPES = new Set(['gene', 'mRNA', 'transcript']);
26
- function extendStateModel(stateModel) {
31
+ export function extendStateModel(stateModel) {
27
32
  return stateModel.views((self) => {
28
33
  const superContextMenuItems = self.contextMenuItems;
29
34
  return {
30
35
  contextMenuItems() {
31
- const track = getContainingTrack(self);
32
- const session = getSession(track);
33
- const launch = (feature) => {
34
- session.queueDialog(handleClose => [
35
- LaunchMsaViewDialog,
36
- { model: track, handleClose, feature },
37
- ]);
38
- };
39
- const info = self.contextMenuInfo;
40
- const fetchFullFeature = self.fetchFullFeature;
41
- const legacyFeature = self.contextMenuFeature;
42
- const onClick = info && fetchFullFeature && isGeneLikeType(info.item.type)
43
- ? () => {
44
- fetchFullFeature(info.item.featureId, info.displayedRegionIndex)
45
- .then(feature => {
46
- if (feature) {
47
- launch(feature);
48
- }
49
- else {
50
- session.notify('Could not load feature for MSA view', 'warning');
51
- }
52
- })
53
- .catch((e) => {
54
- session.notifyError(`${e}`, e);
55
- });
56
- }
57
- : legacyFeature &&
58
- GENE_LIKE_TYPES.has(String(legacyFeature.get('type')))
59
- ? () => {
60
- launch(legacyFeature);
61
- }
62
- : undefined;
36
+ const target = launchTarget(self);
63
37
  return [
64
- ...superContextMenuItems(),
65
- ...(onClick
66
- ? [{ label: 'Launch MSA view', icon: AddIcon, onClick }]
38
+ // .call(self), not a bare call: a host's own contextMenuItems may
39
+ // reach its sibling views through `this`, which is undefined when the
40
+ // captured super is invoked detached. It throws, the ErrorBoundary the
41
+ // menu builds inside swallows it, and the user right-clicks a feature
42
+ // and gets no menu at all -- the host's own rows gone too, which is
43
+ // worse than this plugin contributing nothing. jbrowse-components hit
44
+ // exactly this with `this.isGeneLike` and fixed its side in
45
+ // 104bbfc581, but a plugin cannot choose which host build it runs on.
46
+ ...superContextMenuItems.call(self),
47
+ ...(target
48
+ ? [
49
+ {
50
+ label: 'Launch MSA view',
51
+ icon: AddIcon,
52
+ onClick: () => {
53
+ openDialog(self, target);
54
+ },
55
+ },
56
+ ]
67
57
  : []),
68
58
  ];
69
59
  },
@@ -0,0 +1,25 @@
1
+ import type { MenuItem } from '@jbrowse/core/ui';
2
+ import type { Feature } from '@jbrowse/core/util';
3
+ export interface ContextMenuInfo {
4
+ item: {
5
+ featureId: string;
6
+ type?: string;
7
+ };
8
+ displayedRegionIndex: number;
9
+ }
10
+ export interface DisplayModel {
11
+ contextMenuItems: () => MenuItem[];
12
+ contextMenuInfo?: ContextMenuInfo;
13
+ fetchFullFeature?: (featureId: string, displayedRegionIndex: number) => Promise<Feature | undefined>;
14
+ contextMenuFeature?: Feature;
15
+ }
16
+ export declare function isGeneLikeType(type: unknown): boolean;
17
+ /**
18
+ * How to get the right-clicked feature, or nothing when there is no gene to
19
+ * launch on. Both host shapes reduce to a thunk, so the menu item is built and
20
+ * the dialog is opened by one code path — and the same gene test decides both.
21
+ * The strict three-name set the legacy branch used to carry disagreed with the
22
+ * loose one above, so a `lnc_RNA` offered the menu item on a 4.3 host and not
23
+ * on a 3.7 one.
24
+ */
25
+ export declare function launchTarget(self: DisplayModel): (() => Promise<Feature | undefined>) | undefined;
@@ -0,0 +1,42 @@
1
+ // Read off the clicked item rather than off the display.
2
+ //
3
+ // LinearBasicDisplay used to publish an `isGeneLike` getter and this gated on
4
+ // it. jbrowse-components 684142b3 (2026-08-16) inlined that getter into its own
5
+ // `contextMenuItems`, and every host built after it returns `undefined` here --
6
+ // so the gate was never satisfied, `onClick` stayed undefined, and the item
7
+ // silently left the right-click menu on every gene track. Nothing failed loudly:
8
+ // the display still had contextMenuInfo and fetchFullFeature, and the menu still
9
+ // opened with its own items in it.
10
+ //
11
+ // A predicate over the type we were already given cannot go the same way, and it
12
+ // costs one comparison. Deliberately the same loose case-insensitive test the
13
+ // host applies (`isGeneLikeType` in collapseIntronsMenu.ts): real GFFs carry
14
+ // 'mRNA', 'lnc_RNA', 'protein_coding_gene', 'transcript'.
15
+ export function isGeneLikeType(type) {
16
+ const t = String(type ?? '').toLowerCase();
17
+ return t.includes('gene') || t.includes('rna') || t.includes('transcript');
18
+ }
19
+ /**
20
+ * How to get the right-clicked feature, or nothing when there is no gene to
21
+ * launch on. Both host shapes reduce to a thunk, so the menu item is built and
22
+ * the dialog is opened by one code path — and the same gene test decides both.
23
+ * The strict three-name set the legacy branch used to carry disagreed with the
24
+ * loose one above, so a `lnc_RNA` offered the menu item on a 4.3 host and not
25
+ * on a 3.7 one.
26
+ */
27
+ export function launchTarget(self) {
28
+ const info = self.contextMenuInfo;
29
+ const fetchFullFeature = self.fetchFullFeature;
30
+ // exclusive, not a fallthrough: a display publishing contextMenuInfo has
31
+ // already said what was clicked, and reading contextMenuFeature after it
32
+ // rejects the click can only answer with some other feature
33
+ if (info && fetchFullFeature) {
34
+ return isGeneLikeType(info.item.type)
35
+ ? () => fetchFullFeature(info.item.featureId, info.displayedRegionIndex)
36
+ : undefined;
37
+ }
38
+ const legacy = self.contextMenuFeature;
39
+ return legacy && isGeneLikeType(legacy.get('type'))
40
+ ? () => Promise.resolve(legacy)
41
+ : undefined;
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { isGeneLikeType, launchTarget } from './launchTarget';
3
+ function feature(type) {
4
+ return {
5
+ get: (key) => (key === 'type' ? type : undefined),
6
+ };
7
+ }
8
+ const modernHost = (type) => ({
9
+ contextMenuItems: () => [],
10
+ contextMenuInfo: { item: { featureId: 'f1', type }, displayedRegionIndex: 0 },
11
+ fetchFullFeature: (featureId) => Promise.resolve(feature(`fetched:${featureId}`)),
12
+ });
13
+ const legacyHost = (type) => ({
14
+ contextMenuItems: () => [],
15
+ contextMenuFeature: feature(type),
16
+ });
17
+ describe('isGeneLikeType', () => {
18
+ test.each(['gene', 'mRNA', 'transcript', 'lnc_RNA', 'protein_coding_gene'])('accepts %s', type => {
19
+ expect(isGeneLikeType(type)).toBe(true);
20
+ });
21
+ test.each(['CDS', 'exon', 'match', 'SNV', undefined, null, 42])('rejects %s', type => {
22
+ expect(isGeneLikeType(type)).toBe(false);
23
+ });
24
+ });
25
+ describe('launchTarget', () => {
26
+ test('resolves the clicked feature through fetchFullFeature', async () => {
27
+ const target = launchTarget(modernHost('mRNA'));
28
+ expect(target).toBeDefined();
29
+ expect((await target())?.get('type')).toBe('fetched:f1');
30
+ });
31
+ test('offers nothing for a non-gene click', () => {
32
+ expect(launchTarget(modernHost('CDS'))).toBeUndefined();
33
+ expect(launchTarget(modernHost(undefined))).toBeUndefined();
34
+ });
35
+ test('offers nothing when nothing was clicked', () => {
36
+ expect(launchTarget({ contextMenuItems: () => [] })).toBeUndefined();
37
+ });
38
+ // v3.7.0 hosts have contextMenuFeature and nothing else; dropping this
39
+ // fallback once took "Launch MSA view" off every host in the wild.
40
+ test('falls back to a synchronous contextMenuFeature', async () => {
41
+ const target = launchTarget(legacyHost('mRNA'));
42
+ expect(target).toBeDefined();
43
+ expect((await target())?.get('type')).toBe('mRNA');
44
+ });
45
+ test('applies the same gene test on both host shapes', () => {
46
+ expect(launchTarget(legacyHost('lnc_RNA'))).toBeDefined();
47
+ expect(launchTarget(legacyHost('exon'))).toBeUndefined();
48
+ });
49
+ // a host that has both shapes must not fall through to the legacy branch and
50
+ // launch on a stale feature when the click was not on a gene
51
+ test('a non-gene click on a host carrying both shapes offers nothing', () => {
52
+ expect(launchTarget({ ...modernHost('CDS'), ...legacyHost('mRNA') })).toBeUndefined();
53
+ });
54
+ });
@@ -98,8 +98,10 @@ async function queryRowLabel(taxId, rows) {
98
98
  catch (e) {
99
99
  console.warn('[msaview-orthologs] taxonomy name lookup failed:', e);
100
100
  }
101
- return dedupeLabels([...rows.map(r => r.label), `${name ?? 'query'}_query`])
102
- .at(-1);
101
+ return dedupeLabels([
102
+ ...rows.map(r => r.label),
103
+ `${name ?? 'query'}_query`,
104
+ ]).at(-1);
103
105
  }
104
106
  /**
105
107
  * A failed lookup only costs the query row its domain overlay and, for a launch