From 5f7b403cef343f44a15f861f8e6620c7a5c0f026 Mon Sep 17 00:00:00 2001 From: Isabella-Mitchell Date: Wed, 1 Apr 2026 17:45:50 +0100 Subject: [PATCH 1/9] WS-2379: Initial commmit --- src/app/hooks/useScrollToLinkable/index.ts | 41 +++++++++++++++++++ .../[service]/live/[id]/LivePageLayout.tsx | 4 +- .../[service]/live/[id]/Stream/index.tsx | 8 ++++ .../live/[id]/[[...variant]].page.tsx | 3 ++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 src/app/hooks/useScrollToLinkable/index.ts diff --git a/src/app/hooks/useScrollToLinkable/index.ts b/src/app/hooks/useScrollToLinkable/index.ts new file mode 100644 index 00000000000..91fc79b8e61 --- /dev/null +++ b/src/app/hooks/useScrollToLinkable/index.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from 'react'; + +const scrollIntoView = (componentToScrollTo, hasScrolled, isReducedMotion) => { + if (componentToScrollTo && !hasScrolled.current) { + componentToScrollTo.scrollIntoView({ + behavior: isReducedMotion ? 'auto' : 'smooth', + }); + setTimeout(() => { + // eslint-disable-next-line no-param-reassign + componentToScrollTo.tabIndex = '-1'; + componentToScrollTo.focus(); + // eslint-disable-next-line no-param-reassign + hasScrolled.current = true; + }, 1000); + } +}; + +// to do - minifiy to remove scroll +const useScrollToLinkable = ({ elementId, isReducedMotion }) => { + const hasScrolled = useRef(null); + + useEffect(() => { + hasScrolled.current = null; + }, [elementId]); + + useEffect(() => { + let timer; + + if (elementId) { + timer = setTimeout(() => { + const component = document.getElementById(elementId); // to tidy + + scrollIntoView(component, hasScrolled, isReducedMotion); + }, 800); + } + return () => clearTimeout(timer); + }, [elementId, hasScrolled, isReducedMotion]); + + return { hasScrolled }; +}; +export default useScrollToLinkable; diff --git a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx index 71d8f0a4669..9611f87554c 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx @@ -64,9 +64,10 @@ export type ComponentProps = { interface LivePageProps extends ComponentProps { assetId?: string | null; + postTest: string | null; // maybe should be ? } -const LivePage = ({ pageData, assetId }: LivePageProps) => { +const LivePage = ({ pageData, assetId, postTest }: LivePageProps) => { const { lang, translations, defaultImage, brandName } = use(ServiceContext); const { canonicalNonUkLink } = use(RequestContext); const { enabled: livePagePollingEnabled } = useToggle('livePagePolling'); @@ -192,6 +193,7 @@ const LivePage = ({ pageData, assetId }: LivePageProps) => { setIsFirstPostVisible={setIsFirstPostVisible} streamRef={streamRef} applyPendingUpdate={applyPendingUpdate} + postTest={postTest} /> >; streamRef: ForwardedRef; applyPendingUpdate: () => void; + postTest: string | null; }; const Stream = ({ @@ -28,6 +30,7 @@ const Stream = ({ setIsFirstPostVisible, streamRef, applyPendingUpdate, + postTest, }: Props) => { const { translations: { @@ -35,6 +38,11 @@ const Stream = ({ }, } = use(ServiceContext); + useScrollToLinkable({ + elementId: postTest, + isReducedMotion: true, + }); + const firstPostRef = useRef(null); const [hasShareApi, setHasShareApi] = useState(false); const [hashValue, setHashValue] = useState(''); diff --git a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx index 4b9e21b5a79..28ad905a0c3 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx @@ -40,6 +40,8 @@ export const getServerSideProps: GetServerSideProps = async context => { post: assetId, } = context.query as PageDataParams; + console.log('Live page query params assetId', assetId); + const variant = deriveVariant(variantFromUrl); if (!isValidPageNumber(page)) { @@ -111,6 +113,7 @@ export const getServerSideProps: GetServerSideProps = async context => { status: data.status, timeOnServer: Date.now(), // TODO: check if needed? variant, + postTest: assetId || null, }, }; }; From 33be3e0e83287d29f85c980e1952546057d428ba Mon Sep 17 00:00:00 2001 From: Isabella-Mitchell Date: Thu, 2 Apr 2026 09:04:24 +0100 Subject: [PATCH 2/9] WS-2379: [copilot] adds hook unit tests --- .../hooks/useScrollToLinkable/index.test.ts | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/app/hooks/useScrollToLinkable/index.test.ts diff --git a/src/app/hooks/useScrollToLinkable/index.test.ts b/src/app/hooks/useScrollToLinkable/index.test.ts new file mode 100644 index 00000000000..9eca3418e1f --- /dev/null +++ b/src/app/hooks/useScrollToLinkable/index.test.ts @@ -0,0 +1,106 @@ +import { + act, + renderHook, +} from '#app/components/react-testing-library-with-providers'; +import useScrollToLinkable from '.'; + +const createElementWithId = id => { + const element = document.createElement('article'); + element.setAttribute('id', id); + document.body.appendChild(element); + + return element; +}; + +describe('useScrollToLinkable', () => { + const scrollIntoViewMock = jest.fn(); + const focusMock = jest.fn(); + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + document.body.innerHTML = ''; + + window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; + window.HTMLElement.prototype.focus = focusMock; + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('should set the element as focusable and focus it after scrolling', () => { + const element = createElementWithId('post-123'); + + const { result } = renderHook(() => + useScrollToLinkable({ elementId: 'post-123', isReducedMotion: true }), + ); + + act(() => { + jest.advanceTimersByTime(1800); + }); + + expect(element.tabIndex).toBe(-1); + expect(focusMock).toHaveBeenCalledTimes(1); + expect(result.current.hasScrolled.current).toBe(true); + }); + + it('should scroll to the provided element with smooth behavior by default', () => { + createElementWithId('post-123'); + + renderHook(() => + // @ts-expect-error - initentional missing prop for this test + useScrollToLinkable({ elementId: 'post-123' }), + ); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(800); + }); + + expect(scrollIntoViewMock).toHaveBeenCalledTimes(1); + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth' }); + }); + + it('should use auto behavior when reduced motion is enabled', () => { + createElementWithId('post-123'); + + renderHook(() => + useScrollToLinkable({ elementId: 'post-123', isReducedMotion: true }), + ); + + act(() => { + jest.advanceTimersByTime(800); + }); + + expect(scrollIntoViewMock).toHaveBeenCalledTimes(1); + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'auto' }); + }); + + it('should not scroll when elementId is not provided', () => { + // @ts-expect-error - initentional missing elementId for this test + renderHook(() => useScrollToLinkable({})); + + act(() => { + jest.runAllTimers(); + }); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + expect(focusMock).not.toHaveBeenCalled(); + }); + + it('should not scroll when the element is not in the DOM', () => { + renderHook(() => + useScrollToLinkable({ elementId: 'missing-id', isReducedMotion: true }), + ); + + act(() => { + jest.runAllTimers(); + }); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + expect(focusMock).not.toHaveBeenCalled(); + }); +}); From 591487dd4b2bd716286aa1c59a8fe81d91bc4467 Mon Sep 17 00:00:00 2001 From: Isabella-Mitchell Date: Thu, 2 Apr 2026 09:04:54 +0100 Subject: [PATCH 3/9] WS-2379: Tidies --- ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx | 6 +++--- ws-nextjs-app/pages/[service]/live/[id]/Stream/index.tsx | 6 +++--- .../pages/[service]/live/[id]/[[...variant]].page.tsx | 6 ++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx index 9611f87554c..a707674f6a6 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx @@ -64,10 +64,10 @@ export type ComponentProps = { interface LivePageProps extends ComponentProps { assetId?: string | null; - postTest: string | null; // maybe should be ? + postToScrollTo?: string | null; } -const LivePage = ({ pageData, assetId, postTest }: LivePageProps) => { +const LivePage = ({ pageData, assetId, postToScrollTo }: LivePageProps) => { const { lang, translations, defaultImage, brandName } = use(ServiceContext); const { canonicalNonUkLink } = use(RequestContext); const { enabled: livePagePollingEnabled } = useToggle('livePagePolling'); @@ -193,7 +193,7 @@ const LivePage = ({ pageData, assetId, postTest }: LivePageProps) => { setIsFirstPostVisible={setIsFirstPostVisible} streamRef={streamRef} applyPendingUpdate={applyPendingUpdate} - postTest={postTest} + {...(postToScrollTo && { postToScrollTo })} /> >; streamRef: ForwardedRef; applyPendingUpdate: () => void; - postTest: string | null; + postToScrollTo?: string | null; }; const Stream = ({ @@ -30,7 +30,7 @@ const Stream = ({ setIsFirstPostVisible, streamRef, applyPendingUpdate, - postTest, + postToScrollTo, }: Props) => { const { translations: { @@ -39,7 +39,7 @@ const Stream = ({ } = use(ServiceContext); useScrollToLinkable({ - elementId: postTest, + elementId: postToScrollTo, isReducedMotion: true, }); diff --git a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx index 28ad905a0c3..e63a44e0a90 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx @@ -37,11 +37,9 @@ export const getServerSideProps: GetServerSideProps = async context => { variant: variantFromUrl, renderer_env: rendererEnv, page = '1', - post: assetId, + postToScrollTo: assetId, } = context.query as PageDataParams; - console.log('Live page query params assetId', assetId); - const variant = deriveVariant(variantFromUrl); if (!isValidPageNumber(page)) { @@ -113,7 +111,7 @@ export const getServerSideProps: GetServerSideProps = async context => { status: data.status, timeOnServer: Date.now(), // TODO: check if needed? variant, - postTest: assetId || null, + postToScrollTo: assetId || null, }, }; }; From 6a49fa5e5fc42858ffa5ba0d75411cca207aaa8d Mon Sep 17 00:00:00 2001 From: Isabella-Mitchell Date: Thu, 2 Apr 2026 09:11:41 +0100 Subject: [PATCH 4/9] WS-2379: Fix --- ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx index e63a44e0a90..ad90bb3fafe 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx @@ -37,7 +37,7 @@ export const getServerSideProps: GetServerSideProps = async context => { variant: variantFromUrl, renderer_env: rendererEnv, page = '1', - postToScrollTo: assetId, + post: assetId, } = context.query as PageDataParams; const variant = deriveVariant(variantFromUrl); From 315f17a4f741a17eca27115857e0a1e5bcdb7e13 Mon Sep 17 00:00:00 2001 From: Isabella-Mitchell Date: Thu, 2 Apr 2026 09:56:01 +0100 Subject: [PATCH 5/9] WS-2379: Temp, adds console log --- ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx index ad90bb3fafe..2c9891af272 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx @@ -40,6 +40,8 @@ export const getServerSideProps: GetServerSideProps = async context => { post: assetId, } = context.query as PageDataParams; + console.log("I'm the assetId", assetId); + const variant = deriveVariant(variantFromUrl); if (!isValidPageNumber(page)) { From 1375663bf05af706837dcdfc9d5a9fa5f90569e7 Mon Sep 17 00:00:00 2001 From: Isabella-Mitchell Date: Thu, 2 Apr 2026 09:59:17 +0100 Subject: [PATCH 6/9] WS-2379: Temp, adds console log --- src/app/hooks/useScrollToLinkable/index.ts | 2 ++ ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx | 2 ++ ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx | 2 -- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/useScrollToLinkable/index.ts b/src/app/hooks/useScrollToLinkable/index.ts index 91fc79b8e61..a812b81aff6 100644 --- a/src/app/hooks/useScrollToLinkable/index.ts +++ b/src/app/hooks/useScrollToLinkable/index.ts @@ -19,6 +19,8 @@ const scrollIntoView = (componentToScrollTo, hasScrolled, isReducedMotion) => { const useScrollToLinkable = ({ elementId, isReducedMotion }) => { const hasScrolled = useRef(null); + console.log("I'm the elementId in the scroll hook", elementId); + useEffect(() => { hasScrolled.current = null; }, [elementId]); diff --git a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx index a707674f6a6..2689616c6ab 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx @@ -72,6 +72,8 @@ const LivePage = ({ pageData, assetId, postToScrollTo }: LivePageProps) => { const { canonicalNonUkLink } = use(RequestContext); const { enabled: livePagePollingEnabled } = useToggle('livePagePolling'); + console.log("I'm the postToScrollTo in the live page layout", postToScrollTo); + const streamRef = useRef(null); const [isFirstPostVisible, setIsFirstPostVisible] = useState(true); diff --git a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx index 2c9891af272..ad90bb3fafe 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/[[...variant]].page.tsx @@ -40,8 +40,6 @@ export const getServerSideProps: GetServerSideProps = async context => { post: assetId, } = context.query as PageDataParams; - console.log("I'm the assetId", assetId); - const variant = deriveVariant(variantFromUrl); if (!isValidPageNumber(page)) { From 7aae76288f0181d44f6b01d611aeb470c753ca47 Mon Sep 17 00:00:00 2001 From: Isabella-Mitchell Date: Thu, 2 Apr 2026 16:52:54 +0100 Subject: [PATCH 7/9] WS-2379: Tidies --- src/app/hooks/useScrollToLinkable/index.ts | 2 -- ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/app/hooks/useScrollToLinkable/index.ts b/src/app/hooks/useScrollToLinkable/index.ts index a812b81aff6..91fc79b8e61 100644 --- a/src/app/hooks/useScrollToLinkable/index.ts +++ b/src/app/hooks/useScrollToLinkable/index.ts @@ -19,8 +19,6 @@ const scrollIntoView = (componentToScrollTo, hasScrolled, isReducedMotion) => { const useScrollToLinkable = ({ elementId, isReducedMotion }) => { const hasScrolled = useRef(null); - console.log("I'm the elementId in the scroll hook", elementId); - useEffect(() => { hasScrolled.current = null; }, [elementId]); diff --git a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx index 2689616c6ab..a707674f6a6 100644 --- a/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx +++ b/ws-nextjs-app/pages/[service]/live/[id]/LivePageLayout.tsx @@ -72,8 +72,6 @@ const LivePage = ({ pageData, assetId, postToScrollTo }: LivePageProps) => { const { canonicalNonUkLink } = use(RequestContext); const { enabled: livePagePollingEnabled } = useToggle('livePagePolling'); - console.log("I'm the postToScrollTo in the live page layout", postToScrollTo); - const streamRef = useRef(null); const [isFirstPostVisible, setIsFirstPostVisible] = useState(true); From 19360902ba3cb0414024a2c9cdeb429df1a5ede1 Mon Sep 17 00:00:00 2001 From: Isabella-Mitchell Date: Thu, 2 Apr 2026 16:53:36 +0100 Subject: [PATCH 8/9] WS-2379: Tidies --- src/app/hooks/useScrollToLinkable/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/hooks/useScrollToLinkable/index.ts b/src/app/hooks/useScrollToLinkable/index.ts index 91fc79b8e61..780ee0cb541 100644 --- a/src/app/hooks/useScrollToLinkable/index.ts +++ b/src/app/hooks/useScrollToLinkable/index.ts @@ -15,7 +15,6 @@ const scrollIntoView = (componentToScrollTo, hasScrolled, isReducedMotion) => { } }; -// to do - minifiy to remove scroll const useScrollToLinkable = ({ elementId, isReducedMotion }) => { const hasScrolled = useRef(null); From 1c2069a44bef0776614f924e0ef74255182a7a5f Mon Sep 17 00:00:00 2001 From: Isabella-Mitchell Date: Thu, 2 Apr 2026 16:54:15 +0100 Subject: [PATCH 9/9] WS-2379: Tidies --- src/app/hooks/useScrollToLinkable/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/hooks/useScrollToLinkable/index.ts b/src/app/hooks/useScrollToLinkable/index.ts index 780ee0cb541..02a4838e09f 100644 --- a/src/app/hooks/useScrollToLinkable/index.ts +++ b/src/app/hooks/useScrollToLinkable/index.ts @@ -27,7 +27,7 @@ const useScrollToLinkable = ({ elementId, isReducedMotion }) => { if (elementId) { timer = setTimeout(() => { - const component = document.getElementById(elementId); // to tidy + const component = document.getElementById(elementId); scrollIntoView(component, hasScrolled, isReducedMotion); }, 800);