Skip to content

[Bug] Maximum update depth exceeded, infinite re-render loop in VizarrViewer #101

Description

@davehorsfall

Describe the bug

VizarrViewerComponent creates a new jotai atom (viewStateAtomWithEffect) directly inside the component function body on every render. This atom is passed as the value of ViewStateContext.Provider, so its reference changes on every render, causing all context consumers (Viewer, LayerFitToViewportButton, etc.) to re-render. In Viewer.tsx, setViewState changing reference causes resetViewState (via useCallback) to be recreated, which triggers a useEffect that calls setViewState, perpetuating the loop. React terminates this with:

Error: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.

To Reproduce

  1. Render <VizarrViewer sources={["..."]} onViewStateChange={fn} />
  2. Open the browser console
  3. Observe "Maximum update depth exceeded" immediately on load

Expected behavior

The viewer loads and renders the image without triggering an infinite re-render loop.

Root cause

// BUG: creates a new atom instance on every render
const viewStateAtomWithEffect: PrimitiveAtom<ViewState | null> = atom(
  (get) => get(viewStateAtom),
  (get, set, update) => { ... onViewStateChange?.(...) ... }
);

A jotai atom created inside a component body is a new object reference on every render. Passing it to Context.Provider value={...} invalidates the context for all consumers on every render.

Fix

Stabilise the atom with React.useState's factory form (runs once on mount), and keep the onViewStateChange callback current via React.useRef:

const onViewStateChangeRef = React.useRef(onViewStateChange);
React.useLayoutEffect(() => {
  onViewStateChangeRef.current = onViewStateChange;
}, [onViewStateChange]);

const [viewStateAtomWithEffect] = React.useState<PrimitiveAtom<ViewState | null>>(() =>
  atom(
    (get) => get(viewStateAtom),
    (get, set, update) => {
      const viewState = typeof update === "function" ? update(get(viewStateAtom)) : update;
      if (viewState) {
        onViewStateChangeRef.current?.({ target: viewState.target, zoom: viewState.zoom });
        set(viewStateAtom, update);
      }
    },
  )
);

Additional context

Issue #89 was originally reported against this bug. PR #95 references it as fixed but additional work appears to be needed. This issue can track.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions