more targeted refresh

This commit is contained in:
Moon.eth
2026-07-29 12:51:33 +09:00
parent 4f682857ac
commit a42145c678
11 changed files with 229 additions and 4 deletions
+3 -1
View File
@@ -19,8 +19,10 @@
import Compose from '$routes/Compose.svelte' import Compose from '$routes/Compose.svelte'
import NotFound from '$routes/NotFound.svelte' import NotFound from '$routes/NotFound.svelte'
import type { TimelineKind } from '$lib/api/endpoints' import type { TimelineKind } from '$lib/api/endpoints'
import { provideTimelineRefresh } from '$lib/timeline-refresh'
const { router, session } = useAppServices() const { router, session } = useAppServices()
const timelineRefresh = provideTimelineRefresh()
let booted = $state(false) let booted = $state(false)
@@ -57,7 +59,7 @@
) )
</script> </script>
<RefreshHotkey /> <RefreshHotkey onrefresh={() => timelineRefresh.refresh()} />
<div class="site"> <div class="site">
<SiteHeader /> <SiteHeader />
+4 -3
View File
@@ -6,7 +6,8 @@
* navigating, and editable controls are ignored so composing text is safe. * navigating, and editable controls are ignored so composing text is safe.
*/ */
interface Props { interface Props {
onrefresh?: () => void /** Returning false means this page has no refreshable timeline. */
onrefresh?: () => void | boolean
} }
let { onrefresh = () => window.location.reload() }: Props = $props() let { onrefresh = () => window.location.reload() }: Props = $props()
@@ -35,8 +36,8 @@
return return
} }
event.preventDefault() const handled = onrefresh()
onrefresh() if (handled !== false) event.preventDefault()
} }
</script> </script>
@@ -33,4 +33,18 @@ describe('RefreshHotkey', () => {
expect(onrefresh).not.toHaveBeenCalled() 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)
})
}) })
+24
View File
@@ -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)
})
})
+40
View File
@@ -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<TimelineRefreshController | undefined>(TIMELINE_REFRESH) ?? null
}
+50
View File
@@ -17,12 +17,14 @@
import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile' import { displayNameOf, fallbackMood, formatCount, profilePath } from '$lib/util/profile'
import { toPlainText } from '$lib/util/html' import { toPlainText } from '$lib/util/html'
import { relativeTime, shortDate, stampDate } from '$lib/util/time' import { relativeTime, shortDate, stampDate } from '$lib/util/time'
import { useTimelineRefresh } from '$lib/timeline-refresh'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte' import Avatar from '$components/common/Avatar.svelte'
import RichText from '$components/common/RichText.svelte' import RichText from '$components/common/RichText.svelte'
import Composer from '$components/blog/Composer.svelte' import Composer from '$components/blog/Composer.svelte'
const { endpoints, session } = useAppServices() const { endpoints, session } = useAppServices()
const timelineRefresh = useTimelineRefresh()
let friendStatus = $state<Status[]>([]) let friendStatus = $state<Status[]>([])
let bulletins = $state<Status[]>([]) let bulletins = $state<Status[]>([])
@@ -38,6 +40,7 @@
let friendStatusError = $state<string | null>(null) let friendStatusError = $state<string | null>(null)
let bulletinError = $state<string | null>(null) let bulletinError = $state<string | null>(null)
let loadGeneration = 0 let loadGeneration = 0
let primaryRefreshGeneration = 0
const me = $derived(session.me) const me = $derived(session.me)
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '') 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<void> { async function load(signedIn: boolean, host = session.host, generation = ++loadGeneration): Promise<void> {
loading = true loading = true
error = null 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<void> {
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 { function moodFor(status: Status): string {
return fallbackMood(status.account.id) return fallbackMood(status.account.id)
} }
+40
View File
@@ -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()
})
})
+9
View File
@@ -14,6 +14,7 @@
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import { Feed } from '$lib/stores/feed.svelte' import { Feed } from '$lib/stores/feed.svelte'
import { profileCssFromFields } from '$lib/stores/theme.svelte' import { profileCssFromFields } from '$lib/stores/theme.svelte'
import { useTimelineRefresh } from '$lib/timeline-refresh'
import { import {
buildProfileView, buildProfileView,
displayNameOf, displayNameOf,
@@ -42,6 +43,7 @@
let { acct, view = 'profile' }: Props = $props() let { acct, view = 'profile' }: Props = $props()
const { endpoints, session, theme } = useAppServices() const { endpoints, session, theme } = useAppServices()
const timelineRefresh = useTimelineRefresh()
let account = $state<Account | null>(null) let account = $state<Account | null>(null)
let relationship = $state<Relationship | null>(null) let relationship = $state<Relationship | null>(null)
let loading = $state(true) 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( async function load(
handle: string, handle: string,
currentView: Props['view'], currentView: Props['view'],
+28
View File
@@ -2,6 +2,7 @@ import { act, render, waitFor } from '@testing-library/svelte'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { APP_SERVICES } from '$lib/app-services' import { APP_SERVICES } from '$lib/app-services'
import type { Account } from '$lib/api/types' 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 { account, deferred, session, status, testServices, theme } from '$test/fixtures'
import Profile from './Profile.svelte' import Profile from './Profile.svelte'
@@ -139,4 +140,31 @@ describe('Profile', () => {
expect(view.queryByText('A movie')).not.toBeInTheDocument() expect(view.queryByText('A movie')).not.toBeInTheDocument()
expect(view.queryByText('A reposted picture')).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: '<p>Timeline note</p>' })],
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()
})
}) })
+13
View File
@@ -12,6 +12,7 @@
import { displayNameOf, profilePath } from '$lib/util/profile' import { displayNameOf, profilePath } from '$lib/util/profile'
import { stampDate, isoDate } from '$lib/util/time' import { stampDate, isoDate } from '$lib/util/time'
import { toPlainText } from '$lib/util/html' import { toPlainText } from '$lib/util/html'
import { useTimelineRefresh } from '$lib/timeline-refresh'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import Avatar from '$components/common/Avatar.svelte' import Avatar from '$components/common/Avatar.svelte'
import RichText from '$components/common/RichText.svelte' import RichText from '$components/common/RichText.svelte'
@@ -25,6 +26,7 @@
let { id }: Props = $props() let { id }: Props = $props()
const { endpoints, session } = useAppServices() const { endpoints, session } = useAppServices()
const timelineRefresh = useTimelineRefresh()
let status = $state<Status | null>(null) let status = $state<Status | null>(null)
let ancestors = $state<Status[]>([]) let ancestors = $state<Status[]>([])
let descendants = $state<Status[]>([]) let descendants = $state<Status[]>([])
@@ -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<void> { async function load(currentId: string, host: string, generation: number): Promise<void> {
loading = true loading = true
error = null error = null
+4
View File
@@ -9,6 +9,7 @@
import { useAppServices } from '$lib/app-services' import { useAppServices } from '$lib/app-services'
import { Feed } from '$lib/stores/feed.svelte' import { Feed } from '$lib/stores/feed.svelte'
import { instanceDomain } from '$lib/api/endpoints' import { instanceDomain } from '$lib/api/endpoints'
import { useTimelineRefresh } from '$lib/timeline-refresh'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import TabBar from '$components/common/TabBar.svelte' import TabBar from '$components/common/TabBar.svelte'
import BlogList from '$components/blog/BlogList.svelte' import BlogList from '$components/blog/BlogList.svelte'
@@ -22,6 +23,7 @@
let { kind, tag }: Props = $props() let { kind, tag }: Props = $props()
const { endpoints, session } = useAppServices() const { endpoints, session } = useAppServices()
const timelineRefresh = useTimelineRefresh()
let feed = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} }))) let feed = $state<Feed<Status>>(new Feed<Status>(async () => ({ items: [], links: {} })))
const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '') const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '')
@@ -61,6 +63,8 @@
document.title = `${title} | plspace` document.title = `${title} | plspace`
}) })
}) })
$effect(() => timelineRefresh?.register(() => void feed.reload()))
</script> </script>
<div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}> <div class="page timeline-page" data-timeline={kind} data-tag={tag ?? ''}>