Skip to content

fix: decoContent() Vite plugin reloads open pages after the CMS has the saved content - #690

Merged
tlgimenes merged 4 commits into
mainfrom
fix/vite-content-reload
Oct 14, 2026
Merged

tlgimenes merged 4 commits into
mainfrom
fix/vite-content-reload

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Vite sites no longer need template code to show a block saved with deco serve. Today every v8 Vite site carries two pieces: an import.meta.hot.accept("../.deco/blocks.gen", ...) block in src/cms.ts that calls createCMS again and sends site:content-updated, and a site-content-reload plugin in vite.config.ts that turns that event into full-reload. This PR ships that as one opt-in Vite plugin:

// vite.config.ts
import { decoContent } from "@decocms/blocks/vite";

export default defineConfig({ plugins: [/* cloudflare(), tanstackStart(), react(), */ decoContent()] });

Only Vite sites add it. Next.js sites never import it, so nothing changes for them. The generated .deco/blocks.gen.ts on disk is unchanged (deco content is untouched).

Supersedes #676. That PR fixed only the timing, in the docs (reload after createCMS takes the new module, not on the file change). This PR makes the plugin do that and rewrites the same "Editing locally" section, so #676 can be closed.

How it works

  • @decocms/blocks/vite, decoContent(): apply: "serve". In a server environment only (this.environment.config.consumer === "server", or ssr before the Environment API), its transform hook finds each module that statically imports the site's .deco/blocks.gen.ts. It reads the module's static imports and re-exports and resolves each one with this.resolve, so any specifier form counts: "../.deco/blocks.gen", an alias like "~/.deco/blocks.gen", with or without .ts. Modules under node_modules, non-JS files and the content module itself are skipped. To each importer (usually src/cms.ts) it appends the block sites used to write by hand:
    import { adoptContent as __decoAdoptContent } from "@decocms/blocks/vite/hot";
    if (import.meta.hot) {
      import.meta.hot.accept("../.deco/blocks.gen", (next) => {
        if (next) __decoAdoptContent(next.default);
        import.meta.hot?.send("decocms:content-updated");
      });
    }
    The block is appended, so no line moves and the source map holds (map: null). The importer is the update's HMR boundary, so neither it nor anything above it (the router entry, routeTree.gen) is invalidated or runs again. JSON block edits bubble up through blocks.gen.ts to the same boundary.
  • Already accepted by hand: if the importer already has an ….accept(<specifier>) (string or array) that resolves to the content module, the plugin adds nothing to it.
  • Pre-bundled hot module: configEnvironment adds @decocms/blocks/vite/hot to optimizeDeps.include in the server environments. The appended import appears only after the transform, so the dependency scan can't see it. Discovered on the first request instead, the worker re-optimizes its dependencies and reloads the whole program once.
  • One reload per save: configureServer listens for the event on every server environment (the @cloudflare/vite-plugin worker whatever it's named, rsc, plain ssr) and sends { type: "full-reload" } to the client environment. It waits 100 ms because a save swaps the module twice (the block's JSON, then blocks.gen.ts), so open pages reload once.
  • adoptContent(content) (src/v8/cms.ts, exposed only on @decocms/blocks/vite/hot): hands the module to every CMS instance keyed for that content's .deco root (module:<root>, with or without |site:…), the same adopt path a second createCMS call takes. It doesn't need the site's config: each instance re-adopts with its own stored config, so a hosted CMS keeps its site/dev. It returns how many instances took it. With none (no CMS yet, or a module without root) it does nothing.
  • vite is an optional peer (>=6.0.0, for the Environment API) and a devDependency. The plugin uses import type only, so nothing is imported from vite at runtime. New exports entries are ./vite and ./vite/hot (types/source/default, like the others), and bun run build emits dist/v8/vite/*.

Sites that still have the old code: they keep working.

  • Without the plugin, nothing changes.
  • With the plugin and the old cms.ts accept block both in place, the plugin leaves that module alone. The old callback re-creates the CMS and the old site-content-reload plugin reloads the page, as before. If you delete only the old Vite plugin, pages don't reload, so the migration reference says to delete both together.
  • The older docs recipe's reload plugin is a hotUpdate hook on the file change. That hook fires early, so the reference says to delete it.

Review fixes

  • optimizeDeps (041a9b4): the server environments pre-bundle @decocms/blocks/vite/hot. Without that, the first request found the new import and re-optimized the worker, which reloaded the whole program once.
  • Importer boundary instead of a self-accepting content module (ea8618c): the first version made blocks.gen.ts accept its own updates. Vite still invalidates every importer of an updated module unless the importer accepts it. TanStack Start imports its server entry again on each request, so with the cloudflare plugin src/cms.ts and the route tree re-ran on every save. On the demo store, bursts of saves gave 500s (Cannot read properties of undefined (reading 'method') in handleServerAction, Route undefined in routeTree.gen), and the client then stopped reloading. The plugin now writes the accept that worked before (the hand-written cms.ts block had 14/14 clean bursts), on the importer.

Tests

  • src/v8/vite/vite.test.ts (unit tests):
    • adoptContent: the same instance, another .deco root left alone, a hosted instance keeps its options, nothing before a CMS exists or for a root-less module.
    • The import reader: static imports and re-exports, not dynamic import() or import.meta. It also finds accepted dependencies in string and array forms.
    • The transform: relative, alias, .ts and bare-alias specifiers resolving to the content module get the block with their own specifier. Nothing is added in the client, to the content module itself, under node_modules, to non-JS files, to modules that don't import it, or to a module that accepts it by hand. An unrelated accept doesn't count. The ssr flag works without the Environment API.
    • optimizeDeps.include only in server environments; configureServer listens on every server environment, and two events make one full-reload.
  • src/v8/vite/devServer.test.ts (integration): a real Vite dev server (createServer, SSR RunnableDevEnvironment). Like TanStack Start, every "request" imports the site entry again (entry.ts → cms.ts, which imports ~/.deco/blocks.gen through an alias).
    • Three saves: each gives a full-reload, and a request at that moment already resolves the new content.
    • Regression: the test counts module runs. cms.ts and the entry each ran once, and the transformed cms.ts has exactly one accept while blocks.gen.ts has none. With the previous self-accepting design the same test fails with { cms: 4, entry: 4 }.
    • A cms.ts with its own accept block keeps exactly one accept, its callback runs, and the plugin sends no reload.
  • End to end with @cloudflare/vite-plugin (Node vite dev; 10 bursts of 3 saves 120 ms apart, each save writing the block JSON and then blocks.gen.ts; two request loops running throughout; a vite-hmr WebSocket counting reloads; cms.ts logging each run):
    • Demo store (fix(dev): decoContent() from @decocms/blocks/vite replaces the content reload template code deco-sites/demo-storefront#133 with this build, Vite 7, TanStack Start):
      • This PR: 52/52 responses 200, cms.ts ran once in all, hot updated: /.deco/blocks.gen.ts via /src/cms.ts, and the page had each burst's last save 10/10.
      • Previous self-accepting design: 12 of 54 responses were 500, cms.ts ran 16 times, and 1 burst's page lacked the last save.
    • examples/tanstack-smoke: 276/276 responses 200, cms.ts ran once, and 10/10 bursts showed the last save. Saves more than 100 ms apart each get their own reload, so a burst gave 1 to 3 reloads, never more than its saves.
  • bun run check (typecheck, biome, knip), bun run build and skills:check pass. In bun run test, 1926 tests pass. The failures are not from this PR:

Also in this PR

  • examples/tanstack-smoke and examples/tanstack-rsc-smoke add decoContent(). The ts-09 guide conformance test now expects that one @decocms/blocks/vite import.
  • .agents/skills/deco-v7-to-v8-migration/reference/content.md, "Editing locally": add the plugin; drop the old snippet and plugin together when you do; and why: the "client is not a function" and one-edit-behind history from docs(skills): reload open pages after the server has the saved content #676, and why the boundary is the importer.
  • README.md mentions the plugin and the /vite subpath.

Follow-ups opened alongside:

🤖 Generated with Claude Code

https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig

…the saved content

Vite sites carried two pieces of template code to show a block saved with
`deco serve`: an import.meta.hot.accept block in src/cms.ts that called
createCMS again and sent an event, and a vite.config.ts plugin that turned
the event into full-reload.

@decocms/blocks/vite now ships it as one opt-in plugin, decoContent()
(apply: "serve"). In the server environments it appends a self-accepting
HMR footer to .deco/blocks.gen.ts: the new module goes to the CMS already
created for that .deco folder (adoptContent, from @decocms/blocks/vite/hot,
keeping that CMS's own options), then the module sends
decocms:content-updated; configureServer turns that into one full-reload of
the client (debounced, as a save swaps the module twice: JSON, then
blocks.gen.ts). src/cms.ts never runs again, so "client is not a function"
stays fixed. The generated file on disk and Next.js sites are unchanged;
vite is an optional peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
@tlgimenes
tlgimenes requested a review from a team October 14, 2026 10:40
@tlgimenes tlgimenes changed the title fix(vite): decoContent() reloads open pages after the CMS has the saved content fix: decoContent() Vite plugin reloads open pages after the CMS has the saved content Oct 14, 2026
…res next to decoContent()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
tlgimenes and others added 2 commits October 14, 2026 07:54
… server environments

The footer's import of @decocms/blocks/vite/hot only appears after the
transform, so the dependency scan never saw it. With @decocms/blocks
installed from npm and @cloudflare/vite-plugin, the worker discovered it on
the first request, re-optimized its dependencies and reloaded the whole
program ("new dependencies optimized: @decocms/blocks/vite/hot").
configEnvironment now adds it to optimizeDeps.include for every non-client
environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
…ts updates

The content module accepting its own updates still left every module above
it stale: Vite invalidates the importers of an updated module unless they
accept it, and TanStack Start imports its server entry again on each request,
so src/cms.ts and the route tree re-ran on every save and bursts of saves gave
500s (handleServerAction reading `method` of undefined, Route undefined in
routeTree.gen).

In the server environments the plugin now appends, to each module that
statically imports the site's .deco/blocks.gen (any specifier that resolves
to it: relative, alias, with or without the extension), the accept block
sites used to write by hand: import.meta.hot.accept(<specifier>, next =>
adoptContent(next.default), then the content-updated event). The importer is
the update's boundary, so nothing above it is invalidated. A module that
already accepts the content module by hand is left as it is. The content
module itself is no longer transformed. optimizeDeps include and the 100 ms
reload debounce are unchanged.

The dev-server test now imports its entry again on every request, as
TanStack Start does, and asserts that neither cms.ts nor the entry re-runs
over three saves (the self-accepting design runs them 4 times); a second
test covers a module with its own accept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
@tlgimenes
tlgimenes merged commit b354bfa into main Oct 14, 2026
4 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 8.2.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant