jbrowse-plugin-msaview 3.4.0 → 3.4.1

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 (86) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +4 -6
  2. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +7 -0
  3. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts +1 -0
  5. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
  6. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +2 -1
  7. package/dist/LaunchMsaViewExtensionPoint/index.js +11 -6
  8. package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
  9. package/dist/LaunchMsaViewExtensionPoint/index.test.js +43 -0
  10. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +15 -0
  11. package/dist/MsaViewPanel/afterCreateAutoruns.js +93 -70
  12. package/dist/MsaViewPanel/components/LaunchProgress.js +11 -2
  13. package/dist/MsaViewPanel/components/MsaViewPanel.test.js +19 -0
  14. package/dist/MsaViewPanel/doLaunchBlast.d.ts +3 -1
  15. package/dist/MsaViewPanel/doLaunchBlast.js +10 -12
  16. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
  17. package/dist/MsaViewPanel/doLaunchOrthologs.js +6 -5
  18. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +38 -24
  19. package/dist/MsaViewPanel/model.d.ts +160 -10
  20. package/dist/MsaViewPanel/model.js +51 -1
  21. package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
  22. package/dist/MsaViewPanel/msaDataStore.js +16 -7
  23. package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
  24. package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
  25. package/dist/MsaViewPanel/runLaunch.d.ts +38 -0
  26. package/dist/MsaViewPanel/runLaunch.js +65 -0
  27. package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
  28. package/dist/MsaViewPanel/runLaunch.test.js +129 -0
  29. package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
  30. package/dist/MsaViewPanel/storedData.test.js +160 -0
  31. package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -35
  32. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  33. package/dist/utils/blastCache.js +32 -0
  34. package/dist/utils/blastCache.test.d.ts +1 -0
  35. package/dist/utils/blastCache.test.js +72 -0
  36. package/dist/utils/ebiBlast.d.ts +4 -2
  37. package/dist/utils/ebiBlast.js +6 -4
  38. package/dist/utils/ebiJobDispatcher.d.ts +6 -3
  39. package/dist/utils/ebiJobDispatcher.js +13 -6
  40. package/dist/utils/fetch.d.ts +8 -1
  41. package/dist/utils/fetch.js +28 -2
  42. package/dist/utils/msa.d.ts +4 -2
  43. package/dist/utils/msa.js +9 -5
  44. package/dist/utils/ncbiDomains.d.ts +2 -2
  45. package/dist/utils/ncbiOrthologs.d.ts +11 -1
  46. package/dist/utils/ncbiOrthologs.js +26 -7
  47. package/dist/utils/ncbiOrthologs.test.js +23 -1
  48. package/dist/utils/pantherOrthologs.js +2 -10
  49. package/dist/utils/phmmer.d.ts +2 -1
  50. package/dist/utils/phmmer.js +4 -2
  51. package/dist/utils/poll.d.ts +6 -1
  52. package/dist/utils/poll.js +7 -2
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +14 -14
  56. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +7 -11
  57. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
  58. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
  59. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +2 -2
  60. package/src/LaunchMsaViewExtensionPoint/index.test.ts +51 -0
  61. package/src/LaunchMsaViewExtensionPoint/index.ts +21 -6
  62. package/src/MsaViewPanel/afterCreateAutoruns.ts +100 -66
  63. package/src/MsaViewPanel/components/LaunchProgress.tsx +20 -2
  64. package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +21 -0
  65. package/src/MsaViewPanel/doLaunchBlast.ts +14 -10
  66. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +41 -26
  67. package/src/MsaViewPanel/doLaunchOrthologs.ts +8 -4
  68. package/src/MsaViewPanel/model.ts +54 -1
  69. package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
  70. package/src/MsaViewPanel/msaDataStore.ts +22 -13
  71. package/src/MsaViewPanel/runLaunch.test.ts +154 -0
  72. package/src/MsaViewPanel/runLaunch.ts +102 -0
  73. package/src/MsaViewPanel/storedData.test.ts +196 -0
  74. package/src/utils/blastCache.test.ts +86 -0
  75. package/src/utils/blastCache.ts +34 -1
  76. package/src/utils/ebiBlast.ts +8 -2
  77. package/src/utils/ebiJobDispatcher.ts +18 -3
  78. package/src/utils/fetch.ts +29 -2
  79. package/src/utils/msa.ts +11 -3
  80. package/src/utils/ncbiDomains.ts +4 -2
  81. package/src/utils/ncbiOrthologs.test.ts +25 -0
  82. package/src/utils/ncbiOrthologs.ts +27 -7
  83. package/src/utils/pantherOrthologs.ts +6 -11
  84. package/src/utils/phmmer.ts +5 -1
  85. package/src/utils/poll.ts +8 -1
  86. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.4.0",
2
+ "version": "3.4.1",
3
3
  "license": "MIT",
4
4
  "name": "jbrowse-plugin-msaview",
5
5
  "repository": {
@@ -28,41 +28,41 @@
28
28
  "@jbrowse/core": "^4.3.0",
29
29
  "@jbrowse/mobx-state-tree": "^5.13.0",
30
30
  "@jbrowse/plugin-linear-genome-view": "^4.3.0",
31
- "@mui/icons-material": "^9.3.1",
32
- "@mui/material": "^9.3.1",
33
- "@mui/system": "^9.3.0",
31
+ "@mui/icons-material": "^9.4.0",
32
+ "@mui/material": "^9.4.0",
33
+ "@mui/system": "^9.4.0",
34
34
  "@mui/x-data-grid": "^9.12.0",
35
35
  "@testing-library/dom": "^10.4.1",
36
- "@testing-library/react": "^16.3.2",
37
- "@types/node": "^26.3.0",
36
+ "@testing-library/react": "^16.3.3",
37
+ "@types/node": "^26.4.1",
38
38
  "@types/react": "^19.2.18",
39
39
  "esbuild": "^0.28.2",
40
40
  "eslint": "^10.9.1",
41
41
  "eslint-plugin-import-x": "^4.17.1",
42
42
  "eslint-plugin-react": "^7.37.5",
43
43
  "eslint-plugin-react-hooks": "^7.1.1",
44
- "eslint-plugin-unicorn": "^73.0.0",
44
+ "eslint-plugin-unicorn": "^74.0.0",
45
45
  "git-cliff": "^2.13.1",
46
46
  "jsdom": "^30.0.1",
47
47
  "mobx": "^6.16.1",
48
48
  "mobx-react": "^9.2.2",
49
- "msa-parsers": "^6.1.0",
49
+ "msa-parsers": "^6.4.2",
50
50
  "oxfmt": "^0.65.0",
51
- "oxlint": "^1.80.0",
51
+ "oxlint": "^1.81.0",
52
52
  "oxlint-tsgolint": "^7.0.2001",
53
53
  "pixelmatch": "^7.2.0",
54
54
  "pngjs": "^7.0.0",
55
- "pretty-bytes": "^7.1.1",
56
- "puppeteer": "^25.8.0",
55
+ "pretty-bytes": "^7.1.2",
56
+ "puppeteer": "^25.9.0",
57
57
  "react": "^19.2.8",
58
58
  "react-dom": "^19.2.8",
59
- "react-msaview": "^6.1.0",
59
+ "react-msaview": "^6.4.2",
60
60
  "rimraf": "^6.1.3",
61
61
  "rxjs": "^7.8.2",
62
62
  "serve": "^14.2.6",
63
63
  "tss-react": "^4.9.21",
64
- "typescript": "^6.0.2",
65
- "typescript-eslint": "^8.68.0",
64
+ "typescript": "^6.0.3",
65
+ "typescript-eslint": "^8.69.0",
66
66
  "vitest": "^4.1.11"
67
67
  },
68
68
  "scripts": {
@@ -24,15 +24,14 @@ import { useTranscriptSelection } from '../useTranscriptSelection'
24
24
  import CachedBlastResults from './CachedBlastResults'
25
25
  import MsaAlgorithmSelect from './MsaAlgorithmSelect'
26
26
  import { blastLaunchView } from './blastLaunchView'
27
+ import { databaseOptionsFor, defaultSearchFor, searchPrograms } from './consts'
27
28
  import {
28
- databaseOptionsFor,
29
- defaultBlastDatabase,
30
- defaultSearchFor,
31
- searchPrograms,
32
- } from './consts'
29
+ useStoredMsaAlgorithm,
30
+ useStoredSearchChoice,
31
+ } from './searchChoiceStorage'
33
32
  import { useCachedBlastResults } from './useCachedBlastResults'
34
33
 
35
- import type { MsaAlgorithm, SearchChoice, SearchProgram } from './consts'
34
+ import type { SearchChoice, SearchProgram } from './consts'
36
35
  import type { AbstractTrackModel, Feature } from '@jbrowse/core/util'
37
36
 
38
37
  const useStyles = makeStyles()({
@@ -70,12 +69,9 @@ const BlastAutomaticPanel = observer(function ({
70
69
  // one piece of state, not two: a program and a database that program does not
71
70
  // have is a 400 from EBI minutes after Submit, and holding them apart is what
72
71
  // would let them drift into that
73
- const [search, setSearch] = useState<SearchChoice>({
74
- program: 'blastp',
75
- database: defaultBlastDatabase,
76
- })
72
+ const [search, setSearch] = useStoredSearchChoice()
77
73
  const [selectedMsaAlgorithm, setSelectedMsaAlgorithm] =
78
- useState<MsaAlgorithm>('clustalo')
74
+ useStoredMsaAlgorithm()
79
75
  const isPhmmer = search.program === 'phmmer'
80
76
 
81
77
  const geneIds = useMemo(() => getGeneIdentifiers(feature), [feature])
@@ -0,0 +1,43 @@
1
+ import { expect, test } from 'vitest'
2
+
3
+ import { validMsaAlgorithm, validSearchChoice } from './searchChoiceStorage'
4
+
5
+ test('a stored pair both services recognise comes back as it went in', () => {
6
+ expect(
7
+ validSearchChoice({ program: 'phmmer', database: 'swissprot' }),
8
+ ).toEqual({
9
+ program: 'phmmer',
10
+ database: 'swissprot',
11
+ })
12
+ })
13
+
14
+ test('a database the stored program does not have falls back to the defaults', () => {
15
+ // uniprotkb_swissprot is blastp's name for it; sending it to phmmer is a 400
16
+ // from EBI minutes after Submit
17
+ expect(
18
+ validSearchChoice({ program: 'phmmer', database: 'uniprotkb_swissprot' }),
19
+ ).toEqual({ program: 'blastp', database: 'uniprotkb_swissprot' })
20
+ // uniprotkb_reference_proteomes shipped as a menu entry EBI rejects, and 3.0.0
21
+ // is old enough that someone's storage still holds it
22
+ expect(
23
+ validSearchChoice({
24
+ program: 'blastp',
25
+ database: 'uniprotkb_reference_proteomes',
26
+ }),
27
+ ).toEqual({ program: 'blastp', database: 'uniprotkb_swissprot' })
28
+ })
29
+
30
+ test('storage holding something else entirely falls back rather than throwing', () => {
31
+ for (const stored of [null, undefined, 'blastp', 42, {}, []]) {
32
+ expect(validSearchChoice(stored)).toEqual({
33
+ program: 'blastp',
34
+ database: 'uniprotkb_swissprot',
35
+ })
36
+ }
37
+ })
38
+
39
+ test('an unknown algorithm falls back to clustalo', () => {
40
+ expect(validMsaAlgorithm('mafft')).toBe('mafft')
41
+ expect(validMsaAlgorithm('t_coffee')).toBe('clustalo')
42
+ expect(validMsaAlgorithm(null)).toBe('clustalo')
43
+ })
@@ -0,0 +1,64 @@
1
+ import { useLocalStorage } from '../../../utils/useLocalStorage'
2
+ import {
3
+ databaseOptionsFor,
4
+ defaultSearchFor,
5
+ msaAlgorithms,
6
+ searchPrograms,
7
+ } from './consts'
8
+
9
+ import type { MsaAlgorithm, SearchChoice } from './consts'
10
+
11
+ // The panel reopens on the last search run rather than on the defaults, the way
12
+ // the Orthologs tab remembers its source.
13
+ //
14
+ // The program and its database stay ONE stored value for the reason consts.ts
15
+ // keeps them one state: neither service knows the other's database names. What
16
+ // comes back is checked against the current options before it is used — an
17
+ // option this plugin has since dropped would otherwise reach EBI and come back
18
+ // a 400, minutes after the user pressed Submit.
19
+ export const SEARCH_CHOICE_STORAGE_KEY = 'msaView-blastSearch'
20
+ export const MSA_ALGORITHM_STORAGE_KEY = 'msaView-msaAlgorithm'
21
+
22
+ const defaultSearch = defaultSearchFor('blastp')
23
+ const defaultMsaAlgorithm: MsaAlgorithm = 'clustalo'
24
+
25
+ export function validSearchChoice(stored: unknown): SearchChoice {
26
+ const { program, database } = (stored ?? {}) as {
27
+ program?: string
28
+ database?: string
29
+ }
30
+ const known = searchPrograms.find(p => p === program)
31
+ return known && databaseOptionsFor(known).some(d => d === database)
32
+ ? ({ program: known, database } as SearchChoice)
33
+ : defaultSearch
34
+ }
35
+
36
+ export function validMsaAlgorithm(stored: unknown): MsaAlgorithm {
37
+ return msaAlgorithms.find(a => a === stored) ?? defaultMsaAlgorithm
38
+ }
39
+
40
+ export function useStoredSearchChoice() {
41
+ const [stored, setStored] = useLocalStorage<unknown>(
42
+ SEARCH_CHOICE_STORAGE_KEY,
43
+ defaultSearch,
44
+ )
45
+ return [
46
+ validSearchChoice(stored),
47
+ (choice: SearchChoice) => {
48
+ setStored(choice)
49
+ },
50
+ ] as const
51
+ }
52
+
53
+ export function useStoredMsaAlgorithm() {
54
+ const [stored, setStored] = useLocalStorage<unknown>(
55
+ MSA_ALGORITHM_STORAGE_KEY,
56
+ defaultMsaAlgorithm,
57
+ )
58
+ return [
59
+ validMsaAlgorithm(stored),
60
+ (algorithm: MsaAlgorithm) => {
61
+ setStored(algorithm)
62
+ },
63
+ ] as const
64
+ }
@@ -14,6 +14,7 @@ import {
14
14
  getTranscriptDisplayName,
15
15
  } from '../../util'
16
16
  import MsaAlgorithmSelect from '../BlastQuery/MsaAlgorithmSelect'
17
+ import { useStoredMsaAlgorithm } from '../BlastQuery/searchChoiceStorage'
17
18
  import LaunchPanelContent from '../LaunchPanelContent'
18
19
  import SubmitCancelActions from '../SubmitCancelActions'
19
20
  import TranscriptSelector from '../TranscriptSelector'
@@ -25,7 +26,6 @@ import QuerySpeciesSelect from './QuerySpeciesSelect'
25
26
  import { orthologLaunchView } from './orthologLaunchView'
26
27
 
27
28
  import type { OrthologSource } from '../../../MsaViewPanel/model'
28
- import type { MsaAlgorithm } from '../BlastQuery/consts'
29
29
  import type { AbstractTrackModel, Feature } from '@jbrowse/core/util'
30
30
 
31
31
  const useStyles = makeStyles()({
@@ -51,7 +51,7 @@ const OrthologPanel = observer(function ({
51
51
  ORTHOLOG_SOURCE_STORAGE_KEY,
52
52
  'ncbi',
53
53
  )
54
- const [msaAlgorithm, setMsaAlgorithm] = useState<MsaAlgorithm>('clustalo')
54
+ const [msaAlgorithm, setMsaAlgorithm] = useStoredMsaAlgorithm()
55
55
  const [maxSpecies, setMaxSpecies] = useState(String(defaultMaxSpecies))
56
56
 
57
57
  const geneCandidates = useMemo(() => getGeneIdentifiers(feature), [feature])
@@ -0,0 +1,51 @@
1
+ import { expect, test } from 'vitest'
2
+
3
+ import LaunchMsaViewExtensionPointF from './index'
4
+
5
+ import type PluginManager from '@jbrowse/core/PluginManager'
6
+ import type { AbstractSessionModel } from '@jbrowse/core/util'
7
+
8
+ function launch(args: Record<string, unknown>) {
9
+ let run: ((args: unknown) => unknown) | undefined
10
+ LaunchMsaViewExtensionPointF({
11
+ addToExtensionPoint(_name: string, cb: (args: unknown) => unknown) {
12
+ run = cb
13
+ },
14
+ } as unknown as PluginManager)
15
+ const added: Record<string, unknown>[] = []
16
+ const session = {
17
+ addView(_type: string, snapshot: Record<string, unknown>) {
18
+ added.push(snapshot)
19
+ return { id: 'view-1' }
20
+ },
21
+ } as unknown as AbstractSessionModel
22
+ run!({ session, ...args })
23
+ return added[0]!
24
+ }
25
+
26
+ test('inline data carries no init, which the panel would read as a launch in flight', () => {
27
+ const snapshot = launch({ data: { msa: '>a\nMEEP' } })
28
+ expect('init' in snapshot).toBe(false)
29
+ })
30
+
31
+ test('a file location travels through init', () => {
32
+ const snapshot = launch({
33
+ msaFileLocation: { uri: 'http://example.com/a.fa' },
34
+ })
35
+ expect(snapshot.init).toEqual({
36
+ msaUrl: 'http://example.com/a.fa',
37
+ msaIndexedLocation: undefined,
38
+ msaName: undefined,
39
+ querySeqName: undefined,
40
+ })
41
+ })
42
+
43
+ test('one field set is enough to need init', () => {
44
+ const snapshot = launch({ data: { msa: '>a\nMEEP' }, querySeqName: 'QUERY' })
45
+ expect(snapshot.init).toEqual({
46
+ msaUrl: undefined,
47
+ msaIndexedLocation: undefined,
48
+ msaName: undefined,
49
+ querySeqName: 'QUERY',
50
+ })
51
+ })
@@ -4,6 +4,7 @@ import type { OrthologParams } from '../MsaViewPanel/model'
4
4
  import type { MsaViewPlacement } from '../utils/workspaces'
5
5
  import type PluginManager from '@jbrowse/core/PluginManager'
6
6
  import type { AbstractSessionModel } from '@jbrowse/core/util'
7
+ import type { ColumnTrackSpec, Highlight } from 'react-msaview'
7
8
 
8
9
  interface LaunchMsaViewArgs {
9
10
  session: AbstractSessionModel
@@ -25,6 +26,15 @@ interface LaunchMsaViewArgs {
25
26
  showBranchLen?: boolean
26
27
  querySeqName?: string
27
28
  highlightColumns?: number[]
29
+ /**
30
+ * Labeled highlights in 1-based inclusive coordinates: `{row, start, end}`
31
+ * for residues of that row, `{start, end}` for alignment columns, `{rows}`
32
+ * for whole rows. Prefer this over `highlightColumns`: a residue range stays
33
+ * put when `allowedGappyness` changes the visible columns.
34
+ */
35
+ highlights?: Highlight[]
36
+ /** per-column tracks supplied as data, see react-msaview's docs/layers.md */
37
+ columnTracks?: ColumnTrackSpec[]
28
38
  /**
29
39
  * Build the alignment from NCBI orthologs at launch time instead of naming a
30
40
  * file: `{ taxId, geneCandidates }` is enough, and `taxa` and
@@ -96,6 +106,16 @@ export default function LaunchMsaViewExtensionPointF(
96
106
  // directly, and so is orthologParams (the model's own autorun picks it up).
97
107
  // Only sources needing launch-time resolution go through `init`: msaUrl
98
108
  // (AlphaFold sniff) and the name-indexed bgzip block (no native loader).
109
+ //
110
+ // An init whose every field is undefined is still a truthy object, and
111
+ // MsaViewPanel reads any init as a launch in flight, so an inline-data
112
+ // launch flashed "Loading alignment" until processInit cleared it.
113
+ const init = {
114
+ msaUrl: msaFileLocation?.uri,
115
+ msaIndexedLocation,
116
+ msaName,
117
+ querySeqName,
118
+ }
99
119
  launchMsaView(session, {
100
120
  ...rest,
101
121
  data,
@@ -107,12 +127,7 @@ export default function LaunchMsaViewExtensionPointF(
107
127
  },
108
128
  }
109
129
  : {}),
110
- init: {
111
- msaUrl: msaFileLocation?.uri,
112
- msaIndexedLocation,
113
- msaName,
114
- querySeqName,
115
- },
130
+ ...(Object.values(init).some(v => v !== undefined) ? { init } : {}),
116
131
  })
117
132
 
118
133
  return args
@@ -1,4 +1,5 @@
1
1
  import { getSession } from '@jbrowse/core/util'
2
+ import { transaction } from 'mobx'
2
3
 
3
4
  import { doLaunchBlast } from './doLaunchBlast'
4
5
  import { doLaunchOrthologs } from './doLaunchOrthologs'
@@ -11,10 +12,15 @@ import {
11
12
  retrieveMsaData,
12
13
  storeMsaData,
13
14
  } from './msaDataStore'
15
+ import { runLaunch } from './runLaunch'
14
16
  import { getProteinViews } from './structureConnection'
15
17
  import { getUniprotIdFromAlphaFoldUrl, hasQueryRow } from './util'
16
18
 
17
19
  import type { JBrowsePluginMsaViewModel } from './model'
20
+ import type { MsaDataPayload } from './msaDataStore'
21
+
22
+ const EXPIRED_MESSAGE =
23
+ "This view's alignment is no longer in browser storage. Stored alignments are kept for 7 days after they were last used, and are lost when site data is cleared. Relaunch the alignment to rebuild it."
18
24
 
19
25
  export function loadStoredData(self: JBrowsePluginMsaViewModel) {
20
26
  const { dataStoreId, rows } = self
@@ -24,15 +30,27 @@ export function loadStoredData(self: JBrowsePluginMsaViewModel) {
24
30
  self.setLoadingStoredData(true)
25
31
  const storedData = await retrieveMsaData(dataStoreId)
26
32
  if (storedData) {
27
- if (storedData.msa) {
28
- self.setMSA(storedData.msa)
29
- }
30
- if (storedData.tree) {
31
- self.setTree(storedData.tree)
32
- }
33
- if (storedData.treeMetadata) {
34
- self.setTreeMetadata(storedData.treeMetadata)
35
- }
33
+ // one transaction, so storeDataToIndexedDB sees the restored data and
34
+ // the record of it together and does not write it straight back
35
+ transaction(() => {
36
+ if (storedData.msa) {
37
+ self.setMSA(storedData.msa)
38
+ }
39
+ if (storedData.tree) {
40
+ self.setTree(storedData.tree)
41
+ }
42
+ if (storedData.treeMetadata) {
43
+ self.setTreeMetadata(storedData.treeMetadata)
44
+ }
45
+ self.setLastStoredData(currentData(self))
46
+ })
47
+ } else {
48
+ // the id names nothing, so clearing it is what lets react-msaview's
49
+ // "Return to import form" actually return instead of landing back here
50
+ transaction(() => {
51
+ self.setDataStoreId(undefined)
52
+ self.setError(new Error(EXPIRED_MESSAGE))
53
+ })
36
54
  }
37
55
  } catch (e) {
38
56
  console.error('Failed to load MSA data from IndexedDB:', e)
@@ -43,40 +61,68 @@ export function loadStoredData(self: JBrowsePluginMsaViewModel) {
43
61
  }
44
62
  }
45
63
 
46
- export function storeDataToIndexedDB(self: JBrowsePluginMsaViewModel) {
47
- const { rows, dataStoreId, isStoringData } = self
48
- if (rows.length > 0 && !dataStoreId && !isStoringData) {
49
- if (self.msaFilehandle || self.treeFilehandle) {
50
- return
51
- }
64
+ function currentData(self: JBrowsePluginMsaViewModel): MsaDataPayload {
65
+ const { msa, tree, treeMetadata } = self.data
66
+ return { msa, tree, treeMetadata }
67
+ }
52
68
 
53
- const msaData = self.data.msa
54
- const treeData = self.data.tree
69
+ function sameData(a: MsaDataPayload | undefined, b: MsaDataPayload) {
70
+ return (
71
+ !!a &&
72
+ a.msa === b.msa &&
73
+ a.tree === b.tree &&
74
+ a.treeMetadata === b.treeMetadata
75
+ )
76
+ }
55
77
 
56
- if (msaData || treeData) {
57
- // mark as storing synchronously so re-runs of this autorun (e.g. when
58
- // data observables change while the write is pending) don't kick off a
59
- // duplicate write and leave an orphan IndexedDB entry
60
- self.setIsStoringData(true)
61
- void (async () => {
62
- try {
63
- const newId = generateDataStoreId()
64
- const success = await storeMsaData(newId, {
65
- msa: msaData,
66
- tree: treeData,
67
- treeMetadata: self.data.treeMetadata,
68
- })
69
- if (success) {
70
- self.setDataStoreId(newId)
71
- }
72
- } catch (e) {
73
- console.error('Failed to store MSA data to IndexedDB:', e)
74
- } finally {
75
- self.setIsStoringData(false)
76
- }
77
- })()
78
- }
78
+ /**
79
+ * Keep IndexedDB holding what the view holds. The first run writes a new row and
80
+ * records its id in the session snapshot; later runs update that row, because
81
+ * the alignment keeps changing after it arrives — react-msaview's
82
+ * "calculate neighbor-joining tree" replaces `data.tree` — and a session
83
+ * reopened against a stale row restores the wrong picture.
84
+ *
85
+ * `lastStoredData` is what makes that safe to run on every data change: it is
86
+ * the only thing separating "this is new" from "this is what we just wrote", and
87
+ * it is recorded whether or not the write succeeded, so a browser refusing
88
+ * IndexedDB (private mode) fails once rather than in a loop.
89
+ *
90
+ * A view whose data comes from a filehandle stores nothing at all: the file is
91
+ * the source of truth and react-msaview refetches it at startup.
92
+ */
93
+ export function storeDataToIndexedDB(self: JBrowsePluginMsaViewModel) {
94
+ const { rows, dataStoreId, isStoringData, lastStoredData } = self
95
+ const data = currentData(self)
96
+ if (
97
+ rows.length === 0 ||
98
+ isStoringData ||
99
+ self.msaFilehandle ||
100
+ self.treeFilehandle ||
101
+ !(data.msa || data.tree) ||
102
+ sameData(lastStoredData, data)
103
+ ) {
104
+ return
79
105
  }
106
+
107
+ // mark as storing synchronously so re-runs of this autorun (e.g. when data
108
+ // observables change while the write is pending) don't kick off a duplicate
109
+ // write and leave an orphan IndexedDB entry
110
+ self.setIsStoringData(true)
111
+ void (async () => {
112
+ try {
113
+ const id = dataStoreId ?? generateDataStoreId()
114
+ if (await storeMsaData(id, data)) {
115
+ self.setDataStoreId(id)
116
+ }
117
+ } catch (e) {
118
+ console.error('Failed to store MSA data to IndexedDB:', e)
119
+ } finally {
120
+ transaction(() => {
121
+ self.setLastStoredData(data)
122
+ self.setIsStoringData(false)
123
+ })
124
+ }
125
+ })()
80
126
  }
81
127
 
82
128
  /**
@@ -88,39 +134,27 @@ export function storeDataToIndexedDB(self: JBrowsePluginMsaViewModel) {
88
134
  */
89
135
  export function launchOrthologsIfNeeded(self: JBrowsePluginMsaViewModel) {
90
136
  if (self.orthologParams) {
91
- void (async () => {
92
- try {
93
- self.setProgress('Resolving orthologs')
94
- self.setError(undefined)
95
- const data = await doLaunchOrthologs({ self })
96
- self.setData(data)
137
+ runLaunch({
138
+ self,
139
+ message: 'Resolving orthologs',
140
+ launch: scope => doLaunchOrthologs({ self, scope }),
141
+ onLaunched: () => {
97
142
  self.setOrthologParams(undefined)
98
- } catch (e) {
99
- self.setError(e)
100
- console.error(e)
101
- } finally {
102
- self.setProgress('')
103
- }
104
- })()
143
+ },
144
+ })
105
145
  }
106
146
  }
107
147
 
108
148
  export function launchBlastIfNeeded(self: JBrowsePluginMsaViewModel) {
109
149
  if (self.blastParams) {
110
- void (async () => {
111
- try {
112
- self.setProgress('Submitting query')
113
- self.setError(undefined)
114
- const data = await doLaunchBlast({ self })
115
- self.setData(data)
150
+ runLaunch({
151
+ self,
152
+ message: 'Submitting query',
153
+ launch: scope => doLaunchBlast({ self, scope }),
154
+ onLaunched: () => {
116
155
  self.setBlastParams(undefined)
117
- } catch (e) {
118
- self.setError(e)
119
- console.error(e)
120
- } finally {
121
- self.setProgress('')
122
- }
123
- })()
156
+ },
157
+ })
124
158
  }
125
159
  }
126
160
 
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
 
3
3
  import { ErrorMessage, LoadingEllipses } from '@jbrowse/core/ui'
4
- import { Typography } from '@mui/material'
4
+ import { Button, Typography } from '@mui/material'
5
5
  import { observer } from 'mobx-react'
6
6
  import { makeStyles } from 'tss-react/mui'
7
7
 
@@ -13,6 +13,11 @@ const useStyles = makeStyles()({
13
13
  margin: {
14
14
  padding: 20,
15
15
  },
16
+ progressRow: {
17
+ display: 'flex',
18
+ alignItems: 'center',
19
+ gap: 12,
20
+ },
16
21
  })
17
22
 
18
23
  /**
@@ -52,7 +57,20 @@ const LaunchProgress = observer(function LaunchProgress2({
52
57
  <>
53
58
  <LoadingEllipses message={message} variant="h5" />
54
59
  {rid ? <JobLink jobId={rid} /> : null}
55
- <Typography>{progress || 'Initializing'}</Typography>
60
+ <div className={classes.progressRow}>
61
+ <Typography>{progress || 'Initializing'}</Typography>
62
+ {/* stops the polling and returns to the import form; the EBI job
63
+ itself runs on regardless, which is what its JobLink is for */}
64
+ <Button
65
+ variant="outlined"
66
+ size="small"
67
+ onClick={() => {
68
+ model.cancelLaunch()
69
+ }}
70
+ >
71
+ Cancel
72
+ </Button>
73
+ </div>
56
74
  </>
57
75
  )}
58
76
  </div>
@@ -70,6 +70,27 @@ test('a failed init shows why', () => {
70
70
  expect(screen.getByText(/No alignment named ENST00000288602/)).toBeTruthy()
71
71
  })
72
72
 
73
+ // a launch runs for 10+ minutes, so leaving with no way out means watching it
74
+ test('a running launch offers a way out, and a failed one does not', () => {
75
+ const cancelLaunch = vi.fn()
76
+ panel({
77
+ blastParams: { proteinSequence: 'MKV' } as never,
78
+ progress: 'Re-checking BLAST status in... 7',
79
+ cancelLaunch,
80
+ })
81
+ screen.getByRole('button', { name: 'Cancel' }).click()
82
+ expect(cancelLaunch).toHaveBeenCalled()
83
+
84
+ cleanup()
85
+ panel({
86
+ blastParams: { proteinSequence: 'MKV' } as never,
87
+ progress: '',
88
+ error: new Error('No hits found'),
89
+ cancelLaunch,
90
+ })
91
+ expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()
92
+ })
93
+
73
94
  test('a running job links out to it', () => {
74
95
  panel({
75
96
  blastParams: { proteinSequence: 'MKV' } as never,