From 759f831098a30ae4da7b88ca7f5c03066f407d26 Mon Sep 17 00:00:00 2001
From: James Garbutt <43081j@users.noreply.github.com>
Date: Fri, 13 Feb 2026 15:43:16 +0000
Subject: [PATCH 1/2] feat: show replaceable dependencies
This adds a replacements notice to the dependency list just like outdated,
vulnerable, etc.
Each dependency with community replacements shows an amber warning and
tooltip.
---
app/components/Package/Dependencies.vue | 35 +++++-
.../npm/useReplacementDependencies.ts | 55 +++++++++
i18n/locales/en.json | 3 +-
i18n/schema.json | 3 +
lunaria/files/en-GB.json | 3 +-
lunaria/files/en-US.json | 3 +-
.../use-replacement-dependencies.spec.ts | 113 ++++++++++++++++++
7 files changed, 210 insertions(+), 5 deletions(-)
create mode 100644 app/composables/npm/useReplacementDependencies.ts
create mode 100644 test/nuxt/composables/use-replacement-dependencies.spec.ts
diff --git a/app/components/Package/Dependencies.vue b/app/components/Package/Dependencies.vue
index 74427ceb2..e0f12c46a 100644
--- a/app/components/Package/Dependencies.vue
+++ b/app/components/Package/Dependencies.vue
@@ -2,6 +2,8 @@
import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity'
import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies'
+const { t } = useI18n()
+
const props = defineProps<{
packageName: string
version: string
@@ -14,6 +16,9 @@ const props = defineProps<{
// Fetch outdated info for dependencies
const outdatedDeps = useOutdatedDependencies(() => props.dependencies)
+// Fetch replacement suggestions for dependencies
+const replacementDeps = useReplacementDependencies(() => props.dependencies)
+
// Get vulnerability info from shared cache (already fetched by PackageVulnerabilityTree)
const { data: vulnTree } = useDependencyAnalysis(
() => props.packageName,
@@ -66,6 +71,24 @@ const sortedOptionalDependencies = computed(() => {
return Object.entries(props.optionalDependencies).sort(([a], [b]) => a.localeCompare(b))
})
+// Get version tooltip
+function getDepVersionTooltip(dep: string, version: string) {
+ const outdated = outdatedDeps.value[dep]
+ if (outdated) return getOutdatedTooltip(outdated, t)
+ if (getVulnerableDepInfo(dep) || getDeprecatedDepInfo(dep)) return version
+ if (replacementDeps.value[dep]) return t('package.dependencies.has_replacement')
+ return version
+}
+
+// Get version class
+function getDepVersionClass(dep: string) {
+ const outdated = outdatedDeps.value[dep]
+ if (outdated) return getVersionClass(outdated)
+ if (getVulnerableDepInfo(dep) || getDeprecatedDepInfo(dep)) return getVersionClass(undefined)
+ if (replacementDeps.value[dep]) return 'text-amber-700 dark:text-amber-500'
+ return getVersionClass(undefined)
+}
+
const numberFormatter = useNumberFormatter()
@@ -104,6 +127,14 @@ const numberFormatter = useNumberFormatter()
>
+
+
+
{{ version }}
diff --git a/app/composables/npm/useReplacementDependencies.ts b/app/composables/npm/useReplacementDependencies.ts
new file mode 100644
index 000000000..40709b5c8
--- /dev/null
+++ b/app/composables/npm/useReplacementDependencies.ts
@@ -0,0 +1,55 @@
+import type { ShallowRef } from 'vue'
+import type { ModuleReplacement } from 'module-replacements'
+
+async function fetchReplacements(
+ deps: Record | undefined,
+ replacements: ShallowRef>,
+) {
+ if (!deps || Object.keys(deps).length === 0) {
+ replacements.value = {}
+ return
+ }
+
+ const names = Object.keys(deps)
+
+ const results = await Promise.all(
+ names.map(async name => {
+ try {
+ const replacement = await $fetch(`/api/replacements/${name}`)
+ return { name, replacement }
+ } catch {
+ return { name, replacement: null }
+ }
+ }),
+ )
+
+ const map: Record = {}
+ for (const { name, replacement } of results) {
+ if (replacement) {
+ map[name] = replacement
+ }
+ }
+ replacements.value = map
+}
+
+/**
+ * Fetch module replacement suggestions for a set of dependencies.
+ * Returns a reactive map of dependency name to ModuleReplacement.
+ */
+export function useReplacementDependencies(
+ dependencies: MaybeRefOrGetter | undefined>,
+) {
+ const replacements = shallowRef>({})
+
+ if (import.meta.client) {
+ watch(
+ () => toValue(dependencies),
+ deps => {
+ fetchReplacements(deps, replacements).catch(() => {})
+ },
+ { immediate: true },
+ )
+ }
+
+ return replacements
+}
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index e483a2f77..1c44e5c9b 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -316,7 +316,8 @@
"view_vulnerabilities": "View vulnerabilities",
"outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})",
"outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})",
- "outdated_patch": "Patch update available (latest: {latest})"
+ "outdated_patch": "Patch update available (latest: {latest})",
+ "has_replacement": "This dependency has suggested replacements"
},
"peer_dependencies": {
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
diff --git a/i18n/schema.json b/i18n/schema.json
index 5604780cd..05d22e2fe 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -954,6 +954,9 @@
},
"outdated_patch": {
"type": "string"
+ },
+ "has_replacement": {
+ "type": "string"
}
},
"additionalProperties": false
diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json
index bb8076de4..d1278dc75 100644
--- a/lunaria/files/en-GB.json
+++ b/lunaria/files/en-GB.json
@@ -315,7 +315,8 @@
"view_vulnerabilities": "View vulnerabilities",
"outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})",
"outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})",
- "outdated_patch": "Patch update available (latest: {latest})"
+ "outdated_patch": "Patch update available (latest: {latest})",
+ "has_replacement": "This dependency has suggested replacements"
},
"peer_dependencies": {
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json
index f9b9ccfc2..8f70103ce 100644
--- a/lunaria/files/en-US.json
+++ b/lunaria/files/en-US.json
@@ -315,7 +315,8 @@
"view_vulnerabilities": "View vulnerabilities",
"outdated_major": "{count} major version behind (latest: {latest}) | {count} major versions behind (latest: {latest})",
"outdated_minor": "{count} minor version behind (latest: {latest}) | {count} minor versions behind (latest: {latest})",
- "outdated_patch": "Patch update available (latest: {latest})"
+ "outdated_patch": "Patch update available (latest: {latest})",
+ "has_replacement": "This dependency has suggested replacements"
},
"peer_dependencies": {
"title": "Peer Dependency ({count}) | Peer Dependencies ({count})",
diff --git a/test/nuxt/composables/use-replacement-dependencies.spec.ts b/test/nuxt/composables/use-replacement-dependencies.spec.ts
new file mode 100644
index 000000000..ec5f89bfb
--- /dev/null
+++ b/test/nuxt/composables/use-replacement-dependencies.spec.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
+import type { ModuleReplacement } from 'module-replacements'
+
+const SIMPLE_REPLACEMENT: ModuleReplacement = {
+ type: 'simple',
+ moduleName: 'is-even',
+ replacement: 'Use (n % 2) === 0',
+ category: 'micro-utilities',
+}
+
+const NATIVE_REPLACEMENT: ModuleReplacement = {
+ type: 'native',
+ moduleName: 'array-includes',
+ nodeVersion: '6.0.0',
+ replacement: 'Array.prototype.includes',
+ mdnPath: 'Global_Objects/Array/includes',
+ category: 'native',
+}
+
+async function mountWithDeps(deps: Record | undefined) {
+ const captured = ref>({})
+
+ const WrapperComponent = defineComponent({
+ setup() {
+ const replacements = useReplacementDependencies(() => deps)
+
+ watchEffect(() => {
+ captured.value = { ...replacements.value }
+ })
+
+ return () => h('div')
+ },
+ })
+
+ await mountSuspended(WrapperComponent)
+
+ return captured
+}
+
+describe('useReplacementDependencies', () => {
+ it('returns replacements for dependencies that have them', async () => {
+ registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT)
+ registerEndpoint('/api/replacements/picoquery', () => null)
+
+ const replacements = await mountWithDeps({
+ 'is-even': '^1.0.0',
+ 'picoquery': '^1.0.0',
+ })
+
+ await vi.waitFor(() => {
+ expect(replacements.value['is-even']).toBeDefined()
+ })
+
+ expect(replacements.value['is-even']?.type).toBe('simple')
+ expect(replacements.value['picoquery']).toBeUndefined()
+ })
+
+ it('returns empty object for undefined dependencies', async () => {
+ const replacements = await mountWithDeps(undefined)
+
+ await vi.waitFor(() => {
+ expect(replacements.value).toEqual({})
+ })
+ })
+
+ it('returns empty object for empty dependencies', async () => {
+ const replacements = await mountWithDeps({})
+
+ await vi.waitFor(() => {
+ expect(replacements.value).toEqual({})
+ })
+ })
+
+ it('handles multiple dependencies with replacements', async () => {
+ registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT)
+ registerEndpoint('/api/replacements/array-includes', () => NATIVE_REPLACEMENT)
+ registerEndpoint('/api/replacements/picoquery', () => null)
+
+ const replacements = await mountWithDeps({
+ 'is-even': '^1.0.0',
+ 'array-includes': '^3.0.0',
+ 'picoquery': '^1.0.0',
+ })
+
+ await vi.waitFor(() => {
+ expect(Object.keys(replacements.value)).toHaveLength(2)
+ })
+
+ expect(replacements.value['is-even']?.type).toBe('simple')
+ expect(replacements.value['array-includes']?.type).toBe('native')
+ expect(replacements.value['picoquery']).toBeUndefined()
+ })
+
+ it('handles fetch errors gracefully', async () => {
+ registerEndpoint('/api/replacements/failing-package', () => {
+ throw new Error('Network error')
+ })
+ registerEndpoint('/api/replacements/is-even', () => SIMPLE_REPLACEMENT)
+
+ const replacements = await mountWithDeps({
+ 'failing-package': '^1.0.0',
+ 'is-even': '^1.0.0',
+ })
+
+ await vi.waitFor(() => {
+ expect(replacements.value['is-even']).toBeDefined()
+ })
+
+ expect(replacements.value['failing-package']).toBeUndefined()
+ expect(replacements.value['is-even']?.type).toBe('simple')
+ })
+})
From c82bf2108a519ceff81d53483ce3aec420b98887 Mon Sep 17 00:00:00 2001
From: James Garbutt <43081j@users.noreply.github.com>
Date: Fri, 13 Feb 2026 16:09:14 +0000
Subject: [PATCH 2/2] fix: handle funky race conditions
---
.../npm/useReplacementDependencies.ts | 32 ++++++++++++-------
1 file changed, 20 insertions(+), 12 deletions(-)
diff --git a/app/composables/npm/useReplacementDependencies.ts b/app/composables/npm/useReplacementDependencies.ts
index 40709b5c8..e0aa85293 100644
--- a/app/composables/npm/useReplacementDependencies.ts
+++ b/app/composables/npm/useReplacementDependencies.ts
@@ -1,15 +1,8 @@
-import type { ShallowRef } from 'vue'
import type { ModuleReplacement } from 'module-replacements'
async function fetchReplacements(
- deps: Record | undefined,
- replacements: ShallowRef>,
-) {
- if (!deps || Object.keys(deps).length === 0) {
- replacements.value = {}
- return
- }
-
+ deps: Record,
+): Promise> {
const names = Object.keys(deps)
const results = await Promise.all(
@@ -29,7 +22,7 @@ async function fetchReplacements(
map[name] = replacement
}
}
- replacements.value = map
+ return map
}
/**
@@ -40,12 +33,27 @@ export function useReplacementDependencies(
dependencies: MaybeRefOrGetter | undefined>,
) {
const replacements = shallowRef>({})
+ let generation = 0
if (import.meta.client) {
watch(
() => toValue(dependencies),
- deps => {
- fetchReplacements(deps, replacements).catch(() => {})
+ async deps => {
+ const currentGeneration = ++generation
+
+ if (!deps || Object.keys(deps).length === 0) {
+ replacements.value = {}
+ return
+ }
+
+ try {
+ const result = await fetchReplacements(deps)
+ if (currentGeneration === generation) {
+ replacements.value = result
+ }
+ } catch {
+ // catastrophic failure, just keep whatever we have
+ }
},
{ immediate: true },
)