Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions app/components/Package/LikeCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const { user } = useAtproto()

const authModal = useModal('auth-modal')

const { data: likesData } = useFetch(() => `/api/social/likes/${name.value}`, {
const { data: likesData, status: likesStatus } = useFetch(() => `/api/social/likes/${name.value}`, {
default: () => ({ totalLikes: 0, userHasLiked: false }),
server: false,
})
Comment on lines +20 to 23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "likesStatus|isLikeActionPending|likeAction|:disabled|aria-busy" app/components/Package/LikeCard.vue -C3

Repository: npmx-dev/npmx.dev

Length of output: 2014


Prevent user interactions whilst initial likes data is still pending.

The UI displays a pending indicator but does not block the button. Users can click during the loading window, and likeAction will use default fallback values to compute the state change, risking incorrect optimistic updates based on uninitialised data.

The likeAction function only guards isLikeActionPending (line 33) but does not check whether likesStatus is pending. Additionally, the button lacks both :disabled and :aria-busy attributes to reflect the pending state.

Suggested fix
 const likeAction = async () => {
   if (user.value?.handle == null) {
     authModal.open()
     return
   }

-  if (isLikeActionPending.value) return
+  if (likesStatus.value === 'pending' || isLikeActionPending.value) return
             <button
               `@click.prevent`="likeAction"
               type="button"
+              :disabled="likesStatus === 'pending' || isLikeActionPending"
+              :aria-busy="likesStatus === 'pending'"
               :title="
                 likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')
               "

Expand Down Expand Up @@ -92,14 +92,20 @@ const likeAction = async () => {
>
<span
:class="
likesData?.userHasLiked
? 'i-lucide-heart-minus text-red-500'
: 'i-lucide-heart-plus'
likesStatus === 'pending'
? 'i-lucide-heart'
: likesData?.userHasLiked
? 'i-lucide-heart-minus text-red-500'
: 'i-lucide-heart-plus'
"
class="w-4 h-4"
aria-hidden="true"
/>
<span>{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}</span>
<span
v-if="likesStatus === 'pending'"
class="inline-block w-4 h-4 bg-bg-subtle rounded animate-pulse"
/>
<span v-else>{{ compactNumberFormatter.format(likesData?.totalLikes ?? 0) }}</span>
</button>
</TooltipApp>
</ClientOnly>
Expand Down
3 changes: 2 additions & 1 deletion app/pages/profile/[identity]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ if (!profile.value || profileError.value?.statusCode === 404) {
})
}

const { user } = useAtproto()
const { user, pending: userPending } = useAtproto()
const isEditing = ref(false)
const displayNameInput = ref()
const descriptionInput = ref()
Expand Down Expand Up @@ -84,6 +84,7 @@ const showInviteSection = computed(() => {
profile.value.recordExists === false &&
status.value === 'success' &&
!likes.value?.records?.length &&
!userPending.value &&
user.value?.handle !== profile.value.handle
)
})
Expand Down
19 changes: 19 additions & 0 deletions test/nuxt/components/PackageLikeCard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,23 @@ describe('PackageLikeCard', () => {

expect(wrapper.find('span.truncate').text()).toBe('@scope/pkg')
})

it('shows a loading skeleton instead of zero while like data is pending', async () => {
wrapper = await mountLikeCard('https://npmx.dev/package/vue')

const button = wrapper.find('button')
expect(button.text()).not.toContain('0')

const skeleton = button.find('.animate-pulse')
expect(skeleton.exists()).toBe(true)
})

it('shows a neutral heart icon while like data is pending', async () => {
wrapper = await mountLikeCard('https://npmx.dev/package/vue')

const icon = wrapper.find('button span[aria-hidden]')
expect(icon.classes()).toContain('i-lucide-heart')
expect(icon.classes()).not.toContain('i-lucide-heart-plus')
expect(icon.classes()).not.toContain('i-lucide-heart-minus')
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the full spec file to understand setup and mocking
cat -n test/nuxt/components/PackageLikeCard.spec.ts

# Also search for any mock or endpoint setup patterns
rg -n "registerEndpoint|mockNuxtImport|useFakeTimers|setTimeout|new Promise|beforeEach|vi\." test/nuxt/components/PackageLikeCard.spec.ts || echo "No timing control patterns found"

Repository: npmx-dev/npmx.dev

Length of output: 2780


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find and read the LikeCard component
find . -name "LikeCard.vue" -type f | head -5

Repository: npmx-dev/npmx.dev

Length of output: 98


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the LikeCard component
cat -n ~/components/Package/LikeCard.vue 2>/dev/null || cat -n src/components/Package/LikeCard.vue 2>/dev/null || find . -path "*components/Package/LikeCard.vue" -exec cat -n {} \;

Repository: npmx-dev/npmx.dev

Length of output: 4465


Control fetch timing to stabilise pending-state assertions.

The tests at lines 48–65 check pending-state UI (skeleton, neutral heart icon) immediately after mount. However, useFetch() status in the component is not mocked, so its resolution is uncontrolled. If the endpoint responds before assertions run, the pending state disappears and tests fail.

Introduce a deliberate delay in the endpoint response using registerEndpoint():

Stabilisation example
+import { registerEndpoint } from '@nuxt/test-utils/runtime'
+
+registerEndpoint('/api/social/likes/vue', async () => {
+  await new Promise(resolve => setTimeout(resolve, 50))
+  return { totalLikes: 12, userHasLiked: false }
+})

})
84 changes: 84 additions & 0 deletions test/nuxt/components/ProfileInviteSection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { mockNuxtImport, mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
import { describe, expect, it, vi, beforeEach } from 'vitest'

const { mockUseAtproto, mockUseProfileLikes } = vi.hoisted(() => ({
mockUseAtproto: vi.fn(),
mockUseProfileLikes: vi.fn(),
}))

mockNuxtImport('useAtproto', () => mockUseAtproto)
mockNuxtImport('useProfileLikes', () => mockUseProfileLikes)

import ProfilePage from '~/pages/profile/[identity]/index.vue'

registerEndpoint('/api/social/profile/test-handle', () => ({
displayName: 'Test User',
description: '',
website: '',
handle: 'test-handle',
recordExists: false,
}))

describe('Profile invite section', () => {
beforeEach(() => {
mockUseAtproto.mockReset()
mockUseProfileLikes.mockReset()
})

it('does not show invite section while auth is still loading', async () => {
mockUseAtproto.mockReturnValue({
user: ref(null),
pending: ref(true),
logout: vi.fn(),
})

mockUseProfileLikes.mockReturnValue({
data: ref({ records: [] }),
status: ref('success'),
})

const wrapper = await mountSuspended(ProfilePage, {
route: '/profile/test-handle',
})

expect(wrapper.text()).not.toContain("It doesn't look like they're using npmx yet")
})

it('shows invite section after auth resolves for non-owner', async () => {
mockUseAtproto.mockReturnValue({
user: ref({ handle: 'other-user' }),
pending: ref(false),
logout: vi.fn(),
})

mockUseProfileLikes.mockReturnValue({
data: ref({ records: [] }),
status: ref('success'),
})

const wrapper = await mountSuspended(ProfilePage, {
route: '/profile/test-handle',
})

expect(wrapper.text()).toContain("It doesn't look like they're using npmx yet")
})

it('does not show invite section for profile owner', async () => {
mockUseAtproto.mockReturnValue({
user: ref({ handle: 'test-handle' }),
pending: ref(false),
logout: vi.fn(),
})

mockUseProfileLikes.mockReturnValue({
data: ref({ records: [] }),
status: ref('success'),
})

const wrapper = await mountSuspended(ProfilePage, {
route: '/profile/test-handle',
})

expect(wrapper.text()).not.toContain("It doesn't look like they're using npmx yet")
})
})
Loading