(null);
useLaneSwipe(feedRef, log);
- useComposerVisibility(composer, tabsHeight);
useLayoutEffect(() => {
followAnchor.current = null;
@@ -127,23 +301,6 @@ export default function Show({
};
}, [log]);
- useLayoutEffect(() => {
- const tabs = tabsRef.current;
-
- if (!tabs) {
- return;
- }
-
- const updateHeight = () =>
- setTabsHeight(tabs.getBoundingClientRect().height);
- updateHeight();
-
- const observer = new ResizeObserver(updateHeight);
- observer.observe(tabs);
-
- return () => observer.disconnect();
- }, []);
-
const preserveReadingPosition = useCallback(
(ids: Set) => {
if (returningToTop.current) return;
@@ -181,7 +338,7 @@ export default function Show({
style.overflowAnchor = 'none';
}
},
- [composer],
+ [composer, tabsRef],
);
const restoreReadingPosition = useCallback(() => {
@@ -759,11 +916,6 @@ export default function Show({
};
}, [log, viewerId]);
- // Showing a log remembers it, so pages prefetched before it still carry the previous log in their shared props.
- useEffect(() => {
- router.flushAll();
- }, [log]);
-
useEffect(
() =>
router.on('location', (event) => {
@@ -850,48 +1002,6 @@ export default function Show({
return (
<>
-
-
-
-
- {user !== null && (
-
-
-
- )}
-
{notice !== null && (
middleware(['throttle:exports', 'throttle:5,1'])
->name('export');
+ Route::put('settings/log', [UserLogController::class, 'update'])->name('user-log.update');
+
// User Lane...
Route::put('settings/lane', [UserLaneController::class, 'update'])->name('user-lane.update');
diff --git a/tests/Feature/Controllers/LogControllerTest.php b/tests/Feature/Controllers/LogControllerTest.php
index 0473daa49..3b2cee738 100644
--- a/tests/Feature/Controllers/LogControllerTest.php
+++ b/tests/Feature/Controllers/LogControllerTest.php
@@ -178,42 +178,24 @@
->each->not->toContain('count(*)');
});
-it('remembers the last visited log', function (Log $log): void {
- $user = User::factory()->create(['log' => $log === Log::Following ? Log::Trending : Log::Following]);
+it('reading or prefetching a log does not change the remembered log', function (Log $log, bool $prefetch): void {
+ $remembered = $log === Log::Following ? Log::Trending : Log::Following;
+ $user = User::factory()->create(['log' => $remembered]);
$response = $this->actingAs($user)
+ ->withHeaders($prefetch ? ['Purpose' => 'prefetch', 'X-Inertia' => 'true', 'X-Inertia-Version' => resolve(HandleInertiaRequests::class)->version(request())] : [])
->get(route('log.show', $log));
- $response->assertOk()
- ->assertInertia(fn ($page) => $page->where('auth.log', $log->value));
-
- expect($user->refresh()->log)->toBe($log);
-})->with(Log::cases());
-
-it('does not remember a log that is only prefetched', function (): void {
- $user = User::factory()->create(['log' => Log::Latest]);
-
- $this->actingAs($user)->withHeaders([
- 'X-Inertia' => 'true',
- 'X-Inertia-Version' => (string) new HandleInertiaRequests()->version(request()),
- 'Purpose' => 'prefetch',
- ])->get(route('log.show', Log::Following))->assertOk();
+ $response->assertOk();
- expect($user->refresh()->log)->toBe(Log::Latest);
-});
-
-it('does not update the user when the log is unchanged', function (): void {
- $user = User::factory()->create(['log' => Log::Trending]);
- $updatedAt = $user->updated_at;
-
- $this->travel(1)->minute();
-
- $this->actingAs($user)
- ->get(route('log.show', Log::Trending))
- ->assertOk();
+ if ($prefetch) {
+ $response->assertJsonPath('props.auth.log', $remembered->value);
+ } else {
+ $response->assertInertia(fn ($page) => $page->where('auth.log', $remembered->value));
+ }
- expect($user->refresh()->updated_at->equalTo($updatedAt))->toBeTrue();
-});
+ expect($user->refresh()->log)->toBe($remembered);
+})->with(Log::cases())->with([false, true]);
it('returns not found for unknown logs', function (): void {
$response = $this->actingAs(User::factory()->create())
diff --git a/tests/Feature/Controllers/UserLogControllerTest.php b/tests/Feature/Controllers/UserLogControllerTest.php
new file mode 100644
index 000000000..1cf753265
--- /dev/null
+++ b/tests/Feature/Controllers/UserLogControllerTest.php
@@ -0,0 +1,42 @@
+putJson(route('user-log.update'), ['log' => 'latest'])->assertUnauthorized();
+});
+
+it('remembers a viewed log only for the authenticated user', function (Log $log): void {
+ $user = User::factory()->create(['log' => Log::Following]);
+ $other = User::factory()->create(['log' => Log::Following]);
+
+ $this->actingAs($user)->putJson(route('user-log.update'), ['log' => $log->value])->assertNoContent();
+
+ expect($user->refresh()->log)->toBe($log)
+ ->and($other->refresh()->log)->toBe(Log::Following);
+})->with(Log::cases());
+
+it('rejects invalid log preferences', function (mixed $log): void {
+ $user = User::factory()->create(['log' => Log::Following]);
+
+ $this->actingAs($user)->putJson(route('user-log.update'), ['log' => $log])->assertUnprocessable();
+
+ expect($user->refresh()->log)->toBe(Log::Following);
+})->with([null, '', 'unknown', 12, [['latest']]]);
+
+it('does not write or announce an unchanged preference', function (): void {
+ Event::fake([UsersUpdated::class]);
+ $user = User::factory()->create(['log' => Log::Latest]);
+ $updatedAt = $user->updated_at;
+ $this->travel(1)->minute();
+
+ $this->actingAs($user)->putJson(route('user-log.update'), ['log' => 'latest'])->assertNoContent();
+
+ expect($user->refresh()->updated_at->equalTo($updatedAt))->toBeTrue();
+ Event::assertNotDispatched(UsersUpdated::class);
+});
diff --git a/tests/Feature/LiveUpdatesTest.php b/tests/Feature/LiveUpdatesTest.php
index 63f0edd5a..ea3fc789a 100644
--- a/tests/Feature/LiveUpdatesTest.php
+++ b/tests/Feature/LiveUpdatesTest.php
@@ -421,6 +421,30 @@ function recipientsOf(array $events): array
expect($this->get('/settings/appearance', [...$headers, 'Accept-Language' => 'pt-BR'])->json('props.auth.translationLanguage'))->toBe('de');
});
+it("keeps the remembered log across pages, and reads it again once another of the user's pages remembers one", function (): void {
+ $user = User::factory()->create(['log' => Log::Latest]);
+ $headers = ['X-Inertia' => 'true', 'X-Inertia-Version' => (string) new HandleInertiaRequests()->version(request())];
+
+ $page = $this->actingAs($user)->get('/settings/appearance', $headers)->json();
+
+ expect($page['props']['auth']['log'])->toBe('latest')
+ ->and($page['onceProps']['auth.log:'.$user->id]['prop'])->toBe('auth.log')
+ ->and($page['liveProps']['auth.log']['listeners'])->toBe([[
+ 'channel' => ['name' => 'users.'.$user->id, 'type' => 'private'],
+ 'events' => ['account'],
+ ]]);
+
+ $loaded = [...$headers, 'X-Inertia-Except-Once-Props' => 'auth.log:'.$user->id];
+ resolve(UpdateUserLog::class)->handle($user, Log::Trending);
+
+ expect($this->get('/settings/appearance', $loaded)->json('props.auth'))->not->toHaveKey('log')
+ ->and($this->get('/settings/appearance', [
+ ...$loaded,
+ 'X-Inertia-Partial-Component' => 'appearance/update',
+ 'X-Inertia-Partial-Data' => 'auth.log',
+ ])->json('props.auth.log'))->toBe('trending');
+});
+
it("tells the recipient's profile a pastel arrived, without its total, which each reader reads again as they may", function (): void {
$sender = User::factory()->create(['pasteis_mode' => true]);
$recipient = User::factory()->create();
diff --git a/tests/Unit/FollowRequests.test.tsx b/tests/Unit/FollowRequests.test.tsx
index c9df6c6c8..dbba73687 100644
--- a/tests/Unit/FollowRequests.test.tsx
+++ b/tests/Unit/FollowRequests.test.tsx
@@ -162,3 +162,15 @@ test('unrelated reads do not wait for a follow', async () => {
assert.equal(config.url, unrelated.url.href);
finish(follow);
});
+
+test('a follow from an instant log does not send a stale range boundary', async () => {
+ const instant = page();
+ delete instant.props.commits;
+ stop = trackFollowRequests(instant);
+
+ const follow = visit('follow', true);
+ const config = await start(follow);
+
+ assert.equal(config.headers?.['X-Log-Through'], undefined);
+ finish(follow);
+});
diff --git a/tests/Unit/LogInsertion.test.tsx b/tests/Unit/LogInsertion.test.tsx
index e01364717..f15e3f500 100644
--- a/tests/Unit/LogInsertion.test.tsx
+++ b/tests/Unit/LogInsertion.test.tsx
@@ -84,7 +84,9 @@ function scheduleEffect(
});
}
}
+const Deferred = () => null;
vi.doMock('@inertiajs/react', () => ({
+ Deferred,
Head: () => null,
Link: () => null,
usePage: () => ({ props: { auth: { organizations: [], user: viewer } } }),
@@ -122,6 +124,14 @@ type Element = ReactElement>;
function elements(node: ReactNode): Element[] {
if (!React.isValidElement(node)) return [];
const element = node as Element;
+ if (element.type === Deferred) {
+ // The loaded log renders in place of its skeleton.
+ const loaded = element.props.children as ReactElement<
+ Record,
+ (props: Record) => ReactNode
+ >;
+ return elements(loaded.type(loaded.props));
+ }
return [
element,
...React.Children.toArray(element.props.children as ReactNode).flatMap(
diff --git a/tests/Unit/LogNavigation.test.tsx b/tests/Unit/LogNavigation.test.tsx
new file mode 100644
index 000000000..8c347481c
--- /dev/null
+++ b/tests/Unit/LogNavigation.test.tsx
@@ -0,0 +1,267 @@
+///
+import assert from 'node:assert/strict';
+import type { InertiaLinkProps } from '@inertiajs/react';
+import * as React from 'react';
+import type { ComponentProps, ReactElement } from 'react';
+import { afterAll, afterEach, test, vi } from 'vite-plus/test';
+
+let pending: string | null = null;
+let user: { id: string; username: string } | null = null;
+const getPrefetching = vi.fn();
+const getCached = vi.fn();
+const flush = vi.fn();
+const replaceProp = vi.fn();
+
+vi.doMock('react', () => ({
+ ...React,
+ useEffect: () => {},
+ useLayoutEffect: () => {},
+ useRef: (initial: unknown) => ({ current: initial }),
+ useState: (initial: unknown) => [
+ initial,
+ (value: unknown) => {
+ pending = typeof value === 'function' ? value(pending) : value;
+ },
+ ],
+}));
+vi.doMock('@inertiajs/react', async () => ({
+ ...(await vi.importActual('@inertiajs/react')),
+ router: { getPrefetching, getCached, flush, replaceProp },
+ usePage: () => ({ props: { auth: { user, log: 'latest' } } }),
+}));
+vi.doMock('@/components/commit-composer-dialog', () => ({
+ useCommitDialogClosedCount: () => 0,
+}));
+vi.doMock('@/hooks/use-composer-visibility', () => ({
+ useComposerVisibility: () => {},
+}));
+vi.doMock('@/layouts/app-layout', () => ({ default: () => null }));
+
+const { PageTabs } = await import('../../resources/js/components/page-tabs');
+const { default: Show } = await import('../../resources/js/pages/log/show');
+const { pageReadAt, pageReadTime } =
+ await import('../../resources/js/lib/realtime');
+
+const dispatchEvent = vi.fn();
+vi.stubGlobal('window', {
+ location: { pathname: '/log/trending' },
+ dispatchEvent,
+});
+afterAll(() => vi.unstubAllGlobals());
+afterEach(() => {
+ pending = null;
+ user = null;
+ vi.clearAllMocks();
+ getPrefetching.mockReset();
+ getCached.mockReset();
+ window.location.pathname = '/log/trending';
+});
+
+function logTabs() {
+ const page = Show({ log: 'latest' } as ComponentProps);
+ const header = React.Children.toArray(
+ page.props.children,
+ )[1] as ReactElement<{
+ children: React.ReactNode;
+ }>;
+ const tabs = React.Children.toArray(
+ header.props.children,
+ )[1] as ReactElement>;
+
+ return tabs.props;
+}
+
+function trendingLink() {
+ const tabs = PageTabs(logTabs());
+
+ return (
+ React.Children.toArray(
+ tabs.props.children,
+ )[2] as ReactElement
+ ).props;
+}
+
+for (const viewer of [null, { id: 'viewer', username: 'viewer' }]) {
+ test(`${viewer ? 'signed-in' : 'guest'} instant log visits let Inertia carry once props with their metadata`, () => {
+ user = viewer;
+ const tab = logTabs().tabs[2];
+ const shared = {
+ auth: { user: viewer, log: 'latest' },
+ suggestedUsers: viewer
+ ? [{ id: 'author', username: 'author' }]
+ : undefined,
+ flash: { status: 'Saved' },
+ };
+
+ assert.equal(tab.component, 'log/show');
+ assert.equal(typeof tab.pageProps, 'function');
+ if (typeof tab.pageProps !== 'function') {
+ assert.fail('Expected instant page props');
+ }
+
+ const props = tab.pageProps(shared, shared);
+
+ assert.equal(props.log, 'trending');
+ assert.deepEqual(props.auth, { user: viewer, log: undefined });
+ assert.equal(props.suggestedUsers, undefined);
+ assert.deepEqual(props.flash, shared.flash);
+ assert.equal(shared.auth.log, 'latest');
+ });
+}
+
+test('clicking a pending prefetch exposes retry when that request fails', async () => {
+ const response = Promise.withResolvers();
+ getPrefetching.mockReturnValue({ response: response.promise });
+ const link = trendingLink();
+
+ link.onBefore?.(
+ {} as Parameters>[0],
+ );
+ assert.equal(pending, 'Trending');
+
+ response.reject(new Error('Network error'));
+ await response.promise.catch(() => {});
+
+ assert.equal(pending, null);
+ assert.deepEqual(replaceProp.mock.calls, [['logLoadFailed', true]]);
+});
+
+test('a completed pending prefetch does not show a load error', async () => {
+ const response = Promise.withResolvers();
+ getPrefetching.mockReturnValue({ response: response.promise });
+ const link = trendingLink();
+
+ link.onBefore?.(
+ {} as Parameters>[0],
+ );
+ response.resolve();
+ await response.promise;
+ link.onFinish?.(
+ {} as Parameters>[0],
+ );
+
+ assert.equal(pending, null);
+ assert.equal(replaceProp.mock.calls.length, 0);
+});
+
+test('an old prefetch failure cannot mark a different log as failed', async () => {
+ const response = Promise.withResolvers();
+ getPrefetching.mockReturnValue({ response: response.promise });
+ const link = trendingLink();
+
+ link.onBefore?.(
+ {} as Parameters>[0],
+ );
+ window.location.pathname = '/log/latest';
+ pending = 'Latest';
+ response.reject(new Error('Network error'));
+ await response.promise.catch(() => {});
+
+ assert.equal(pending, 'Latest');
+ assert.equal(replaceProp.mock.calls.length, 0);
+});
+
+test('guest Following keeps the server redirect instead of opening an instant log', () => {
+ const tab = logTabs().tabs[0];
+
+ assert.equal(tab.component, undefined);
+ assert.equal(tab.prefetch, false);
+ assert.equal(tab.onLoadError?.(), undefined);
+ assert.equal(replaceProp.mock.calls.length, 0);
+});
+
+test('a failed log visit uses the retry state instead of the default error dialog', () => {
+ const link = trendingLink();
+
+ assert.equal(
+ link.onHttpException?.(
+ {} as Parameters<
+ NonNullable
+ >[0],
+ ),
+ false,
+ );
+ assert.deepEqual(replaceProp.mock.calls, [['logLoadFailed', true]]);
+ assert.deepEqual(flush.mock.calls, [
+ [{ url: '/log/trending', method: 'get' }],
+ ]);
+});
+
+for (const [visit, prefetched] of [
+ ['shows a prefetched page', 'cached'],
+ ['waits for its prefetch', 'in flight'],
+ ['loads its own page', null],
+] as const) {
+ test(`an instant log visit that ${visit} ${prefetched === null ? 'was read once its request started' : 'catches up on what it missed since'}`, () => {
+ getPrefetching.mockReturnValue(
+ prefetched === 'in flight' ? { response: Promise.resolve() } : null,
+ );
+ getCached.mockReturnValue(
+ prefetched === 'cached' ? { response: Promise.resolve() } : null,
+ );
+ pageReadAt(1);
+ const link = trendingLink();
+
+ link.onBefore?.(
+ {} as Parameters>[0],
+ );
+
+ if (prefetched === null) {
+ const now = vi.spyOn(Date, 'now').mockReturnValue(5);
+ link.onStart?.(
+ {} as Parameters>[0],
+ );
+ now.mockRestore();
+ }
+
+ link.onSuccess?.(
+ {} as Parameters>[0],
+ );
+
+ assert.equal(pageReadTime(), prefetched === null ? 5 : null);
+ });
+}
+
+function currentTrendingTabs(commits?: { data: never[] }) {
+ const page = Show({ log: 'trending', commits } as unknown as ComponentProps<
+ typeof Show
+ >);
+ const header = React.Children.toArray(
+ page.props.children,
+ )[1] as ReactElement<{ children: React.ReactNode }>;
+
+ return (
+ React.Children.toArray(header.props.children)[1] as ReactElement<
+ ComponentProps
+ >
+ ).props.tabs;
+}
+
+test('selecting the current Trending reads its ranking again in place, never from a prefetch', () => {
+ const tabs = currentTrendingTabs({ data: [] });
+
+ tabs[2]?.onClick?.(
+ {} as Parameters>[0],
+ );
+
+ assert.deepEqual(flush.mock.calls, [
+ [{ url: '/log/trending', method: 'get' }],
+ ]);
+ assert.equal(tabs[2]?.component, undefined);
+ assert.equal(tabs[2]?.onLoadError, undefined);
+ assert.equal(tabs[0]?.onClick, undefined);
+ assert.equal(tabs[1]?.onClick, undefined);
+ assert.equal(tabs[1]?.component, 'log/show');
+});
+
+test('selecting Trending again while it is still loading leaves that load alone', () => {
+ const preventDefault = vi.fn();
+
+ currentTrendingTabs()[2]?.onClick?.({
+ button: 0,
+ preventDefault,
+ } as unknown as Parameters>[0]);
+
+ assert.equal(preventDefault.mock.calls.length, 1);
+ assert.equal(flush.mock.calls.length, 0);
+});
diff --git a/tests/Unit/LogPreference.test.ts b/tests/Unit/LogPreference.test.ts
new file mode 100644
index 000000000..4746fe36d
--- /dev/null
+++ b/tests/Unit/LogPreference.test.ts
@@ -0,0 +1,107 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { createLogPreference } from '../../resources/js/lib/log-preference.ts';
+import type { Log } from '../../resources/js/types/index.ts';
+
+const tick = () => new Promise((resolve) => setImmediate(resolve));
+
+void test('rapid tab visits serialize saves and keep only the latest queued log', async () => {
+ const writes: Log[] = [];
+ const complete: (() => void)[] = [];
+ const { remember } = createLogPreference(
+ 'following',
+ (log) => {
+ writes.push(log);
+ return new Promise((resolve) => complete.push(resolve));
+ },
+ () => assert.fail('unexpected save failure'),
+ () => assert.fail('nothing was heard from another page'),
+ );
+
+ remember('latest');
+ remember('trending');
+ remember('following');
+ assert.deepEqual(writes, ['latest']);
+ complete.shift()!();
+ await tick();
+ assert.deepEqual(writes, ['latest', 'following']);
+ complete.shift()!();
+ await tick();
+ remember('following');
+ assert.deepEqual(writes, ['latest', 'following']);
+});
+
+void test('a failed save reports once and a different log can still be saved', async () => {
+ let attempts = 0;
+ let failures = 0;
+ const { remember } = createLogPreference(
+ 'following',
+ async () => {
+ if (++attempts === 1) throw new Error('offline');
+ },
+ () => failures++,
+ () => assert.fail('nothing was heard from another page'),
+ );
+
+ remember('latest');
+ await tick();
+ assert.equal(failures, 1);
+ assert.equal(attempts, 1);
+ remember('trending');
+ await tick();
+ assert.equal(attempts, 2);
+ remember('trending');
+ assert.equal(attempts, 2);
+});
+
+void test('a failed older save does not discard the newest tab', async () => {
+ const writes: Log[] = [];
+ let fail: (reason: Error) => void = () => {};
+ const { remember } = createLogPreference(
+ 'following',
+ (log) => {
+ writes.push(log);
+ return writes.length === 1
+ ? new Promise((_, reject) => {
+ fail = reject;
+ })
+ : Promise.resolve();
+ },
+ () => assert.fail('superseded failures must not show a toast'),
+ () => assert.fail('nothing was heard from another page'),
+ );
+
+ remember('latest');
+ remember('trending');
+ fail(new Error('offline'));
+ await tick();
+ assert.deepEqual(writes, ['latest', 'trending']);
+});
+
+void test('a log heard while this page is saving its own is read again once the saves are done', async () => {
+ const writes: Log[] = [];
+ const complete: (() => void)[] = [];
+ let reads = 0;
+ const { remember, adopt } = createLogPreference(
+ 'following',
+ (log) => {
+ writes.push(log);
+ return new Promise((resolve) => complete.push(resolve));
+ },
+ () => assert.fail('unexpected save failure'),
+ () => reads++,
+ );
+
+ remember('latest');
+ assert.equal(adopt('trending'), false);
+ remember('trending');
+ complete.shift()!();
+ await tick();
+ assert.equal(reads, 0);
+ complete.shift()!();
+ await tick();
+ assert.equal(reads, 1);
+ assert.equal(adopt('latest'), true);
+ remember('trending');
+ assert.deepEqual(writes, ['latest', 'trending', 'trending']);
+});
diff --git a/tests/Unit/RememberLog.test.tsx b/tests/Unit/RememberLog.test.tsx
new file mode 100644
index 000000000..0e4f91967
--- /dev/null
+++ b/tests/Unit/RememberLog.test.tsx
@@ -0,0 +1,212 @@
+///
+import assert from 'node:assert/strict';
+import { afterEach, test, vi } from 'vite-plus/test';
+import type { Log } from '../../resources/js/types';
+import { hookHarness } from './HookHarness';
+
+const harness = hookHarness();
+const page = {
+ component: 'log/show',
+ url: '/log/following',
+ props: {} as {
+ auth: { user: { id: string }; log: Log };
+ log?: Log;
+ commits?: { data: never[] };
+ },
+};
+const saves: { log: Log; done: () => void }[] = [];
+const replaced: [string, Log][] = [];
+const reloads: string[][] = [];
+type VisitEvent = { detail: { visit: { only: string[] } } };
+const listeners = {
+ start: new Set<(event: VisitEvent) => void>(),
+ finish: new Set<(event: VisitEvent) => void>(),
+};
+let payload = (): { log: Log } => ({ log: 'following' });
+const transform = (data: () => { log: Log }) => {
+ payload = data;
+};
+const put = () =>
+ new Promise((resolve) => {
+ saves.push({ log: payload().log, done: resolve });
+ });
+
+vi.doMock('react', () => harness.react);
+vi.doMock('@inertiajs/react', async () => ({
+ ...(await vi.importActual('@inertiajs/react')),
+ usePage: () => page,
+ useHttp: () => ({ put, transform }),
+ router: {
+ replaceProp: (prop: string, value: Log) => {
+ replaced.push([prop, value]);
+ page.props.auth.log = value;
+ },
+ reload: (options: { only?: string[]; onFinish?: () => void }) => {
+ reloads.push(options.only ?? []);
+ options.onFinish?.();
+ },
+ on: (
+ name: keyof typeof listeners,
+ listener: (event: VisitEvent) => void,
+ ) => {
+ listeners[name].add(listener);
+
+ return () => listeners[name].delete(listener);
+ },
+ },
+}));
+vi.doMock('sonner', () => ({
+ toast: { error: () => assert.fail('unexpected save failure') },
+}));
+
+const { useRememberLog } =
+ await import('../../resources/js/hooks/use-remember-log');
+
+function RememberLog() {
+ useRememberLog();
+
+ return null;
+}
+
+const show = (log: Log, remembered: Log) => {
+ page.component = 'log/show';
+ page.url = `/log/${log}`;
+ page.props = {
+ auth: { user: { id: 'viewer' }, log: remembered },
+ log,
+ commits: { data: [] },
+ };
+};
+
+const profile = (remembered: Log) => {
+ page.component = 'user/show';
+ page.url = '/@someone';
+ page.props = { auth: { user: { id: 'viewer' }, log: remembered } };
+};
+
+/** Renders again whenever the hook replaced the remembered log, as the page update that follows would. */
+const rerender = () => {
+ let remembered: Log;
+
+ do {
+ remembered = page.props.auth.log;
+ harness.rerender();
+ } while (page.props.auth.log !== remembered);
+};
+
+const emit = (name: keyof typeof listeners, only: string[]) =>
+ listeners[name].forEach((listener) =>
+ listener({ detail: { visit: { only } } }),
+ );
+
+/** A request reads the remembered log again, and the page it answers with shows it. */
+const hear = (log: Log, url = page.url) => {
+ emit('start', ['auth.log']);
+ page.url = url;
+ page.props.auth.log = log;
+ rerender();
+ emit('finish', ['auth.log']);
+};
+
+const settle = () => new Promise((resolve) => setImmediate(resolve));
+
+afterEach(() => {
+ harness.unmount();
+ saves.length = 0;
+ replaced.length = 0;
+ reloads.length = 0;
+});
+
+test("a log another page remembered shows here without saving this page's log again", async () => {
+ show('latest', 'latest');
+ void harness.render(RememberLog, {});
+ hear('trending');
+
+ assert.equal(saves.length, 0);
+ assert.equal(replaced.length, 0);
+
+ show('following', 'trending');
+ rerender();
+
+ assert.deepEqual(
+ saves.map((save) => save.log),
+ ['following'],
+ );
+ assert.deepEqual(replaced, [['auth.log', 'following']]);
+
+ saves[0]?.done();
+ await settle();
+
+ assert.equal(reloads.length, 0);
+});
+
+test('a log heard while this page is still saving its own is read again once the save is done', async () => {
+ show('latest', 'trending');
+ void harness.render(RememberLog, {});
+ rerender();
+
+ assert.deepEqual(
+ saves.map((save) => save.log),
+ ['latest'],
+ );
+ assert.deepEqual(replaced, [['auth.log', 'latest']]);
+
+ hear('trending');
+
+ assert.equal(page.props.auth.log, 'latest');
+ assert.equal(reloads.length, 0);
+
+ saves[0]?.done();
+ await settle();
+
+ assert.deepEqual(reloads, [['auth.log']]);
+
+ hear('trending');
+
+ assert.equal(page.props.auth.log, 'trending');
+ assert.equal(saves.length, 1);
+});
+
+test('going back to a log remembers it again, instead of taking the log that page showed then', async () => {
+ show('following', 'following');
+ void harness.render(RememberLog, {});
+ show('latest', 'following');
+ rerender();
+ saves[0]?.done();
+ await settle();
+
+ // History brings the Following page back as it was, with the log it showed then.
+ show('following', 'following');
+ rerender();
+
+ assert.deepEqual(
+ saves.map((save) => save.log),
+ ['latest', 'following'],
+ );
+});
+
+test('a page from history keeps the log remembered since it was shown', async () => {
+ profile('following');
+ void harness.render(RememberLog, {});
+ show('latest', 'following');
+ rerender();
+ saves[0]?.done();
+ await settle();
+
+ profile('following');
+ rerender();
+
+ assert.equal(page.props.auth.log, 'latest');
+ assert.equal(saves.length, 1);
+});
+
+test('a log read again shows even when the page comes back with its address written differently', () => {
+ page.component = 'search/index';
+ page.url = '/search?q=foo+bar';
+ page.props = { auth: { user: { id: 'viewer' }, log: 'latest' } };
+ void harness.render(RememberLog, {});
+ hear('trending', '/search?q=foo%20bar');
+
+ assert.equal(page.props.auth.log, 'trending');
+ assert.equal(replaced.length, 0);
+});