1+ import { useMemo , useReducer } from 'react' ;
12import { vi , Mock } from 'vitest' ;
23import { MemoryRouter } from 'react-router' ;
3- import { waitFor , within , screen } from '@testing-library/react' ;
4+ import { waitFor , screen , fireEvent } from '@testing-library/react' ;
45import '@testing-library/jest-dom/vitest' ;
56
67import StartPage from './StartPage' ;
@@ -11,10 +12,19 @@ import { FilterContext } from '../../context/FilterContext';
1112import { useLocaleContent } from '../../util/hooks/useLocaleContent' ;
1213import { renderWithProviders } from '../../util/testing-utils' ;
1314import * as startPageRender from '../../util/startPageRender' ;
14- import * as configModule from '../../util/config/getConfig' ;
1515import { getConfig } from '../../util/config/getConfig' ;
1616import { mockedConfig } from '../../../../test/setupTests' ;
1717
18+ // Note: `vi.mock` calls are hoisted by Vitest. We declare this mock early so that when `StartPage`
19+ // (which imports `createTableListSEO`) is evaluated, it receives the mocked implementation.
20+ vi . mock ( '../../util/seo/tableListSEO' , ( ) => {
21+ return {
22+ createTableListSEO : vi . fn ( ( ) => < div data-testid = "table-list-seo" /> ) ,
23+ } ;
24+ } ) ;
25+
26+ import { createTableListSEO } from '../../util/seo/tableListSEO' ;
27+
1828// Mock screen size via useApp with mutable flags we can control per test
1929let mockIsMobile = false ;
2030let mockIsTablet = false ;
@@ -151,7 +161,6 @@ const baseState: StartPageState = {
151161 lastUsedYearRange : null ,
152162 availableTablesWhenQueryApplied : [ ] ,
153163} ;
154- const config = configModule . getConfig ( ) ;
155164
156165describe ( 'StartPage' , ( ) => {
157166 it ( 'should render successfully' , async ( ) => {
@@ -200,49 +209,69 @@ describe('StartPage', () => {
200209 } ) ;
201210 } ) ;
202211
203- it ( 'renders the hidden SEO table list with correct number of links' , async ( ) => {
204- const { findByRole } = renderWithProviders (
205- < AccessibilityProvider >
206- < MemoryRouter >
207- < StartPage />
208- </ MemoryRouter >
209- </ AccessibilityProvider > ,
210- ) ;
212+ it ( 'memoizes TableListSEO content and does not re-render when unrelated state changes' , async ( ) => {
213+ mockIsMobile = true ;
214+ mockIsTablet = false ;
211215
212- const heading = await findByRole ( 'heading' , {
213- name : 'TableList(SEO)' ,
214- hidden : true ,
215- } ) ;
216- const nav = heading . closest ( 'nav' ) as HTMLElement ;
217- expect ( nav ) . toHaveAttribute ( 'aria-hidden' , 'true' ) ;
218- const links = await within ( nav ) . findAllByRole ( 'link' , { hidden : true } ) ;
219- expect ( links ) . toHaveLength ( 2 ) ;
220- links . forEach ( ( a ) => expect ( a ) . toHaveAttribute ( 'tabindex' , '-1' ) ) ;
221- } ) ;
216+ const createTableListSEOMock = createTableListSEO as unknown as Mock ;
217+ createTableListSEOMock . mockClear ( ) ;
218+
219+ // Keep the availableTables array reference stable across re-renders.
220+ const tables = [
221+ {
222+ id : 't1' ,
223+ label : 'Table 1' ,
224+ } ,
225+ ] as unknown as Table [ ] ;
222226
223- it ( 'prefixes href with language when showDefaultLanguageInPath=true' , async ( ) => {
224- config . language . showDefaultLanguageInPath = true ;
225- config . language . defaultLanguage = 'en' ;
227+ const mockState : StartPageState = {
228+ ...baseState ,
229+ availableTables : tables ,
230+ filteredTables : tables ,
231+ } ;
226232
227- const { findByRole } = renderWithProviders (
233+ const mockDispatch = vi . fn ( ) ;
234+
235+ function Harness ( ) {
236+ const [ tick , bump ] = useReducer ( ( n : number ) => n + 1 , 0 ) ;
237+
238+ const contextValue = useMemo (
239+ ( ) => ( { state : mockState , dispatch : mockDispatch } ) ,
240+ [ ] ,
241+ ) ;
242+
243+ return (
244+ < div data-testid = "harness" data-tick = { tick } >
245+ < button type = "button" onClick = { bump } >
246+ unrelated
247+ </ button >
248+ < FilterContext . Provider value = { contextValue } >
249+ < StartPage />
250+ </ FilterContext . Provider >
251+ </ div >
252+ ) ;
253+ }
254+
255+ renderWithProviders (
228256 < AccessibilityProvider >
229257 < MemoryRouter >
230- < StartPage />
258+ < Harness />
231259 </ MemoryRouter >
232260 </ AccessibilityProvider > ,
233261 ) ;
234262
235- const heading = await findByRole ( 'heading' , {
236- name : 'TableList(SEO)' ,
237- hidden : true ,
263+ await waitFor ( ( ) => {
264+ expect ( screen . getByTestId ( 'table-list-seo' ) ) . toBeInTheDocument ( ) ;
238265 } ) ;
239- const nav = heading . closest ( 'nav' ) as HTMLElement ;
240- const links = await within ( nav ) . findAllByRole ( 'link' , { hidden : true } ) ;
241- links . forEach ( ( a ) => {
242- expect ( a ) . toHaveAttribute (
243- 'href' ,
244- expect . stringMatching ( / ^ \/ e n \/ t a b l e \/ / ) ,
245- ) ;
266+
267+ const callsBefore = createTableListSEOMock . mock . calls . length ;
268+ expect ( callsBefore ) . toBeGreaterThan ( 0 ) ;
269+
270+ // Trigger an unrelated re-render (parent state), without changing i18n.language or availableTables.
271+ fireEvent . click ( screen . getByRole ( 'button' , { name : 'unrelated' } ) ) ;
272+
273+ await waitFor ( ( ) => {
274+ expect ( createTableListSEOMock . mock . calls . length ) . toBe ( callsBefore ) ;
246275 } ) ;
247276 } ) ;
248277
@@ -404,11 +433,11 @@ describe('StartPage', () => {
404433
405434 describe ( 'getTopicIcon size selection' , ( ) => {
406435 // Minimal harness that reproduces the getTopicIcon logic using hooks
407- function IconProbe ( { table } : { table : Partial < Table > } ) {
436+ function IconProbe ( { table } : Readonly < { table : Partial < Table > } > ) {
408437 const { isMobile, isTablet } = useApp ( ) ;
409438 const isSmallScreen = isTablet === true || isMobile === true ;
410439 const topicIconComponents = useTopicIcons ( ) ;
411- const topicId = table . subjectCode as string | undefined ;
440+ const topicId = table . subjectCode ;
412441 const size = isSmallScreen ? 'small' : 'medium' ;
413442 const icon = topicId
414443 ? ( topicIconComponents . find ( ( i ) => i . id === topicId ) ?. [ size ] ?? null )
0 commit comments