diff --git a/.changeset/proud-rabbits-read.md b/.changeset/proud-rabbits-read.md new file mode 100644 index 000000000..791914c77 --- /dev/null +++ b/.changeset/proud-rabbits-read.md @@ -0,0 +1,5 @@ +--- +'@aragon/gov-ui-kit': minor +--- + +Fix ssr hydration errors diff --git a/jest.config.js b/jest.config.js index a6ad1a8ce..879034639 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,7 @@ const config = { testEnvironment: 'jsdom', collectCoverageFrom: ['./src/**/*.{ts,tsx}'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], coveragePathIgnorePatterns: ['.d.ts', '.api.ts', 'index.ts', '.stories.tsx', './src/core/test/*'], setupFilesAfterEnv: ['/src/core/test/setup.ts'], globalSetup: '/src/core/test/globalSetup.ts', diff --git a/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.test.tsx b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.test.tsx index 69e774f40..205d607d5 100644 --- a/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.test.tsx +++ b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.test.tsx @@ -67,11 +67,11 @@ describe(' component', () => { expect(screen.getByRole('heading', { level: 3, name: '420.69K ETH Voting Power' })).toBeInTheDocument(); }); - it('renders a you tag when the user is the current connected account', () => { + it('renders a you tag when the user is the current connected account', async () => { const address = '0x50ce432B38eE98dE5Fa375D5125aA6d0d054E662'; useAccountMock.mockReturnValue({ isConnected: true, address } as unknown as wagmi.UseAccountReturnType); render(createTestComponent({ address })); - expect(screen.getByText('You')).toBeInTheDocument(); + expect(await screen.findByText('You')).toBeInTheDocument(); }); it('hides the voting power label when hideLabelTokenVoting is true', () => { diff --git a/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.tsx b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.tsx index 011cb4c5f..8c3b35b1f 100644 --- a/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.tsx +++ b/src/modules/components/member/memberDataListItem/memberDataListItemStructure/memberDataListItemStructure.tsx @@ -1,4 +1,5 @@ import classNames from 'classnames'; +import { useEffect, useState } from 'react'; import { useAccount } from 'wagmi'; import { DataList, Heading, NumberFormat, Tag, formatterUtils, type IDataListItemProps } from '../../../../../core'; import { addressUtils } from '../../../../utils'; @@ -58,7 +59,13 @@ export const MemberDataListItemStructure: React.FC = ( const { copy } = useGukModulesContext(); - const isCurrentUser = isConnected && address && addressUtils.isAddressEqual(currentUserAddress, address); + // Avoid SSR/CSR hydration mismatches: the connected address is only known on + // the client, so only show the "You" tag after mount. + const [hasMounted, setHasMounted] = useState(false); + useEffect(() => setHasMounted(true), []); + + const isCurrentUser = + hasMounted && isConnected && address && addressUtils.isAddressEqual(currentUserAddress, address); const resolvedUserHandle = ensName != null && ensName.length > 0 ? ensName : addressUtils.truncateAddress(address); diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.test.tsx b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.test.tsx index 690e8d966..dfb7ec187 100644 --- a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.test.tsx +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.test.tsx @@ -44,7 +44,7 @@ describe(' component', () => { expect(screen.getByText(children)).toBeInTheDocument(); }); - it("renders 'You' as the publisher if the connected address is the publisher address", () => { + it("renders 'You' as the publisher if the connected address is the publisher address", async () => { const publisher = { address: '0x0000000000000000000000000000000000000000', link: '#' }; useAccountMock.mockReturnValue({ @@ -54,7 +54,7 @@ describe(' component', () => { render(createTestComponent({ publisher })); - expect(screen.getByRole('link', { name: 'You' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'You' })).toBeInTheDocument(); }); it('renders multiple publishers', () => { diff --git a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx index cbc2ed4e3..91523184c 100644 --- a/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx +++ b/src/modules/components/proposal/proposalDataListItem/proposalDataListItemStructure/proposalDataListItemStructure.tsx @@ -1,4 +1,5 @@ import classNames from 'classnames'; +import { useEffect, useMemo, useState } from 'react'; import { useAccount } from 'wagmi'; import { DataList, Heading, Link, Tag } from '../../../../../core'; import { addressUtils } from '../../../../utils/addressUtils'; @@ -8,13 +9,19 @@ import { type IProposalDataListItemStructureProps, type IPublisher } from './pro export const maxPublishersDisplayed = 3; -const parsePublisher = (publisher: IPublisher, isConnected: boolean, connectedAddress: string | undefined) => { - const publisherIsConnected = isConnected && addressUtils.isAddressEqual(publisher.address, connectedAddress); +const parsePublisher = ( + publisher: IPublisher, + canShowConnectedLabel: boolean, + isConnected: boolean, + connectedAddress: string | undefined, +) => { + const publisherIsConnected = + canShowConnectedLabel && isConnected && addressUtils.isAddressEqual(publisher.address, connectedAddress); const publisherLabel = publisherIsConnected ? 'You' : (publisher.name ?? addressUtils.truncateAddress(publisher.address)); - return { label: publisherLabel, link: publisher.link }; + return { label: publisherLabel, link: publisher.link, address: publisher.address }; }; export const ProposalDataListItemStructure: React.FC = (props) => { @@ -37,9 +44,16 @@ export const ProposalDataListItemStructure: React.FC parsePublisher(p, isConnected, connectedAddress)) - : [parsePublisher(publisher, isConnected, connectedAddress)]; + // Avoid SSR/CSR hydration mismatches: the connected address is only known on + // the client, so we only render the "You" label after mount. + const [hasMounted, setHasMounted] = useState(false); + useEffect(() => setHasMounted(true), []); + + const parsedPublisher = useMemo(() => { + return Array.isArray(publisher) + ? publisher.map((p) => parsePublisher(p, hasMounted, isConnected, connectedAddress)) + : [parsePublisher(publisher, hasMounted, isConnected, connectedAddress)]; + }, [publisher, hasMounted, isConnected, connectedAddress]); const showParsedPublisher = parsedPublisher.length <= maxPublishersDisplayed; @@ -71,8 +85,8 @@ export const ProposalDataListItemStructure: React.FC )} {showParsedPublisher && - parsedPublisher.map(({ label, link }, index) => ( - + parsedPublisher.map(({ address, label, link }, index) => ( + {link != null && ( // Using solution from https://kizu.dev/nested-links/ to nest anchor tags diff --git a/src/modules/components/vote/voteDataListItem/voteDataListItemStructure/voteDataListItemStructure.test.tsx b/src/modules/components/vote/voteDataListItem/voteDataListItemStructure/voteDataListItemStructure.test.tsx index 4042e21f8..85e955557 100644 --- a/src/modules/components/vote/voteDataListItem/voteDataListItemStructure/voteDataListItemStructure.test.tsx +++ b/src/modules/components/vote/voteDataListItem/voteDataListItemStructure/voteDataListItemStructure.test.tsx @@ -72,13 +72,13 @@ describe(' component', () => { expect(screen.getByText(voter.name)).toBeInTheDocument(); }); - it('renders the "You" tag if the voter is the current user', () => { + it('renders the "You" tag if the voter is the current user', async () => { const voter = { address: '0x1234567890123456789012345678901234567890' }; useAccountSpy.mockReturnValue({ address: voter.address, isConnected: true } as wagmi.UseAccountReturnType); render(createTestComponent({ voter })); - expect(screen.getByText('You')).toBeInTheDocument(); + expect(await screen.findByText('You')).toBeInTheDocument(); }); it('renders "Your delegate" tag if the voter is a delegate of the current user', () => { diff --git a/src/modules/components/vote/voteDataListItem/voteDataListItemStructure/voteDataListItemStructure.tsx b/src/modules/components/vote/voteDataListItem/voteDataListItemStructure/voteDataListItemStructure.tsx index 1586eced9..9e0ca9f0e 100644 --- a/src/modules/components/vote/voteDataListItem/voteDataListItemStructure/voteDataListItemStructure.tsx +++ b/src/modules/components/vote/voteDataListItem/voteDataListItemStructure/voteDataListItemStructure.tsx @@ -1,4 +1,5 @@ import classNames from 'classnames'; +import { useEffect, useState } from 'react'; import { useAccount } from 'wagmi'; import { DataList, NumberFormat, Tag, formatterUtils, type IDataListItemProps } from '../../../../../core'; import { type ICompositeAddress } from '../../../../types'; @@ -55,7 +56,12 @@ export const VoteDataListItemStructure: React.FC setHasMounted(true), []); + + const isCurrentUser = hasMounted && isConnected && addressUtils.isAddressEqual(currentUserAddress, voter.address); const resolvedUserHandle = voter.name != null && voter.name.length > 0 ? voter.name : addressUtils.truncateAddress(voter.address);