From a48c3b4df65be66f49d9e8e17a46dcfe7af988dd Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 17 Oct 2026 00:37:04 +1100 Subject: [PATCH 1/2] fix(likes): sync hearts after account removal --- layers/identity/app/composables/useLikes.ts | 14 +++++- layers/identity/app/pages/me/index.vue | 4 +- test/unit/like-button.nuxt.test.ts | 27 ++++++++++++ test/unit/me-watchlist.nuxt.test.ts | 49 ++++++++++++++++++++- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/layers/identity/app/composables/useLikes.ts b/layers/identity/app/composables/useLikes.ts index 586988e7..b22b36d1 100644 --- a/layers/identity/app/composables/useLikes.ts +++ b/layers/identity/app/composables/useLikes.ts @@ -138,6 +138,18 @@ export function useLikes() { return !wasLiked } + /** Account removal must also update hearts already loaded in this session. */ + async function remove(ref: SkillLikeRef): Promise { + // An older list response must settle before it can overwrite the removal. + await ensureLoaded() + const key = likeKey(ref) + const path = `/api/me/likes/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}/${encodeURIComponent(ref.name)}` + const result = await $fetch(path, { method: 'DELETE' }) + liked.value = applyLiked(liked.value, key, false) + likeCounts.value = { ...likeCounts.value, [key]: result.likeCount } + return result + } + /** Idempotent unlike `toggle`: automatic flows can only move toward liked. */ async function ensureLiked(ref: SkillLikeRef): Promise { if (!isAuthenticated.value) @@ -184,7 +196,7 @@ export function useLikes() { return request } - return { liked, isLiked, isPending, likeCount, observeLikeCount, ensureLoaded, ensureLiked, toggle } + return { liked, isLiked, isPending, likeCount, observeLikeCount, ensureLoaded, ensureLiked, toggle, remove } } function applyLiked(map: Record, key: string, next: boolean): Record { diff --git a/layers/identity/app/pages/me/index.vue b/layers/identity/app/pages/me/index.vue index caec44cb..10f39982 100644 --- a/layers/identity/app/pages/me/index.vue +++ b/layers/identity/app/pages/me/index.vue @@ -42,6 +42,7 @@ const { const actionFailed = useActionFailure() const rpc = useNuxtRpc() const { fetchSession } = useAuth() +const { remove: removeLike } = useLikes() async function refreshAccount() { me.value = await rpc.query(identityAccountQueries.me()) @@ -90,8 +91,7 @@ async function setRepoIndexing(repoIndexing: boolean) { const removeLikeMutation = useNuxtMutation({ mutation: async (skill) => { - const path = `/api/me/likes/${skill.owner}/${skill.repo}/${skill.name}` - const result = await $fetch<{ ok: true }>(path, { method: 'DELETE' }) + const result = await removeLike(skill) await Promise.all([refreshLikes(), refreshSubscriptions()]) return result }, diff --git a/test/unit/like-button.nuxt.test.ts b/test/unit/like-button.nuxt.test.ts index 3d860da5..286524b3 100644 --- a/test/unit/like-button.nuxt.test.ts +++ b/test/unit/like-button.nuxt.test.ts @@ -197,6 +197,33 @@ describe('likeButton toggle semantics', () => { }) describe('useLikes hydration', () => { + it('keeps the heart and count when account removal fails', async () => { + likedItems = [{ ...skill, likeCount: 3 }] + const likes = useLikes() + await likes.ensureLoaded() + mutation = { _tag: 'error' } + + await expect(likes.remove(skill)).rejects.toThrow('offline') + + expect(likes.isLiked(skill)).toBe(true) + expect(likes.likeCount(skill)).toBe(3) + }) + + it('does not let an in-flight list restore a removed like', async () => { + likedItems = [{ ...skill, likeCount: 3 }] + mutation = { _tag: 'ok', likeCount: 2 } + const release = holdListRequest() + const likes = useLikes() + const loading = likes.ensureLoaded() + const removing = likes.remove(skill) + + release() + await Promise.all([loading, removing]) + + expect(likes.isLiked(skill)).toBe(false) + expect(likes.likeCount(skill)).toBe(2) + }) + it('clears the failed like after retry without dismissing another action', async () => { const likes = useLikes() mutation = { _tag: 'error' } diff --git a/test/unit/me-watchlist.nuxt.test.ts b/test/unit/me-watchlist.nuxt.test.ts index 4da4c75c..edf01b02 100644 --- a/test/unit/me-watchlist.nuxt.test.ts +++ b/test/unit/me-watchlist.nuxt.test.ts @@ -1,6 +1,8 @@ import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' import { flushPromises } from '@vue/test-utils' import { nextTick, ref } from 'vue' +import LikeButton from '../../layers/identity/app/components/LikeButton.vue' +import { useLikes } from '../../layers/identity/app/composables/useLikes' const oldSyncTime = 1_755_000_000 const newSyncTime = 1_786_000_000 @@ -65,6 +67,12 @@ vi.mock('@harlan-zw/nuxt-use-query/rpc', async (importOriginal) => { mockNuxtImport('$fetch', () => requestFetch) +mockNuxtImport('useAuth', () => () => ({ + state: ref({ _tag: 'signed-in', user: { login: 'harlan', onboarded: true } }), + isAuthenticated: ref(true), + fetchSession: vi.fn(), +})) + mockNuxtImport('useNuxtRpcQuery', () => { return (operation: { path: string }) => { if (operation.path === '/api/me/validation') @@ -172,6 +180,10 @@ describe('account skill watchlist', () => { likesStatus.value = 'success' refreshLikes.mockClear() requestFetch.mockReset() + useState('skill-likes').value = {} + useState('skill-like-counts').value = {} + useState('skill-likes-loaded').value = false + useState('skill-likes-pending').value = {} executeRpc.mockReset() queryRpc.mockReset() @@ -183,7 +195,14 @@ describe('account skill watchlist', () => { throw new Error(`Unexpected query: ${operation.path}`) }) - requestFetch.mockImplementation(async (path: string) => { + requestFetch.mockImplementation(async (path: string, options?: { method?: string }) => { + if (path === '/api/me/likes') { + if (options?.method === 'POST') { + serverLikes = [likedSkillFixture()] + return { ok: true, likeCount: 1 } + } + return { items: serverLikes.map(skill => ({ ...skill, likeCount: 1 })) } + } if (path === '/api/me/stars/sync') { serverAccount = accountFixture(newSyncTime) return { @@ -201,7 +220,7 @@ describe('account skill watchlist', () => { await new Promise(resolve => releaseDelete = resolve) serverLikes = [] serverSubscriptions = [] - return { ok: true } + return { ok: true, likeCount: 0 } } throw new Error(`Unexpected request: ${path}`) }) @@ -278,6 +297,32 @@ describe('account skill watchlist', () => { expect(wrapper.text()).not.toContain('antfu/skills') }) + it('clears the Skill heart after account removal and lets the next click save it again', async () => { + const skill = likedSkillFixture() + await useLikes().ensureLoaded() + const wrapper = await mountPage() + + await wrapper.get('button[aria-label="Remove nuxt from your skills"]').trigger('click') + await flushPromises() + wrapper.unmount() + + const heart = await mountSuspended(LikeButton, { props: { ...skill, count: 1 } }) + await flushPromises() + expect(heart.get('button').attributes('aria-pressed')).toBe('false') + expect(heart.get('button').text()).toBe('0') + + requestFetch.mockClear() + await heart.get('button').trigger('click') + await flushPromises() + expect(requestFetch).toHaveBeenCalledWith('/api/me/likes', { + method: 'POST', + body: { owner: skill.owner, repo: skill.repo, name: skill.name }, + }) + expect(heart.get('button').attributes('aria-pressed')).toBe('true') + expect(serverLikes.map(item => item.name)).toEqual(['nuxt']) + heart.unmount() + }) + it('disables skill removal while the request is pending', async () => { releaseDelete = () => {} const wrapper = await mountPage() From 89f55a9711d6660330a499037316ba1c2ebfb4f1 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 17 Oct 2026 01:16:19 +1100 Subject: [PATCH 2/2] fix(auth): preserve pending likes for existing sessions --- layers/identity/app/pages/login.vue | 13 ++++++-- test/unit/login-benefits.nuxt.test.ts | 48 +++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/layers/identity/app/pages/login.vue b/layers/identity/app/pages/login.vue index 3926aa62..fc364632 100644 --- a/layers/identity/app/pages/login.vue +++ b/layers/identity/app/pages/login.vue @@ -41,8 +41,17 @@ const actionMessage = computed(() => { }) watchEffect(async () => { - if (loggedIn.value) - await navigateTo(returnTo.value || '/me', { replace: true }) + if (!loggedIn.value || error.value) + return + + // Public pages can still offer anonymous Like or Watch links after another + // tab signs in. Keep the intent on the sealed OAuth replay path instead of + // returning to the Skill without applying it. Failed replays stay retryable. + if (returnTo.value && ['like-skill', 'watch-skill', 'watch-collection'].includes(action.value)) { + await navigateTo(href.value, { external: true, replace: true }) + return + } + await navigateTo(returnTo.value || '/me', { replace: true }) }) useSeoMeta({ diff --git a/test/unit/login-benefits.nuxt.test.ts b/test/unit/login-benefits.nuxt.test.ts index 295995fe..4a4b41ee 100644 --- a/test/unit/login-benefits.nuxt.test.ts +++ b/test/unit/login-benefits.nuxt.test.ts @@ -1,14 +1,58 @@ import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' import Login from '../../layers/identity/app/pages/login.vue' -mockNuxtImport('useUserSession', () => () => ({ loggedIn: ref(false), ready: ref(true), fetch: async () => {} })) +const mocks = vi.hoisted(() => ({ signedIn: false, navigate: vi.fn() })) +mockNuxtImport('navigateTo', () => mocks.navigate) +mockNuxtImport('useUserSession', () => () => ({ loggedIn: ref(mocks.signedIn), ready: ref(true), fetch: async () => {} })) mockNuxtImport('useAuth', () => () => ({ loginUrl: ({ returnTo, action }: { returnTo: string, action: string }) => `/auth/github?${new URLSearchParams({ return_to: returnTo, action })}`, })) describe('account entry', () => { + beforeEach(() => { + mocks.signedIn = false + mocks.navigate.mockReset() + }) + + it.each(['like-skill', 'watch-skill', 'watch-collection'])('keeps %s when a stale anonymous page reaches an existing session', async (action) => { + mocks.signedIn = true + const returnTo = action === 'watch-collection' ? '/@octocat/tools' : '/gh/nuxt/ui/motion' + const wrapper = await mountSuspended(Login, { + route: `/login?${new URLSearchParams({ return_to: returnTo, action })}`, + }) + + expect(mocks.navigate).toHaveBeenCalledWith( + `/auth/github?${new URLSearchParams({ return_to: returnTo, action })}`, + { external: true, replace: true }, + ) + wrapper.unmount() + }) + + it('leaves a failed replay available to retry instead of looping through OAuth', async () => { + mocks.signedIn = true + const wrapper = await mountSuspended(Login, { + route: '/login?return_to=%2Fgh%2Fnuxt%2Fui%2Fmotion&action=like-skill&error=oauth', + }) + + expect(mocks.navigate).not.toHaveBeenCalled() + expect(wrapper.get('[role="alert"]').text()).toContain('Try again.') + expect(wrapper.findAll('a').find(link => link.text().includes('Continue with GitHub'))!.attributes('href')) + .toContain('action=like-skill') + wrapper.unmount() + }) + + it.each(['', '&action=unknown'])('returns a signed-in visitor directly when there is no supported action%s', async (action) => { + mocks.signedIn = true + const wrapper = await mountSuspended(Login, { + route: `/login?return_to=%2Fgh%2Fnuxt%2Fui${action}`, + }) + + expect(mocks.navigate).toHaveBeenCalledWith('/gh/nuxt/ui', { replace: true }) + wrapper.unmount() + }) + it('preserves a failed like intent when switching from sign-in to sign-up', async () => { const wrapper = await mountSuspended(Login, { route: '/login?return_to=%2Fgh%2Fnuxt%2Fui%3Ffrom%3Dsearch&action=like-skill&error=oauth',