@jbrowse/product-core 5.0.0-beta.1 → 5.0.0-beta.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.
@@ -87,6 +87,7 @@ export function BaseRootModelFactory({ pluginManager, jbrowseModelType, sessionM
87
87
  const unbuildable = [];
88
88
  let migrated = sessionSnapshot;
89
89
  if (sessionSnapshot && typeof sessionSnapshot === 'object') {
90
+ pluginManager.assertSessionTypesLoaded(sessionSnapshot);
90
91
  // before the cast, because a type this build has no plugin for is a
91
92
  // union failure the try below never gets to see
92
93
  const pruned = pruneUnbuildableNodes(migrateSessionSnapshot(sessionSnapshot), pluginManager, sessionModelType);
@@ -50,4 +50,19 @@ export declare function PreferencesConfigSchemaFactory(): import("@jbrowse/core/
50
50
  readonly type: "boolean";
51
51
  readonly defaultValue: false;
52
52
  };
53
+ /**
54
+ * #slot configuration.preferences.developerMode
55
+ * when true, the ordering-contract checks a display or plugin can break
56
+ * report themselves in the app rather than only in a development build.
57
+ * Off by default and deliberately not in the Preferences dialog: the
58
+ * messages are about code, and a reader who cannot change the code cannot
59
+ * act on one. Turn it on for a site that is running a plugin under
60
+ * development; a plugin author needs nothing here, since a plugin served
61
+ * from localhost arms the same channel on its own, as does
62
+ * `localStorage.jbrowseDeveloperMode`. Takes effect on reload.
63
+ */
64
+ readonly developerMode: {
65
+ readonly type: "boolean";
66
+ readonly defaultValue: false;
67
+ };
53
68
  }, import("@jbrowse/core/configuration/configurationSchema").ConfigurationSchemaOptions<undefined, undefined>>;
@@ -57,5 +57,20 @@ export function PreferencesConfigSchemaFactory() {
57
57
  type: 'boolean',
58
58
  defaultValue: false,
59
59
  },
60
+ /**
61
+ * #slot configuration.preferences.developerMode
62
+ * when true, the ordering-contract checks a display or plugin can break
63
+ * report themselves in the app rather than only in a development build.
64
+ * Off by default and deliberately not in the Preferences dialog: the
65
+ * messages are about code, and a reader who cannot change the code cannot
66
+ * act on one. Turn it on for a site that is running a plugin under
67
+ * development; a plugin author needs nothing here, since a plugin served
68
+ * from localhost arms the same channel on its own, as does
69
+ * `localStorage.jbrowseDeveloperMode`. Takes effect on reload.
70
+ */
71
+ developerMode: {
72
+ type: 'boolean',
73
+ defaultValue: false,
74
+ },
60
75
  });
61
76
  }
@@ -66,6 +66,10 @@ declare function rootConfigurationSchema(): import("@jbrowse/core/configuration/
66
66
  readonly type: "boolean";
67
67
  readonly defaultValue: false;
68
68
  };
69
+ readonly developerMode: {
70
+ readonly type: "boolean";
71
+ readonly defaultValue: false;
72
+ };
69
73
  }, import("@jbrowse/core/configuration/configurationSchema").ConfigurationSchemaOptions<undefined, undefined>>;
70
74
  readonly formatDetails: import("@jbrowse/core/configuration/configurationSchema").ConfigurationSchemaType<{
71
75
  readonly feature: {
@@ -178,6 +182,10 @@ export declare function createConfigModel(pluginManager: PluginManager, assembly
178
182
  readonly type: "boolean";
179
183
  readonly defaultValue: false;
180
184
  };
185
+ readonly developerMode: {
186
+ readonly type: "boolean";
187
+ readonly defaultValue: false;
188
+ };
181
189
  }, import("@jbrowse/core/configuration/configurationSchema").ConfigurationSchemaOptions<undefined, undefined>>;
182
190
  readonly formatDetails: import("@jbrowse/core/configuration/configurationSchema").ConfigurationSchemaType<{
183
191
  readonly feature: {
@@ -322,6 +322,8 @@ export declare function BaseSessionModel<ROOT_MODEL_TYPE extends BaseRootModelTy
322
322
  pushSnackbarMessage(message: string, level?: import("@jbrowse/core/util").NotificationLevel, actions?: import("@jbrowse/core/util").SnackAction[]): void;
323
323
  popSnackbarMessage(): import("@jbrowse/core/ui/SnackbarModel").SnackbarMessage | undefined;
324
324
  removeSnackbarMessage(message: string): void;
325
+ } & {
326
+ afterAttach(): void;
325
327
  }, import("@jbrowse/mobx-state-tree")._NotCustomized, import("@jbrowse/mobx-state-tree")._NotCustomized>;
326
328
  /** Session mixin MST type for the most basic session */
327
329
  export type BaseSessionType = ReturnType<typeof BaseSessionModel>;
@@ -6,6 +6,7 @@ import { isFeature, unwrapFeature } from '@jbrowse/core/util/simpleFeature';
6
6
  import { ElementId } from '@jbrowse/core/util/types/mst';
7
7
  import { getParent, isStateTreeNode, types } from '@jbrowse/mobx-state-tree';
8
8
  import { observable } from 'mobx';
9
+ import { applyDeveloperMode } from './developerMode.js';
9
10
  function isAnimationMode(val) {
10
11
  return val === 'system' || val === 'enabled' || val === 'disabled';
11
12
  }
@@ -490,7 +491,16 @@ export function BaseSessionModel(_pluginManager) {
490
491
  self.queueOfDialogs = [...self.queueOfDialogs, [component, props]];
491
492
  },
492
493
  }));
493
- return types.compose(baseModel, SnackbarModel());
494
+ return types.compose(baseModel, SnackbarModel()).actions(self => ({
495
+ // after the compose rather than in the afterAttach above, because the
496
+ // notices land in the snackbar this line is the first place to hold both
497
+ // halves of. Web and desktop reach it a second time once the user's stored
498
+ // overrides are loaded (`PreferencesSessionMixin`), and our MST fork
499
+ // auto-chains both hooks.
500
+ afterAttach() {
501
+ applyDeveloperMode(self);
502
+ },
503
+ }));
494
504
  }
495
505
  /** Type guard for BaseSession */
496
506
  export function isBaseSession(thing) {
@@ -1,4 +1,5 @@
1
1
  import type PluginManager from '@jbrowse/core/PluginManager';
2
+ import type { ViewSnapshotInput } from '@jbrowse/core/PluginManager';
2
3
  import type { IBaseViewModel } from '@jbrowse/core/pluggableElementTypes';
3
4
  import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree';
4
5
  /**
@@ -1678,6 +1679,8 @@ export declare function MultipleViewsSessionMixin(pluginManager: PluginManager):
1678
1679
  pushSnackbarMessage(message: string, level?: import("@jbrowse/core/util").NotificationLevel, actions?: import("@jbrowse/core/util").SnackAction[]): void;
1679
1680
  popSnackbarMessage(): import("@jbrowse/core/ui/SnackbarModel").SnackbarMessage | undefined;
1680
1681
  removeSnackbarMessage(message: string): void;
1682
+ } & {
1683
+ afterAttach(): void;
1681
1684
  } & {
1682
1685
  readonly visibleWidget: import("@jbrowse/core/util").Widget | undefined;
1683
1686
  } & {
@@ -1752,8 +1755,10 @@ export declare function MultipleViewsSessionMixin(pluginManager: PluginManager):
1752
1755
  orderViews(ids: string[]): void;
1753
1756
  /**
1754
1757
  * #action
1758
+ * Requires the view type's state model to be loaded; for a lazily
1759
+ * registered type use `launchView`.
1755
1760
  */
1756
- addView(typeName: string, initialState?: any): any;
1761
+ addView<N extends string>(typeName: N, initialState?: NoInfer<ViewSnapshotInput<N>>): any;
1757
1762
  /**
1758
1763
  * #action
1759
1764
  * swap `view` for a new view of `typeName`, in the slot it occupied.
@@ -1783,7 +1788,7 @@ export declare function MultipleViewsSessionMixin(pluginManager: PluginManager):
1783
1788
  * its panel, and the launch that offers a replace is a click on that
1784
1789
  * view's own menu.
1785
1790
  */
1786
- replaceView(view: IBaseViewModel, typeName: string, initialState?: any): any;
1791
+ replaceView<N extends string>(view: IBaseViewModel, typeName: N, initialState?: NoInfer<ViewSnapshotInput<N>>): any;
1787
1792
  /**
1788
1793
  * #action
1789
1794
  */
@@ -1845,6 +1850,16 @@ export declare function MultipleViewsSessionMixin(pluginManager: PluginManager):
1845
1850
  */
1846
1851
  resetUseWorkspaces(): void;
1847
1852
  afterAttach(): void;
1853
+ } & {
1854
+ /**
1855
+ * #action
1856
+ * `addView` for view types whose state model may be lazily loaded. The
1857
+ * view is not in `session.views` until the promise resolves.
1858
+ * `getViewType` first so an unknown type name fails by that name; the
1859
+ * snapshot walk skips unregistered names and covers the displays of the
1860
+ * tracks a launch opens with.
1861
+ */
1862
+ launchView<N extends string>(typeName: N, initialState?: NoInfer<ViewSnapshotInput<N>>): Promise<any>;
1848
1863
  }, import("@jbrowse/mobx-state-tree")._NotCustomized, import("@jbrowse/mobx-state-tree").ModelSnapshotType<Omit<Omit<Omit<{
1849
1864
  id: import("@jbrowse/mobx-state-tree").IOptionalIType<import("@jbrowse/mobx-state-tree").ISimpleType<string>, [undefined]>;
1850
1865
  name: import("@jbrowse/mobx-state-tree").ISimpleType<string>;
@@ -54,10 +54,24 @@ function dropRefsInto(holder, view) {
54
54
  }
55
55
  return found;
56
56
  }
57
+ // A session snapshot's `views`, or undefined for anything that is not one
58
+ function viewsOfSessionSnapshot(snapshot) {
59
+ const views = typeof snapshot === 'object' && snapshot !== null && 'views' in snapshot
60
+ ? snapshot.views
61
+ : undefined;
62
+ return Array.isArray(views) ? views : undefined;
63
+ }
57
64
  /**
58
65
  * #stateModel MultipleViewsSessionMixin
59
66
  */
60
67
  export function MultipleViewsSessionMixin(pluginManager) {
68
+ // an unknown type name passes through to fail at the union as it always has
69
+ function assertViewStateModelLoaded(typeName) {
70
+ if (pluginManager.getElementTypeRecord('view').has(typeName) &&
71
+ !pluginManager.getViewType(typeName).isStateModelLoaded) {
72
+ throw new Error(`state model for view type ${typeName} is not loaded yet — use launchView(), or await getViewType('${typeName}').loadStateModel() first`);
73
+ }
74
+ }
61
75
  return types
62
76
  .compose(BaseSessionModel(pluginManager), DrawerWidgetSessionMixin(pluginManager), types.model({
63
77
  /**
@@ -208,8 +222,11 @@ export function MultipleViewsSessionMixin(pluginManager) {
208
222
  },
209
223
  /**
210
224
  * #action
225
+ * Requires the view type's state model to be loaded; for a lazily
226
+ * registered type use `launchView`.
211
227
  */
212
- addView(typeName, initialState = {}) {
228
+ addView(typeName, initialState) {
229
+ assertViewStateModelLoaded(typeName);
213
230
  const length = self.views.push({
214
231
  ...initialState,
215
232
  type: typeName,
@@ -245,7 +262,8 @@ export function MultipleViewsSessionMixin(pluginManager) {
245
262
  * its panel, and the launch that offers a replace is a click on that
246
263
  * view's own menu.
247
264
  */
248
- replaceView(view, typeName, initialState = {}) {
265
+ replaceView(view, typeName, initialState) {
266
+ assertViewStateModelLoaded(typeName);
249
267
  // read before the removal, which is what makes both stale
250
268
  const idx = self.views.indexOf(view);
251
269
  // `idx` first, so a view already gone from the session short-circuits
@@ -305,7 +323,13 @@ export function MultipleViewsSessionMixin(pluginManager) {
305
323
  * lands, which is why ADR-069's rule stops there.
306
324
  */
307
325
  takeOutViewsMissingFrom(snapshot) {
308
- const incoming = snapshot?.views ?? [];
326
+ const incoming = viewsOfSessionSnapshot(snapshot);
327
+ // an argument that is not a session snapshot used to read as "keep
328
+ // none" and close every view: an agent introspecting the actions
329
+ // called this with a tab id and a keep-list and lost its session
330
+ if (!incoming) {
331
+ throw new Error('takeOutViewsMissingFrom takes a session snapshot (it is what undo and redo call to close the views a snapshot no longer has); to close one view, session.removeView(view)');
332
+ }
309
333
  const kept = new Map(incoming.map(v => [v.id, v.type]));
310
334
  // a copy, because `takeOut` splices the array it is iterating
311
335
  for (const view of [...self.views]) {
@@ -359,6 +383,23 @@ export function MultipleViewsSessionMixin(pluginManager) {
359
383
  },
360
384
  };
361
385
  })
386
+ .actions(self => ({
387
+ /**
388
+ * #action
389
+ * `addView` for view types whose state model may be lazily loaded. The
390
+ * view is not in `session.views` until the promise resolves.
391
+ * `getViewType` first so an unknown type name fails by that name; the
392
+ * snapshot walk skips unregistered names and covers the displays of the
393
+ * tracks a launch opens with.
394
+ */
395
+ async launchView(typeName, initialState) {
396
+ await pluginManager.getViewType(typeName).loadStateModel();
397
+ await pluginManager.preloadSessionTypes({
398
+ views: [{ type: typeName, ...initialState }],
399
+ });
400
+ return self.addView(typeName, initialState);
401
+ },
402
+ }))
362
403
  .postProcessSnapshot(snap => {
363
404
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
364
405
  if (!snap) {
@@ -1662,4 +1662,6 @@ export declare function PreferencesSessionMixin(pluginManager: PluginManager): i
1662
1662
  removeSnackbarMessage(message: string): void;
1663
1663
  } & {
1664
1664
  afterAttach(): void;
1665
+ } & {
1666
+ afterAttach(): void;
1665
1667
  }, import("@jbrowse/mobx-state-tree")._NotCustomized, import("@jbrowse/mobx-state-tree")._NotCustomized>;
@@ -3,6 +3,7 @@ import { freezeDeep } from '@jbrowse/core/util/freezeDeep';
3
3
  import { addDisposer, types } from '@jbrowse/mobx-state-tree';
4
4
  import { autorun } from 'mobx';
5
5
  import { BaseSessionModel } from './BaseSession.js';
6
+ import { applyDeveloperMode } from './developerMode.js';
6
7
  const PREFS_KEY = 'jbrowsePreferences';
7
8
  function loadStoredPreferences() {
8
9
  const stored = localStorageGetItem(PREFS_KEY);
@@ -43,6 +44,10 @@ export function PreferencesSessionMixin(pluginManager) {
43
44
  // main-thread update would leave the app formatted two ways at once.
44
45
  // The dialog says a reload is needed; this is where the reload lands.
45
46
  setNumberGrouping(self.numberGrouping);
47
+ // and the same second pass for the developer-notice channel, whose
48
+ // admin default BaseSession already read: a user's stored override is
49
+ // only loaded a line above this
50
+ applyDeveloperMode(self);
46
51
  addDisposer(self, autorun(function persistPreferences() {
47
52
  // observable.map doesn't JSON-serialize to an object on its own;
48
53
  // fromEntries also reads every key so the autorun re-persists on
@@ -1689,6 +1689,8 @@ export declare function SessionTracksManagerSessionMixin(pluginManager: PluginMa
1689
1689
  pushSnackbarMessage(message: string, level?: import("@jbrowse/core/util").NotificationLevel, actions?: import("@jbrowse/core/util").SnackAction[]): void;
1690
1690
  popSnackbarMessage(): import("@jbrowse/core/ui/SnackbarModel").SnackbarMessage | undefined;
1691
1691
  removeSnackbarMessage(message: string): void;
1692
+ } & {
1693
+ afterAttach(): void;
1692
1694
  } & {
1693
1695
  getReferringMultiple(trackIds: string[]): Map<string, import("./ReferenceManagement.ts").ReferringNode[]>;
1694
1696
  } & {
@@ -4,6 +4,7 @@ import { expandLooseTrackConfig } from '@jbrowse/core/util/tracks';
4
4
  import { applySnapshot, getSnapshot, isStateTreeNode, types, } from '@jbrowse/mobx-state-tree';
5
5
  import { compareStructural } from 'mobx';
6
6
  import { TracksManagerSessionMixin } from './Tracks.js';
7
+ import { assertNotReaddedDifferently } from './readdedTrackConf.js';
7
8
  import { assertTrackConfOutlivesItsAssemblies } from './temporaryAssemblyTracks.js';
8
9
  // jbrowse.tracks holds frozen plain objects (app-core, web/desktop) or MST
9
10
  // config nodes (product-core, embedded react views); the delta math reads both
@@ -342,6 +343,13 @@ export function SessionTracksManagerSessionMixin(pluginManager) {
342
343
  // and dropping its trackConfigDeltas override semantics.
343
344
  const existing = self.getTrackById(trackId);
344
345
  if (existing) {
346
+ if (self.sessionTracks.includes(existing)) {
347
+ assertNotReaddedDifferently(pluginManager, existing, {
348
+ ...trackConf,
349
+ trackId,
350
+ type,
351
+ });
352
+ }
345
353
  return existing;
346
354
  }
347
355
  // sessionTracks is a typed MST array (unlike the frozen
@@ -44,7 +44,7 @@ export function trackActionItems({ session, config, view, canEdit, isSessionOver
44
44
  // visitor is served, one per click since the copy's stamped trackId defeats
45
45
  // the dedupe, and a non-admin's lands as the dead `sessionTracks` entry
46
46
  // ADR-084 removed the sweep for. Neither is worth offering, so the item says
47
- // so instead of producing garbage; the dev-only contract check on the adders
47
+ // so instead of producing garbage; the contract check on the adders
48
48
  // is what found this.
49
49
  //
50
50
  // Greying a row tells nobody anything, and this reason is one nobody guesses:
@@ -86,7 +86,7 @@ export function trackActionItems({ session, config, view, canEdit, isSessionOver
86
86
  onClick: () => {
87
87
  const snap = makeCopy();
88
88
  if (session.publishTrackConf(snap)) {
89
- view.showTrack(snap.trackId);
89
+ void view.launchTrack(snap.trackId);
90
90
  }
91
91
  },
92
92
  },
@@ -1657,6 +1657,8 @@ export declare function TracksManagerSessionMixin(pluginManager: PluginManager):
1657
1657
  pushSnackbarMessage(message: string, level?: import("@jbrowse/core/util").NotificationLevel, actions?: import("@jbrowse/core/util").SnackAction[]): void;
1658
1658
  popSnackbarMessage(): import("@jbrowse/core/ui/SnackbarModel").SnackbarMessage | undefined;
1659
1659
  removeSnackbarMessage(message: string): void;
1660
+ } & {
1661
+ afterAttach(): void;
1660
1662
  } & {
1661
1663
  getReferringMultiple(trackIds: string[]): Map<string, import("./ReferenceManagement.ts").ReferringNode[]>;
1662
1664
  } & {
@@ -1,8 +1,10 @@
1
+ import { expandLooseTrackConfig } from '@jbrowse/core/util/tracks';
1
2
  import { types } from '@jbrowse/mobx-state-tree';
2
3
  import { computed } from 'mobx';
3
4
  import { BaseSessionModel, isBaseSession } from './BaseSession.js';
4
5
  import { isSessionWithConnections } from './Connections.js';
5
6
  import { ReferenceManagementSessionMixin } from './ReferenceManagement.js';
7
+ import { assertNotReaddedDifferently } from './readdedTrackConf.js';
6
8
  import { assertTrackConfOutlivesItsAssemblies } from './temporaryAssemblyTracks.js';
7
9
  /**
8
10
  * #stateModel TracksManagerSessionMixin
@@ -24,6 +26,14 @@ export function TracksManagerSessionMixin(pluginManager) {
24
26
  // here; hydration to MST nodes happens lazily in TrackConfigurationReference
25
27
  // on first access. Cached, so the N per-id lookups below share one rebuild
26
28
  // per change rather than each re-scanning.
29
+ //
30
+ // `keepAlive` is what makes that last sentence true, and it is load
31
+ // bearing: an unobserved computed re-derives on EVERY read, and this one
32
+ // walks every track, assembly and connection the session can resolve. A
33
+ // reader inside a reaction or an MST action was already fine — MobX
34
+ // caches within a batch, which is why the resolvers and `showTrackGeneric`
35
+ // never showed it. Ranking search hits is the caller that is neither, and
36
+ // it cost 33ms per search on a 2000-track config against 0.07ms held.
27
37
  const tracksByIdRecord = computed(() => {
28
38
  const temporaryAssemblies = 'temporaryAssemblies' in self
29
39
  ? self.temporaryAssemblies
@@ -49,7 +59,7 @@ export function TracksManagerSessionMixin(pluginManager) {
49
59
  e.config,
50
60
  ]),
51
61
  ]);
52
- });
62
+ }, { keepAlive: true });
53
63
  // Per-id computed cache backing getTrackById. Resolving one track's config
54
64
  // subscribes only to that id's computed, so editing track A leaves track
55
65
  // B's observers untouched: an unedited id's entry keeps its object identity
@@ -108,6 +118,19 @@ export function TracksManagerSessionMixin(pluginManager) {
108
118
  */
109
119
  addSessionTrackConf(trackConf) {
110
120
  assertTrackConfOutlivesItsAssemblies(self, trackConf, 'jbrowse.tracks');
121
+ // the config's own adder appends without looking, so a re-add under a
122
+ // known id used to leave two entries with the first one winning
123
+ const expanded = expandLooseTrackConfig(trackConf, pluginManager);
124
+ const { trackId, type } = expanded;
125
+ const existing = self.getTrackById(trackId);
126
+ if (existing) {
127
+ assertNotReaddedDifferently(pluginManager, existing, {
128
+ ...expanded,
129
+ trackId,
130
+ type,
131
+ });
132
+ return existing;
133
+ }
111
134
  return self.jbrowse.addTrackConf(trackConf);
112
135
  },
113
136
  /**
@@ -0,0 +1,7 @@
1
+ import type { NotificationSink } from '@jbrowse/core/util/types';
2
+ import type { IStateTreeNode } from '@jbrowse/mobx-state-tree';
3
+ interface DeveloperModeHost extends IStateTreeNode, Pick<NotificationSink, 'notify'> {
4
+ getPreference: (key: string) => unknown;
5
+ }
6
+ export declare function applyDeveloperMode(self: DeveloperModeHost): void;
7
+ export {};
@@ -0,0 +1,43 @@
1
+ import { addDisposer, isAlive } from '@jbrowse/mobx-state-tree';
2
+ import { enableContractReports, setContractReportSink, } from '@jbrowse/render-core/contractReports';
3
+ /**
4
+ * What a plugin author sees when their display breaks an ordering contract in
5
+ * somebody else's production build.
6
+ *
7
+ * The checks themselves have always run and always reported; what they had no
8
+ * way to reach was this population, which is the one least able to diagnose the
9
+ * symptom and the one nobody can write a test for. A `console.error` surviving
10
+ * the build would not have fixed that — nobody has a console open on a page
11
+ * they did not write — so the report has to land in the app, in the session the
12
+ * broken display is in, and it has to explain itself: the reader did not turn
13
+ * this on deliberately in the common case (a plugin served from their own
14
+ * machine did), and a message they cannot place reads as the app being broken.
15
+ *
16
+ * `warning` rather than `error`: an error snackbar in this app means the user's
17
+ * data or their request failed, and every word here is about code they are
18
+ * writing. Warnings persist until dismissed, which is what a message with a fix
19
+ * in it wants, and identical ones collapse in the snackbar — so a violation
20
+ * repeating across ten tracks of one broken type is still one notice.
21
+ */
22
+ function notification({ family, message, armedBy }) {
23
+ return (`JBrowse ${family} contract broken — a bug in code, not in your data or ` +
24
+ `configuration.\n\n${message}\n\n` +
25
+ `Shown because ${armedBy}. These notices can be turned on anywhere with ` +
26
+ `localStorage.jbrowseDeveloperMode = 1, or site-wide with ` +
27
+ `configuration.preferences.developerMode.`);
28
+ }
29
+ export function applyDeveloperMode(self) {
30
+ if (self.getPreference('developerMode') === true) {
31
+ enableContractReports('this site sets configuration.preferences.developerMode');
32
+ }
33
+ addDisposer(self, setContractReportSink(report => {
34
+ // out of whatever reaction, fetch handler or upload found the violation:
35
+ // `notify` writes observables, and a diagnostic that throws where it is
36
+ // read is worse than the bug it describes
37
+ queueMicrotask(() => {
38
+ if (isAlive(self)) {
39
+ self.notify(notification(report), 'warning');
40
+ }
41
+ });
42
+ }));
43
+ }
@@ -0,0 +1,8 @@
1
+ import type PluginManager from '@jbrowse/core/PluginManager';
2
+ import type { AnyConfigurationModel } from '@jbrowse/core/configuration';
3
+ type TrackConfInput = {
4
+ trackId: string;
5
+ type: string;
6
+ } & Record<string, unknown>;
7
+ export declare function assertNotReaddedDifferently(pluginManager: PluginManager, existing: AnyConfigurationModel | TrackConfInput, trackConf: TrackConfInput): void;
8
+ export {};
@@ -0,0 +1,25 @@
1
+ import { getSnapshot, isStateTreeNode } from '@jbrowse/mobx-state-tree';
2
+ import { compareStructural } from 'mobx';
3
+ // What a config means once its defaults are resolved: a live config node
4
+ // answers with its snapshot, and a plain object (desktop's `jbrowse.tracks` is
5
+ // frozen, so its entries are the input as written) is hydrated through the
6
+ // same schema first. Two inputs that would build the same node compare equal.
7
+ function resolvedTrackConf(pluginManager, conf) {
8
+ return isStateTreeNode(conf)
9
+ ? getSnapshot(conf)
10
+ : getSnapshot(pluginManager
11
+ .pluggableConfigSchemaType('track')
12
+ .create(conf, { pluginManager }));
13
+ }
14
+ // Re-adding a trackId the session already holds returns the existing config,
15
+ // which is what keeps a content-hashed trackId idempotent. It is also the trap
16
+ // a recomputed track walks into: the new features arrive under the old id, the
17
+ // old config is handed back, nothing is said, and the track keeps showing the
18
+ // first values it saw. Same id with different content is therefore refused,
19
+ // naming the two-step that works.
20
+ export function assertNotReaddedDifferently(pluginManager, existing, trackConf) {
21
+ const same = compareStructural(resolvedTrackConf(pluginManager, existing), resolvedTrackConf(pluginManager, trackConf));
22
+ if (!same) {
23
+ throw new Error(`Track "${trackConf.trackId}" is already in this session with a different configuration, and adding it again would keep the old one. Delete it first (session.deleteTrackConf(session.getTrackById("${trackConf.trackId}"))), then add — and give a recomputed FromConfigAdapter a new adapterId, since the adapter cache is keyed on it.`);
24
+ }
25
+ }
@@ -1,7 +1,7 @@
1
1
  import type { AnyConfigurationModel } from '@jbrowse/core/configuration';
2
2
  /**
3
- * Dev-only check that a track config being written into a session or config list
4
- * is one something can still draw tomorrow. No-op in production.
3
+ * Checks that a track config being written into a session or config list is one
4
+ * something can still draw tomorrow.
5
5
  *
6
6
  * A config naming nothing but a temporary assembly is dead the moment its view
7
7
  * closes, and no list outside that view has anyone to sweep it — which is the
@@ -18,6 +18,7 @@ import type { AnyConfigurationModel } from '@jbrowse/core/configuration';
18
18
  * `console.error` and never `throw`, matching `assertDisplayContract`: these
19
19
  * writes happen inside launchers and menu handlers where an exception is caught
20
20
  * and reported as a failed track, which would hide the violation. The jest gate
21
- * (`config/jest/contractGate.js`) is what listens.
21
+ * (`config/jest/contractGate.js`) is what listens in tree; out of tree
22
+ * `contractReports` does, once a host arms it.
22
23
  */
23
24
  export declare function assertTrackConfOutlivesItsAssemblies(session: unknown, trackConf: AnyConfigurationModel | Record<string, unknown>, destination: string): void;
@@ -1,7 +1,8 @@
1
1
  import { namesTemporaryAssembly } from '@jbrowse/core/util';
2
+ import { reportContractViolation } from '@jbrowse/render-core/contractReports';
2
3
  /**
3
- * Dev-only check that a track config being written into a session or config list
4
- * is one something can still draw tomorrow. No-op in production.
4
+ * Checks that a track config being written into a session or config list is one
5
+ * something can still draw tomorrow.
5
6
  *
6
7
  * A config naming nothing but a temporary assembly is dead the moment its view
7
8
  * closes, and no list outside that view has anyone to sweep it — which is the
@@ -18,17 +19,15 @@ import { namesTemporaryAssembly } from '@jbrowse/core/util';
18
19
  * `console.error` and never `throw`, matching `assertDisplayContract`: these
19
20
  * writes happen inside launchers and menu handlers where an exception is caught
20
21
  * and reported as a failed track, which would hide the violation. The jest gate
21
- * (`config/jest/contractGate.js`) is what listens.
22
+ * (`config/jest/contractGate.js`) is what listens in tree; out of tree
23
+ * `contractReports` does, once a host arms it.
22
24
  */
23
25
  export function assertTrackConfOutlivesItsAssemblies(session, trackConf, destination) {
24
- if (process.env.NODE_ENV === 'production') {
25
- return;
26
- }
27
26
  if (!namesTemporaryAssembly(session, trackConf)) {
28
27
  return;
29
28
  }
30
29
  const { trackId } = trackConf;
31
- console.error(`[jbrowse session contract] ${destination} was given "${trackId}", ` +
30
+ reportContractViolation('session', `${destination} was given "${trackId}", ` +
32
31
  `which names a temporary assembly — one a comparative view synthesized ` +
33
32
  `and gives back when it closes, so this config outlives the only ` +
34
33
  `assembly that could draw it and nothing sweeps the list it landed in. ` +
@@ -14,7 +14,7 @@ interface ControllerView {
14
14
  trackId: string;
15
15
  };
16
16
  }[];
17
- showTrack: (trackId: string) => unknown;
17
+ launchTrack: (trackId: string) => Promise<unknown>;
18
18
  hideTrack: (trackId: string) => unknown;
19
19
  }
20
20
  export interface ControllerSession extends IStateTreeNode {
@@ -48,9 +48,9 @@ export declare function resolveTracks(tracks: TrackInput[], node: IStateTreeNode
48
48
  * Session-scoped: these are the host's declared tracks for one embed, not a
49
49
  * catalog an admin curates.
50
50
  */
51
- export declare function openTracks(session: ControllerSession, tracks: TrackConf[]): void;
51
+ export declare function openTracks(session: ControllerSession, tracks: TrackConf[]): Promise<void>;
52
52
  /** Open every wanted track and close any others the view currently shows. */
53
- export declare function reconcileTracks(session: ControllerSession, tracks: TrackConf[]): void;
53
+ export declare function reconcileTracks(session: ControllerSession, tracks: TrackConf[]): Promise<void>;
54
54
  /** Merge a hub's own search adapters with the ones the caller supplied. */
55
55
  export declare function mergeSearchAdapters<T>(a: readonly T[] | undefined, b: readonly T[] | undefined): T[] | undefined;
56
56
  export {};
@@ -1,5 +1,6 @@
1
1
  import { getEnv } from '@jbrowse/core/util';
2
2
  import { guessTrackConf } from '@jbrowse/core/util/tracks';
3
+ import { isAlive } from '@jbrowse/mobx-state-tree';
3
4
  import { resolveLocalFileUris } from './localFiles.js';
4
5
  export function isLooseTrack(track) {
5
6
  return typeof track === 'string' || (!('adapter' in track) && 'uri' in track);
@@ -47,20 +48,32 @@ export function resolveTracks(tracks, node, assemblyName, localFiles) {
47
48
  * Session-scoped: these are the host's declared tracks for one embed, not a
48
49
  * catalog an admin curates.
49
50
  */
50
- export function openTracks(session, tracks) {
51
+ export async function openTracks(session, tracks) {
52
+ // sequential, not Promise.all: each display's state model may be a separate
53
+ // dynamic import, and racing them would land the tracks on the view in
54
+ // whatever order the chunks happen to resolve rather than the caller's
51
55
  for (const conf of tracks) {
56
+ // the engine can be destroyed while a chunk is in flight (an unmounted
57
+ // embed); reading the dead tree from this floating promise would throw
58
+ // uncatchably
59
+ if (!isAlive(session.view)) {
60
+ return;
61
+ }
52
62
  const trackId = conf.trackId;
53
63
  if (!session.getTrackById(trackId)) {
54
64
  session.addSessionTrackConf(conf);
55
65
  }
56
- session.view.showTrack(trackId);
66
+ await session.view.launchTrack(trackId);
57
67
  }
58
68
  }
59
69
  /** Open every wanted track and close any others the view currently shows. */
60
- export function reconcileTracks(session, tracks) {
70
+ export async function reconcileTracks(session, tracks) {
61
71
  const { view } = session;
62
72
  const wanted = new Set(tracks.map(t => t.trackId));
63
- openTracks(session, tracks);
73
+ await openTracks(session, tracks);
74
+ if (!isAlive(view)) {
75
+ return;
76
+ }
64
77
  // materialize the ids first: hideTrack splices view.tracks, so iterating it
65
78
  // live would skip entries
66
79
  const unwanted = view.tracks
package/esm/index.d.ts CHANGED
@@ -21,7 +21,7 @@ export type { ControllerSession, TrackConf, TrackInput, } from './controllerTrac
21
21
  export { resolveAssemblies, resolveAssembly } from './resolveAssemblies.ts';
22
22
  export type { AssemblyInput, ResolvedAssemblies } from './resolveAssemblies.ts';
23
23
  export { destroyViewState } from './destroyViewState.ts';
24
- export { useCreateOnce, useDestroyOnUnmount } from './useEngineLifecycle.ts';
24
+ export { useAsyncEngineLifecycle, useCreateOnce, useCreateOnceAsync, useDestroyOnUnmount, } from './useEngineLifecycle.ts';
25
25
  export { getSessionSnapshot, observeSession } from './observeSession.ts';
26
26
  export type { SessionObservers, ViewLocation } from './observeSession.ts';
27
27
  export { MIGRATED_DISPLAY_INSTANCE_KEYS, migrateConfigSnapshot, migrateSessionSnapshot, } from './sessionMigrations/index.ts';
package/esm/index.js CHANGED
@@ -22,7 +22,7 @@ export { resolveAssemblies, resolveAssembly } from './resolveAssemblies.js';
22
22
  export { destroyViewState } from './destroyViewState.js';
23
23
  // the two halves of "React owns this engine", both of which have a StrictMode
24
24
  // trap in them that the obvious spelling walks straight into
25
- export { useCreateOnce, useDestroyOnUnmount } from './useEngineLifecycle.js';
25
+ export { useAsyncEngineLifecycle, useCreateOnce, useCreateOnceAsync, useDestroyOnUnmount, } from './useEngineLifecycle.js';
26
26
  // the JS -> host direction of an embedded controller: where each view is
27
27
  // looking, what got selected, and the layout as plain JSON
28
28
  export { getSessionSnapshot, observeSession } from './observeSession.js';
@@ -30,4 +30,4 @@ export interface LoadPluginsArgs {
30
30
  export declare function loadRuntimePlugins(pluginDefinitions: PluginDefinition[], { dropVendored, jbrowseVersion, ...args }: LoadPluginsArgs & {
31
31
  dropVendored: boolean;
32
32
  jbrowseVersion: string;
33
- }): Promise<import("@jbrowse/core/PluginLoader").PluginRecord[]>;
33
+ }): Promise<never[] | import("@jbrowse/core/PluginLoader").PluginRecord[]>;
@@ -28,11 +28,14 @@ export async function loadRuntimePlugins(pluginDefinitions, { dropVendored, jbro
28
28
  if (failures[0]) {
29
29
  throw failures[0].error;
30
30
  }
31
- const pluginLoader = new PluginLoader(dropVendored ? dropVendoredPlugins(definitions) : definitions, args);
32
- pluginLoader.installGlobalReExports(window);
31
+ const toLoad = dropVendored ? dropVendoredPlugins(definitions) : definitions;
33
32
  // the default matters: `new URL('umd_plugin.js', undefined)` throws, so a
34
33
  // relative plugin url used to fail outright instead of resolving against the
35
34
  // page the way every other relative url does
36
35
  const base = args.baseUri ?? args.baseUrl;
37
- return pluginLoader.load(base ?? window.location.href);
36
+ return toLoad.length
37
+ ? new PluginLoader(toLoad, args)
38
+ .installGlobalReExports(window)
39
+ .load(base ?? window.location.href)
40
+ : [];
38
41
  }
package/esm/rpcWorker.js CHANGED
@@ -3,7 +3,6 @@ import PluginManager from '@jbrowse/core/PluginManager';
3
3
  import { setNumberGrouping, throttleStatusEmits } from '@jbrowse/core/util';
4
4
  import { RpcServer, serializeError } from '@jbrowse/core/util/librpc';
5
5
  import { setStackTraceLimit } from '@jbrowse/core/util/setStackTraceLimit';
6
- import { enableStaticRendering } from 'mobx-react';
7
6
  // waits for a message from the main thread containing our configuration, which
8
7
  // must be sent on boot
9
8
  function receiveConfiguration() {
@@ -25,13 +24,16 @@ async function getPluginManager(corePlugins, opts) {
25
24
  // tooltip against the full feature here rather than shipping it back — so it
26
25
  // needs the main thread's display preference before any RPC method runs
27
26
  setNumberGrouping(config.numberGrouping);
28
- const pluginLoader = new PluginLoader(config.plugins, opts).installGlobalReExports(self);
29
27
  // Keep each runtime plugin's `definition` on its load record (mirroring the
30
28
  // main thread's createPluginManager) so PluginManager populates
31
29
  // runtimePluginDefinitions. A plugin that resolves a sibling asset from its
32
30
  // own bundle url in the worker — e.g. GraphGenomeView's Bandage layout
33
31
  // engine — needs its definition here, not just its instance.
34
- const runtimePlugins = await pluginLoader.load(config.windowHref);
32
+ const runtimePlugins = config.plugins.length
33
+ ? await new PluginLoader(config.plugins, opts)
34
+ .installGlobalReExports(self)
35
+ .load(config.windowHref)
36
+ : [];
35
37
  return new PluginManager([
36
38
  ...corePlugins.map(P => ({ plugin: new P() })),
37
39
  ...runtimePlugins.map(({ plugin: P, definition }) => ({
@@ -92,7 +94,6 @@ export async function initializeWorker(corePlugins, opts) {
92
94
  // copies meant a fifth product would silently ship without it — the plugins
93
95
  // that could trip it are not loaded until getPluginManager below, so this is
94
96
  // still ahead of anything that observes.
95
- enableStaticRendering(true);
96
97
  // Add global error handler to catch uncaught errors in the worker
97
98
  self.addEventListener('error', event => {
98
99
  console.error('[Worker uncaught error]', event.error || event.message);
@@ -60,6 +60,6 @@ const DisplayDefaultsSection = observer(function DisplayDefaultsSection({ sessio
60
60
  }));
61
61
  return changes.length ? (_jsxs(_Fragment, { children: [_jsx(Typography, { children: "These apply to every track of a display type that hasn't set its own value. Clear one to put those tracks back on the setting's default." }), _jsx(SettingsChangesTable, { changes: changes, onResetRow: change => {
62
62
  session.setDisplayTypeDefault(change.path[0], change.path[1], undefined);
63
- } })] })) : (_jsx(Typography, { children: "None set. The pin beside a setting in a track menu makes that value the default for every track of the same display type." }));
63
+ } })] })) : (_jsx(Typography, { children: "None set. The pin beside a setting in a track menu applies that value to every open track of the same display type, and then offers to keep it as the default for the ones you open later." }));
64
64
  });
65
65
  export default DisplayDefaultsSection;
@@ -1,5 +1,11 @@
1
1
  import type { EmbeddedRoot } from './destroyViewState.ts';
2
- export { useCreateOnce } from '@jbrowse/core/util/hooks';
2
+ export { useCreateOnce, useCreateOnceAsync } from '@jbrowse/core/util/hooks';
3
+ /**
4
+ * Async `useCreateOnce` + `useDestroyOnUnmount`: undefined until the engine is
5
+ * ready. An engine resolving after the final unmount is destroyed on arrival
6
+ * instead of orphaning its worker pool and autoruns.
7
+ */
8
+ export declare function useAsyncEngineLifecycle<T extends EmbeddedRoot>(create: () => Promise<T>): T | undefined;
3
9
  /**
4
10
  * Destroy an engine when its component really unmounts — terminating its RPC
5
11
  * worker threads and stopping its autoruns.
@@ -1,10 +1,32 @@
1
- import { useFinalUnmount } from '@jbrowse/core/util/hooks';
1
+ import { useRef } from 'react';
2
+ import { useCreateOnceAsync, useFinalUnmount } from '@jbrowse/core/util/hooks';
2
3
  import { destroyViewState } from './destroyViewState.js';
3
4
  // `useCreateOnce` is core's, and is re-exported rather than wrapped: an engine
4
5
  // is only the most expensive thing StrictMode's double-invoked state
5
6
  // initializer can build twice, not the only one. The faceted track selector's
6
7
  // MST model hit the same trap in a plugin, which cannot reach this package.
7
- export { useCreateOnce } from '@jbrowse/core/util/hooks';
8
+ export { useCreateOnce, useCreateOnceAsync } from '@jbrowse/core/util/hooks';
9
+ /**
10
+ * Async `useCreateOnce` + `useDestroyOnUnmount`: undefined until the engine is
11
+ * ready. An engine resolving after the final unmount is destroyed on arrival
12
+ * instead of orphaning its worker pool and autoruns.
13
+ */
14
+ export function useAsyncEngineLifecycle(create) {
15
+ const unmounted = useRef(false);
16
+ const engine = useCreateOnceAsync(() => create().then(built => {
17
+ if (unmounted.current) {
18
+ destroyViewState(built);
19
+ }
20
+ return built;
21
+ }));
22
+ useFinalUnmount(() => {
23
+ unmounted.current = true;
24
+ if (engine) {
25
+ destroyViewState(engine);
26
+ }
27
+ });
28
+ return engine;
29
+ }
8
30
  /**
9
31
  * Destroy an engine when its component really unmounts — terminating its RPC
10
32
  * worker threads and stopping its autoruns.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/product-core",
3
- "version": "5.0.0-beta.1",
3
+ "version": "5.0.0-beta.2",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "JBrowse 2 code shared between products but not used by plugins",
@@ -26,11 +26,12 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "@jbrowse/mobx-state-tree": "^6.5.1",
29
- "@mui/icons-material": "^9.3.1",
30
- "@mui/material": "^9.3.1",
29
+ "@mui/icons-material": "^9.4.0",
30
+ "@mui/material": "^9.4.0",
31
31
  "mobx": "^7.0.3",
32
32
  "mobx-react": "^10.0.2",
33
- "@jbrowse/core": "^5.0.0-beta.1"
33
+ "@jbrowse/core": "^5.0.0-beta.2",
34
+ "@jbrowse/render-core": "^5.0.0-beta.2"
34
35
  },
35
36
  "peerDependencies": {
36
37
  "react": ">=19.0.0",