diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs index 3a7d341..88bb908 100644 --- a/scripts/test-client.mjs +++ b/scripts/test-client.mjs @@ -131,7 +131,19 @@ const statefulReact = { return slots[i] }, useEffect: () => {}, - useRef: (init) => ({ current: init }), + useRef(init) { + // Like React: the SAME ref object is returned on every render (stored + // in the fiber slot), so tree ref props and effect closures agree. + const i = hookCursor++ + if (currentHooks[i] === undefined) currentHooks[i] = { ref: { current: init } } + return currentHooks[i].ref + }, + useLayoutEffect(fn) { + // Store the callback in a hook slot so tests can drive the layout effect. + const i = hookCursor++ + if (currentHooks[i] === undefined) currentHooks[i] = { effect: fn } + return currentHooks[i] + }, } const requireStateful = (spec) => { assert.equal(spec, 'react') @@ -387,4 +399,24 @@ ctxSlots[0][1](snapshot) // restore tr = renderView() assert.equal(byClass(tr, 'lc-bar').length, 4, 'snapshot restored') -console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history)') +// ---- default anchor: the newest bars sit at the right edge ---- +const scrollNode = byClass(tr, 'lc-chart-scroll')[0] +const scrollEl = { scrollLeft: 0, clientWidth: 120, scrollWidth: 800 } +scrollNode.args[1].ref.current = scrollEl // attach a fake layout element +const trendKey = [...hookStates.keys()].find(k => k.includes('TrendChart')) +assert.ok(trendKey, 'TrendChart fiber registered') +const layoutEffectSlot = hookStates.get(trendKey).find(s => s && typeof s.effect === 'function') +assert.ok(layoutEffectSlot, 'chart layout effect captured') +layoutEffectSlot.effect() +assert.equal(scrollEl.scrollLeft, 800, 'initial layout anchors at the right (newest bars)') +scrollEl.scrollLeft = 200 +layoutEffectSlot.effect() +assert.equal(scrollEl.scrollLeft, 200, 'scrolling away from the end is respected') +scrollEl.scrollLeft = 780 +layoutEffectSlot.effect() +assert.equal(scrollEl.scrollLeft, 800, 'stays anchored at the end while near it') +tr = renderView() +assert.equal(byClass(tr, 'lc-chart-fade-l').length, 1, 'left fade shown once anchored at the newest bars') +assert.equal(byClass(tr, 'lc-chart-fade-r').length, 0, 'no right fade at the end') + +console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default)') diff --git a/src/client.cts b/src/client.cts index 45c334e..bd88c64 100644 --- a/src/client.cts +++ b/src/client.cts @@ -408,20 +408,29 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re grp.count++ } - // Stick to the newest bars unless the user scrolled away from the end, - // and keep the edge fades in sync with the scroll position. + // Default anchor: the newest bars at the RIGHT edge. The first layout + // after mount scrolls unconditionally; afterwards the chart only sticks + // to the end while the user is already near it (scrolling away is + // respected). useLayoutEffect avoids a first-paint flash at the left. + // Edge fades stay in sync with the scroll position. const scrollRef = React.useRef(null) + const scrolledOnce = React.useRef(false) const [edges, setEdges] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false }) const updateEdges = (el: HTMLDivElement): void => { const left = el.scrollLeft > 4 const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 4 setEdges(prev => (prev.left === left && prev.right === right) ? prev : { left, right }) } - React.useEffect(() => { + React.useLayoutEffect(() => { const el = scrollRef.current if (el === null) return + if (!scrolledOnce.current) { + scrolledOnce.current = true + el.scrollLeft = el.scrollWidth + } else if (el.scrollLeft + el.clientWidth >= el.scrollWidth - 24) { + el.scrollLeft = el.scrollWidth + } updateEdges(el) - if (el.scrollLeft + el.clientWidth >= el.scrollWidth - 24) el.scrollLeft = el.scrollWidth }) return h('div', { className: 'lc-chartrow' }, @@ -669,6 +678,9 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re ? h('div', { className: 'lc-empty' }, t('trend.empty')) : h('div', null, h(TrendChart, { + // Remount per session: switching sessions re-anchors the chart + // at the newest bars instead of inheriting stale scroll state. + key: sessionId, // The host caps the log at 160 requests; render them ALL so // earlier turns/steps stay reachable via horizontal scroll. requests: displayRequests,