Skip to content

plan template: runtime state is written inside the Vite-watched root, causing a self-sustaining HMR full-reload loop in dev #2612

Description

@kjblythe

Summary

The plan template writes its runtime state — the SQLite database under data/
and a per-plan MDX mirror under plans/ — inside the project root, which is also
the Vite watch root. Neither @agent-native/core's Vite plugin nor the template's
vite.config.ts excludes those paths from the watcher, so an app write registers
as a source change and the dev server commands a full page reload. The reload
drives the next write, and the cycle sustains itself.

Measured on a plan-template app: 24 full reloads in 15 seconds, settling into
a reload every 2–3 seconds indefinitely, with the plan editor open and idle.

Environment

  • @agent-native/core 0.133.1 (where this was measured)
  • Template: plan, standalone shape
  • Node 22.22.2, pnpm 11.18.0, macOS

The relevant code paths are unchanged on main @ 215ed11 (0.136.2) — see
Evidence below — so this should still reproduce on current main.

Reproduction

npx agent-native create my-plan-app --template plan
cd my-plan-app
pnpm install
pnpm dev

Open a plan in the browser and leave it idle. The page reloads every few seconds.

To see the loop name its own trigger, add this plugin as the first entry in
plugins in vite.config.ts:

const reloadSpy = () => ({
  name: "reload-spy",
  configureServer(server: any) {
    const send = server.ws.send.bind(server.ws);
    server.ws.send = (...args: any[]) => {
      if (args[0]?.type === "full-reload") {
        console.log(`[reload-spy] full-reload ${JSON.stringify(args[0])}`);
      }
      return send(...args);
    };
    server.watcher.on("change", (p: string) =>
      console.log(`[reload-spy] watcher change: ${p}`),
    );
  },
});

The dev-server log then shows watcher change events for data/app.db-wal and
plans/<slug>/plan.mdx, each followed by a full-reload.

Evidence

Runtime state is resolved relative to the project root:

  • packages/core/src/server/better-auth-instance.ts:1553
    getDatabaseUrl("file:./data/app.db"). SQLite in WAL mode also churns
    data/app.db-wal and data/app.db-shm on every write.
  • templates/plan/server/lib/local-plan-files.ts:87
    return path.resolve(process.cwd(), "plans")
  • templates/plan/server/lib/local-plan-files.ts:407,411fs.mkdir(folder) then
    fs.writeFile(path.join(folder, "plan.mdx"), …), reached from every plan-writing
    action (update-visual-plan, create-*, patch-visual-plan-source,
    restore-plan-version, …).

Nothing excludes those paths from the watcher:

  • templates/plan/vite.config.ts — the whole file is 24 lines and sets no server
    key at all, so server.watch.ignored is Vite's default (which does not cover
    data/ or plans/).
  • packages/core/src/vite/ — no watch.ignored is contributed by the
    agentNative() plugin.

The template already treats these as generated, which is what makes the omission
look like an oversight rather than a decision —
templates/plan/.gitignore carries /plans/* under the comment
# Local plan data generated by tests/dev runs.

A Tailwind-shaped detail worth noting: the plan template ships @tailwindcss/vite
(templates/plan/package.json:79), whose hotUpdate hook treats the .mdx write
as a source change because MDX is inside its content scan. Removing Tailwind from
the app made the loop stop in our testing, which is why the report singles it out —
but the underlying cause is the unexcluded watch paths, not Tailwind.

Suggested fix

Set watch exclusions for app-written state at the framework level, so every
scaffold inherits them rather than each app rediscovering this. Either:

  1. have the agentNative() Vite plugin contribute server.watch.ignored entries
    for the directories core itself owns (data/, .generated/), and have the
    plan template add its own (plans/); or
  2. ship the exclusions in the template's vite.config.ts.

Option 1 seems preferable — data/ is core's own path, so an app author has no
reason to know it needs excluding.

Workaround

Adding this to the app's vite.config.ts stopped the loop completely (0 reloads
and 0 watcher events with a plan open and scrolled end to end):

server: {
  watch: {
    ignored: ["**/data/**", "**/plans/**"],
  },
},

Offer

Happy to submit a PR implementing either option (my lean is option 1) if the maintainers are open to it — glad to contribute the fix back.

Metadata

Metadata

Assignees

No one assigned

    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