Skip to content

Commit 85c5373

Browse files
fix: memoize SEO rendering for table list on start page (#1132)
Add memoized SEO tablelist to startpage, now using json-ld instead of DOM elements -------- Co-authored-by: Sjur Sutterud Sagen <sjs@ssb.no>
1 parent 9a2ba75 commit 85c5373

File tree

4 files changed

+174
-84
lines changed

4 files changed

+174
-84
lines changed

packages/pxweb2/src/app/pages/StartPage/StartPage.spec.tsx

Lines changed: 67 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { useMemo, useReducer } from 'react';
12
import { vi, Mock } from 'vitest';
23
import { MemoryRouter } from 'react-router';
3-
import { waitFor, within, screen } from '@testing-library/react';
4+
import { waitFor, screen, fireEvent } from '@testing-library/react';
45
import '@testing-library/jest-dom/vitest';
56

67
import StartPage from './StartPage';
@@ -11,10 +12,19 @@ import { FilterContext } from '../../context/FilterContext';
1112
import { useLocaleContent } from '../../util/hooks/useLocaleContent';
1213
import { renderWithProviders } from '../../util/testing-utils';
1314
import * as startPageRender from '../../util/startPageRender';
14-
import * as configModule from '../../util/config/getConfig';
1515
import { getConfig } from '../../util/config/getConfig';
1616
import { 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
1929
let mockIsMobile = false;
2030
let mockIsTablet = false;
@@ -151,7 +161,6 @@ const baseState: StartPageState = {
151161
lastUsedYearRange: null,
152162
availableTablesWhenQueryApplied: [],
153163
};
154-
const config = configModule.getConfig();
155164

156165
describe('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(/^\/en\/table\//),
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)

packages/pxweb2/src/app/pages/StartPage/StartPage.tsx

Lines changed: 11 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useContext, useState, useRef } from 'react';
1+
import { useEffect, useContext, useState, useRef, useMemo } from 'react';
22
import { useTranslation, Trans } from 'react-i18next';
33
import { useNavigate } from 'react-router';
44
import type { TFunction } from 'i18next';
@@ -58,6 +58,7 @@ import {
5858
createBreadcrumbItems,
5959
BreadcrumbItemsParm,
6060
} from '../../util/createBreadcrumbItems';
61+
import { createTableListSEO } from '../../util/seo/tableListSEO';
6162

6263
const StartPage = () => {
6364
const { t, i18n } = useTranslation();
@@ -70,9 +71,10 @@ const StartPage = () => {
7071
const paginationCount = 15;
7172
const isSmallScreen = isTablet === true || isMobile === true;
7273
const topicIconComponents = useTopicIcons();
73-
const hasUrlParams =
74-
typeof window !== 'undefined' &&
75-
new URLSearchParams(window.location.search).toString().length > 0;
74+
const hasUrlParams = globalThis.window
75+
? new URLSearchParams(globalThis.window.location.search).toString().length >
76+
0
77+
: false;
7678

7779
const [isFilterOverlayOpen, setIsFilterOverlayOpen] = useState(false);
7880
const [visibleCount, setVisibleCount] = useState(paginationCount);
@@ -419,8 +421,7 @@ const StartPage = () => {
419421

420422
let lastPeriodString: string | undefined = table.lastPeriod?.slice(0, 4);
421423
if (
422-
table.timeUnit &&
423-
table.timeUnit.toLowerCase() === 'other' &&
424+
table.timeUnit?.toLowerCase() === 'other' &&
424425
table.lastPeriod?.slice(4, 5) === '-'
425426
) {
426427
lastPeriodString = table.lastPeriod?.slice(5, 9);
@@ -709,45 +710,9 @@ const StartPage = () => {
709710
);
710711
};
711712

712-
const renderTableListSEO = () => {
713-
return (
714-
<nav
715-
aria-hidden="true"
716-
style={{
717-
position: 'absolute',
718-
left: '-9999px',
719-
width: '1px',
720-
height: '1px',
721-
overflow: 'hidden',
722-
}}
723-
>
724-
<h2>TableList(SEO)</h2>
725-
<ul>
726-
{state.availableTables.map((table) => {
727-
const config = getConfig();
728-
const language = i18n.language;
729-
const tablePath = getLanguagePath(
730-
`/table/${table.id}`,
731-
language,
732-
config.language.supportedLanguages,
733-
config.language.defaultLanguage,
734-
config.language.showDefaultLanguageInPath,
735-
config.baseApplicationPath,
736-
config.language.positionInPath,
737-
);
738-
739-
return (
740-
<li key={table.id}>
741-
<a href={tablePath} tabIndex={-1}>
742-
{table.label}
743-
</a>
744-
</li>
745-
);
746-
})}
747-
</ul>
748-
</nav>
749-
);
750-
};
713+
const renderMemoizedTableListSEO = useMemo(() => {
714+
return createTableListSEO(i18n.language, state.availableTables);
715+
}, [i18n.language, state.availableTables]);
751716

752717
const renderBreadCrumb = () => {
753718
if (showBreadCrumb) {
@@ -920,7 +885,7 @@ const StartPage = () => {
920885
</div>
921886
</div>
922887
</div>
923-
{renderTableListSEO()}
888+
{state.availableTables.length > 0 && renderMemoizedTableListSEO}
924889
</main>
925890
<div className={cl(styles.footerContent)}>
926891
<div className={cl(styles.container)}>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createTableListSEO } from './tableListSEO';
2+
import { Table } from 'packages/pxweb2-api-client/src';
3+
4+
describe('createTableListSEO', () => {
5+
it('should create correct JSON-LD script for given tables and language', () => {
6+
const tables: Table[] = [
7+
{
8+
id: 'table1',
9+
label: 'Table 1',
10+
updated: '',
11+
firstPeriod: '',
12+
lastPeriod: '',
13+
variableNames: [],
14+
links: [],
15+
},
16+
{
17+
id: 'table2',
18+
label: 'Table 2',
19+
updated: '',
20+
firstPeriod: '',
21+
lastPeriod: '',
22+
variableNames: [],
23+
links: [],
24+
},
25+
];
26+
const language = 'en';
27+
28+
const result = createTableListSEO(language, tables);
29+
30+
// Extract the JSON-LD content from the script element
31+
const jsonLdContent = result.props.children;
32+
const jsonLd = JSON.parse(jsonLdContent);
33+
34+
expect(jsonLd).toEqual({
35+
'@context': 'https://schema.org',
36+
'@type': 'ItemList',
37+
itemListElement: [
38+
{
39+
'@type': 'ListItem',
40+
position: 1,
41+
name: 'Table 1',
42+
url: expect.stringContaining('/en/table/table1'),
43+
},
44+
{
45+
'@type': 'ListItem',
46+
position: 2,
47+
name: 'Table 2',
48+
url: expect.stringContaining('/en/table/table2'),
49+
},
50+
],
51+
});
52+
});
53+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Table } from 'packages/pxweb2-api-client/src';
2+
import { getConfig } from '../config/getConfig';
3+
import { getLanguagePath } from '../language/getLanguagePath';
4+
5+
export function createTableListSEO(language: string, tables: Table[]) {
6+
const config = getConfig();
7+
8+
// Build JSON-LD ItemList with absolute URLs to reduce DOM overhead
9+
const itemListElement = tables.map((table, index) => {
10+
const tablePath = getLanguagePath(
11+
`/table/${table.id}`,
12+
language,
13+
config.language.supportedLanguages,
14+
config.language.defaultLanguage,
15+
config.language.showDefaultLanguageInPath,
16+
config.baseApplicationPath,
17+
config.language.positionInPath,
18+
);
19+
20+
const absoluteUrl = globalThis.window
21+
? new URL(tablePath, globalThis.window.location.origin).href
22+
: tablePath;
23+
24+
return {
25+
'@type': 'ListItem',
26+
position: index + 1,
27+
name: table.label,
28+
url: absoluteUrl,
29+
};
30+
});
31+
32+
const jsonLd = {
33+
'@context': 'https://schema.org',
34+
'@type': 'ItemList',
35+
itemListElement,
36+
};
37+
38+
return (
39+
<script type="application/ld+json" id="seo-table-list-jsonld">
40+
{JSON.stringify(jsonLd)}
41+
</script>
42+
);
43+
}

0 commit comments

Comments
 (0)