ConstThe spinner props (see MoleculeSpinnerProps).
The rotating structure, or the supplied fallback.
<MoleculeSpinner
query={executedQuery}
fallback={<IconSpinner><img src="/static/images/cubane-loader.png" /></IconSpinner>}
/>
export const MoleculeSpinner = memo(function MoleculeSpinner(props: MoleculeSpinnerProps) {
const {
query,
size = DEFAULT_SIZE,
spinSeconds = DEFAULT_SPIN_SECONDS,
atomScale,
showHydrogens = true,
pending,
fallback,
onStatusChange,
} = props;
const [molecule, setMolecule] = useState<Molecule | undefined>();
const [unavailable, setUnavailable] = useState(false);
const [sceneFailed, setSceneFailed] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const handleRef = useRef<MoleculeSceneHandle | undefined>(undefined);
// Read through refs inside effects that must not re-run when these change.
const statusRef = useRef(onStatusChange);
statusRef.current = onStatusChange;
const sizeRef = useRef(size);
sizeRef.current = size;
const spinRef = useRef(spinSeconds);
spinRef.current = spinSeconds;
useEffect(() => {
let cancelled = false;
setMolecule(undefined);
setUnavailable(false);
setSceneFailed(false);
if (query.trim() === '') {
statusRef.current?.({ state: 'idle' });
return;
}
statusRef.current?.({ state: 'loading' });
const startedAt = performance.now();
const resolve = async () => {
const resolved = await resolveMolecule(query);
if (cancelled) return;
if (!resolved) {
setUnavailable(true);
statusRef.current?.({ state: 'unavailable', elapsedMs: performance.now() - startedAt });
return;
}
const drawn = showHydrogens ? resolved.molecule : stripHydrogens(resolved.molecule);
setMolecule(drawn);
statusRef.current?.({
state: 'ready',
cid: resolved.cid,
recordType: resolved.recordType,
atomCount: drawn.atoms.length,
bondCount: drawn.bonds.length,
elapsedMs: performance.now() - startedAt,
});
};
void resolve();
return () => {
cancelled = true;
};
}, [query, showHydrogens]);
useEffect(() => {
const canvas = canvasRef.current;
if (!molecule || !canvas) return;
let cancelled = false;
const build = async () => {
try {
const { createMoleculeScene } = await import('@/utils/molecule/scene');
if (cancelled) return;
handleRef.current = createMoleculeScene(canvas, molecule, {
size: sizeRef.current,
spinSeconds: spinRef.current,
atomScale,
reducedMotion: prefersReducedMotion(),
});
} catch (error) {
// WebGL can be unavailable entirely (blocklisted GPU, too many live contexts,
// a non-rendering test environment). Drop back to the caller's fallback rather
// than leaving an empty canvas or an unhandled rejection.
console.error('Could not render molecule; falling back:', error);
if (cancelled) return;
setSceneFailed(true);
statusRef.current?.({ state: 'unavailable' });
}
};
void build();
return () => {
cancelled = true;
handleRef.current?.dispose();
handleRef.current = undefined;
};
}, [molecule, atomScale]);
useEffect(() => {
handleRef.current?.setSize(size);
}, [size]);
useEffect(() => {
handleRef.current?.setSpinSeconds(spinSeconds);
}, [spinSeconds]);
// Derived synchronously rather than from the status callback, so the very first render
// with a query already shows `pending` — there is no frame where `fallback` flashes.
if (molecule && !sceneFailed) {
return (
<canvas
ref={canvasRef}
data-testid="molecule-spinner"
style={{ width: size, height: size, display: 'block' }}
/>
);
}
if (query.trim() === '' || unavailable || sceneFailed) return <>{fallback}</>;
return <>{pending}</>;
});
Renders the molecule a search query refers to as a slowly rotating ball-and-stick model, as a drop-in replacement for a generic loading spinner.
The structure comes from PubChem: the query is resolved to a CID, and its 3D conformer fetched — falling back to the flat 2D record for the ionic and inorganic compounds PubChem computes no conformer for, which a planar wobble keeps legible. When nothing resolves,
fallbackis rendered instead, so the caller always has something on screen.three.js is loaded through a dynamic import, so the WebGL renderer stays out of the initial bundle and only downloads once a structure is ready to draw. The component is memoized and holds its scene in a ref: the surrounding loading UI re-renders constantly as results arrive, and rebuilding the scene on each of those would restart the animation and leak WebGL contexts.