Skip to content

refreshHandler auto-import keeps a .ts extension and busts the vue-tsc incremental cache #1093

Description

@kboshold

Environment

  • @sidebase/nuxt-auth 1.1.1
  • Nuxt 4.4.8
  • vue-tsc 3.3.5
  • typescript 6.0.3

Reproduction

  1. Use nuxt-auth in a Nuxt 4 project with vue-tsc and incremental builds (incremental: true, which is the Nuxt default).
  2. Run nuxt prepare, then run vue-tsc --noEmit once to build the incremental cache.
  3. Run nuxt typecheck. It rewrites .nuxt/types/imports.d.ts with the refreshHandler import missing the .ts, which differs from the dev/prepare output.
  4. The whole program is rechecked from cold instead of reusing the cache.
  5. Run nuxt dev again and the line flips back, so the next type check is cold again.

Describe the bug

What happens

The module registers the _refreshHandler auto-import using the destination path of the generated template, and that path still carries the .ts extension.

src/module.ts:

const generatedRefreshHandlerPath = addTemplate({
  filename: './refreshHandler.ts',
  // ...
}).dst // ends with ".../refreshHandler.ts"

addImports([{
  name: '_refreshHandler',
  from: generatedRefreshHandlerPath, // registered WITH the .ts extension
}])

Because the from path carries the extension, the generated .nuxt/types/imports.d.ts is not stable across Nuxt commands. The line comes out one of two ways depending on which command generated .nuxt:

  • After nuxt dev or nuxt prepare:
    const _refreshHandler: typeof import('../refreshHandler.ts')._refreshHandler
  • After nuxt typecheck:
    const _refreshHandler: typeof import('../refreshHandler')._refreshHandler

Why this is a problem

imports.d.ts is a global declaration. Almost every file in an app depends on it through auto-imports. When that one line flips, TypeScript treats the global declaration as changed and throws away the whole incremental build cache (tsconfig.tsbuildinfo), so the next vue-tsc run checks every file from scratch.

In normal use you switch between nuxt dev and nuxt typecheck all the time, so the line flips back and forth and the cache is invalidated on almost every type check.

The size of the hit scales with the project. On a large Nuxt 4 app (several thousand first-party TypeScript and Vue files) a warm vue-tsc run takes about 20 seconds, while a nuxt typecheck that follows a nuxt dev or nuxt prepare runs cold and takes a few minutes. Running the same command twice in a row stays warm. The cold run hits when you go from dev to typecheck, which happens constantly during normal work.

Suggested fix

Register the auto-import without the extension. The module already does this for the custom-handler path just above, which strips .ts with TS_ENDS_RE, so the same treatment for the generated path is consistent:

addImports([{
  name: '_refreshHandler',
  from: generatedRefreshHandlerPath.replace(TS_ENDS_RE, ''),
}])

With the extension stripped, nuxt dev, nuxt prepare, and nuxt typecheck all generate the identical import('../refreshHandler') line. The global declaration stops changing between commands, so the cache survives. Resolution is fine without the extension: nuxt typecheck was already emitting and type-checking that exact form.

Additional context

Measured impact

I ran a controlled benchmark of the fix: two phases, unpatched and patched, each from a clean install, differing only by this one-line change.

The benchmark reproduces the real workflow. Each measured sample does a dev-lane check (nuxt prepare + vue-tsc, the state nuxt dev or the editor leaves behind) and then runs nuxt typecheck, so it crosses from dev to typecheck.

The dev to typecheck time:

run unpatched patched
1 151 s 21 s
2 149 s 21 s
average 150 s (cold) 21 s (warm)

That is roughly 7 times faster. I also logged the generated import line at each step. Unpatched it was import('../refreshHandler.ts') in the dev state and import('../refreshHandler') after typecheck; patched it stayed import('../refreshHandler') the whole time.

To rule out other causes I ran loops that never cross from dev to typecheck: typechecks in a row, and typechecks after editing a widely imported file. Those were the same in both phases, cold once then warm, and a steady cascade cost for the edits. So the patch only moves the dev-to-typecheck case.

Full benchmark log (anonymized)
============================================================
[19:44:51] START  node=v24.11.0  pnpm=10.20.0
[19:44:51] Loops per phase: nochange=4 edit=4 devswitch=2
============================================================
[19:44:51] SETUP [BASELINE-unpatched]: clean + pnpm i + codegen
[19:44:52] RUN  [BASELINE-unpatched] pnpm i
[19:44:59]   -> 7s  ok
[19:44:59] RUN  [BASELINE-unpatched] codegen
[19:45:07]   -> 8s  ok
============================================================
[19:45:07] PHASE: BASELINE-unpatched
[19:45:07] refreshHandler import line (after setup): const _refreshHandler: typeof import('../refreshHandler.ts')._refreshHandler
[19:45:07] RUN  [BASELINE-unpatched] nochange typecheck #1
[19:46:42]   -> 95s  ok
[19:46:42] RUN  [BASELINE-unpatched] nochange typecheck #2
[19:49:01]   -> 139s  ok
[19:49:01] RUN  [BASELINE-unpatched] nochange typecheck #3
[19:49:23]   -> 22s  ok
[19:49:23] RUN  [BASELINE-unpatched] nochange typecheck #4
[19:49:46]   -> 23s  ok
[19:49:46] RUN  [BASELINE-unpatched] edit typecheck #1
[19:51:17]   -> 91s  ok
[19:51:17] RUN  [BASELINE-unpatched] edit typecheck #2
[19:52:49]   -> 92s  ok
[19:52:49] RUN  [BASELINE-unpatched] edit typecheck #3
[19:54:22]   -> 93s  ok
[19:54:22] RUN  [BASELINE-unpatched] edit typecheck #4
[19:56:07]   -> 105s  ok
[19:56:07] RUN  [BASELINE-unpatched] dev-lane (prepare+vue-tsc) #1
[19:57:45]   -> 98s  ok
[19:57:45]     dev-lane imports line: const _refreshHandler: typeof import('../refreshHandler.ts')._refreshHandler
[19:57:45] RUN  [BASELINE-unpatched] dev->typecheck #1
[20:00:16]   -> 151s  ok
[20:00:16] RUN  [BASELINE-unpatched] dev-lane (prepare+vue-tsc) #2
[20:02:46]   -> 150s  ok
[20:02:46]     dev-lane imports line: const _refreshHandler: typeof import('../refreshHandler.ts')._refreshHandler
[20:02:46] RUN  [BASELINE-unpatched] dev->typecheck #2
[20:05:15]   -> 149s  ok
[20:05:15]   imports line after phase: const _refreshHandler: typeof import('../refreshHandler')._refreshHandler
============================================================
[20:05:15] APPLY PATCH @sidebase/nuxt-auth@1.1.1
============================================================
[20:05:15] SETUP [PATCHED]: clean + pnpm i + codegen
[20:05:16] RUN  [PATCHED] pnpm i
[20:05:29]   -> 13s  ok
[20:05:29] RUN  [PATCHED] codegen
[20:05:44]   -> 15s  ok
============================================================
[20:05:44] PHASE: PATCHED
[20:05:44] refreshHandler import line (after setup): const _refreshHandler: typeof import('../refreshHandler')._refreshHandler
[20:05:44] RUN  [PATCHED] nochange typecheck #1
[20:07:22]   -> 98s  ok
[20:07:22] RUN  [PATCHED] nochange typecheck #2
[20:07:46]   -> 24s  ok
[20:07:46] RUN  [PATCHED] nochange typecheck #3
[20:08:09]   -> 23s  ok
[20:08:09] RUN  [PATCHED] nochange typecheck #4
[20:08:32]   -> 23s  ok
[20:08:32] RUN  [PATCHED] edit typecheck #1
[20:10:05]   -> 93s  ok
[20:10:05] RUN  [PATCHED] edit typecheck #2
[20:11:37]   -> 92s  ok
[20:11:37] RUN  [PATCHED] edit typecheck #3
[20:13:13]   -> 96s  ok
[20:13:13] RUN  [PATCHED] edit typecheck #4
[20:14:50]   -> 97s  ok
[20:14:50] RUN  [PATCHED] dev-lane (prepare+vue-tsc) #1
[20:16:27]   -> 97s  ok
[20:16:27]     dev-lane imports line: const _refreshHandler: typeof import('../refreshHandler')._refreshHandler
[20:16:27] RUN  [PATCHED] dev->typecheck #1
[20:16:48]   -> 21s  ok
[20:16:48] RUN  [PATCHED] dev-lane (prepare+vue-tsc) #2
[20:17:10]   -> 22s  ok
[20:17:10]     dev-lane imports line: const _refreshHandler: typeof import('../refreshHandler')._refreshHandler
[20:17:10] RUN  [PATCHED] dev->typecheck #2
[20:17:31]   -> 21s  ok
[20:17:31]   imports line after phase: const _refreshHandler: typeof import('../refreshHandler')._refreshHandler
============================================================
[20:17:31] SUMMARY (all runs)
============================================================
run                                             seconds   exit
[BASELINE-unpatched] pnpm i                           7      0
[BASELINE-unpatched] codegen                          8      0
[BASELINE-unpatched] nochange typecheck #1           95      0
[BASELINE-unpatched] nochange typecheck #2          139      0
[BASELINE-unpatched] nochange typecheck #3           22      0
[BASELINE-unpatched] nochange typecheck #4           23      0
[BASELINE-unpatched] edit typecheck #1               91      0
[BASELINE-unpatched] edit typecheck #2               92      0
[BASELINE-unpatched] edit typecheck #3               93      0
[BASELINE-unpatched] edit typecheck #4              105      0
[BASELINE-unpatched] dev-lane (prepare+vue-tsc) #1       98      0
[BASELINE-unpatched] dev->typecheck #1              151      0
[BASELINE-unpatched] dev-lane (prepare+vue-tsc) #2      150      0
[BASELINE-unpatched] dev->typecheck #2              149      0
[PATCHED] pnpm i                                     13      0
[PATCHED] codegen                                    15      0
[PATCHED] nochange typecheck #1                      98      0
[PATCHED] nochange typecheck #2                      24      0
[PATCHED] nochange typecheck #3                      23      0
[PATCHED] nochange typecheck #4                      23      0
[PATCHED] edit typecheck #1                          93      0
[PATCHED] edit typecheck #2                          92      0
[PATCHED] edit typecheck #3                          96      0
[PATCHED] edit typecheck #4                          97      0
[PATCHED] dev-lane (prepare+vue-tsc) #1              97      0
[PATCHED] dev->typecheck #1                          21      0
[PATCHED] dev-lane (prepare+vue-tsc) #2              22      0
[PATCHED] dev->typecheck #2                          21      0
============================================================
[20:17:31] VALIDATION (loop C: dev -> typecheck, the scenario the patch fixes)
[20:17:31]   baseline dev->typecheck: 151 149  (avg 150s)
[20:17:31]   patched  dev->typecheck: 21 21  (avg 21s)
============================================================
[20:17:31]   RESULT: PASS
[20:17:31]   Unpatched is cold (~150s) on every dev->typecheck; patched stays warm (~21s).
[20:17:31]   The patch keeps the vue-tsc incremental cache alive across the dev->typecheck switch.
============================================================
[20:17:31] DONE. Patch is currently APPLIED. To revert: pnpm patch-remove "@sidebase/nuxt-auth@1.1.1" && pnpm install

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugA bug that needs to be resolvedp4Important Issue

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions