@@ -718,8 +718,21 @@ export function TrajectoryViewer({
718718 const stepRefs = useRef < ( HTMLDivElement | null ) [ ] > ( [ ] ) ;
719719 const stepReset = useRef < string | null > ( null ) ;
720720 // The step a #step-<id> address asked for, held here once the address has
721- // been relieved of it (see the capture effect below).
722- const [ pendingStep , setPendingStep ] = useState < number | null > ( null ) ;
721+ // been relieved of it (see the capture effect below), TAGGED WITH THE TRIAL
722+ // it arrived for. The tag is the correctness property, not the reset below:
723+ // clearing on a trial switch is a state update, so the honour effect still
724+ // runs this flush with the old step, and re-runs because `trajectory` just
725+ // changed -- handing the step being left to the trial arriving. With the
726+ // tag, honouring is conditional on the trial matching, so no ordering
727+ // between the two effects can apply a step to the wrong run.
728+ const [ pendingStep , setPendingStep ] = useState < {
729+ trial : string ;
730+ step : number ;
731+ } | null > ( null ) ;
732+ // Read by the capture effect, which mounts once and would otherwise close
733+ // over the mount-time trial for every later hashchange.
734+ const trialIdRef = useRef ( trialId ) ;
735+ trialIdRef . current = trialId ;
723736
724737 // Reset expanded steps and search when switching to a different trial
725738 useEffect ( ( ) => {
@@ -763,7 +776,7 @@ export function TrajectoryViewer({
763776 const take = ( ) => {
764777 const m = / ^ # s t e p - ( \d + ) $ / . exec ( window . location . hash ) ;
765778 if ( ! m ) return ;
766- setPendingStep ( Number ( m [ 1 ] ) ) ;
779+ setPendingStep ( { trial : trialIdRef . current , step : Number ( m [ 1 ] ) } ) ;
767780 const spent = `${ window . location . pathname } ${ window . location . search } ` ;
768781 window . history . replaceState ( window . history . state , "" , spent ) ;
769782 } ;
@@ -878,19 +891,23 @@ export function TrajectoryViewer({
878891 useEffect ( ( ) => {
879892 const steps = trajectory ?. steps ;
880893 if ( pendingStep === null || ! steps ?. length ) return ;
894+ // Not this trial's step: leave it alone. It belongs to the run the reader
895+ // left, and the reset effect clears it on the next render.
896+ if ( pendingStep . trial !== trialId ) return ;
897+ const step = pendingStep . step ;
881898 setPendingStep ( null ) ;
882- const idx = stepIdToIndex ( pendingStep ) ;
899+ const idx = stepIdToIndex ( step ) ;
883900 if ( idx >= 0 ) {
884901 setDeepLinkError ( null ) ;
885902 handleStepClick ( idx ) ;
886- } else if ( steps . some ( ( s ) => Number ( s . step_id ) === pendingStep ) ) {
903+ } else if ( steps . some ( ( s ) => Number ( s . step_id ) === step ) ) {
887904 // Present but empty: the list never draws it, so expanding the item
888905 // would scroll to nothing.
889- setDeepLinkError ( `Step ${ pendingStep } is empty and is not shown.` ) ;
906+ setDeepLinkError ( `Step ${ step } is empty and is not shown.` ) ;
890907 } else {
891- setDeepLinkError ( `Step ${ pendingStep } is not in this trajectory.` ) ;
908+ setDeepLinkError ( `Step ${ step } is not in this trajectory.` ) ;
892909 }
893- } , [ pendingStep , trajectory , handleStepClick , stepIdToIndex ] ) ;
910+ } , [ pendingStep , trialId , trajectory , handleStepClick , stepIdToIndex ] ) ;
894911
895912 if ( isLoading ) {
896913 return (
0 commit comments