Make profile SSR formatting deterministic
This commit is contained in:
@@ -7,10 +7,10 @@ import FollowButton from '../social/FollowButton'
|
|||||||
import FollowersPreview from '../social/FollowersPreview'
|
import FollowersPreview from '../social/FollowersPreview'
|
||||||
import MutualFollowersBadge from '../social/MutualFollowersBadge'
|
import MutualFollowersBadge from '../social/MutualFollowersBadge'
|
||||||
import { shinyFlagUrl } from '../../utils/flagUrl'
|
import { shinyFlagUrl } from '../../utils/flagUrl'
|
||||||
|
import { formatProfileDate, formatProfileNumber } from '../../lib/profileFormatters'
|
||||||
|
|
||||||
function formatCompactNumber(value) {
|
function formatCompactNumber(value) {
|
||||||
const numeric = Number(value ?? 0)
|
return formatProfileNumber(value)
|
||||||
return numeric.toLocaleString('en-US')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
|
export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) {
|
||||||
@@ -25,7 +25,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
|||||||
const uname = user.username || user.name || 'Unknown'
|
const uname = user.username || user.name || 'Unknown'
|
||||||
const displayName = user.name || uname
|
const displayName = user.name || uname
|
||||||
const joinDate = user.created_at
|
const joinDate = user.created_at
|
||||||
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
? formatProfileDate(user.created_at, { month: 'long', year: 'numeric' })
|
||||||
: null
|
: null
|
||||||
const progressPercent = Math.round(Number(user?.progress_percent ?? 0))
|
const progressPercent = Math.round(Number(user?.progress_percent ?? 0))
|
||||||
const heroStats = [
|
const heroStats = [
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { hydrateRoot } from 'react-dom/client'
|
||||||
|
import { renderToString } from 'react-dom/server'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../artwork/ArtworkGallery', () => ({
|
||||||
|
default: () => <div data-testid="mock-artwork-gallery" />,
|
||||||
|
}))
|
||||||
|
|
||||||
|
import TabArtworks from './TabArtworks'
|
||||||
|
|
||||||
|
describe('TabArtworks SSR hydration formatting', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates formatted artwork statistics without recoverable mismatches', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false })))
|
||||||
|
const props = {
|
||||||
|
artworks: [],
|
||||||
|
featuredArtworks: [{
|
||||||
|
id: 1,
|
||||||
|
name: 'Featured artwork',
|
||||||
|
views: 9529106,
|
||||||
|
likes: 1216,
|
||||||
|
downloads: 12,
|
||||||
|
}],
|
||||||
|
username: 'alice',
|
||||||
|
galleryUrl: '/@alice/artworks',
|
||||||
|
}
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.innerHTML = renderToString(<TabArtworks {...props} />)
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const recoverableErrors = []
|
||||||
|
|
||||||
|
hydrateRoot(container, <TabArtworks {...props} />, {
|
||||||
|
onRecoverableError: (error) => recoverableErrors.push(error),
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
expect(recoverableErrors).toHaveLength(0)
|
||||||
|
expect(container.textContent).toContain('9,529,106')
|
||||||
|
expect(container.textContent).toContain('1,216')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import ArtworkGallery from '../../artwork/ArtworkGallery'
|
import ArtworkGallery from '../../artwork/ArtworkGallery'
|
||||||
|
import { formatProfileNumber } from '../../../lib/profileFormatters'
|
||||||
|
|
||||||
function slugify(value) {
|
function slugify(value) {
|
||||||
return String(value ?? '')
|
return String(value ?? '')
|
||||||
@@ -9,7 +10,7 @@ function slugify(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return Number(value ?? 0).toLocaleString()
|
return formatProfileNumber(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortByPublishedAt(items) {
|
function sortByPublishedAt(items) {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const PROFILE_LOCALE = 'en-US'
|
||||||
|
const PROFILE_TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
export function formatProfileNumber(value) {
|
||||||
|
const numeric = Number(value ?? 0)
|
||||||
|
return Number.isFinite(numeric) ? new Intl.NumberFormat(PROFILE_LOCALE).format(numeric) : '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatProfileDate(value, options = {}) {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const date = value instanceof Date ? value : new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(PROFILE_LOCALE, {
|
||||||
|
...options,
|
||||||
|
timeZone: PROFILE_TIME_ZONE,
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { formatProfileDate, formatProfileNumber } from './profileFormatters'
|
||||||
|
|
||||||
|
describe('profile formatters', () => {
|
||||||
|
it('uses a stable en-US number format independent of the browser locale', () => {
|
||||||
|
expect(formatProfileNumber(9529106)).toBe('9,529,106')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses UTC for stable calendar dates at timezone boundaries', () => {
|
||||||
|
expect(formatProfileDate('2024-01-01T00:30:00.000Z', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})).toBe('January 1, 2024')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user