+ {loading ? Loading worlds history…
: null}
+ {error ? (
+
+ Worlds history could not be loaded.
+
+
+ ) : null}
)
-}
\ No newline at end of file
+}
diff --git a/resources/js/lib/useLazyProfileWorldHistory.js b/resources/js/lib/useLazyProfileWorldHistory.js
new file mode 100644
index 00000000..144b703c
--- /dev/null
+++ b/resources/js/lib/useLazyProfileWorldHistory.js
@@ -0,0 +1,79 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+export default function useLazyProfileWorldHistory({ username, initialWorldHistory, enabled }) {
+ const hasInitialValue = initialWorldHistory !== null && initialWorldHistory !== undefined
+ const [data, setData] = useState(() => hasInitialValue ? initialWorldHistory : null)
+ const [loaded, setLoaded] = useState(hasInitialValue)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(false)
+ const requestKeyRef = useRef(username)
+ const requestedRef = useRef(hasInitialValue)
+ const abortRef = useRef(null)
+
+ useEffect(() => {
+ if (requestKeyRef.current === username) return undefined
+
+ abortRef.current?.abort()
+ abortRef.current = null
+ requestKeyRef.current = username
+ requestedRef.current = false
+ setData(null)
+ setLoaded(false)
+ setLoading(false)
+ setError(false)
+ return undefined
+ }, [username])
+
+ useEffect(() => {
+ if (!hasInitialValue || requestKeyRef.current !== username) return
+
+ abortRef.current?.abort()
+ abortRef.current = null
+ requestedRef.current = true
+ setData(initialWorldHistory)
+ setLoaded(true)
+ setLoading(false)
+ setError(false)
+ }, [hasInitialValue, initialWorldHistory, username])
+
+ const load = useCallback(async () => {
+ if (!enabled || hasInitialValue || requestKeyRef.current !== username || requestedRef.current || !username) return
+
+ requestedRef.current = true
+ const controller = new AbortController()
+ const requestKey = requestKeyRef.current
+ abortRef.current = controller
+ setLoading(true)
+ setError(false)
+
+ try {
+ const response = await fetch(`/api/profile/${encodeURIComponent(username)}/world-history`, {
+ headers: { Accept: 'application/json' },
+ credentials: 'same-origin',
+ signal: controller.signal,
+ })
+ if (!response.ok) throw new Error('World history could not be loaded.')
+ const payload = await response.json()
+ if (requestKey !== requestKeyRef.current || controller.signal.aborted) return
+ setData(payload)
+ setLoaded(true)
+ } catch (caught) {
+ if (caught?.name === 'AbortError' || requestKey !== requestKeyRef.current) return
+ requestedRef.current = false
+ setError(true)
+ } finally {
+ if (requestKey === requestKeyRef.current) {
+ abortRef.current = null
+ setLoading(false)
+ }
+ }
+ }, [enabled, hasInitialValue, username])
+
+ useEffect(() => {
+ if (enabled) load()
+ }, [enabled, load])
+
+ useEffect(() => () => abortRef.current?.abort(), [])
+
+ return { data, loaded, loading, error, retry: load }
+}
diff --git a/resources/js/lib/useLazyProfileWorldHistory.test.jsx b/resources/js/lib/useLazyProfileWorldHistory.test.jsx
new file mode 100644
index 00000000..a682f095
--- /dev/null
+++ b/resources/js/lib/useLazyProfileWorldHistory.test.jsx
@@ -0,0 +1,81 @@
+import React from 'react'
+import { render, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import useLazyProfileWorldHistory from './useLazyProfileWorldHistory'
+
+function Harness({ username = 'alice', initial = null, enabled = false }) {
+ const state = useLazyProfileWorldHistory({
+ username,
+ initialWorldHistory: initial,
+ enabled,
+ })
+
+ return
+}
+
+describe('useLazyProfileWorldHistory', () => {
+ beforeEach(() => vi.restoreAllMocks())
+
+ it('does not request on Posts until a consuming tab is active', () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ render()
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('requests once on first consuming-tab activation and reuses the result', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ json: async () => ({ summary: { available: true }, entries: [{ id: 'world-1' }] }),
+ })
+ const view = render()
+
+ view.rerender()
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
+ view.rerender()
+ view.rerender()
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true))
+ })
+
+ it('uses direct Worlds/Achievements SSR data without a lazy request', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ const initial = { summary: { available: true }, entries: [{ id: 'world-1' }] }
+ const view = render()
+ view.rerender()
+ view.rerender()
+
+ await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).data).toEqual(initial))
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('treats an empty response as loaded', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ summary: { available: false }, entries: [] }) })
+ const view = render()
+ await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).loaded).toBe(true))
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('resets on profile navigation and ignores the stale response', async () => {
+ let resolveA
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() => new Promise((resolve) => { resolveA = resolve }))
+ const view = render()
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
+
+ view.rerender()
+ resolveA({ ok: true, json: async () => ({ summary: { available: true }, entries: [{ id: 'alice' }] }) })
+ await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).data).toBe(null))
+ })
+
+ it('does not loop after an error and permits an explicit retry', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce({ ok: false })
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ summary: { available: false }, entries: [] }) })
+ const view = render()
+ await waitFor(() => expect(JSON.parse(view.getByTestId('state').textContent).error).toBe(true))
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ // The hook's returned retry is intentionally exercised through a small rerender activation.
+ view.rerender()
+ view.rerender()
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
+ })
+})
diff --git a/routes/api.php b/routes/api.php
index c588bdef..1b7c257a 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -911,6 +911,7 @@ Route::middleware(['web', 'throttle:60,1'])
Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites');
Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats');
Route::get('featured-artworks', [\App\Http\Controllers\User\ProfileController::class, 'featuredArtworks'])->name('featured-artworks');
+ Route::get('world-history', [\App\Http\Controllers\User\ProfileController::class, 'worldHistory'])->name('world-history');
});
// ── Link Preview (auth, throttled) ─────────────────────────────────────────────
diff --git a/tests/Feature/Profile/ProfileWorldHistoryLazyTest.php b/tests/Feature/Profile/ProfileWorldHistoryLazyTest.php
new file mode 100644
index 00000000..224cf020
--- /dev/null
+++ b/tests/Feature/Profile/ProfileWorldHistoryLazyTest.php
@@ -0,0 +1,73 @@
+create(['username' => 'lazyworlds']);
+ DB::flushQueryLog();
+ DB::enableQueryLog();
+
+ $this->get('/@' . $user->username)
+ ->assertOk()
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('initialTab', 'posts')
+ ->where('worldHistory', null));
+
+ $historyQueries = collect(DB::getQueryLog())->pluck('query')->filter(function (string $query): bool {
+ $query = strtolower($query);
+
+ return str_contains($query, 'world_submissions')
+ || str_contains($query, 'group_challenge_outcomes')
+ || str_contains($query, 'world_relations');
+ });
+
+ expect($historyQueries)->toBeEmpty();
+});
+
+it('keeps world history in direct Worlds and Achievements SSR payloads', function (string $tab): void {
+ $user = User::factory()->create(['username' => 'ssr' . $tab]);
+
+ $this->get('/@' . $user->username . '/' . $tab)
+ ->assertOk()
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('initialTab', $tab)
+ ->has('worldHistory.summary')
+ ->has('worldHistory.entries'));
+})->with(['worlds', 'achievements']);
+
+it('returns the exact public service payload without tracking a profile view', function (): void {
+ $user = User::factory()->create(['username' => 'lazyworldapi']);
+ $expected = app(WorldProfileHistoryService::class)->publicPayloadForUser($user);
+ $before = (int) ($user->profile_views_count ?? 0);
+
+ $this->getJson('/api/profile/' . $user->username . '/world-history')
+ ->assertOk()
+ ->assertJson($expected);
+
+ expect((int) $user->fresh()->profile_views_count)->toBe($before);
+});
+
+it('returns owner context only to the profile owner', function (): void {
+ $user = User::factory()->create(['username' => 'ownerworldapi']);
+ $other = User::factory()->create(['username' => 'otherworldapi']);
+
+ $this->actingAs($user)
+ ->getJson('/api/profile/' . $user->username . '/world-history')
+ ->assertOk()
+ ->assertJsonPath('owner_context.pending_submissions', 0);
+
+ $this->actingAs($other)
+ ->getJson('/api/profile/' . $user->username . '/world-history')
+ ->assertOk()
+ ->assertJsonPath('owner_context', null);
+});
+
+it('returns not found for an unknown world history profile', function (): void {
+ $this->getJson('/api/profile/missing-world-history/world-history')->assertNotFound();
+});
diff --git a/tests/Feature/Profile/WorldProfileHistoryTest.php b/tests/Feature/Profile/WorldProfileHistoryTest.php
index ef2764c2..cd4040b6 100644
--- a/tests/Feature/Profile/WorldProfileHistoryTest.php
+++ b/tests/Feature/Profile/WorldProfileHistoryTest.php
@@ -95,7 +95,7 @@ it('exposes normalized world history on public profile pages', function (): void
'awarded_at' => now()->subHour(),
]);
- $this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
+ $this->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
@@ -161,7 +161,7 @@ it('filters stale public world rewards while preserving owner-only context count
'status' => WorldSubmission::STATUS_PENDING,
]);
- $this->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
+ $this->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
@@ -170,7 +170,7 @@ it('filters stale public world rewards while preserving owner-only context count
->where('worldHistory.owner_context', null));
$this->actingAs($creator)
- ->get(route('profile.show', ['username' => strtolower((string) $creator->username)]))
+ ->get(route('profile.tab', ['username' => strtolower((string) $creator->username), 'tab' => 'worlds']))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
@@ -223,4 +223,4 @@ it('supports the canonical worlds profile tab route', function (): void {
->where('profileTabUrls.worlds', url('/@' . strtolower((string) $creator->username) . '/worlds'))
->where('worldHistory.summary.available', true)
->where('worldHistory.entries.0.primary_recognition.key', 'featured'));
-});
\ No newline at end of file
+});