Skip to content

Commit 0703a5a

Browse files
r4topunkclaude
andauthored
fix(proposals): stop hydration mismatch on proposal detail tabs (#83)
`ProposalDetail` swapped between a plain `<div>` fallback and a `<Tabs>` wrapper depending on client-only data (`address` from thirdweb, async propdates fetch), causing a hydration error on /proposals/base/[id]. - Always render the `<Tabs>` shell; only the `<TabsList>` is gated by `visibleTabsCount > 1`, so the tree structure is stable between SSR and first client render. - Deduplicate the Details description/transactions block — it previously existed in both branches of the ternary. - Belt-and-suspenders: gate `isProposalOwner` and `hasPropdates` on a `mounted` flag so `<TabsTrigger>`s do not appear during the first hydration pass if thirdweb restores the address synchronously. Tracked follow-up in Trello: proper Server Components refactor of the ProposalDetail tree will delete the `mounted` flag entirely. Refs: https://trello.com/c/v9Ta5BFX Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b0c84f0 commit 0703a5a

1 file changed

Lines changed: 49 additions & 54 deletions

File tree

src/components/proposals/detail/ProposalDetail.tsx

Lines changed: 49 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ export function ProposalDetail({ proposal }: ProposalDetailProps) {
6060
// Snapshot and Ethereum proposals are read-only (no voting)
6161
const isReadOnly = isSnapshot || isEthereum;
6262

63+
// Mount flag keeps the initial client render identical to the SSR tree.
64+
// `address` (thirdweb, synchronous from storage) and `propdates` (async)
65+
// are undefined on the server, so we must treat them as undefined during
66+
// the first hydration pass too, then let post-mount updates reveal tabs.
67+
// The <Tabs> shell is always rendered, so this only affects which
68+
// <TabsTrigger>s appear — no structural mismatch either way.
69+
const [mounted, setMounted] = useState(false);
70+
useEffect(() => setMounted(true), []);
71+
6372
const [userVote, setUserVote] = useState<"FOR" | "AGAINST" | "ABSTAIN" | null>(null);
6473
const [userVoteReason, setUserVoteReason] = useState<string | null>(null);
6574
const [hasRecentVoteConfirmation, setHasRecentVoteConfirmation] = useState(false);
@@ -189,10 +198,10 @@ export function ProposalDetail({ proposal }: ProposalDetailProps) {
189198

190199
// Show propdates tab if there's at least one propdate OR the connected user is the proposal owner
191200
const isProposalOwner =
192-
address && proposal.proposer
201+
mounted && address && proposal.proposer
193202
? address.toLowerCase() === proposal.proposer.toLowerCase()
194203
: false;
195-
const hasPropdates = (propdates?.length ?? 0) > 0;
204+
const hasPropdates = mounted && (propdates?.length ?? 0) > 0;
196205
const shouldShowPropdatesTab = hasPropdates || isProposalOwner;
197206

198207
// Show votes tab if there are any votes (for any status: Active, Executed, Defeated, etc.)
@@ -206,9 +215,11 @@ export function ProposalDetail({ proposal }: ProposalDetailProps) {
206215
proposal.targets &&
207216
proposal.targets.length > 0;
208217

209-
// Count visible tabs to determine if we should show tabs at all
218+
// Count visible tabs to decide whether to render the TabsList.
219+
// The <Tabs> shell itself is always rendered so the SSR tree is stable
220+
// and does not depend on client-only state (address, fetched propdates).
210221
const visibleTabsCount = 1 + (shouldShowVotesTab ? 1 : 0) + (shouldShowPropdatesTab ? 1 : 0);
211-
const shouldShowTabs = visibleTabsCount > 1;
222+
const shouldShowTabsList = visibleTabsCount > 1;
212223

213224
// Show voting card for active proposals (connection check moved to VotingControls to avoid hydration issues)
214225
// Hide voting for read-only proposals (Snapshot and Ethereum)
@@ -277,8 +288,8 @@ export function ProposalDetail({ proposal }: ProposalDetailProps) {
277288
{isProposalSuccessful(proposal.status) && (
278289
<ProposalActions proposal={proposal} onActionSuccess={handleActionSuccess} />
279290
)}
280-
{shouldShowTabs ? (
281-
<Tabs defaultValue="details" className="w-full">
291+
<Tabs defaultValue="details" className="w-full">
292+
{shouldShowTabsList && (
282293
<div className="overflow-x-auto">
283294
<TabsList
284295
className={`grid w-full ${visibleTabsCount === 2 ? "grid-cols-2" : "grid-cols-3"} min-w-fit`}
@@ -288,52 +299,11 @@ export function ProposalDetail({ proposal }: ProposalDetailProps) {
288299
{shouldShowPropdatesTab && <TabsTrigger value="propdates">Propdates</TabsTrigger>}
289300
</TabsList>
290301
</div>
291-
<TabsContent value="details" className="space-y-6 mt-6">
292-
<ProposalDescriptionCard description={proposal.description} />
293-
{hasTransactionData && (
294-
<Card>
295-
<CardHeader>
296-
<CardTitle>Proposed Transactions</CardTitle>
297-
</CardHeader>
298-
<CardContent>
299-
<ProposalTransactionVisualization
300-
targets={proposal.targets}
301-
values={proposal.values}
302-
signatures={proposal.signatures}
303-
calldatas={proposal.calldatas}
304-
descriptions={(proposal as MultiChainProposal).txDescriptions}
305-
/>
306-
</CardContent>
307-
</Card>
308-
)}
309-
</TabsContent>
310-
{shouldShowVotesTab && (
311-
<TabsContent value="votes" className="mt-6 space-y-6">
312-
<ProposalVotesList
313-
votes={votesList.map((v) => ({
314-
voter: v.voter,
315-
choice: v.choice,
316-
votes: v.votes,
317-
reason: (v as { reason?: string | null }).reason ?? null,
318-
timestamp: (v as { timestamp?: number }).timestamp,
319-
}))}
320-
proposalId={proposal.proposalId}
321-
isActive={proposal.status === "Active"}
322-
/>
323-
</TabsContent>
324-
)}
325-
{shouldShowPropdatesTab && (
326-
<TabsContent value="propdates" className="mt-6">
327-
<Propdates
328-
proposalId={proposal.proposalId}
329-
proposer={proposal.proposer}
330-
targets={proposal.targets}
331-
/>
332-
</TabsContent>
333-
)}
334-
</Tabs>
335-
) : (
336-
<div className="space-y-6">
302+
)}
303+
<TabsContent
304+
value="details"
305+
className={`space-y-6 ${shouldShowTabsList ? "mt-6" : ""}`}
306+
>
337307
<ProposalDescriptionCard description={proposal.description} />
338308
{hasTransactionData && (
339309
<Card>
@@ -346,12 +316,37 @@ export function ProposalDetail({ proposal }: ProposalDetailProps) {
346316
values={proposal.values}
347317
signatures={proposal.signatures}
348318
calldatas={proposal.calldatas}
319+
descriptions={(proposal as MultiChainProposal).txDescriptions}
349320
/>
350321
</CardContent>
351322
</Card>
352323
)}
353-
</div>
354-
)}
324+
</TabsContent>
325+
{shouldShowVotesTab && (
326+
<TabsContent value="votes" className="mt-6 space-y-6">
327+
<ProposalVotesList
328+
votes={votesList.map((v) => ({
329+
voter: v.voter,
330+
choice: v.choice,
331+
votes: v.votes,
332+
reason: (v as { reason?: string | null }).reason ?? null,
333+
timestamp: (v as { timestamp?: number }).timestamp,
334+
}))}
335+
proposalId={proposal.proposalId}
336+
isActive={proposal.status === "Active"}
337+
/>
338+
</TabsContent>
339+
)}
340+
{shouldShowPropdatesTab && (
341+
<TabsContent value="propdates" className="mt-6">
342+
<Propdates
343+
proposalId={proposal.proposalId}
344+
proposer={proposal.proposer}
345+
targets={proposal.targets}
346+
/>
347+
</TabsContent>
348+
)}
349+
</Tabs>
355350
</div>
356351
);
357352
}

0 commit comments

Comments
 (0)