Skip to content

Commit 232e179

Browse files
authored
Render Token page <h1> on the server (blockscout#1862)
* extract TokenPageTitle into separate component * remove dynamic import for TokenPage * add initialData to tokenQuery * add ENV variable * don't update metadata on client * fix test
1 parent faa24a3 commit 232e179

File tree

16 files changed

+249
-158
lines changed

16 files changed

+249
-158
lines changed

configs/app/meta.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const meta = Object.freeze({
1010
imageUrl: app.baseUrl + (getExternalAssetFilePath('NEXT_PUBLIC_OG_IMAGE_URL') || defaultImageUrl),
1111
enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED') === 'true',
1212
},
13+
seo: {
14+
enhancedDataEnabled: getEnvValue('NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED') === 'true',
15+
},
1316
});
1417

1518
export default meta;

configs/envs/.env.eth

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,5 @@ NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
5555

5656
#meta
5757
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true
58+
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
59+
NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED=true

deploy/tools/envs-validator/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,7 @@ const schema = yup
604604
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
605605
NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest),
606606
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: yup.boolean(),
607+
NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED: yup.boolean(),
607608
NEXT_PUBLIC_SAFE_TX_SERVICE_URL: yup.string().test(urlTest),
608609
NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(),
609610
NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(),

deploy/tools/envs-validator/test/.env.base

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
4242
NEXT_PUBLIC_OG_DESCRIPTION='Hello world!'
4343
NEXT_PUBLIC_OG_IMAGE_URL=https://example.com/image.png
4444
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
45+
NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED=true
4546
NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://blockscout.com','text':'Blockscout'}]
4647
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE=true
4748
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-mainnet.safe.global

docs/ENVS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,15 @@ By default, the app has generic favicon. You can override this behavior by provi
172172

173173
### Meta
174174

175-
Settings for meta tags and OG tags
175+
Settings for meta tags, OG tags and SEO
176176

177177
| Variable | Type| Description | Compulsoriness | Default value | Example value |
178178
| --- | --- | --- | --- | --- | --- |
179179
| NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE | `boolean` | Set to `true` to promote Blockscout in meta and OG titles | - | `true` | `true` |
180180
| NEXT_PUBLIC_OG_DESCRIPTION | `string` | Custom OG description | - | - | `Blockscout is the #1 open-source blockchain explorer available today. 100+ chains and counting rely on Blockscout data availability, APIs, and ecosystem tools to support their networks.` |
181181
| NEXT_PUBLIC_OG_IMAGE_URL | `string` | OG image url. Minimum image size is 200 x 20 pixels (recommended: 1200 x 600); maximum supported file size is 8 MB; 2:1 aspect ratio; supported formats: image/jpeg, image/gif, image/png | - | `static/og_placeholder.png` | `https://placekitten.com/1200/600` |
182182
| NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED | `boolean` | Set to `true` to populate OG tags (title, description) with API data for social preview robot requests | - | `false` | `true` |
183+
| NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED | `boolean` | Set to `true` to pre-render page titles (e.g Token page) on the server side and inject page h1-tag to the markup before it is sent to the browser. | - | `false` | `true` |
183184

184185
&nbsp;
185186

lib/contexts/app.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { createContext, useContext } from 'react';
22

3+
import type { Route } from 'nextjs-routes';
34
import type { Props as PageProps } from 'nextjs/getServerSideProps';
45

56
type Props = {
@@ -23,6 +24,6 @@ export function AppContextProvider({ children, pageProps }: Props) {
2324
);
2425
}
2526

26-
export function useAppContext() {
27-
return useContext(AppContext);
27+
export function useAppContext<Pathname extends Route['pathname'] = never>() {
28+
return useContext<PageProps<Pathname>>(AppContext);
2829
}

lib/metadata/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import type { TokenInfo } from 'types/api/token';
2+
13
import type { Route } from 'nextjs-routes';
24

35
/* eslint-disable @typescript-eslint/indent */
46
export type ApiData<Pathname extends Route['pathname']> =
57
(
68
Pathname extends '/address/[hash]' ? { domain_name: string } :
7-
Pathname extends '/token/[hash]' ? { symbol: string } :
9+
Pathname extends '/token/[hash]' ? TokenInfo :
810
Pathname extends '/token/[hash]/instance/[id]' ? { symbol: string } :
911
Pathname extends '/apps/[id]' ? { app_name: string } :
1012
never

pages/token/[hash]/index.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { GetServerSideProps, NextPage } from 'next';
2-
import dynamic from 'next/dynamic';
32
import React from 'react';
43

54
import type { Route } from 'nextjs-routes';
@@ -11,8 +10,7 @@ import fetchApi from 'nextjs/utils/fetchApi';
1110

1211
import config from 'configs/app';
1312
import getQueryParamString from 'lib/router/getQueryParamString';
14-
15-
const Token = dynamic(() => import('ui/pages/Token'), { ssr: false });
13+
import Token from 'ui/pages/Token';
1614

1715
const pathname: Route['pathname'] = '/token/[hash]';
1816

@@ -29,19 +27,18 @@ export default Page;
2927
export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => {
3028
const baseResponse = await gSSP.base<typeof pathname>(ctx);
3129

32-
if (config.meta.og.enhancedDataEnabled && 'props' in baseResponse) {
33-
const botInfo = detectBotRequest(ctx.req);
34-
35-
if (botInfo?.type === 'social_preview') {
30+
if ('props' in baseResponse) {
31+
if (
32+
config.meta.seo.enhancedDataEnabled ||
33+
(config.meta.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview')
34+
) {
3635
const tokenData = await fetchApi({
3736
resource: 'token',
3837
pathParams: { hash: getQueryParamString(ctx.query.hash) },
39-
timeout: 1_000,
38+
timeout: 500,
4039
});
4140

42-
(await baseResponse.props).apiData = tokenData && tokenData.symbol ? {
43-
symbol: tokenData.symbol,
44-
} : null;
41+
(await baseResponse.props).apiData = tokenData ?? null;
4542
}
4643
}
4744

ui/pages/Token.pw.tsx

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22

3+
import config from 'configs/app';
34
import * as verifiedAddressesMocks from 'mocks/account/verifiedAddresses';
45
import { token as contract } from 'mocks/address/address';
56
import { tokenInfo, tokenCounters, bridgedTokenA } from 'mocks/tokens/tokenInfo';
@@ -10,9 +11,12 @@ import * as configs from 'playwright/utils/configs';
1011

1112
import Token from './Token';
1213

14+
const hash = tokenInfo.address;
15+
const chainId = config.chain.id;
16+
1317
const hooksConfig = {
1418
router: {
15-
query: { hash: '1', tab: 'token_transfers' },
19+
query: { hash, tab: 'token_transfers' },
1620
isReady: true,
1721
},
1822
};
@@ -22,17 +26,17 @@ const hooksConfig = {
2226
test.describe.configure({ mode: 'serial' });
2327

2428
test.beforeEach(async({ mockApiResponse }) => {
25-
await mockApiResponse('token', tokenInfo, { pathParams: { hash: '1' } });
26-
await mockApiResponse('address', contract, { pathParams: { hash: '1' } });
27-
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash: '1' } });
28-
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash: '1' } });
29+
await mockApiResponse('token', tokenInfo, { pathParams: { hash } });
30+
await mockApiResponse('address', contract, { pathParams: { hash } });
31+
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash } });
32+
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash } });
2933
});
3034

3135
test('base view', async({ render, page, createSocket }) => {
3236
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
3337

3438
const socket = await createSocket();
35-
const channel = await socketServer.joinChannel(socket, 'tokens:1');
39+
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
3640
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
3741

3842
await expect(component).toHaveScreenshot({
@@ -42,13 +46,13 @@ test('base view', async({ render, page, createSocket }) => {
4246
});
4347

4448
test('with verified info', async({ render, page, createSocket, mockApiResponse, mockAssetResponse }) => {
45-
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId: '1', hash: '1' } });
49+
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId, hash } });
4650
await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
4751

4852
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
4953

5054
const socket = await createSocket();
51-
const channel = await socketServer.joinChannel(socket, 'tokens:1');
55+
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
5256
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
5357

5458
await page.getByRole('button', { name: /project info/i }).click();
@@ -60,17 +64,24 @@ test('with verified info', async({ render, page, createSocket, mockApiResponse,
6064
});
6165

6266
test('bridged token', async({ render, page, createSocket, mockApiResponse, mockAssetResponse, mockEnvs }) => {
67+
const hash = bridgedTokenA.address;
68+
const hooksConfig = {
69+
router: {
70+
query: { hash, tab: 'token_transfers' },
71+
},
72+
};
73+
6374
await mockEnvs(ENVS_MAP.bridgedTokens);
64-
await mockApiResponse('token', bridgedTokenA, { pathParams: { hash: '1' } });
65-
await mockApiResponse('address', contract, { pathParams: { hash: '1' } });
66-
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash: '1' } });
67-
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash: '1' } });
68-
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId: '1', hash: '1' } });
75+
await mockApiResponse('token', bridgedTokenA, { pathParams: { hash } });
76+
await mockApiResponse('address', contract, { pathParams: { hash } });
77+
await mockApiResponse('token_counters', tokenCounters, { pathParams: { hash } });
78+
await mockApiResponse('token_transfers', { items: [], next_page_params: null }, { pathParams: { hash } });
79+
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId, hash } });
6980
await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
7081

7182
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
7283
const socket = await createSocket();
73-
const channel = await socketServer.joinChannel(socket, 'tokens:1');
84+
const channel = await socketServer.joinChannel(socket, `tokens:${ hash.toLowerCase() }`);
7485
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
7586

7687
await expect(component).toHaveScreenshot({
@@ -85,7 +96,7 @@ test.describe('mobile', () => {
8596
test('base view', async({ render, page, createSocket }) => {
8697
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
8798
const socket = await createSocket();
88-
const channel = await socketServer.joinChannel(socket, 'tokens:1');
99+
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
89100
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
90101

91102
await expect(component).toHaveScreenshot({
@@ -95,12 +106,12 @@ test.describe('mobile', () => {
95106
});
96107

97108
test('with verified info', async({ render, page, createSocket, mockApiResponse, mockAssetResponse }) => {
98-
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId: '1', hash: '1' } });
109+
await mockApiResponse('token_verified_info', verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED, { pathParams: { chainId, hash } });
99110
await mockAssetResponse(tokenInfo.icon_url as string, './playwright/mocks/image_s.jpg');
100111

101112
const component = await render(<Token/>, { hooksConfig }, { withSocket: true });
102113
const socket = await createSocket();
103-
const channel = await socketServer.joinChannel(socket, 'tokens:1');
114+
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
104115
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
105116

106117
await expect(component).toHaveScreenshot({

0 commit comments

Comments
 (0)