Skip to content

feat: module replacements v3#2068

Open
gameroman wants to merge 43 commits intonpmx-dev:mainfrom
gameroman:module-replacements-v3
Open

feat: module replacements v3#2068
gameroman wants to merge 43 commits intonpmx-dev:mainfrom
gameroman:module-replacements-v3

Conversation

@gameroman
Copy link
Contributor

@gameroman gameroman commented Mar 13, 2026

Try module replacements v3

NOTE

AI disclosure: I used AI to help me write some of the code but verified everything manually

@vercel
Copy link

vercel bot commented Mar 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 16, 2026 9:40pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 16, 2026 9:40pm
npmx-lunaria Ignored Ignored Mar 16, 2026 9:40pm

Request Review

@codecov
Copy link

codecov bot commented Mar 13, 2026

Codecov Report

❌ Patch coverage is 64.00000% with 18 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/Package/Replacement.vue 60.71% 11 Missing ⚠️
app/utils/resolve-replacement-url.ts 66.66% 4 Missing ⚠️
app/composables/useModuleReplacement.ts 0.00% 2 Missing ⚠️
app/components/Compare/ReplacementSuggestion.vue 83.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
app/components/Package/Replacement.vue (2)

42-50: Guard against missing description for simple replacements.

If replacement.description is undefined or empty for a simple replacement, line 43 will render nothing, leaving an empty <div class="mb-2"> element. Consider adding a fallback or conditional rendering.

Proposed fix
     <div v-else-if="replacement.type === 'simple'" class="block">
-      <div class="mb-2">{{ replacement.description }}</div>
+      <div v-if="replacement.description" class="mb-2">{{ replacement.description }}</div>
       <div v-if="replacement.example">

71-73: Guard against missing description for removal replacements.

Similar to the simple type, if replacement.description is undefined for a removal, the template will render nothing. Consider adding a fallback.

Proposed fix
     <template v-else-if="replacement.type === 'removal'">
-      {{ replacement.description }}
+      {{ replacement.description || replacement.id }}
     </template>

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f0dd13a4-3ed7-4e63-858d-9ea81677b376

📥 Commits

Reviewing files that changed from the base of the PR and between 03584bd and 375bd0a.

📒 Files selected for processing (2)
  • app/components/Package/Replacement.vue
  • i18n/locales/en.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • i18n/locales/en.json

@gameroman
Copy link
Contributor Author

@coderabbitai what should I do with these failing readonly types?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 14, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look at the server API file for replacements
fd -t f "pkg.get.ts" --exec cat {}

echo "---"
# Look at the resolve-replacement-url utility
fd -t f "resolve-replacement-url.ts" --exec cat {}

echo "---"
# Search for readonly-related type errors or usages
rg -n "readonly" --type ts -C 3

echo "---"
# Check the module-replacements package types
fd -t f "*.d.ts" -p "node_modules/module-replacements" --exec cat {}

echo "---"
# Also check what types are imported from module-replacements
rg -n "from 'module-replacements'" --type ts -C 2
rg -n 'from "module-replacements"' --type ts -C 2

echo "---"
# Check the composables related to replacements
fd -t f "use-replacement*" --exec cat {}
fd -t f "use-compare-replacements*" --exec cat {}

Length of output: 30290


🌐 Web query:

module-replacements 3.0.0-beta.0 TypeScript types readonly engines

💡 Result:

In module-replacements@3.0.0-beta.0, the manifest v3 work introduced an engines field on replacements to express compatibility constraints (e.g. { engine: "node", minVersion: "X.Y.Z" }). [1], [2]

TypeScript types (beta.0)

The published/source TypeScript types define:

  • interface EngineConstraint { engine: string; minVersion?: string; maxVersion?: string }
  • engines?: EngineConstraint[] on the shared ModuleReplacementLike shape (which is extended by native, simple, documented, and removal replacements)

Notably, in the source types for 3.0.0-beta.0, engines is not declared readonly (it’s a normal EngineConstraint[]). [3]

If you’re seeing readonly ...[] in your codebase, that’s typically coming from your JSON being imported/typed as const (or otherwise inferred as immutable), not from module-replacements’ own EngineConstraint[] declaration. [3]

Sources:
[1] Release notes for 3.0.0-beta.0 (schema v3 iteration) (github.com)
[2] PR #266 discussion/examples showing engines in v3 schema (github.com)
[3] src/types.ts and package.json at the 3.0.0-beta.0 tag/commit (shows EngineConstraint + engines?: EngineConstraint[]) (raw.githubusercontent.com)

Citations:


🏁 Script executed:

#!/bin/bash
# Check the server API file
cat server/api/replacements/\[...pkg\].get.ts

echo "==="
# Check all composables that import from module-replacements
cat app/composables/useModuleReplacement.ts
echo "---"
cat app/composables/useCompareReplacements.ts
echo "---"
cat app/composables/npm/useReplacementDependencies.ts

echo "==="
# Check the vue components
fd -t f "Replacement.vue" --exec cat {}
echo "---"
fd -t f "ReplacementSuggestion.vue" --exec cat {}

echo "==="
# Check the a11y spec for the full test data
cat test/nuxt/a11y.spec.ts | head -150

Length of output: 17106


🏁 Script executed:

#!/bin/bash
# Try to fetch the actual types from the npm package
curl -s "https://registry.npmjs.org/module-replacements/3.0.0-beta.0" | python3 -c "
import json, sys
data = json.load(sys.stdin)
print('dist:', data.get('dist', {}).get('tarball'))
print('types:', data.get('types'))
print('main:', data.get('main'))
print('exports:', json.dumps(data.get('exports', {}), indent=2))
"

echo "==="
# Try fetching the source types from github
curl -s "https://raw.githubusercontent.com/es-tooling/module-replacements/main/src/types.ts" | head -100

Length of output: 2086


🏁 Script executed:

#!/bin/bash
# Download and inspect the tarball
curl -sL "https://registry.npmjs.org/module-replacements/-/module-replacements-3.0.0-beta.0.tgz" -o /tmp/mr.tgz
tar -tzf /tmp/mr.tgz | grep -E "\.(d\.ts|ts)$" | head -30
echo "==="
tar -xzf /tmp/mr.tgz -C /tmp package/dist/esm/main.d.ts 2>/dev/null && cat /tmp/package/dist/esm/main.d.ts | head -80
echo "==="
tar -xzf /tmp/mr.tgz -C /tmp package/dist/commonjs/main.d.ts 2>/dev/null && cat /tmp/package/dist/commonjs/main.d.ts | head -80

Length of output: 904


🏁 Script executed:

#!/bin/bash
tar -xzf /tmp/mr.tgz -C /tmp package/dist/esm/types.d.ts 2>/dev/null
cat /tmp/package/dist/esm/types.d.ts

echo "==="
# Also check the actual JS files to understand the structure at runtime
tar -xzf /tmp/mr.tgz -C /tmp package/dist/esm/main.js 2>/dev/null
head -30 /tmp/package/dist/esm/main.js

echo "==="
# Check if there's a none type
tar -xzf /tmp/mr.tgz -C /tmp package/dist/esm/manifests-dir.d.ts 2>/dev/null
cat /tmp/package/dist/esm/manifests-dir.d.ts

Length of output: 2408


@gameroman The root cause isn't in the module-replacements package itself — its types are clean and non-readonly. The problem comes from Vue's readonly() wrapper in useCompareReplacements.ts.

When you wrap a ComputedRef in readonly():

noDepSuggestions: readonly(noDepSuggestions), // ComputedRef<ReplacementSuggestion[]>

Vue's type system produces DeepReadonly<ComputedRef<ReplacementSuggestion[]>>. Accessing .value then yields readonly DeepReadonly<ReplacementSuggestion>[], where each item's .replacement becomes DeepReadonly<ModuleReplacement>. Passing that into component props typed as ModuleReplacement causes the TypeScript errors.

The fix: ComputedRef is already inherently readonly (you can't assign to .value externally), so there's no need to wrap them in readonly(). Only the shallowRefs (replacements, loading) need that protection.

In app/composables/useCompareReplacements.ts, change the return statement:

  return {
    replacements: readonly(replacements),
-   noDepSuggestions: readonly(noDepSuggestions),
-   infoSuggestions: readonly(infoSuggestions),
+   noDepSuggestions,
+   infoSuggestions,
    loading: readonly(loading),
  }

This removes the unnecessary DeepReadonly wrapping while still protecting the mutable shallowRefs, and the component props typed as ModuleReplacement will accept the values without errors.

@serhalp
Copy link
Member

serhalp commented Mar 14, 2026

Attaching some screenshots

Screenshot 2026-03-14 at 11 56 05 Screenshot 2026-03-14 at 11 58 22 Screenshot 2026-03-14 at 11 57 40 Screenshot 2026-03-14 at 12 00 04 Screenshot 2026-03-14 at 12 00 38

Copy link
Member

@serhalp serhalp left a comment

Choose a reason for hiding this comment

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

LGTM! no notes :shipit:

@gameroman
Copy link
Contributor Author

@coderabbitai How can I add a e2e test that /package/strip-ansi shows a replacement suggestion with a link to https://e18e.dev/docs/replacements/strip-ansi ?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 16, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and read all e2e test files
fd -t f -e ts -e tsx . --search-path e2e --exec echo "=== {} ===" \; --exec cat {}

echo "---"
# Also look for any playwright config
fd -t f "playwright.config*" --exec cat {}

Length of output: 1278


🏁 Script executed:

#!/bin/bash
# Find and read all e2e test files
fd -t f -e ts -e tsx . --search-path test/e2e --exec echo "=== {} ===" \; --exec cat {}

Length of output: 72445


🏁 Script executed:

#!/bin/bash
# Check mock-routes fixture
fd -t f "mock-routes.cjs" --exec cat {}

echo "==="
# Check test/fixtures directory structure
fd -t f . --search-path test/fixtures | head -50

echo "==="
# Check if there's a strip-ansi fixture
fd -t f . --search-path test/fixtures | xargs grep -l "strip-ansi" 2>/dev/null

echo "==="
# Look at the replacements API file more carefully
fd -t f "*.get.ts" --search-path server/api/replacements --exec cat {}

echo "==="
# Check how gzip-size replacement fixture might look
fd -t f . --search-path test/fixtures | xargs grep -l "gzip\|replacement" 2>/dev/null | head -5

Length of output: 18535


🏁 Script executed:

#!/bin/bash
# Read the server API file
cat "server/api/replacements/[...pkg].get.ts"

echo "==="
# Read a minimal packument fixture as template
cat test/fixtures/npm-registry/packuments/is-odd.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
# Show just the top-level keys and dist-tags
print('Top-level keys:', list(data.keys()))
print('dist-tags:', data.get('dist-tags'))
versions = list(data.get('versions', {}).keys())
print('versions count:', len(versions))
print('first version keys:', list(data['versions'][versions[0]].keys()) if versions else [])
"

echo "==="
# Also check what the Replacement.vue learn-more link looks like
fd -t f "Replacement.vue" --search-path app --exec cat {}

echo "==="
# Check how replacement data is fetched in the package page
fd -t f "useModuleReplacement*" --search-path app --exec cat {}
rg -n "api/replacements" --type ts --type vue -C 2

Length of output: 5106


@gameroman Here's how to add the test. You need two things:


1. Add an npm registry packument fixture for strip-ansi

Without this, the mock intercepts the npm registry call and returns 404, making the package page show "not found" before the replacement suggestion ever loads.

Create test/fixtures/npm-registry/packuments/strip-ansi.json:

{
  "_id": "strip-ansi",
  "name": "strip-ansi",
  "description": "Strip ANSI escape codes from a string",
  "dist-tags": {
    "latest": "7.1.0"
  },
  "versions": {
    "7.1.0": {
      "name": "strip-ansi",
      "version": "7.1.0",
      "description": "Strip ANSI escape codes from a string",
      "license": "MIT",
      "main": "index.js",
      "engines": { "node": ">=12" },
      "dependencies": { "ansi-regex": "^6.0.1" },
      "_id": "strip-ansi@7.1.0",
      "dist": {
        "tarball": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
        "shasum": "d81d4723a5abe40f9b371d00d96e566d998d8db8"
      }
    }
  },
  "time": {
    "7.1.0": "2022-10-01T00:00:00.000Z"
  },
  "license": "MIT",
  "readme": "Strip ANSI escape codes from a string",
  "keywords": ["strip", "ansi", "escape codes", "cli"]
}

2. Write the test

Create test/e2e/package-replacements.spec.ts:

import { expect, test } from './test-utils'

test.describe('Package Replacement Suggestions', () => {
  test('/package/strip-ansi shows a replacement suggestion with a link to e18e docs', async ({
    page,
    goto,
  }) => {
    await goto('/package/strip-ansi', { waitUntil: 'hydration' })

    // Wait for the package page to load
    await expect(page.locator('h1')).toContainText('strip-ansi', { timeout: 15_000 })

    // The replacement component renders a "Learn more" anchor driven by resolveReplacementUrl.
    // For strip-ansi the url.type is 'e18e', so the href resolves to:
    // https://e18e.dev/docs/replacements/strip-ansi
    //
    // useLazyFetch is used, so the suggestion arrives after hydration — use a generous timeout.
    const replacementLink = page.locator(
      'a[href="https://e18e.dev/docs/replacements/strip-ansi"]',
    )
    await expect(replacementLink).toBeVisible({ timeout: 15_000 })
  })
})

Why this works

  • The internal /api/replacements/strip-ansi Nuxt server route is not intercepted by the external-API mock system — it runs normally and reads from the module-replacements package on disk.
  • Replacement.vue unconditionally renders <a :href="externalUrl"> at the bottom when externalUrl is truthy, so page.locator('a[href="..."]') is the right selector.
  • The useLazyFetch in useModuleReplacement means the suggestion loads asynchronously after SSR, hence the 15-second timeout on the assertion.

🧠 Learnings used
Learnt from: CR
Repo: npmx-dev/npmx.dev PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-03T10:09:38.777Z
Learning: Applies to **/e2e/**/*.{test,spec}.{ts,tsx} : Write end-to-end tests using Playwright and `nuxt/test-utils`

Learnt from: CR
Repo: npmx-dev/npmx.dev PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-02-03T10:09:38.777Z
Learning: Applies to **/*.{test,spec}.{ts,tsx} : Write unit tests for core functionality using `vitest`

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants