Forge tunnel is stuck in an endless reload loop whenever I change a frontend component

It’s very annoying having to reload forge tunnel while testing the UI;

It doesn’t happen on every project; But if I knew what was different about this one I would have fixed it already.

Any ideas

Hey Owen,
I haven’t seen that before, have you tried running it in debugging mode to get more information about what is happening?
Cheers,
Mel

Tried

npx forge tunnel --verbose

But I don’t get any more information; Is there a better command to use?

I can confirm that it happens both in Chrome and in Firefox

Hey, I have the same problem here!
Using UI KIT the forge tunnel goes into loop, I have the same import { example } from ‘./example’; logic on few .jsx files.
After I added that forge tunnel went looping.

It would be great if you can take some video recording so that we can know more on how it happens. Thanks a lot

Hello,
The problem is this.

The problem still exists. @KhanhNguyen has this ever been addressed?

Hi all
I’m hitting the same issue: Forge tunnel gets stuck in an endless “Bundling → Reloading code → Running forge lint” loop when starting tunnel and also when editing a resolver function. This makes the dev cycle very slow and frustrating. Same behavior when running with --no-verify.

Environment:

OS: Windows
IDE: IntelliJ IDEA
Node.js: v22.x
@forge/cli: latest
App type: Custom UI (Vite dev server)
Project layout: frontend under ./static/frontend, backend in ./src

Symptoms Forge tunnel repeatedly prints:

=== Bundling code…
✔ Functions bundled.
Reloading code…
=== Running forge lint…
Listening for requests on local port …
No issues found.

…and keeps looping.

I tried ran a file watcher in parallel to see which files change:
npx chokidar-cli ‘**/*’ --verbose
I don’t see any repetitively changing files while the tunnel loops.
I tried also to add .forgeignore and nothing append

Observations:

Even with the watcher showing no file changes, forge tunnel still loops.
The loop also occurs with --no-verify.

Questions:

Is there a known issue with Forge tunnel’s file watching?
Does forge tunnel fully honor .forgeignore for its watch on Windows?
Any recommended flags or configuration to avoid these loops?

I am experiencing the same issue.
Windows, cli@latest, App type: Custom UI (Parcel)

Solved: the endless reload loop is caused by a Windows 8.3 short-name alias in the path where the Forge CLI is installed

Nothing to do with your source code, your imports, or UI Kit vs Custom UI. If the Forge CLI resolves through a path containing an 8.3 alias (e.g. C:\Users\NICOLA~1 instead of C:\Users\Nicolas Dupre), webpack’s watcher marks that path as deleted on every poll and rebundles forever. On my machine the alias came from nvm-windows.

Why it’s so hard to see

  • The CLI never logs which file changed — there is no modifiedFiles/removedFiles output anywhere, so forge tunnel --verbose tells you nothing.
  • --no-verify doesn’t help (it only skips lint).
  • .forgeignore doesn’t exist in Atlassian Forge — zero occurrences in the whole @forge/cli package tree. The .forgeignore docs you find by searching belong to an unrelated homonym tool (forge.readthedocs.io). I tried it earlier in this thread; that’s why it did nothing.

Confirm it in 2 minutes

Save this as forge-watch-spy.cjs anywhere:

const Module = require("module");
const NEEDLE = /~\d(\\|\/|$)/;
const originalLoad = Module._load;
let patched = false;

Module._load = function (request, ...rest) {
  const exported = originalLoad.call(this, request, ...rest);
  if (!patched && request === "webpack" && exported && exported.Compiler) {
    patched = true;
    const proto = exported.Compiler.prototype;
    const originalWatch = proto.watch;
    proto.watch = function (watchOptions, handler) {
      this.hooks.watchRun.tap("spy", (c) => {
        console.log(`\n[spy] REBUILD modified=${[...(c.modifiedFiles || [])].length} removed=${[...(c.removedFiles || [])].length}`);
        for (const f of c.modifiedFiles || []) console.log(`  M ${f}`);
        for (const f of c.removedFiles || []) console.log(`  D ${f}`);
      });
      const wfs = this.watchFileSystem;
      if (wfs && !wfs.__spied) {
        wfs.__spied = true;
        const orig = wfs.watch.bind(wfs);
        wfs.watch = function (files, dirs, missing, ...args) {
          for (const [name, set] of Object.entries({ files, dirs, missing })) {
            for (const p of set ? [...set] : []) if (NEEDLE.test(String(p))) console.log(`  [spy] SUSPECT in ${name}: ${p}`);
          }
          return orig(files, dirs, missing, ...args);
        };
      }
      return originalWatch.call(this, watchOptions, handler);
    };
  }
  return exported;
};

Then run the tunnel with it preloaded. No changes to your app, no changes to the CLI:

NODE_OPTIONS='--require "C:/path/to/forge-watch-spy.cjs"' forge tunnel

My output, identical on every cycle:

[spy] REBUILD modified=0 removed=1
  D C:\Users\NICOLA~1
  [spy] SUSPECT in files: C:\Users\NICOLA~1\AppData\Roaming\nvm\v24.11.1\node_modules\@forge\cli\node_modules\typescript\lib\lib.es5.d.ts
  ... 96 suspects in files, 18 in missing (all ts-loader resolution probes)

modified=0 on every rebuild is the tell: no file of yours ever changed.

The mechanism, precisely

  1. nvm-windows stored its root using the 8.3 alias: settings.txtroot: C:\Users\NICOLA~1\AppData\Roaming\nvm. This happens when your Windows profile name contains a space.
  2. So the nodejs symlink’s target string is the alias: fs.readlinkSync('…\nvm\nodejs')C:\Users\NICOLA~1\AppData\Roaming\nvm\v24.11.1.
  3. Node resolves symlinks but does not expand 8.3 names (only fs.realpathSync.native does), so the CLI’s __dirname keeps the alias.
  4. ts-loader therefore registers the CLI’s bundled typescript/lib/*.d.ts as webpack file dependencies under the alias path.
  5. The alias resolves for stat/open (fs.existsSync('C:\Users\NICOLA~1')true) but never appears in a directory listingfs.readdirSync('C:\Users') returns Nicolas Dupre, never NICOLA~1. watchpack determines existence from the parent listing, so it reports the whole subtree as removed.
  6. The FaaS bundler watches with compiler.watch({ poll: 1000 }) and no ignored option (@forge/bundler/out/webpack.js), so this repeats every second: phantom deletion → invalidate → Reloading code... → rebundle → phantom deletion → … The visible cadence is ~8 s because lint + bundle take that long.

Fix (nvm-windows case), all in PowerShell, no admin needed

# 1. back up the user PATH first
[Environment]::GetEnvironmentVariable('Path','User') | Set-Content "$env:USERPROFILE\path-user-backup.txt"

# 2. user-scope nvm vars (they override the correct machine-scope ones)
[Environment]::SetEnvironmentVariable('NVM_HOME',    'C:\Users\Nicolas Dupre\AppData\Roaming\nvm',        'User')
[Environment]::SetEnvironmentVariable('NVM_SYMLINK', 'C:\Users\Nicolas Dupre\AppData\Roaming\nvm\nodejs', 'User')

# 3. alias entries in the user PATH
$p = [Environment]::GetEnvironmentVariable('Path','User')
$p = $p -replace [regex]::Escape('C:\Users\NICOLA~1'), 'C:\Users\Nicolas Dupre'
[Environment]::SetEnvironmentVariable('Path', $p, 'User')

# 4. nvm's own config, then recreate the link with a long-name target
$f = 'C:\Users\Nicolas Dupre\AppData\Roaming\nvm\settings.txt'
Copy-Item $f "$f.bak"
(Get-Content $f -Raw) -replace 'NICOLA~1', 'Nicolas Dupre' | Set-Content $f -NoNewline

cmd /c rmdir "C:\Users\Nicolas Dupre\AppData\Roaming\nvm\nodejs"
cmd /c mklink /J "C:\Users\Nicolas Dupre\AppData\Roaming\nvm\nodejs" "C:\Users\Nicolas Dupre\AppData\Roaming\nvm\v24.11.1"

Restart the terminal (and your IDE) afterwards, then verify: readlinkSync returns the long path, env | grep '~1' is empty, and the tunnel sits quietly on Listening for requests....

Instant workaround if you don’t want to touch your environment yet — call the CLI by its real path so the symlink is bypassed entirely:

"/c/Users/Nicolas Dupre/AppData/Roaming/nvm/v24.11.1/forge" tunnel

Generalises beyond nvm

Any install path with a ~1 component does this, a profile name with a space is the common trigger, but a moved npm prefix or PROGRA~1 would behave the same. Quick check before anything else: env | grep '~1', plus fs.readlinkSync on any symlink in your toolchain.

Ask for the Forge team

Two small changes would kill this class of bug:

  1. Apply fs.realpathSync.native() to the paths handed to webpack/ts-loader (it expands 8.3 names; plain realpathSync does not), or set watchOptions.ignored for CLI-internal dependencies. The app’s own bundle has no business watching @forge/cli’s typescript/lib.
  2. Log compiler.modifiedFiles / removedFiles under --verbose. That single line would have turned a multi-hour investigation into a 10-second read.

Environment: Forge CLI 13.0.0, Node 24.11.1, Windows 11 Pro, nvm-windows, Custom UI + resolver app.