JBrowse 2 · Circular Genome View examples

Put the session in the URL

Serialize the session with encodeSession and restore it with decodeSession, for a sharable link.

encodeSession serializes the live session — open tracks, rotation, everything the user changed — into a compact URL-safe string, and decodeSession turns one back into a snapshot for the session option:

const param = new URLSearchParams(location.hash.slice(1)).get('session')
const state = createViewState({
  assembly,
  tracks,
  session: param ? await decodeSession(param) : undefined,
})
  • session vs defaultSession fill the same slot but are checked differently. defaultSession is validated against the session model’s shape, which suits one you wrote; a decoded session’s shape is only known at runtime, so it goes in session and is checked as it is applied.
  • Use the hash fragment, not the query string. It never reaches the server, so a long session can’t come back as an HTTP 414.
  • Only the session travels. The receiving page supplies its own assembly and tracks; the snapshot names them. The encoding is JBrowse Web’s own, so the encoded-… value its ?session= accepts is the same one.
  • encodeSession does more than getSnapshot: display settings a user picked up from a promoted display-type default live in their browser rather than the session, and it folds those in first.

To keep a session for one browser rather than share it, write the same snapshot to localStorage; onSnapshot(state.session, …) tells you when it changed.

View source — 129 lines
import { useEffect, useState } from 'react'

import { useCreateOnce } from '@jbrowse/core/util/hooks'
import {
  JBrowseCircularGenomeView,
  createViewState,
  decodeSession,
  encodeSession,
} from '@jbrowse/react-circular-genome-view2'

type ViewState = ReturnType<typeof createViewState>

const assembly = {
  name: 'volvox',
  sequence: {
    adapter: {
      type: 'TwoBitAdapter',
      uri: 'https://jbrowse.org/genomes/volvox/volvox.2bit',
    },
  },
}

const tracks = [
  {
    type: 'VariantTrack',
    trackId: 'volvox_sv_test',
    name: 'volvox structural variant test',
    category: ['VCF'],
    assemblyNames: ['volvox'],
    adapter: {
      type: 'VcfTabixAdapter',
      uri: 'https://jbrowse.org/code/jb2/main/test_data/volvox/volvox.dup.vcf.gz',
    },
  },
]

// The session goes in the hash fragment rather than the query string. The
// fragment is never sent to the server, so a long session can't overflow the
// request line (HTTP 414) — the same reason JBrowse Web keeps its own there.
function readSessionParam() {
  return (
    new URLSearchParams(window.location.hash.slice(1)).get('session') ??
    undefined
  )
}

function writeSessionParam(value: string) {
  const params = new URLSearchParams(window.location.hash.slice(1))
  params.set('session', value)
  window.history.replaceState(null, '', `#${params.toString()}`)
}

export default function SessionInUrl() {
  // undefined while the URL is being decoded, so the view isn't built with an
  // empty session first and then replaced. With no session to decode there is
  // nothing to wait for, so build the normal starting state right here rather
  // than rendering nothing and setting it from the effect below.
  //
  // `useCreateOnce`, not a `useState` initializer, and this is the one page on
  // the site where the difference has teeth: React double-invokes an
  // initializer under StrictMode and throws the SECOND result away, so an
  // engine built in one is orphaned per mount — alive, fetching, and with
  // nothing left holding it. The sibling examples get this from
  // `useCreateViewState`; this one cannot, because whether there is an engine
  // to build at all depends on the URL.
  const initial = useCreateOnce(() =>
    readSessionParam() ? undefined : createViewState({ assembly, tracks }),
  )
  const [state, setState] = useState<ViewState | undefined>(initial)
  const [status, setStatus] = useState('')

  useEffect(() => {
    const param = readSessionParam()
    if (!param) {
      return
    }
    // The same trap one level out: StrictMode runs this effect twice, so
    // without the guard both passes decode and both build an engine, and the
    // first is orphaned the moment the second `setState` lands.
    let cancelled = false
    decodeSession(param)
      .then(session => {
        if (cancelled) {
          return
        }
        // `session` is the slot for a snapshot whose shape is only known at
        // runtime; `defaultSession` is for one you author and want checked
        setState(createViewState({ assembly, tracks, session }))
        setStatus(`restored "${session.name}" from the URL`)
      })
      .catch((e: unknown) => {
        if (cancelled) {
          return
        }
        // a truncated or hand-edited link shouldn't strand the user on a
        // blank view: fall back to the normal starting state and say so
        console.error(e)
        setState(createViewState({ assembly, tracks }))
        setStatus(`could not restore the session in the URL: ${e}`)
      })
    return () => {
      cancelled = true
    }
  }, [])

  return state ? (
    <div>
      <div style={{ padding: 8, fontSize: 13, background: '#8881' }}>
        <button
          type="button"
          onClick={() => {
            // eslint-disable-next-line @typescript-eslint/no-floating-promises
            ;(async () => {
              const encoded = await encodeSession(state)
              writeSessionParam(encoded)
              setStatus(
                `saved to the URL (${encoded.length} chars) — copy the address bar, or reload to restore it`,
              )
            })()
          }}
        >
          Save this view to the URL
        </button>{' '}
        {status || 'show a track or rotate the view, then save'}
      </div>
      <JBrowseCircularGenomeView viewState={state} />
    </div>
  ) : null
}