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 }, )