Skip to content

Comments

feat: size impact analysis#486

Open
mary-ext wants to merge 1 commit intonpmx-dev:mainfrom
mary-ext:feat/teardown
Open

feat: size impact analysis#486
mary-ext wants to merge 1 commit intonpmx-dev:mainfrom
mary-ext:feat/teardown

Conversation

@mary-ext
Copy link

@mary-ext mary-ext commented Jan 31, 2026

this is still very rough, I would need help because I am very unfamiliar with Nuxt here and with how npmx project is set up

image

we can extend this page with additional options later (like a platform config so you can test bundle sizes with various import conditions applied)

@vercel
Copy link

vercel bot commented Jan 31, 2026

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

Project Deployment Actions Updated (UTC)
docs.npmx.dev Ready Ready Preview, Comment Feb 8, 2026 0:35am
npmx.dev Ready Ready Preview, Comment Feb 8, 2026 0:35am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
npmx-lunaria Ignored Ignored Feb 8, 2026 0:35am

Request Review

Comment on lines 90 to 95
'/impact/**': {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
Copy link
Author

Choose a reason for hiding this comment

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

I am not sure what the ideal approach is here, setting the COOP/COEP headers here means that it is not possible to transition to /impact/ route directly without a full page navigation

@github-actions
Copy link

github-actions bot commented Jan 31, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
lunaria/files/en-GB.json Localization changed, will be marked as complete. 🔄️
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@mary-ext
Copy link
Author

blocked on rolldown/rolldown#8145 for Safari support

@trueberryless
Copy link
Contributor

blocked on rolldown/rolldown#8145 for Safari support

Which Safari version are you using? People upstream think you need to use latest version to fix the rolldown issue 🤔

@mary-ext
Copy link
Author

mary-ext commented Feb 6, 2026

Which Safari version are you using? People upstream think you need to use latest version to fix the rolldown issue 🤔

hrm I must've caught a different issue then, someone reported that https://teardown.kelinci.dev/ didn't work on iOS Safari 26. I don't have an Apple device so the only way I could test it is with GNOME Web, since that runs WebKitGTK and should've been pretty close to Safari (even then 'pretty close' is an understatement because Safari runs on a closed source fork of WebKit)

@mary-ext
Copy link
Author

mary-ext commented Feb 6, 2026

in any case, since it doesn't block Safari on macOS and iOS I believe this PR is mostly ready to go aside from figuring out how we should be adding COOP/COEP/CORP headers, if we just have it in /impact then we need to forbid client-side navigation, while if we have it globally I'm not certain what it would do (which is why I'm hoping someone would verify this PR)

I can try rebasing this branch later during the weekend

@codecov
Copy link

codecov bot commented Feb 7, 2026

Codecov Report

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

Files with missing lines Patch % Lines
app/components/ImpactDependencyBar.vue 74.07% 14 Missing ⚠️
app/pages/package/[[org]]/[name].vue 0.00% 10 Missing and 3 partials ⚠️
app/middleware/cross-origin-isolation.global.ts 83.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 8, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive bundle impact analysis feature to the application. It adds a complete bundler system with package resolution, dependency hoisting, tarball fetching, and AST-based module analysis, utilising a dedicated Web Worker for processing. The bundler computes bundle metrics including gzip and Brotli compression sizes, generates chunks, and provides detailed export information. A new user-facing page and components display bundle analysis results, including subpath selection, export filtering, and dependency breakdowns. Supporting infrastructure includes type schemas for validation, internationalisation strings, HTTP headers for cross-origin isolation, and updated package configuration.

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description relates to the changeset by explaining the feature being added (size impact analysis page) and acknowledges the work is rough, seeking help with Nuxt/project setup.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

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.

Actionable comments posted: 13

🧹 Nitpick comments (11)
app/bundler/lib/module-type.ts (1)

74-91: Type cast as unknown as Expression is fragile.

The double cast on line 77 bypasses type safety. When node.computed is true, node.property is an Expression, but the cast assumes this without proper type narrowing.

♻️ Suggested improvement
 function getStaticPropertyName(node: StaticMemberExpression): string | null {
   if (node.computed) {
     // computed property like exports["foo"]
-    const prop = node.property as unknown as Expression
-    if (isStringLiteral(prop)) {
-      return prop.value
+    // For computed properties, property is an Expression
+    if (isStringLiteral(node.property)) {
+      return (node.property as { value: string }).value
     }
     return null
   }
app/bundler/emitter.ts (1)

72-77: Array index access could return undefined.

When listener.length === 2, accessing listener[index ^ 1] is safe since index is 0 or 1, but TypeScript cannot verify this. The coding guidelines require checking array access by index for type safety.

♻️ Suggested improvement for explicit safety
             if (listener.length === 2) {
               // ^ flips the bit, it's either 0 or 1 here.
-              listener = listener[index ^ 1]
+              const other = listener[index ^ 1]
+              if (other) listener = other
             } else {

As per coding guidelines: "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index".

app/bundler/lib/fetch.ts (2)

192-196: Mutating input parameter node.unpackedSize.

The function modifies node.unpackedSize on the input HoistedNode. While this may be intentional to backfill missing data, mutating input parameters can lead to unexpected side effects. Consider documenting this behaviour or returning the updated value separately.

This could be documented in the function's JSDoc or the HoistedNode type could be made explicitly mutable for this use case.


178-200: Worker loop structure is correct but could be clearer.

The semaphore pattern with index++ and while (true) works correctly, but a clearer structure might improve readability.

♻️ Alternative structure (optional)
   async function worker(): Promise<void> {
-    while (true) {
-      const i = index++
-      if (i >= queue.length) {
-        break
-      }
+    let i: number
+    while ((i = index++) < queue.length) {
       const item = queue[i]
       if (!item) continue
app/bundler/lib/types.ts (1)

169-177: Map key in ResolutionResult.packages may need registry disambiguation.

The packages Map uses a string key (presumably name@version), but if multiple registries are supported (npm and jsr as defined in Registry), the same name@version could theoretically exist on both registries. Consider including the registry in the key or documenting the assumption.

This may not be an issue if the resolution always operates within a single registry context, but it's worth verifying the intended usage pattern.

app/bundler/lib/registry.ts (1)

6-7: Consider reusing the existing NPM_REGISTRY constant.

The NPM_REGISTRY constant is already defined in shared/utils/constants.ts. Consider importing it to avoid duplication and ensure consistency.

♻️ Proposed fix
+import { NPM_REGISTRY } from '#shared/utils/constants'
+
-const NPM_REGISTRY = 'https://registry.npmjs.org'
 const JSR_REGISTRY = 'https://npm.jsr.io'
app/pages/impact/[...path].vue (1)

60-65: Potential null access on regex match.

The regex match could be null if the pattern doesn't match. While the startsWith('@') check makes a match likely, the current code safely handles this with match ? match[1] : null. However, match[1] could still be undefined if the capture group doesn't match. Consider adding a fallback.

♻️ Proposed fix for robustness
 const orgName = computed(() => {
   const name = packageName.value
   if (!name.startsWith('@')) return null
   const match = name.match(/^@([^/]+)\//)
-  return match ? match[1] : null
+  return match?.[1] ?? null
 })
app/bundler/lib/hoist.ts (1)

136-141: Minor: consider defensive check for node existence.

While root.get(rootPkg.name) should always return a value (since it's set in the first pass), adding a defensive check would make the code more robust against future refactoring.

♻️ Proposed defensive check
   // second pass: process dependencies of all root packages
   for (const rootPkg of roots) {
     const node = root.get(rootPkg.name)
-    if (node) {
+    if (node !== undefined) {
       processDependencies(rootPkg, node)
     }
   }

The current check is functionally correct; this is purely a stylistic suggestion for explicitness.

app/bundler/lib/subpaths.ts (1)

180-289: Consider splitting discoverSubpaths into smaller helpers.

The function is well over 50 lines and mixes export parsing, legacy fallback, and default selection, which makes it harder to test in isolation. Extracting those steps into helpers would improve readability.

As per coding guidelines: “Keep functions focused and manageable (generally under 50 lines).”

app/bundler/lib/resolve.ts (2)

66-95: Consider splitting pickVersion into smaller helpers.

The function is well over 50 lines; extracting dist-tag handling, exact-version handling, and range resolution would improve readability and test coverage.

As per coding guidelines: “Keep functions focused and manageable (generally under 50 lines).”

Also applies to: 130-143


178-277: Consider breaking resolvePackage into smaller helpers.

The function exceeds 50 lines and mixes fetch/selection, cycle handling, and dependency collection/resolution. Splitting those stages would make it easier to reason about and test.

As per coding guidelines: “Keep functions focused and manageable (generally under 50 lines).”

Comment on lines 358 to 361
if (expr.type === 'MemberExpression') {
const memberExpr = expr as StaticMemberExpression
return containsImportMeta(memberExpr.object)
}
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 | 🟡 Minor

containsImportMeta may access object on computed member expressions.

When expr.type === 'MemberExpression', the cast to StaticMemberExpression assumes it's non-computed, but the function doesn't verify this. For computed member expressions, the object structure may differ.

🛡️ Proposed fix
   if (expr.type === 'MemberExpression') {
-    const memberExpr = expr as StaticMemberExpression
-    return containsImportMeta(memberExpr.object)
+    // Both computed and non-computed MemberExpressions have an object property
+    return containsImportMeta(expr.object as Expression)
   }

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.

Actionable comments posted: 2

🧹 Nitpick comments (11)
app/components/ImpactDependencyBar.vue (1)

47-56: Unused parameter _index in getColor.

The _index parameter is never used. Consider removing it.

♻️ Proposed refactor
-function getColor(packageName: string, _index: number): string {
+function getColor(packageName: string): string {
   // Use a hash of the package name for consistent colors
   let hash = 0
   for (let i = 0; i < packageName.length; i++) {
     hash = (hash << 5) - hash + packageName.charCodeAt(i)
     hash |= 0
   }
   return COLORS[Math.abs(hash) % COLORS.length] ?? 'bg-gray-500'
 }

Then update the template calls:

-        :class="getColor(pkg.name, index)"
+        :class="getColor(pkg.name)"
app/bundler/emitter.ts (2)

23-26: Documentation comment is inaccurate.

The JSDoc states "uses a Set internally for O(1) add/remove operations" but the implementation uses an array with indexOf (O(n)) and toSpliced (O(n)). Update the comment to reflect the actual implementation.

📝 Proposed fix
 /**
  * creates a typed event emitter.
- * uses a Set internally for O(1) add/remove operations.
+ * optimizes for the common single-listener case, falling back to an array for multiple listeners.
  *
  * `@returns` an event emitter with listen and emit methods

72-77: Consider adding type assertion for array element access.

When listener.length === 2, accessing listener[index ^ 1] is safe at runtime, but TypeScript may infer the result as Callback<T> | undefined. An assertion clarifies intent.

🛡️ Proposed fix
             if (listener.length === 2) {
               // ^ flips the bit, it's either 0 or 1 here.
-              listener = listener[index ^ 1]
+              listener = listener[index ^ 1]!
             } else {
app/bundler/lib/module-type.ts (2)

59-66: Cast to StaticMemberExpression should verify computed property first.

At line 60, the code casts node to StaticMemberExpression before checking memberExpr.computed at line 61. While the subsequent check prevents incorrect property access, the cast itself is misleading since computed member expressions have a different property type. Consider restructuring to check computed before casting, or use a type guard.

♻️ Proposed refactor
   // module.exports
   if (node.type === 'MemberExpression') {
-    const memberExpr = node as StaticMemberExpression
-    if (!memberExpr.computed) {
+    // Check computed first before assuming static member expression structure
+    if (!node.computed) {
+      const memberExpr = node as StaticMemberExpression
       const obj = memberExpr.object
       const prop = memberExpr.property
       return isIdentifier(obj, 'module') && prop.type === 'Identifier' && prop.name === 'exports'
     }
   }

140-143: Unsafe cast to StaticMemberExpression for potentially computed member expressions.

At line 142, left is cast to StaticMemberExpression without first verifying that it's not a computed member expression. This could lead to incorrect behaviour if the AST contains computed property access like exports["foo"] = ....

♻️ Proposed fix
     // exports.foo = ... or module.exports.foo = ...
     if (left.type === 'MemberExpression') {
-      const memberExpr = left as unknown as StaticMemberExpression
+      const memberExpr = left as { computed: boolean; object: Expression; property: unknown }
       const obj = memberExpr.object
 
       // direct assignment to exports.propertyName
       if (isExportsObject(obj)) {
-        const propName = getStaticPropertyName(memberExpr)
+        const propName = getStaticPropertyName(memberExpr as StaticMemberExpression)
app/bundler/lib/fetch.ts (1)

192-195: Mutation of node.unpackedSize modifies the original HoistedNode object.

This mutates the input data structure, which could have unintended side effects if the HoistedResult is used elsewhere. Consider whether this is intentional or if a separate tracking mechanism would be cleaner.

app/pages/package/[[org]]/[name].vue (1)

329-339: Non-null assertion on pkg.value may be unsafe.

At line 336, pkg.value!.name uses a non-null assertion, but the guard only checks resolvedVersion.value. While pkg.value is likely defined when resolvedVersion exists due to data flow, this assumption isn't explicit. Consider adding a guard for pkg.value for defensive coding.

🛡️ Proposed fix
 const impactLink = computed(() => {
-  if (!resolvedVersion.value) return null
+  if (!resolvedVersion.value || !pkg.value) return null
 
   return {
     name: 'impact' as const,
     params: {
-      path: [pkg.value!.name, 'v', resolvedVersion.value] satisfies [string, string, string],
+      path: [pkg.value.name, 'v', resolvedVersion.value] satisfies [string, string, string],
     },
   }
 })
app/pages/impact/[...path].vue (2)

41-49: Consider using navigateTo with replace: true for SSR compatibility.

Using router.replace() in a watch may not work correctly during SSR. The Nuxt navigateTo helper with the replace option handles both SSR and client-side scenarios more reliably.

♻️ Proposed fix
 watch(
   [requestedVersion, latestVersion, packageName],
   ([version, latest, name]) => {
     if (!version && latest && name) {
-      router.replace(`/impact/${name}/v/${latest}`)
+      navigateTo(`/impact/${name}/v/${latest}`, { replace: true })
     }
   },
   { immediate: true },
 )

133-134: Non-null assertion on packageSpec may cause runtime errors.

At line 134, packageSpec! uses a non-null assertion, but the v-else condition only ensures resolvedVersion exists. If packageName is empty, packageSpec would still be null. The guard at line 55 checks both, but TypeScript cannot infer this through the template condition.

🛡️ Proposed fix
     <!-- Main content - client only since it uses Web Workers -->
-    <ClientOnly v-else>
-      <ImpactAnalyzer :package-spec="packageSpec!" :package-name="packageName" />
+    <ClientOnly v-else-if="packageSpec">
+      <ImpactAnalyzer :package-spec="packageSpec" :package-name="packageName" />
app/bundler/lib/subpaths.ts (1)

142-152: Potential issue with slice operations on file paths.

At line 144, filePath.slice(searchDir.length + 1) assumes filePath starts with searchDir/. If listFilesRecursive returns paths that don't match this assumption, relativePath could be incorrect.

At line 151, if suffix is longer than relativePath, the slice would produce an empty string or unexpected result.

🛡️ Proposed defensive check
   for (const filePath of allFiles) {
     // check if file matches the pattern
+    if (!filePath.startsWith(searchDir + '/')) {
+      continue
+    }
     const relativePath = filePath.slice(searchDir.length + 1)
 
     if (suffix && !filePath.endsWith(suffix)) {
       continue
     }
 
     // extract the wildcard match
-    const match = suffix ? relativePath.slice(0, relativePath.length - suffix.length) : relativePath
+    const match = suffix
+      ? relativePath.slice(0, Math.max(0, relativePath.length - suffix.length))
+      : relativePath
app/bundler/lib/resolve.ts (1)

178-277: Consider splitting resolvePackage into smaller helpers.

At ~100 lines it handles selection, cycle detection, dependency collection and recursion. Extracting dependency collection and resolution into helpers would keep each function manageable.

As per coding guidelines, keep functions focused and manageable (generally under 50 lines).

@mary-ext
Copy link
Author

mary-ext commented Feb 8, 2026

I'm not sure why production knip is failing here

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.

Actionable comments posted: 4

🧹 Nitpick comments (5)
app/bundler/lib/errors.ts (1)

44-54: Consider storing the reason parameter as a readonly field.

The reason parameter is used in the error message but not stored as a field. This could limit debuggability when catching the error programmatically. If the reason is useful for message construction, it may also be useful for error handling code.

♻️ Optional improvement
 export class InvalidSpecifierError extends BundlerError {
   readonly specifier: string
+  readonly reason: string | undefined

   constructor(specifier: string, reason?: string) {
     super(
       reason ? `invalid specifier: ${specifier} (${reason})` : `invalid specifier: ${specifier}`,
     )
     this.name = 'InvalidSpecifierError'
     this.specifier = specifier
+    this.reason = reason
   }
 }
app/bundler/lib/fetch.ts (1)

192-195: Mutating node.unpackedSize directly may have unexpected side effects.

The code mutates the unpackedSize property on the HoistedNode object. If callers expect the hoisted structure to remain unchanged after calling fetchPackagesToVolume, this mutation could cause subtle bugs. Consider whether this side effect is intentional and documented, or if a separate tracking mechanism would be clearer.

app/bundler/lib/resolve.ts (2)

102-110: Array access returns undefined when key doesn't exist - add explicit type narrowing.

The direct property access versions[range] and versions[cleanedVersion] could return undefined if the version doesn't exist. While the code handles this implicitly (returning undefined which becomes null later), adding explicit checks would make the intent clearer and satisfy stricter type checking. As per coding guidelines, ensure type-safe array/object access.

🛠️ Proposed fix
   // check if range is an exact version
-  if (versions[range]) {
-    return versions[range]
+  const exactMatch = versions[range]
+  if (exactMatch) {
+    return exactMatch
   }

   // check cleaned version (handles v1.0.0 -> 1.0.0)
   const cleanedVersion = semver.clean(range, { loose: true })
-  if (cleanedVersion && versions[cleanedVersion]) {
-    return versions[cleanedVersion]
+  const cleanedMatch = cleanedVersion ? versions[cleanedVersion] : undefined
+  if (cleanedMatch) {
+    return cleanedMatch
   }

201-213: Cycle detection returns a placeholder without dependencies.

The cycle detection correctly breaks infinite recursion by returning a minimal ResolvedPackage with an empty dependencies map. This is documented, but consumers should be aware that cyclic packages won't have their full dependency tree resolved. Consider adding a flag to indicate the package is a cyclic reference.

💡 Optional enhancement
+  // Note: This placeholder won't have dependencies populated
+  // Consumers should handle cyclic references appropriately
   const cyclic: ResolvedPackage = {
     name,
     version: manifest.version,
     tarball: manifest.dist.tarball,
     integrity: manifest.dist.integrity,
     dependencies: new Map(),
+    // Consider adding: cyclic: true
   }
app/bundler/lib/hoist.ts (1)

29-53: Minor duplication: tryPlaceAtRoot duplicates createNode logic.

Lines 34-42 manually construct a HoistedNode object, duplicating the logic in createNode. Consider reusing createNode for consistency and maintainability.

♻️ Proposed refactor
 function tryPlaceAtRoot(root: Map<string, HoistedNode>, pkg: ResolvedPackage): boolean {
   const existing = root.get(pkg.name)
 
   if (!existing) {
     // no conflict, place at root
-    root.set(pkg.name, {
-      name: pkg.name,
-      version: pkg.version,
-      tarball: pkg.tarball,
-      integrity: pkg.integrity,
-      unpackedSize: pkg.unpackedSize,
-      dependencyCount: pkg.dependencies.size,
-      nested: new Map(),
-    })
+    root.set(pkg.name, createNode(pkg))
     return true
   }

Comment on lines +183 to +190
switch (request.type) {
case 'init':
handleInit(request.id, request.packageSpec, request.options)
break
case 'bundle':
handleBundle(request.id, request.subpath, request.selectedExports, request.options)
break
}
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 | 🟡 Minor

Missing await on async handlers may cause unhandled promise rejections.

The handleInit and handleBundle functions are async, but they are called without await in the message handler. If these functions throw after their first await, the rejection won't be caught by the worker's error handling.

🛠️ Proposed fix
   switch (request.type) {
     case 'init':
-      handleInit(request.id, request.packageSpec, request.options)
+      handleInit(request.id, request.packageSpec, request.options).catch(() => {
+        // errors are already posted to main thread in handleInit
+      })
       break
     case 'bundle':
-      handleBundle(request.id, request.subpath, request.selectedExports, request.options)
+      handleBundle(request.id, request.subpath, request.selectedExports, request.options).catch(() => {
+        // errors are already posted to main thread in handleBundle
+      })
       break
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
switch (request.type) {
case 'init':
handleInit(request.id, request.packageSpec, request.options)
break
case 'bundle':
handleBundle(request.id, request.subpath, request.selectedExports, request.options)
break
}
switch (request.type) {
case 'init':
handleInit(request.id, request.packageSpec, request.options).catch(() => {
// errors are already posted to main thread in handleInit
})
break
case 'bundle':
handleBundle(request.id, request.subpath, request.selectedExports, request.options).catch(() => {
// errors are already posted to main thread in handleBundle
})
break
}

Comment on lines +9 to +12
const fetchOptionsSchema = v.object({
concurrency: v.optional(v.number()),
exclude: v.optional(v.array(v.instance(RegExp))),
})
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

RegExp instances cannot be serialised via postMessage.

The exclude field uses v.instance(RegExp), but RegExp objects cannot be cloned through the structured clone algorithm used by postMessage. If this option is passed to the worker, it will throw a DataCloneError at runtime.

Consider using string patterns instead and converting them to RegExp inside the worker.

🛠️ Proposed fix
 const fetchOptionsSchema = v.object({
   concurrency: v.optional(v.number()),
-  exclude: v.optional(v.array(v.instance(RegExp))),
+  exclude: v.optional(v.array(v.string())), // patterns converted to RegExp in worker
 })

Then in the worker, convert strings to RegExp:

const excludePatterns = options.fetch?.exclude?.map(pattern => new RegExp(pattern))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const fetchOptionsSchema = v.object({
concurrency: v.optional(v.number()),
exclude: v.optional(v.array(v.instance(RegExp))),
})
const fetchOptionsSchema = v.object({
concurrency: v.optional(v.number()),
exclude: v.optional(v.array(v.string())), // patterns converted to RegExp in worker
})

Comment on lines +22 to +60
// Start analysis on mount
onMounted(async () => {
try {
const result = await initPackage(props.packageSpec)

// Set default subpath
if (result.subpaths.defaultSubpath) {
selectedSubpath.value = result.subpaths.defaultSubpath

// Start initial bundle
await bundle(result.subpaths.defaultSubpath, null)
}
} catch {
// init failed — state.error will be set by useBundleAnalyzer
}
})

// Re-bundle when subpath changes
let suppressExportWatch = false
watch(selectedSubpath, async newSubpath => {
if (newSubpath && state.initResult) {
suppressExportWatch = true
selectedExports.value = null // Reset to all exports
await bundle(newSubpath, null)
suppressExportWatch = false
}
})

// Re-bundle when selected exports change
watch(
selectedExports,
async newExports => {
if (suppressExportWatch) return
if (selectedSubpath.value && state.initResult && state.bundleResult) {
await bundle(selectedSubpath.value, newExports)
}
},
{ deep: true },
)
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

🌐 Web query:

Vue 3 watch does updating a watched ref inside onMounted trigger the watcher and its side effects

💡 Result:

Yes—if the watch() is already registered (e.g., in setup() / <script setup>) and you change the watched ref inside onMounted(), that change counts as a normal reactive mutation and will trigger the watcher (and any side effects in its callback).

Key details:

  • watch() is “lazy” by default: it does not run on initial mount unless you set { immediate: true }; it runs when the watched source changes. [1]
  • Watcher callbacks are batched. By default (flush: 'pre'), the callback runs in the component update cycle after parent updates (if any) and before the component’s own DOM updates; you can change timing with flush: 'post' or flush: 'sync'. [2][3]
  • onMounted() runs after the component is mounted (DOM created/inserted), so a mutation you do there happens after the watcher was created (if created in setup()), and will therefore be observed. [4]

If you don’t want the onMounted() assignment to trigger the watcher, common patterns are: set the value before creating the watcher, or create/register the watcher inside onMounted() after your initial assignment (or use the watch handle to pause()/resume()/stop() when appropriate). [3]

Sources:
[1] Vue docs – watch() is lazy by default / immediate option: https://vuejs.org/api/reactivity-core.html
[2] Vue docs – watcher callback flush timing (default + flush: 'post'|'sync'): https://vuejs.org/guide/essentials/watchers.html
[3] Vue docs – flush options and watch handle (stop/pause/resume): https://vuejs.org/api/reactivity-core.html
[4] Vue docs – onMounted() timing: https://vuejs.org/api/composition-api-lifecycle


🏁 Script executed:

find . -type f -name "ImpactAnalyzer.vue" | head -5

Repository: npmx-dev/npmx.dev

Length of output: 96


🏁 Script executed:

cat -n ./app/components/ImpactAnalyzer.vue | head -80

Repository: npmx-dev/npmx.dev

Length of output: 2738


Remove the explicit bundle() call in onMounted and use finally to guarantee the flag is reset.

Setting selectedSubpath in onMounted (line 29) triggers the selectedSubpath watcher (line 41), which calls bundle() again at line 45. This causes double bundling. Additionally, if bundle() throws an error, suppressExportWatch stays true on line 43, preventing export changes from triggering future bundles. Use finally to ensure the flag is always reset:

🔧 Suggested fix
 onMounted(async () => {
   try {
     const result = await initPackage(props.packageSpec)

     // Set default subpath
     if (result.subpaths.defaultSubpath) {
       selectedSubpath.value = result.subpaths.defaultSubpath

-      // Start initial bundle
-      await bundle(result.subpaths.defaultSubpath, null)
     }
   } catch {
     // init failed — state.error will be set by useBundleAnalyzer
   }
 })

 watch(selectedSubpath, async newSubpath => {
   if (newSubpath && state.initResult) {
     suppressExportWatch = true
-    selectedExports.value = null // Reset to all exports
-    await bundle(newSubpath, null)
-    suppressExportWatch = false
+    try {
+      selectedExports.value = null // Reset to all exports
+      await bundle(newSubpath, null)
+    } finally {
+      suppressExportWatch = false
+    }
   }
 })

Comment on lines +12 to +27
const parsedRoute = computed(() => {
const segments = route.params.path || []
const vIndex = segments.indexOf('v')

if (vIndex === -1 || vIndex >= segments.length - 1) {
return {
packageName: segments.join('/'),
version: null as string | null,
}
}

return {
packageName: segments.slice(0, vIndex).join('/'),
version: segments.slice(vIndex + 1).join('/'),
}
})
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 | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Nuxt 4 catch-all route params [...path] type string or array

💡 Result:

In Nuxt 4, a catch-all page like app/pages/[...path].vue (or [...slug].vue) produces a repeatable param, so at runtime you’ll get an array of segments:

  • /hello/worldroute.params.path is ["hello", "world"] (dev.nuxt.com)

However, in TypeScript, useRoute().params comes from vue-router, whose params are typed broadly (so a param can be string | string[]), even if your particular route always yields an array. (dev.nuxt.com)

Practical pattern:

const route = useRoute()
const path = Array.isArray(route.params.path)
  ? route.params.path
  : [route.params.path].filter(Boolean)

If you know it’s always an array for that page, you can also assert:

const path = route.params.path as string[]

Citations:


🌐 Web query:

Nuxt RouteParamValue type definition route.params.path

💡 Result:

In Nuxt 3 (Vue Router v4), the types come from Vue Router:

  • type RouteParamValue = string [1]
  • type RouteParamsGeneric = Record<string, RouteParamValue | RouteParamValue[]> → i.e. Record<string, string | string[]> [2]
  • useRoute().params is RouteParamsGeneric [3]

So route.params.path is typed as string | string[] (for a catch-all like pages/[...path].vue, it’s typically a string[] at runtime).

const route = useRoute()
const p = route.params.path // string | string[]

const fullPath = Array.isArray(p) ? p.join('/') : p

Sources: [1] https://router.vuejs.org/api/type-aliases/routeparamvalue, [2] https://router.vuejs.kr/api/, [3] https://router.vuejs.org/api/interfaces/routelocationgeneric


Handle the string | string[] type of route.params.path.

Vue Router types route params generically as string | string[], even though catch-all routes in Nuxt 4 always yield arrays at runtime. The current code lacks a type guard, risking incorrect semantics if the string case occurs.

🔧 Suggested fix
 const parsedRoute = computed(() => {
-  const segments = route.params.path || []
+  const rawPath = route.params.path
+  const segments = Array.isArray(rawPath) ? rawPath : rawPath ? [rawPath] : []
   const vIndex = segments.indexOf('v')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parsedRoute = computed(() => {
const segments = route.params.path || []
const vIndex = segments.indexOf('v')
if (vIndex === -1 || vIndex >= segments.length - 1) {
return {
packageName: segments.join('/'),
version: null as string | null,
}
}
return {
packageName: segments.slice(0, vIndex).join('/'),
version: segments.slice(vIndex + 1).join('/'),
}
})
const parsedRoute = computed(() => {
const rawPath = route.params.path
const segments = Array.isArray(rawPath) ? rawPath : rawPath ? [rawPath] : []
const vIndex = segments.indexOf('v')
if (vIndex === -1 || vIndex >= segments.length - 1) {
return {
packageName: segments.join('/'),
version: null as string | null,
}
}
return {
packageName: segments.slice(0, vIndex).join('/'),
version: segments.slice(vIndex + 1).join('/'),
}
})

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