Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion 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
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 @@ -76,6 +76,7 @@ const likeAction = async () => {
<div class="flex items-center gap-4 justify-between shrink-0">
<ClientOnly>
<TooltipApp
v-if="likesStatus !== 'pending'"
:text="likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like')"
position="bottom"
>
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
7 changes: 7 additions & 0 deletions test/nuxt/components/PackageLikeCard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,11 @@ describe('PackageLikeCard', () => {

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

it('hides the like button entirely while like data is pending', async () => {
wrapper = await mountLikeCard('https://npmx.dev/package/vue')

const button = wrapper.find('button')
expect(button.exists()).toBe(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