Skip to content

Commit 3a7ff44

Browse files
fix: full-reload on document shell and server-only module edits (solidjs/solid#3151)
The document shell hydrates the whole document, so no refresh-boundary component swap can ever apply an edit to it — updates were accepted and silently absorbed. The client compile of the resolved start.document / src/Document.* module now declines HMR (self-accept + invalidate), so a save full-reloads the page. Separately, server-environment hot updates for files with no modules in the client graph (client-mode documents, authored entry-server, middleware) now send a browser full-reload instead of being suppressed: the suppression protects client HMR from full-reload races, but a server-only file has no client update to race with — staying silent just leaves the browser rendering stale server output. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7914afa commit 3a7ff44

3 files changed

Lines changed: 56 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/vite-plugin": patch
3+
---
4+
5+
Document shell edits now trigger a full page reload instead of being silently absorbed (solidjs/solid#3151). Two sides: the resolved `start.document` / `src/Document.*` module declines HMR in its client compile (it hydrates the whole `document`, so no component swap can ever apply — self-accept + invalidate makes Vite reload instead), and server-environment updates for files with no client-graph counterpart (client-mode documents, authored entry-server, middleware) send a browser full-reload rather than staying suppressed — the suppression exists to protect client HMR from full-reload races, but a server-only file has no client update to race with.

‎src/index.ts‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ const LAZY_PLACEHOLDER_PREFIX = '__SOLID_LAZY_MODULE__:';
5656
*/
5757
const REFRESH_RUNTIME_SOURCE = 'solid-js/refresh';
5858

59+
// Appended to the document shell's client compile instead of a refresh
60+
// boundary (see documentModuleId in solidPlugin): self-accept, then
61+
// invalidate — Vite's spelling for "this module cannot hot-update, reload".
62+
const DOCUMENT_HMR_DECLINE =
63+
'\nif (import.meta.hot) {\n import.meta.hot.accept(() => import.meta.hot.invalidate());\n}\n';
64+
5965
const DEFAULT_STYLE_EXCLUDE = /node_modules/;
6066

6167
const VIRTUAL_MANIFEST_ID = 'virtual:solid-manifest';
@@ -596,6 +602,15 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
596602

597603
let needHmr = false;
598604
let replaceDev = false;
605+
// Resolved absolute path of the start-mode document shell (normalized to
606+
// forward slashes, matching Vite ids), reported back by the start plugin's
607+
// config hook. The document is the one module whose client compile must
608+
// decline HMR instead of taking a refresh boundary: it hydrates the whole
609+
// `document`, and no component swap can re-claim `document.documentElement`
610+
// — an accepted update would be absorbed with nothing visibly changing
611+
// (solidjs/solid#3151). Declining makes a save invalidate the module, so
612+
// Vite falls back to a full page reload: the honest cost.
613+
let documentModuleId: string | null = null;
599614
// The live dev server, kept so the dev manifest module can bake the bridge
600615
// endpoint URL in when its code is generated (see devManifestBridgeUrl).
601616
let devServer: ViteDevServer | null = null;
@@ -943,7 +958,7 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
943958
} as typeof hot.send;
944959
},
945960

946-
hotUpdate({ modules }) {
961+
hotUpdate({ modules, file }) {
947962
// solid-refresh only injects HMR boundaries into client modules, so
948963
// non-client environments have no accept handlers. Without this, Vite
949964
// would see no boundaries and send full-reload messages that race with
@@ -960,6 +975,17 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
960975
// browser websocket, so client HMR stays free of full-reload races.
961976
if (modules.length > 0) {
962977
this.environment.hot.send({ type: 'full-reload' });
978+
// Server-only modules are the exception to the suppression: a file
979+
// with no modules in the client graph has no browser HMR path at
980+
// all — nothing client-side accepts it, so staying silent leaves
981+
// the browser rendering stale server output until a manual refresh
982+
// (e.g. the document shell, which only the server ever imports;
983+
// solidjs/solid#3151). Reload the page: the honest cost, and there
984+
// is no client update to race with by construction.
985+
const clientEnv = devServer?.environments.client;
986+
if (clientEnv && !clientEnv.moduleGraph.getModulesByFile(file)?.size) {
987+
clientEnv.hot.send({ type: 'full-reload' });
988+
}
963989
}
964990
return [];
965991
}
@@ -1084,7 +1110,11 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
10841110
plugins.push('typescript');
10851111
}
10861112

1087-
const needRefresh = needHmr && !isSsr && !inNodeModules;
1113+
// See the documentModuleId declaration: the document shell declines HMR
1114+
// (no refresh boundary, explicit self-invalidation) so edits full-reload.
1115+
const isDocumentShell = documentModuleId !== null && id === documentModuleId;
1116+
const needRefresh = needHmr && !isSsr && !inNodeModules && !isDocumentShell;
1117+
const declineHmr = isDocumentShell && needHmr && !isSsr;
10881118

10891119
const babelUserOptions = await getBabelUserOptions(options, source, id, !!isSsr);
10901120

@@ -1172,7 +1202,10 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
11721202
!!isSsr,
11731203
);
11741204

1175-
return { code: finalCode, map: combineSourcemaps(maps) };
1205+
return {
1206+
code: declineHmr ? finalCode + DOCUMENT_HMR_DECLINE : finalCode,
1207+
map: combineSourcemaps(maps),
1208+
};
11761209
}
11771210

11781211
// Babel JSX backend: one babel.transformAsync hosting the user's
@@ -1197,7 +1230,10 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
11971230
!!isSsr,
11981231
);
11991232

1200-
return { code: finalCode, map: combineSourcemaps(maps) };
1233+
return {
1234+
code: declineHmr ? finalCode + DOCUMENT_HMR_DECLINE : finalCode,
1235+
map: combineSourcemaps(maps),
1236+
};
12011237
},
12021238
};
12031239

@@ -1235,6 +1271,10 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
12351271
ssr: !!options.ssr,
12361272
styleFilter: filterDevStyles,
12371273
diagnostics: !!options.diagnostics,
1274+
onDocumentResolved(documentPath) {
1275+
// Normalize to forward slashes to match Vite's transform ids.
1276+
documentModuleId = documentPath ? documentPath.split(path.sep).join('/') : null;
1277+
},
12381278
}),
12391279
);
12401280
}

‎src/ssr/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,12 @@ export function startServe(
435435
ssr?: boolean;
436436
styleFilter?: DevStyleFilter;
437437
diagnostics?: boolean;
438+
/**
439+
* Reports the resolved document shell path (absolute, or null when the
440+
* built-in virtual document is used) back to the main plugin, which
441+
* declines HMR for that module's client compile (solidjs/solid#3151).
442+
*/
443+
onDocumentResolved?: (documentPath: string | null) => void;
438444
} = {},
439445
): Plugin[] {
440446
// Client mode (the `start` option without `ssr: true`) rides this exact
@@ -1115,6 +1121,7 @@ export function startServe(
11151121
devtoolsResolutions = {};
11161122
devtoolsIds = {};
11171123
entries = resolveEntries(root, options, clientMode);
1124+
internal.onDocumentResolved?.(entries.document);
11181125
middlewarePath = options.middleware
11191126
? path.resolve(root, normalizeUserPath(root, options.middleware, 'middleware'))
11201127
: null;

0 commit comments

Comments
 (0)