import type {} from '../../../types/globals'; /* Vue HMR update handler */ import type { VueComponentInstance, VueVNode } from '../../../types/vue'; import { saveDOMState, restoreDOMState } from '../domState'; import { detectCurrentFramework, findIndexPath } from '../frameworkDetect'; /* Collect reactive value from a setup state entry into the target record */ const collectSetupValue = ( target: Record, key: string, value: unknown ) => { if (value && typeof value === 'object' && 'value' in value) { target[key] = value.value; return; } if (typeof value !== 'function') { target[key] = value; } }; /* Copy all setup state entries from a record into the target */ const collectSetupState = ( target: Record, setupState: Record ) => { const keys = Object.keys(setupState); for (let idx = 0; idx < keys.length; idx++) { const key = keys[idx]; if (key === undefined) continue; collectSetupValue(target, key, setupState[key]); } }; /* Walk a VNode tree and collect setup state from all child components */ const walkVNode = ( vnode: VueVNode | undefined, state: Record ) => { if (!vnode) return; if (vnode.component && vnode.component.setupState) { collectSetupState(state, vnode.component.setupState); } if (vnode.children && Array.isArray(vnode.children)) { vnode.children.forEach((child) => { walkVNode(child, state); }); } if (vnode.component && vnode.component.subTree) { walkVNode(vnode.component.subTree, state); } }; /* Extract state from child Vue component instances recursively */ const extractChildComponentState = ( instance: VueComponentInstance, state: Record ) => { if (!instance || !instance.subTree) return; walkVNode(instance.subTree, state); }; /* Find an existing stylesheet link matching the given base name */ const findMatchingStylesheetLink = (cssBaseName: string) => { let found: HTMLLinkElement | null = null; document .querySelectorAll('link[rel="stylesheet"]') .forEach((link) => { const href = link.getAttribute('href') ?? ''; if (cssBaseName && href.includes(cssBaseName)) { found = link; } }); return found; }; /* Swap a stylesheet link with a new one, removing the old on load */ const swapStylesheet = (cssUrl: string, cssBaseName: string) => { const existingLink = findMatchingStylesheetLink(cssBaseName); if (!existingLink) return; const capturedExisting: HTMLLinkElement = existingLink; const newLink = document.createElement('link'); newLink.rel = 'stylesheet'; newLink.href = `${cssUrl}?t=${Date.now()}`; newLink.onload = function () { if (capturedExisting && capturedExisting.parentNode) { capturedExisting.remove(); } }; document.head.appendChild(newLink); }; /* Extract Vue reactive state from app instance */ const extractVueAppState = (vuePreservedState: Record) => { if (!window.__VUE_APP__ || !window.__VUE_APP__._instance) return; const instance = window.__VUE_APP__._instance; if (instance.setupState) { collectSetupState(vuePreservedState, instance.setupState); } extractChildComponentState(instance, vuePreservedState); }; /* DOM fallback: extract count from button text when app instance is unavailable */ const extractCountFromDOM = (vuePreservedState: Record) => { if (Object.keys(vuePreservedState).length > 0) return; const countButton = document.querySelector('button'); if (!countButton || !countButton.textContent) return; const countMatch = countButton.textContent.match(/count is (\d+)/i); if (!countMatch) return; vuePreservedState.initialCount = parseInt(countMatch[1] ?? '0', 10); }; /* Handle completion of Vue module reimport */ const handleVueImportSuccess = ( vueRoot: HTMLElement | null, vueDomState: ReturnType | null ) => { if (vueRoot && vueDomState) { restoreDOMState(vueRoot, vueDomState); } sessionStorage.removeItem('__HMR_ACTIVE__'); }; /* Force-reload a Vue component via HMR runtime when setup() must re-run */ const forceReloadVueComponent = (mod: Record) => { const hmrRuntime = window.__VUE_HMR_RUNTIME__; if (!hmrRuntime) return; const component = mod?.default ?? Object.values(mod ?? {})[0]; if (!component || typeof component !== 'object') return; if (!('__hmrId' in component)) return; const { __hmrId: hmrId } = component; if (typeof hmrId === 'string') { hmrRuntime.reload(hmrId, component); } }; export const handleVueUpdate = (message: { data: { cssBaseName?: string; cssUrl?: string; forceReload?: boolean; html?: string; manifest?: Record; pageModuleUrl?: string; serverDuration?: number; sourceFile?: string; updateType?: string; }; }) => { const vueFrameworkCheck = detectCurrentFramework(); if (vueFrameworkCheck !== 'vue') return; if (message.data.updateType === 'css-only' && message.data.cssUrl) { swapStylesheet(message.data.cssUrl, message.data.cssBaseName || ''); return; } sessionStorage.setItem('__HMR_ACTIVE__', 'true'); const vueRoot = document.getElementById('root'); const vueDomState = vueRoot ? saveDOMState(vueRoot) : null; /* Extract Vue reactive state from app instance (not DOM) */ const vuePreservedState: Record = {}; extractVueAppState(vuePreservedState); /* DOM fallback if app instance not available */ extractCountFromDOM(vuePreservedState); /* Map count -> initialCount for prop-based state (used by CountButton) */ if ( vuePreservedState.count !== undefined && vuePreservedState.initialCount === undefined ) { vuePreservedState.initialCount = vuePreservedState.count; } /* Backup to sessionStorage for resilience */ try { sessionStorage.setItem( '__VUE_HMR_STATE__', JSON.stringify(vuePreservedState) ); } catch { /* ignore */ } window.__HMR_PRESERVED_STATE__ = vuePreservedState; // O(1) Vue HMR: import the changed module directly. // __VUE_HMR_RUNTIME__.reload() inside the module hot-swaps the // component in place — same pattern as React Fast Refresh. const { pageModuleUrl } = message.data; if (pageModuleUrl) { const clientStart = performance.now(); const modulePath = `${pageModuleUrl}?t=${Date.now()}`; import(modulePath) .then((mod) => { // When a composable/utility file changed (not the .vue file itself), // force reload via __VUE_HMR_RUNTIME__ so setup() re-runs. // Vue's rerender only swaps the template, not the setup closure. if (message.data.forceReload) { forceReloadVueComponent(mod); } sessionStorage.removeItem('__HMR_ACTIVE__'); if ( window.__HMR_WS__ && message.data.serverDuration !== undefined ) { const clientMs = Math.round( performance.now() - clientStart ); const total = (message.data.serverDuration ?? 0) + clientMs; window.__HMR_WS__.send( JSON.stringify({ duration: total, type: 'hmr-timing' }) ); } return undefined; }) .catch((err: unknown) => { console.warn('[HMR] Vue HMR failed, reloading:', err); sessionStorage.removeItem('__HMR_ACTIVE__'); window.location.reload(); }); return; } /* CSS pre-update: swap stylesheet BEFORE unmounting to prevent FOUC */ if (message.data.cssUrl) { swapStylesheet(message.data.cssUrl, message.data.cssBaseName || ''); } /* Unmount old Vue app but keep DOM visually intact during async import. unmount() clears the container — snapshot and restore synchronously. */ const savedHTML = vueRoot ? vueRoot.innerHTML : ''; if (window.__VUE_APP__) { window.__VUE_APP__.unmount(); window.__VUE_APP__ = null; } if (vueRoot) { vueRoot.innerHTML = savedHTML; } // Bundled fallback: re-import the index file const indexPath = findIndexPath( message.data.manifest, message.data.sourceFile, 'vue' ); if (!indexPath) { console.warn('[HMR] Vue index path not found, reloading'); window.location.reload(); return; } const modulePath = `${indexPath}?t=${Date.now()}`; import(modulePath) .then(() => { handleVueImportSuccess(vueRoot, vueDomState); return undefined; }) .catch((err: unknown) => { console.warn('[HMR] Vue import failed:', err); sessionStorage.removeItem('__HMR_ACTIVE__'); window.location.reload(); }); };