diff --git a/resources/js/components/profile/ProfileHero.jsx b/resources/js/components/profile/ProfileHero.jsx
index 0e42e38f..e222ff23 100644
--- a/resources/js/components/profile/ProfileHero.jsx
+++ b/resources/js/components/profile/ProfileHero.jsx
@@ -7,10 +7,10 @@ import FollowButton from '../social/FollowButton'
import FollowersPreview from '../social/FollowersPreview'
import MutualFollowersBadge from '../social/MutualFollowersBadge'
import { shinyFlagUrl } from '../../utils/flagUrl'
+import { formatProfileDate, formatProfileNumber } from '../../lib/profileFormatters'
function formatCompactNumber(value) {
- const numeric = Number(value ?? 0)
- return numeric.toLocaleString('en-US')
+ return formatProfileNumber(value)
}
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 displayName = user.name || uname
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
const progressPercent = Math.round(Number(user?.progress_percent ?? 0))
const heroStats = [
diff --git a/resources/js/components/profile/tabs/TabArtworks.hydration.test.jsx b/resources/js/components/profile/tabs/TabArtworks.hydration.test.jsx
new file mode 100644
index 00000000..eefda632
--- /dev/null
+++ b/resources/js/components/profile/tabs/TabArtworks.hydration.test.jsx
@@ -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: () =>
,
+}))
+
+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()
+ document.body.appendChild(container)
+ const recoverableErrors = []
+
+ hydrateRoot(container, , {
+ 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')
+ })
+})
diff --git a/resources/js/components/profile/tabs/TabArtworks.jsx b/resources/js/components/profile/tabs/TabArtworks.jsx
index a47071f4..d69522d9 100644
--- a/resources/js/components/profile/tabs/TabArtworks.jsx
+++ b/resources/js/components/profile/tabs/TabArtworks.jsx
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'
import ArtworkGallery from '../../artwork/ArtworkGallery'
+import { formatProfileNumber } from '../../../lib/profileFormatters'
function slugify(value) {
return String(value ?? '')
@@ -9,7 +10,7 @@ function slugify(value) {
}
function formatNumber(value) {
- return Number(value ?? 0).toLocaleString()
+ return formatProfileNumber(value)
}
function sortByPublishedAt(items) {
diff --git a/resources/js/lib/profileFormatters.js b/resources/js/lib/profileFormatters.js
new file mode 100644
index 00000000..9a6305b8
--- /dev/null
+++ b/resources/js/lib/profileFormatters.js
@@ -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)
+}
diff --git a/resources/js/lib/profileFormatters.test.js b/resources/js/lib/profileFormatters.test.js
new file mode 100644
index 00000000..249a6d67
--- /dev/null
+++ b/resources/js/lib/profileFormatters.test.js
@@ -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')
+ })
+})