Skip to content

[Bug?]: removeUnusedVariables leaves type-only import specifiers alive, triggering server-only guard #2272

Description

@YanAnghelp

Current behavior

When a module uses function-level 'use server' and imports a symbol alongside a TypeScript inline type (import { type Session, verify }), after the transform makes verify unreferenced, removeUnusedVariables removes verify but leaves the type Session specifier. The import declaration survives, Vite follows the module chain, and if that module contains import 'server-only', the boundary-modules guard fires:

Error: Attempt to import 'server-only' in a client module: .../modules/user/auth/session.ts

This does not happen with import { verify } (no type specifier) — the single-specifier path hits parent.remove() and deletes the whole import cleanly.

Steps to reproduce

// modules/user/auth/session.ts
import 'server-only';
export interface Session { ... }
export async function verify(token: Buffer): Promise<Session | null> { ... }

// functions/user/auth/session.ts
import { type Session, verify } from '~/modules/user/auth/session';

export const verifyCookieToken = query(async (): Promise<VerifyResult> => {
  'use server';
  const res = await verify(token);
  // ...
}, 'key');

Expected behavior

After the 'use server' transform replaces the inner function with an RPC stub, all imports only used by that function should be fully removed — including those whose only remaining specifiers are type-only.

Root cause

packages/start/src/directives/remove-unused-variables.ts uses a BindingIdentifier visitor to find zero-reference bindings. compile.ts registers TypeScript only as a parser plugin (parserOpts.plugins), not as a transform plugin, so type annotations are parsed but not stripped. TypeScript type import specifiers (import { type Session }) create no scope bindings in Babel, so the visitor never visits or removes them.

When verify is removed from a two-specifier import:

// remove-unused-variables.ts:42-47
if (isPathValid(parent, t.isImportDeclaration)) {
  if (parent.node.specifiers.length === 1) {
    parent.remove(); // ← NOT reached (was 2→1, triggers the else branch)
  } else {
    binding.path.remove(); // only `verify` specifier removed
  }
}

The surviving import { type Session } from '~/modules/user/auth/session' keeps the import alive, and the entire module chain (including server-only) is pulled into the client bundle.

Suggested fix

After removing the last value specifier from an import, check if all remaining specifiers have importKind === 'type'; if so, remove the entire ImportDeclaration.

Environment

  • OS: Windows 11
  • Node: v26.2.0
  • pnpm: 11.8.0
  • @solidjs/start: 2.0.0-rc.7

Some of the content was generated by AI.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions