vite-syntax-hmr-api

Use when implementing custom HMR handling, debugging HMR issues, writing HMR-aware modules, or understanding why full reloads happen. Prevents missing the import.meta.hot guard (causing production err

SKILL.md

GitHub

vite-syntax-hmr-api

Quick Reference

ViteHotContext — Core Methods

Method Purpose Key Constraint
accept() Self-accept hot updates Whitespace-sensitive static analysis
accept(dep, cb) Accept single dependency update Path MUST be relative
accept(deps, cb) Accept multiple dependency updates Array of relative paths
dispose(cb) Cleanup before module replacement Receives data object for state passing
prune(cb) Cleanup when module removed from graph Called when no longer imported
invalidate(message?) Force propagation upward MUST call accept() before invalidate()
data Persistent object across updates NEVER reassign — ALWAYS mutate properties
on(event, cb) Listen to HMR events 8 built-in events + custom
off(event, cb) Remove event listener Must pass same callback reference
send(event, data?) Send custom event to server Buffered if WebSocket not connected

Built-in HMR Events

Event When Fired
vite:beforeUpdate HMR update is about to be applied
vite:afterUpdate HMR update has been applied
vite:beforeFullReload Full page reload is imminent
vite:beforePrune Modules are about to be pruned
vite:invalidate A module has been invalidated via hot.invalidate()
vite:error An error occurred (e.g., syntax error in updated module)
vite:ws:disconnect WebSocket connection to dev server lost
vite:ws:connect WebSocket connection (re-)established

Critical Warnings

NEVER use HMR APIs without the guard pattern — production builds MUST tree-shake all HMR code:

if (import.meta.hot) {
  // ALL HMR code goes here
}

NEVER add whitespace in import.meta.hot.accept( — Vite uses static analysis that is whitespace-sensitive. Writing import.meta.hot.accept ( or splitting across lines breaks HMR detection.

NEVER reassign import.meta.hot.data — the object reference is shared across updates. ALWAYS mutate properties on it:

// CORRECT: mutate properties
import.meta.hot.data.count = 42

// WRONG: reassignment breaks persistence
import.meta.hot.data = { count: 42 }

NEVER call invalidate() without calling accept() first — Vite requires a module to accept updates before it can invalidate. Without accept(), the module is not an HMR boundary and invalidate() has no effect.

NEVER use const for re-exported values in HMR boundaries — Vite's HMR does NOT swap the originally imported binding. ALWAYS use let for values that must update through re-exports.


Decision Trees

Should This Module Self-Accept?

Does the module have side effects (DOM manipulation, event listeners, timers)?
├─ YES → Does it need to clean up those side effects on update?
│        ├─ YES → Use accept() + dispose() + data persistence
│        └─ NO  → Use accept() only
└─ NO → Is it a pure data/utility module?
         ├─ YES → Do NOT self-accept — let the importer handle updates
         └─ NO  → Does a parent module accept this as a dependency?
                  ├─ YES → No action needed (parent is the HMR boundary)
                  └─ NO  → Full reload will occur — ALWAYS add accept() to prevent this

Why Is My Module Causing Full Reload?

Does the changed module call import.meta.hot.accept()?
├─ NO → Does ANY importer accept it as a dependency?
│       ├─ NO → Full reload: no HMR boundary exists
│       └─ YES → HMR update through importer boundary
└─ YES → Does the accept callback throw an error?
         ├─ YES → Full reload: accept handler failed
         └─ NO → Does it call invalidate() that propagates to root?
                  ├─ YES → Full reload: invalidation reached root
                  └─ NO → HMR update should work — check console for errors

Patterns

Pattern 1: Self-Accepting Module with State Persistence

let state = { count: 0, items: [] }

// Restore state from previous module version
if (import.meta.hot) {
  if (import.meta.hot.data.state) {
    state = import.meta.hot.data.state
  }
}

function render() {
  document.getElementById('app')!.innerHTML = `Count: ${state.count}`
}

render()

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    if (newModule) {
      newModule.default?.() // call updated render if needed
    }
  })

  import.meta.hot.dispose((data) => {
    data.state = state // pass state to next version
  })
}

Pattern 2: Dependency-Accepting Module

import { formatDate } from './formatters.js'
import { validate } from './validators.js'

function processForm(input: string) {
  if (validate(input)) {
    return formatDate(new Date())
  }
}

if (import.meta.hot) {
  import.meta.hot.accept(
    ['./formatters.js', './validators.js'],
    ([newFormatters, newValidators]) => {
      // Re-run logic with updated dependencies
      // Modules are undefined if they had syntax errors
    },
  )
}

Pattern 3: Custom Client-Server Events

// Client side — send event to server
if (import.meta.hot) {
  import.meta.hot.send('my:from-client', { msg: 'Hello server' })

  import.meta.hot.on('my:from-server', (data) => {
    console.log('Server says:', data.msg)
  })
}

Pattern 4: Conditional Invalidation

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    if (!newModule) {
      // Syntax error — module is undefined
      return
    }
    if (newModule.API_VERSION !== currentApiVersion) {
      // Breaking change — propagate upward
      import.meta.hot.invalidate('API version changed')
      return
    }
    // Safe to apply update
    applyUpdate(newModule)
  })
}

Pattern 5: TypeScript Setup for HMR Types

Add to tsconfig.json:

{
  "compilerOptions": {
    "types": ["vite/client"]
  }
}

For typed custom events, declare in a .d.ts file:

import 'vite/types/customEvent.d.ts'

declare module 'vite/types/customEvent.d.ts' {
  interface CustomEventMap {
    'my:from-server': { msg: string }
    'my:from-client': { msg: string }
  }
}

Then use InferCustomEventPayload<T> for type-safe event handling:

import type { InferCustomEventPayload } from 'vite/types/customEvent.d.ts'

type ServerPayload = InferCustomEventPayload<'my:from-server'>

HMR Boundary Concept

An HMR boundary is any module that calls import.meta.hot.accept(). When a file changes, Vite walks up the import chain until it finds a boundary. If no boundary exists before reaching the entry point, a full page reload occurs.

How Updates Propagate

changed-file.ts
  └─ importer-a.ts (no accept) → keeps propagating
       └─ importer-b.ts (has accept) → BOUNDARY — update stops here
            └─ importer-c.ts → not affected

Re-export Constraint

Vite does NOT swap the original module binding. When a boundary module re-exports values from an updated dependency, those re-exports MUST use let, not const:

// CORRECT — allows HMR to update the binding
export let config = loadConfig()

// WRONG — const binding cannot be updated by HMR
export const config = loadConfig()

When Full Reload Occurs

Scenario Why Fix
No HMR boundary in import chain Update propagates to root entry Add accept() in the module or an importer
Accept callback throws Vite treats failed accept as unhandled Fix the error in the callback; use try/catch
invalidate() propagates to root No parent accepts the invalidated module Add accept boundary higher in the chain
HTML file changes HTML is the entry — always reloads Expected behavior, no fix needed
Config file changes Vite restarts the dev server Expected behavior, no fix needed

Reference Links

Official Sources