Skip to content

Commit 2a3b13a

Browse files
committed
fix: create shared cache for user preferences provier, enhance useAtproto mocking
1 parent 2ba237c commit 2a3b13a

File tree

5 files changed

+136
-73
lines changed

5 files changed

+136
-73
lines changed

app/composables/atproto/useAtproto.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,21 @@
1-
export const useAtproto = createSharedComposable(function useAtproto() {
1+
import type { PublicUserSession } from '#shared/schemas/publicUserSession'
2+
3+
export interface UseAtprotoReturn {
4+
user: Ref<PublicUserSession | null | undefined>
5+
pending: Ref<boolean>
6+
logout: () => Promise<void>
7+
}
8+
9+
declare global {
10+
// eslint-disable-next-line no-var
11+
var __useAtprotoMock: UseAtprotoReturn | undefined
12+
}
13+
14+
function _useAtprotoImpl(): UseAtprotoReturn {
15+
if (import.meta.test && globalThis.__useAtprotoMock) {
16+
return globalThis.__useAtprotoMock
17+
}
18+
219
const {
320
data: user,
421
pending,
@@ -17,4 +34,10 @@ export const useAtproto = createSharedComposable(function useAtproto() {
1734
}
1835

1936
return { user, pending, logout }
20-
})
37+
}
38+
39+
// In tests, skip createSharedComposable so each call checks globalThis.__useAtprotoMock fresh.
40+
// In production, import.meta.test is false and the test branch is tree-shaken.
41+
export const useAtproto = import.meta.test
42+
? _useAtprotoImpl
43+
: createSharedComposable(_useAtprotoImpl)

app/composables/useUserPreferencesProvider.ts

Lines changed: 75 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
* a ref with defaults (no real localStorage); on the client, there's only one app instance.
1212
*/
1313

14-
import type { RemovableRef } from '@vueuse/core'
1514
import { useLocalStorage } from '@vueuse/core'
1615
import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences'
1716
import {
@@ -22,56 +21,83 @@ import {
2221

2322
const STORAGE_KEY = 'npmx-user-preferences'
2423

25-
let dataRef: RemovableRef<HydratedUserPreferences> | null = null
24+
let cached: ReturnType<typeof createProvider> | null = null
2625
let syncInitialized = false
2726

28-
export function useUserPreferencesProvider(
29-
defaultValue: HydratedUserPreferences = DEFAULT_USER_PREFERENCES,
30-
) {
31-
if (!dataRef) {
32-
dataRef = useLocalStorage<HydratedUserPreferences>(STORAGE_KEY, defaultValue, {
33-
mergeDefaults: true,
34-
})
35-
}
27+
function createProvider(defaultValue: HydratedUserPreferences) {
28+
const preferences = useLocalStorage<HydratedUserPreferences>(STORAGE_KEY, defaultValue, {
29+
mergeDefaults: true,
30+
})
3631

37-
// After the guard above, dataRef is guaranteed to be initialized.
38-
const preferences: RemovableRef<HydratedUserPreferences> = dataRef
39-
40-
const { user } = useAtproto()
41-
42-
const isAuthenticated = computed(() => !!user.value?.did)
43-
const {
44-
status,
45-
lastSyncedAt,
46-
error,
47-
loadFromServer,
48-
scheduleSync,
49-
setupRouteGuard,
50-
setupBeforeUnload,
51-
} = useUserPreferencesSync()
32+
const isAuthenticated = shallowRef(false)
33+
const status = shallowRef<'idle' | 'syncing' | 'synced' | 'error'>('idle')
34+
const lastSyncedAt = shallowRef<Date | null>(null)
35+
const error = shallowRef<string | null>(null)
5236

5337
const isSyncing = computed(() => status.value === 'syncing')
5438
const isSynced = computed(() => status.value === 'synced')
5539
const hasError = computed(() => status.value === 'error')
5640

57-
async function syncWithServer(): Promise<void> {
58-
const serverResult = await loadFromServer()
59-
60-
// If the server load failed, keep current local preferences untouched
61-
if (hasError.value) return
62-
63-
const { merged, shouldPushToServer } = mergePreferences(preferences.value, serverResult)
64-
if (shouldPushToServer) {
65-
scheduleSync(preferences.value)
66-
} else if (!arePreferencesEqual(preferences.value, merged)) {
67-
preferences.value = merged
68-
}
69-
}
70-
7141
async function initSync(): Promise<void> {
7242
if (syncInitialized || import.meta.server) return
7343
syncInitialized = true
7444

45+
// Resolve auth + sync dependencies lazily
46+
const { user } = useAtproto()
47+
watch(
48+
() => !!user.value?.did,
49+
v => {
50+
isAuthenticated.value = v
51+
},
52+
{ immediate: true },
53+
)
54+
55+
const {
56+
status: syncStatus,
57+
lastSyncedAt: syncLastSyncedAt,
58+
error: syncError,
59+
loadFromServer,
60+
scheduleSync,
61+
setupRouteGuard,
62+
setupBeforeUnload,
63+
} = useUserPreferencesSync(isAuthenticated)
64+
65+
watch(
66+
syncStatus,
67+
v => {
68+
status.value = v
69+
},
70+
{ immediate: true },
71+
)
72+
watch(
73+
syncLastSyncedAt,
74+
v => {
75+
lastSyncedAt.value = v
76+
},
77+
{ immediate: true },
78+
)
79+
watch(
80+
syncError,
81+
v => {
82+
error.value = v
83+
},
84+
{ immediate: true },
85+
)
86+
87+
async function syncWithServer(): Promise<void> {
88+
const serverResult = await loadFromServer()
89+
90+
// If the server load failed, keep current local preferences untouched
91+
if (hasError.value) return
92+
93+
const { merged, shouldPushToServer } = mergePreferences(preferences.value, serverResult)
94+
if (shouldPushToServer) {
95+
scheduleSync(preferences.value)
96+
} else if (!arePreferencesEqual(preferences.value, merged)) {
97+
preferences.value = merged
98+
}
99+
}
100+
75101
setupRouteGuard(() => preferences.value)
76102
setupBeforeUnload(() => preferences.value)
77103

@@ -108,12 +134,21 @@ export function useUserPreferencesProvider(
108134
}
109135
}
110136

137+
export function useUserPreferencesProvider(
138+
defaultValue: HydratedUserPreferences = DEFAULT_USER_PREFERENCES,
139+
) {
140+
if (!cached) {
141+
cached = createProvider(defaultValue)
142+
}
143+
return cached
144+
}
145+
111146
/**
112147
* Reset module-level singleton state. Test-only — do not use in production code.
113148
*/
114149
export function __resetPreferencesForTest(): void {
115150
if (import.meta.test) {
116-
dataRef = null
151+
cached = null
117152
syncInitialized = false
118153
}
119154
}

app/composables/useUserPreferencesSync.client.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,10 @@ function cancelPendingDebounce(): void {
104104
}
105105
}
106106

107-
export function useUserPreferencesSync() {
108-
const { user } = useAtproto()
107+
export function useUserPreferencesSync(isAuthenticated: Ref<boolean>) {
109108
const state = getSyncState()
110109
const router = useRouter()
111110

112-
const isAuthenticated = computed(() => !!user.value?.did)
113-
114111
function scheduleSync(preferences: UserPreferences): void {
115112
if (!isAuthenticated.value) return
116113

app/composables/userPreferences/README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,10 @@ The preference will automatically persist to localStorage and sync to the server
108108
## Architecture overview
109109

110110
```
111-
useUserPreferencesProvider ← singleton, manages localStorage + sync lifecycle
112-
├── useUserPreferencesSync ← client-only: debounced server writes, route guard, sendBeacon
111+
useUserPreferencesProvider ← cached singleton, manages localStorage + sync lifecycle
112+
├── createProvider() ← internal: sets up localStorage ref + lazy sync state
113+
│ └── initSync() ← resolves useAtproto() + useUserPreferencesSync() lazily
114+
├── useUserPreferencesSync ← client-only: receives isAuthenticated ref via DI
113115
├── useUserPreferencesState ← read/write access to reactive ref (used by all composables above)
114116
└── preferences-merge.ts ← merge logic for first-login vs returning-user scenarios
115117
@@ -121,8 +123,9 @@ useLocalStorageHashProvider ← generic localStorage + defu provider (used
121123
### Sync flow (authenticated users)
122124

123125
1. `preferences-sync.client.ts` plugin calls `initSync()` on app boot
124-
2. Preferences are loaded from server and merged with local state
125-
3. A deep watcher on the preferences ref triggers `scheduleSync()` on every change
126-
4. `scheduleSync()` debounces for 2 seconds, then pushes to `PUT /api/user/preferences`
127-
5. On route navigation, `router.beforeEach` flushes any pending sync
128-
6. On tab close, `sendBeacon` fires the latest preferences via `POST /api/user/preferences`
126+
2. `initSync()` lazily resolves `useAtproto()` and `useUserPreferencesSync(isAuthenticated)` — auth is not fetched at provider construction time
127+
3. Preferences are loaded from server and merged with local state
128+
4. A deep watcher on the preferences ref triggers `scheduleSync()` on every change
129+
5. `scheduleSync()` debounces for 2 seconds, then pushes to `PUT /api/user/preferences`
130+
6. On route navigation, `router.beforeEach` flushes any pending sync
131+
7. On tab close, `sendBeacon` fires the latest preferences via `POST /api/user/preferences`

test/nuxt/components/ProfileInviteSection.spec.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
2-
import { describe, expect, it, vi, beforeEach } from 'vitest'
2+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
3+
import type { UseAtprotoReturn } from '~/composables/atproto/useAtproto'
34

4-
const { mockUseAtproto, mockUseProfileLikes } = vi.hoisted(() => ({
5-
mockUseAtproto: vi.fn(),
5+
const { mockUseProfileLikes } = vi.hoisted(() => ({
66
mockUseProfileLikes: vi.fn(),
77
}))
88

9-
mockNuxtImport('useAtproto', () => mockUseAtproto)
109
mockNuxtImport('useProfileLikes', () => mockUseProfileLikes)
1110

1211
import ProfilePage from '~/pages/profile/[identity]/index.vue'
@@ -19,18 +18,32 @@ registerEndpoint('/api/social/profile/test-handle', () => ({
1918
recordExists: false,
2019
}))
2120

21+
function mockUseAtproto(
22+
overrides: {
23+
user?: Ref<Record<string, unknown> | null>
24+
pending?: Ref<boolean>
25+
logout?: () => Promise<void>
26+
} = {},
27+
) {
28+
globalThis.__useAtprotoMock = {
29+
user: ref(null),
30+
pending: ref(false),
31+
logout: vi.fn(),
32+
...overrides,
33+
} as UseAtprotoReturn
34+
}
35+
2236
describe('Profile invite section', () => {
2337
beforeEach(() => {
24-
mockUseAtproto.mockReset()
2538
mockUseProfileLikes.mockReset()
2639
})
2740

41+
afterEach(() => {
42+
globalThis.__useAtprotoMock = undefined
43+
})
44+
2845
it('does not show invite section while auth is still loading', async () => {
29-
mockUseAtproto.mockReturnValue({
30-
user: ref(null),
31-
pending: ref(true),
32-
logout: vi.fn(),
33-
})
46+
mockUseAtproto({ pending: ref(true) })
3447

3548
mockUseProfileLikes.mockReturnValue({
3649
data: ref({ records: [] }),
@@ -45,11 +58,7 @@ describe('Profile invite section', () => {
4558
})
4659

4760
it('shows invite section after auth resolves for non-owner', async () => {
48-
mockUseAtproto.mockReturnValue({
49-
user: ref({ handle: 'other-user' }),
50-
pending: ref(false),
51-
logout: vi.fn(),
52-
})
61+
mockUseAtproto({ user: ref({ handle: 'other-user' }) })
5362

5463
mockUseProfileLikes.mockReturnValue({
5564
data: ref({ records: [] }),
@@ -64,11 +73,7 @@ describe('Profile invite section', () => {
6473
})
6574

6675
it('does not show invite section for profile owner', async () => {
67-
mockUseAtproto.mockReturnValue({
68-
user: ref({ handle: 'test-handle' }),
69-
pending: ref(false),
70-
logout: vi.fn(),
71-
})
76+
mockUseAtproto({ user: ref({ handle: 'test-handle' }) })
7277

7378
mockUseProfileLikes.mockReturnValue({
7479
data: ref({ records: [] }),

0 commit comments

Comments
 (0)