JBrowse 2 · Build Your Own examples

Web workers

Move fetching, parsing and layout off the main thread with one option to createViewState.

One option, one worker

Reference-based CRAM decoding, record parsing and feature layout are real work, and on the main thread every millisecond of it is a millisecond your UI is not repainting. Supplying makeWorkerInstance moves all of it off: the RPC layer switches its default driver, with no defaultDriver config to write. The demo is the stack of tracks from earlier, and that one line is the only difference in its source.

Constructing the worker

RpcWorker is bundler-specific. This site is Astro, so it uses Vite’s ?worker suffix, which bundles a module as a worker entry point and hands back a constructor:

import RpcWorker from '@jbrowse/react-linear-genome-view2/esm/rpcWorker?worker'

On webpack or CRA, import the package’s prebuilt @jbrowse/react-linear-genome-view2/esm/makeWorkerInstance instead. Either way JBrowse wants a function returning a Worker. The RPC worker code-splits, so worker output has to be ES-format rather than the default IIFE (vite: { worker: { format: 'es' } }, set in astro.config.mjs).

What crosses the boundary

Fetching, parsing and layout run on the worker. Rendering does not. That is WebGPU or WebGL on the main thread, and it needs the canvas. The worker is a separate module graph with its own plugin manager, so anything adding an adapter or a renderer has to be registered on both sides.

View source — 275 lines
import { Suspense, useSyncExternalStore } from 'react'

import { SessionPaletteProvider } from '@jbrowse/core/ui/PaletteContext'
import { useCreateOnce, useWidthSetter } from '@jbrowse/core/util/hooks'
import { usePanZoom } from '@jbrowse/core/util/usePanZoom'
import { DisplayUIProvider, TrackOverlaySlot } from '@jbrowse/display-ui'
import { createViewState } from '@jbrowse/react-linear-genome-view2'
// Vite's `?worker` suffix: it bundles the module as a worker entry point and
// hands back a constructor. Astro is a Vite app, so this is the form that works
// here. On webpack or CRA, import the package's prebuilt worker instead --
// `@jbrowse/react-linear-genome-view2/esm/makeWorkerInstance` -- and pass that
// as `makeWorkerInstance` directly.
import RpcWorker from '@jbrowse/react-linear-genome-view2/esm/rpcWorker?worker'
import { observer } from 'mobx-react'

// Every other page on this site parses its data on the main thread. This one
// does not, and the change is one option to `createViewState`.
//
// `makeWorkerInstance` is a function returning a `Worker`. Supply it and the
// RPC layer switches to the web-worker driver on its own: no `defaultDriver`
// config, and nothing else on the page changes. The CRAM below is what makes
// it worth doing -- a pileup at this depth is real reference-based decoding
// and real record parsing per pan, and on the main thread every millisecond of
// it is a millisecond your app's own UI is not repainting.
//
// The rest of the file is the earlier pages: the stack from A stack of tracks,
// the UI provider from Bring your own overlays. Self-contained, like every
// page here, so you can copy the file and run it.

const hg38 = {
  name: 'hg38',
  uri: 'https://jbrowse.org/genomes/GRCh38/fasta/hg38.prefix.fa.gz',
  refNameAliases: {
    uri: 'https://jbrowse.org/genomes/GRCh38/hg38_aliases.txt',
  },
}

const conservationTrack = {
  type: 'QuantitativeTrack',
  trackId: 'hg38_phylop',
  name: 'phyloP 100-way conservation',
  assemblyNames: ['hg38'],
  adapter: {
    type: 'BigWigAdapter',
    uri: 'https://hgdownload.soe.ucsc.edu/goldenpath/hg38/phyloP100way/hg38.phyloP100way.bw',
  },
  displayDefaults: {
    defaultRendering: 'xyplot',
    height: 100,
    color: '#3a7ca5',
  },
}

const featureTrack = {
  type: 'FeatureTrack',
  trackId: 'hg38_genes',
  name: 'RefSeq curated genes',
  assemblyNames: ['hg38'],
  adapter: {
    type: 'Gff3TabixAdapter',
    uri: 'https://jbrowse.org/ucsc/hg38/ncbiRefSeqCurated.gff.gz',
    csi: true,
  },
  displayDefaults: { height: 120 },
}

const alignmentsTrack = {
  type: 'AlignmentsTrack',
  trackId: 'na12878_exome',
  name: 'NA12878 exome reads',
  assemblyNames: ['hg38'],
  adapter: {
    type: 'CramAdapter',
    uri: 'https://s3.amazonaws.com/jbrowse.org/genomes/GRCh38/alignments/NA12878/NA12878.alt_bwamem_GRCh38DH.20150826.CEU.exome.cram',
  },
  displayDefaults: { height: 150 },
}

const trackIds = ['hg38_phylop', 'hg38_genes', 'na12878_exome']

function makeView() {
  const state = createViewState({
    assembly: hg38,
    tracks: [conservationTrack, featureTrack, alignmentsTrack],
    // the whole of it. One worker serves the session, and every adapter's
    // fetching and parsing moves onto it.
    makeWorkerInstance: () => new RpcWorker(),
    init: {
      loc: 'chr17:43,044,295..43,125,364',
      tracks: trackIds,
    },
  })
  const { view } = state.session
  // see the Pan and zoom example: scroll-to-zoom is a session preference, and the
  // pileup below reads the same one to know the plain wheel is spoken for
  view.setScrollZoom(true)
  return { view, session: state.session }
}

type BrowserView = ReturnType<typeof makeView>['view']

// `view.status` is the view's whole lifecycle as one value, so this switches on
// it rather than re-deriving which non-ready state it is out of `error` and
// `loadingMessage`. Two of the four states are easy to leave out and both fail
// silently: a 404 on a sequence file is `error` -- a state on the model rather
// than a throw, so there is no console error either -- and a view nothing has
// navigated yet is `noRegions`, which the older `view.ready` getter reports as
// ready, so gating on that one draws an empty box that never fills. The Loading
// and error states page draws the long form of this, and has a radio that
// breaks the assembly on purpose.
const ViewStatus = observer(function ViewStatus({
  view,
}: {
  view: BrowserView
}) {
  const { status } = view
  if (status.type === 'ready') {
    return null
  }
  return (
    <div
      role={status.type === 'error' ? 'alert' : 'status'}
      style={{ padding: '10px 12px', fontSize: '0.85rem', opacity: 0.75 }}
    >
      {status.type === 'error'
        ? `Could not load: ${status.error instanceof Error ? status.error.message : String(status.error)}`
        : status.type === 'loading'
          ? status.message
          : 'Nothing to show yet'}
    </div>
  )
})

const TrackRow = observer(function TrackRow({
  view,
  trackId,
}: {
  view: BrowserView
  trackId: string
}) {
  // `view.getTrack(id)`, not a scan of `view.tracks` comparing
  // `configuration.trackId` by hand: the view keeps a map for exactly this. The
  // guard stays -- a ready `view.status` says the view can draw, not that your
  // track is instantiated yet.
  const track = view.getTrack(trackId)
  if (!track) {
    return null
  }
  const display = track.activeDisplay
  const { RenderingComponent } = display
  // `TrackOverlaySlot`, not a plain sized div. A display draws floating chrome
  // of its own -- a colour key, a corner control, the loading and error states
  // -- and `contain: strict` seals that into its own stacking context, where
  // nothing you paint over the stack can be out-z-indexed. The slot is the node
  // it portals into, mounted beside the sandbox, and it is what JBrowse's own
  // track container mounts. See the Track settings page.
  return (
    <TrackOverlaySlot zIndex={3} style={{ height: display.height }}>
      <div style={{ position: 'absolute', inset: 0, contain: 'strict' }}>
        <Suspense fallback={null}>
          <RenderingComponent
            model={display}
            onHorizontalScroll={view.horizontalScroll}
          />
        </Suspense>
      </div>
    </TrackOverlaySlot>
  )
})

// The box `usePanZoom`'s handlers go on -- see the Pan and zoom page for what
// each property is doing, and for the one the hook writes itself.
const viewport: React.CSSProperties = {
  position: 'relative',
  overflow: 'hidden',
  cursor: 'grab',
}

/**
 * The parts above, composed: a measured, pan/zoomable column of tracks and
 * nothing else. This is the smallest thing that is recognisably a genome
 * browser.
 */
const TrackStack = observer(function TrackStack({
  view,
}: {
  view: BrowserView
}) {
  const ref = useWidthSetter(view)
  const { containerProps } = usePanZoom(ref, view)
  return (
    <div ref={ref} {...containerProps} style={viewport}>
      {view.status.type === 'ready' ? (
        trackIds.map(trackId => (
          <TrackRow key={trackId} view={view} trackId={trackId} />
        ))
      ) : (
        <ViewStatus view={view} />
      )}
    </div>
  )
})

// A display paints no background of its own -- its labels are drawn straight
// onto whatever is behind them, so light-theme text on a dark page is near-black
// on near-black. This is the page's own answer to "which mode am I in".
function readSiteMode(): 'light' | 'dark' {
  const chosen = document.documentElement.dataset.theme
  if (chosen === 'light' || chosen === 'dark') {
    return chosen
  }
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light'
}

// The two places that answer can change from. The site's toggle writes an
// attribute on <html> and the OS preference arrives as a media query, and
// either can move without the other, so both are watched.
function watchSiteMode(onChange: () => void) {
  const observer = new MutationObserver(onChange)
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ['data-theme'],
  })
  const media = window.matchMedia('(prefers-color-scheme: dark)')
  media.addEventListener('change', onChange)
  return () => {
    observer.disconnect()
    media.removeEventListener('change', onChange)
  }
}

/**
 * Follow whatever the page around this demo is themed as. All of this is the
 * *host's* half, and yours will look nothing like it -- swap it for however
 * your app already knows it is in dark mode.
 *
 * `useSyncExternalStore`, not `useState` + `useEffect`: the mode lives outside
 * React, so this reads it *during* render rather than publishing one value and
 * correcting it a paint later. The third argument is the server snapshot, for
 * a reader pasting this into a framework that prerenders.
 *
 * JBrowse's half is one mount, `SessionPaletteProvider` below. It writes the
 * config slot that *both* halves of the rendering derive from -- the palette
 * React draws with, and the theme shipped to the worker that bakes feature
 * labels into the image. `PaletteProvider` on its own is the near miss: it
 * colours React and leaves those baked labels in the old mode.
 */
function useSiteMode() {
  return useSyncExternalStore(
    watchSiteMode,
    readSiteMode,
    () => 'light' as const,
  )
}

// JBrowse's stock displays read a palette to colour their own *content*: the
// feature display wants a highlight colour, the CDS renderer wants its reading
// frames. That is a palette of colour strings, not a UI toolkit, so it arrives
// through `SessionPaletteProvider` and Material UI is not involved.

const RunItInAWorker = observer(function RunItInAWorker() {
  const { view, session } = useCreateOnce(makeView)
  const mode = useSiteMode()
  return (
    <SessionPaletteProvider session={session} mode={mode}>
      <DisplayUIProvider>
        <TrackStack view={view} />
      </DisplayUIProvider>
    </SessionPaletteProvider>
  )
})

export default RunItInAWorker