From a42145c6780ff15bf6a185ecb6eeb2fa4657826c Mon Sep 17 00:00:00 2001 From: "Moon.eth" Date: Wed, 29 Jul 2026 12:51:33 +0900 Subject: [PATCH] more targeted refresh --- src/App.svelte | 4 +- src/components/common/RefreshHotkey.svelte | 7 +-- src/components/common/RefreshHotkey.test.ts | 14 ++++++ src/lib/timeline-refresh.test.ts | 24 ++++++++++ src/lib/timeline-refresh.ts | 40 +++++++++++++++++ src/routes/Home.svelte | 50 +++++++++++++++++++++ src/routes/Home.test.ts | 40 +++++++++++++++++ src/routes/Profile.svelte | 9 ++++ src/routes/Profile.test.ts | 28 ++++++++++++ src/routes/StatusPage.svelte | 13 ++++++ src/routes/Timeline.svelte | 4 ++ 11 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 src/lib/timeline-refresh.test.ts create mode 100644 src/lib/timeline-refresh.ts create mode 100644 src/routes/Home.test.ts diff --git a/src/App.svelte b/src/App.svelte index c5b0462..e7b5559 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -19,8 +19,10 @@ import Compose from '$routes/Compose.svelte' import NotFound from '$routes/NotFound.svelte' import type { TimelineKind } from '$lib/api/endpoints' + import { provideTimelineRefresh } from '$lib/timeline-refresh' const { router, session } = useAppServices() + const timelineRefresh = provideTimelineRefresh() let booted = $state(false) @@ -57,7 +59,7 @@ ) - + timelineRefresh.refresh()} />
diff --git a/src/components/common/RefreshHotkey.svelte b/src/components/common/RefreshHotkey.svelte index e416436..a8e8254 100644 --- a/src/components/common/RefreshHotkey.svelte +++ b/src/components/common/RefreshHotkey.svelte @@ -6,7 +6,8 @@ * navigating, and editable controls are ignored so composing text is safe. */ interface Props { - onrefresh?: () => void + /** Returning false means this page has no refreshable timeline. */ + onrefresh?: () => void | boolean } let { onrefresh = () => window.location.reload() }: Props = $props() @@ -35,8 +36,8 @@ return } - event.preventDefault() - onrefresh() + const handled = onrefresh() + if (handled !== false) event.preventDefault() } diff --git a/src/components/common/RefreshHotkey.test.ts b/src/components/common/RefreshHotkey.test.ts index 486fb18..4106dbf 100644 --- a/src/components/common/RefreshHotkey.test.ts +++ b/src/components/common/RefreshHotkey.test.ts @@ -33,4 +33,18 @@ describe('RefreshHotkey', () => { expect(onrefresh).not.toHaveBeenCalled() }) + + it('leaves the shortcut unclaimed when the page has no timeline', () => { + render(RefreshHotkey, { props: { onrefresh: () => false } }) + const event = new KeyboardEvent('keydown', { + key: 'R', + shiftKey: true, + bubbles: true, + cancelable: true, + }) + + window.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + }) }) diff --git a/src/lib/timeline-refresh.test.ts b/src/lib/timeline-refresh.test.ts new file mode 100644 index 0000000..abfd9d8 --- /dev/null +++ b/src/lib/timeline-refresh.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import { TimelineRefreshController } from './timeline-refresh' + +describe('TimelineRefreshController', () => { + it('refreshes only the currently registered route target', () => { + const controller = new TimelineRefreshController() + const first = vi.fn() + const second = vi.fn() + const unregisterFirst = controller.register(first) + const unregisterSecond = controller.register(second) + + expect(controller.refresh()).toBe(true) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledOnce() + + // A stale route cleanup cannot remove the current route's target. + unregisterFirst() + expect(controller.refresh()).toBe(true) + expect(second).toHaveBeenCalledTimes(2) + + unregisterSecond() + expect(controller.refresh()).toBe(false) + }) +}) diff --git a/src/lib/timeline-refresh.ts b/src/lib/timeline-refresh.ts new file mode 100644 index 0000000..9fc4fb6 --- /dev/null +++ b/src/lib/timeline-refresh.ts @@ -0,0 +1,40 @@ +/** + * The active route's primary timeline refresh. + * + * App owns one controller and routes register while mounted. The guarded + * cleanup matters during navigation: an old route must not clear a newer + * route's callback if Svelte mounts the replacement before destroying it. + */ +import { getContext, setContext } from 'svelte' + +export type TimelineRefresh = () => void + +export class TimelineRefreshController { + private current: TimelineRefresh | null = null + + register(refresh: TimelineRefresh): () => void { + this.current = refresh + return () => { + if (this.current === refresh) this.current = null + } + } + + refresh(): boolean { + if (!this.current) return false + this.current() + return true + } +} + +export const TIMELINE_REFRESH = Symbol('plspace.timeline-refresh') + +export function provideTimelineRefresh(): TimelineRefreshController { + const controller = new TimelineRefreshController() + setContext(TIMELINE_REFRESH, controller) + return controller +} + +/** Routes remain independently renderable in tests without this context. */ +export function useTimelineRefresh(): TimelineRefreshController | null { + return getContext(TIMELINE_REFRESH) ?? null +} diff --git a/src/routes/Home.svelte b/src/routes/Home.svelte index 78a5d1f..3b8ad26 100644 --- a/src/routes/Home.svelte +++ b/src/routes/Home.svelte @@ -17,12 +17,14 @@ import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile' import { toPlainText } from '$lib/util/html' import { relativeTime, shortDate, stampDate } from '$lib/util/time' + import { useTimelineRefresh } from '$lib/timeline-refresh' import Module from '$components/common/Module.svelte' import Avatar from '$components/common/Avatar.svelte' import RichText from '$components/common/RichText.svelte' import Composer from '$components/blog/Composer.svelte' const { endpoints, session } = useAppServices() + const timelineRefresh = useTimelineRefresh() let friendStatus = $state([]) let bulletins = $state([]) @@ -38,6 +40,7 @@ let friendStatusError = $state(null) let bulletinError = $state(null) let loadGeneration = 0 + let primaryRefreshGeneration = 0 const me = $derived(session.me) const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '') @@ -76,6 +79,13 @@ } }) + $effect(() => { + const host = session.host + const signedIn = session.signedIn + if (!timelineRefresh || !host) return + return timelineRefresh.register(() => void refreshPrimaryTimeline(signedIn, host)) + }) + async function load(signedIn: boolean, host = session.host, generation = ++loadGeneration): Promise { loading = true error = null @@ -132,6 +142,46 @@ } } + /** Refresh only the dashboard's visible primary timeline, not its sidebars. */ + async function refreshPrimaryTimeline(signedIn: boolean, host: string): Promise { + const generation = ++primaryRefreshGeneration + + if (signedIn) { + friendStatusError = null + try { + const page = await endpoints.fetchTimeline(session.api, 'home', { limit: 10 }) + if ( + generation === primaryRefreshGeneration && + session.host === host && + session.signedIn === signedIn + ) { + friendStatus = page.items + } + } catch (cause) { + if (generation === primaryRefreshGeneration && session.host === host) { + friendStatusError = messageOf(cause) + } + } + return + } + + bulletinError = null + try { + const page = await endpoints.fetchTimeline(session.api, 'local', { limit: 10 }) + if ( + generation === primaryRefreshGeneration && + session.host === host && + session.signedIn === signedIn + ) { + bulletins = page.items + } + } catch (cause) { + if (generation === primaryRefreshGeneration && session.host === host) { + bulletinError = messageOf(cause) + } + } + } + function moodFor(status: Status): string { return fallbackMood(status.account.id) } diff --git a/src/routes/Home.test.ts b/src/routes/Home.test.ts new file mode 100644 index 0000000..b947386 --- /dev/null +++ b/src/routes/Home.test.ts @@ -0,0 +1,40 @@ +import { render, waitFor } from '@testing-library/svelte' +import { describe, expect, it, vi } from 'vitest' +import { APP_SERVICES } from '$lib/app-services' +import { TIMELINE_REFRESH, TimelineRefreshController } from '$lib/timeline-refresh' +import { account, session, testServices } from '$test/fixtures' +import Home from './Home.svelte' + +describe('Home timeline refresh', () => { + it('refreshes Friend Status without reloading bulletins or sidebar lists', async () => { + const fetchTimeline = vi.fn().mockResolvedValue({ items: [], links: {} }) + const fetchFollowing = vi.fn().mockResolvedValue({ items: [], links: {} }) + const fetchNotifications = vi.fn().mockResolvedValue({ items: [], links: {} }) + const services = testServices({ + session: session({ + token: 'token', + me: account(), + signedIn: true, + }), + endpoints: { fetchTimeline, fetchFollowing, fetchNotifications }, + }) + const refresh = new TimelineRefreshController() + const context = new Map() + context.set(APP_SERVICES, services) + context.set(TIMELINE_REFRESH, refresh) + + render(Home, { context }) + + await waitFor(() => expect(fetchTimeline).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(fetchFollowing).toHaveBeenCalledOnce()) + await waitFor(() => expect(fetchNotifications).toHaveBeenCalledOnce()) + + expect(refresh.refresh()).toBe(true) + await waitFor(() => expect(fetchTimeline).toHaveBeenCalledTimes(3)) + + expect(fetchTimeline.mock.calls.filter((call) => call[1] === 'home')).toHaveLength(2) + expect(fetchTimeline.mock.calls.filter((call) => call[1] === 'local')).toHaveLength(1) + expect(fetchFollowing).toHaveBeenCalledOnce() + expect(fetchNotifications).toHaveBeenCalledOnce() + }) +}) diff --git a/src/routes/Profile.svelte b/src/routes/Profile.svelte index 79ea3eb..1b13957 100644 --- a/src/routes/Profile.svelte +++ b/src/routes/Profile.svelte @@ -14,6 +14,7 @@ import { useAppServices } from '$lib/app-services' import { Feed } from '$lib/stores/feed.svelte' import { profileCssFromFields } from '$lib/stores/theme.svelte' + import { useTimelineRefresh } from '$lib/timeline-refresh' import { buildProfileView, displayNameOf, @@ -42,6 +43,7 @@ let { acct, view = 'profile' }: Props = $props() const { endpoints, session, theme } = useAppServices() + const timelineRefresh = useTimelineRefresh() let account = $state(null) let relationship = $state(null) let loading = $state(true) @@ -83,6 +85,13 @@ } }) + $effect(() => { + const currentView = view + const loaded = account !== null + if (!timelineRefresh || !loaded || currentView === 'friends') return + return timelineRefresh.register(() => void entries.reload()) + }) + async function load( handle: string, currentView: Props['view'], diff --git a/src/routes/Profile.test.ts b/src/routes/Profile.test.ts index c05ddb0..7485e20 100644 --- a/src/routes/Profile.test.ts +++ b/src/routes/Profile.test.ts @@ -2,6 +2,7 @@ import { act, render, waitFor } from '@testing-library/svelte' import { describe, expect, it, vi } from 'vitest' import { APP_SERVICES } from '$lib/app-services' import type { Account } from '$lib/api/types' +import { TIMELINE_REFRESH, TimelineRefreshController } from '$lib/timeline-refresh' import { account, deferred, session, status, testServices, theme } from '$test/fixtures' import Profile from './Profile.svelte' @@ -139,4 +140,31 @@ describe('Profile', () => { expect(view.queryByText('A movie')).not.toBeInTheDocument() expect(view.queryByText('A reposted picture')).not.toBeInTheDocument() }) + + it('refreshes only the entry feed without reloading the profile', async () => { + const lookupAccount = vi.fn().mockResolvedValue(account()) + const fetchAccountStatuses = vi.fn().mockResolvedValue({ + items: [status({ content: '

Timeline note

' })], + links: {}, + }) + const services = testServices({ + session: session(), + theme: theme(), + endpoints: { lookupAccount, fetchAccountStatuses }, + }) + const refresh = new TimelineRefreshController() + const context = new Map() + context.set(APP_SERVICES, services) + context.set(TIMELINE_REFRESH, refresh) + const view = render(Profile, { + props: { acct: 'alice', view: 'blog' }, + context, + }) + + expect(await view.findByText('Timeline note')).toBeInTheDocument() + expect(refresh.refresh()).toBe(true) + + await waitFor(() => expect(fetchAccountStatuses).toHaveBeenCalledTimes(2)) + expect(lookupAccount).toHaveBeenCalledOnce() + }) }) diff --git a/src/routes/StatusPage.svelte b/src/routes/StatusPage.svelte index 4dfa805..0a574f8 100644 --- a/src/routes/StatusPage.svelte +++ b/src/routes/StatusPage.svelte @@ -12,6 +12,7 @@ import { displayNameOf, profilePath } from '$lib/util/profile' import { stampDate, isoDate } from '$lib/util/time' import { toPlainText } from '$lib/util/html' + import { useTimelineRefresh } from '$lib/timeline-refresh' import Module from '$components/common/Module.svelte' import Avatar from '$components/common/Avatar.svelte' import RichText from '$components/common/RichText.svelte' @@ -25,6 +26,7 @@ let { id }: Props = $props() const { endpoints, session } = useAppServices() + const timelineRefresh = useTimelineRefresh() let status = $state(null) let ancestors = $state([]) let descendants = $state([]) @@ -88,6 +90,17 @@ } }) + $effect(() => { + const currentId = id + const host = session.host + const loaded = status !== null + if (!timelineRefresh || !host || !loaded) return + return timelineRefresh.register(() => { + const generation = ++loadGeneration + void load(currentId, host, generation) + }) + }) + async function load(currentId: string, host: string, generation: number): Promise { loading = true error = null diff --git a/src/routes/Timeline.svelte b/src/routes/Timeline.svelte index 41e95d8..95b3591 100644 --- a/src/routes/Timeline.svelte +++ b/src/routes/Timeline.svelte @@ -9,6 +9,7 @@ import { useAppServices } from '$lib/app-services' import { Feed } from '$lib/stores/feed.svelte' import { instanceDomain } from '$lib/api/endpoints' + import { useTimelineRefresh } from '$lib/timeline-refresh' import Module from '$components/common/Module.svelte' import TabBar from '$components/common/TabBar.svelte' import BlogList from '$components/blog/BlogList.svelte' @@ -22,6 +23,7 @@ let { kind, tag }: Props = $props() const { endpoints, session } = useAppServices() + const timelineRefresh = useTimelineRefresh() let feed = $state>(new Feed(async () => ({ items: [], links: {} }))) const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '') @@ -61,6 +63,8 @@ document.title = `${title} | plspace` }) }) + + $effect(() => timelineRefresh?.register(() => void feed.reload()))